pi-mega-compact 0.8.17 → 0.8.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
  2. package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
  3. package/dist/extensions/dashboard-server/helpers.js +37 -0
  4. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  5. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  6. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  7. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  8. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  9. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  10. package/dist/extensions/dashboard-server/html/script.js +259 -0
  11. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  12. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  13. package/dist/extensions/dashboard-server/html-template.js +41 -0
  14. package/dist/extensions/dashboard-server/server.js +83 -0
  15. package/dist/extensions/mega-runtime/state.js +17 -1
  16. package/dist/src/store/sqlite/connection.js +35 -0
  17. package/dist/src/store/sqlite/global-index.js +199 -1
  18. package/dist/src/store/sqlite/global-index.test.js +136 -0
  19. package/dist/src/store/sqlite/index-store.js +167 -0
  20. package/dist/src/store/sqlite/memory.js +54 -0
  21. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  22. package/dist/src/store/sqlite/sessions.js +39 -0
  23. package/dist/src/store/sqlite/transaction.js +19 -0
  24. package/dist/src/vectorStore/add.js +260 -0
  25. package/dist/src/vectorStore/dedup.js +52 -0
  26. package/dist/src/vectorStore/index.js +10 -0
  27. package/dist/src/vectorStore/queries.js +83 -0
  28. package/dist/src/vectorStore/search.js +95 -0
  29. package/dist/src/vectorStore/session.js +19 -0
  30. package/dist/src/vectorStore/store.js +105 -0
  31. package/dist/src/vectorStore/types.js +6 -0
  32. package/dist/src/vectorStore/utils.js +23 -0
  33. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
  42. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
  43. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
  52. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
  53. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
  59. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
  60. package/extensions/dashboard-client/dist/index.html +2 -2
  61. package/extensions/dashboard-client/package-lock.json +374 -2
  62. package/extensions/dashboard-client/package.json +2 -1
  63. package/extensions/dashboard-client/src/App.tsx +5 -1
  64. package/extensions/dashboard-client/src/api/client.ts +12 -0
  65. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  66. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  67. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  68. package/extensions/dashboard-client/src/main.tsx +1 -0
  69. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  70. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  71. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  72. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  73. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  74. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  75. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  76. package/extensions/dashboard-server/server.ts +100 -0
  77. package/extensions/mega-runtime/state.ts +31 -0
  78. package/package.json +1 -1
  79. package/src/store/sqlite/global-index.test.ts +222 -0
  80. package/src/store/sqlite/global-index.ts +316 -1
  81. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  82. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  83. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  84. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  85. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
@@ -0,0 +1,222 @@
1
+ /**
2
+ * global-index.test.ts — S39 session heartbeats + token_samples round-trip.
3
+ *
4
+ * Verifies recordSessionHeartbeat + appendTokenSample + readActiveSessions +
5
+ * readSessionTimeseries + pruneStaleSessions + pruneTokenSamples +
6
+ * clearSessionHeartbeat, with particular focus on the Step 5 events.log
7
+ * session_sample JSONL line shape (the SSE real-time push wire format).
8
+ *
9
+ * Pi-agnostic. Uses an isolated mock index dir (never the real user
10
+ * `~/.mega-compact-index/` — guardrail G7). PREVENT-PI-004 (zero network):
11
+ * every operation is local SQLite + local FS append.
12
+ */
13
+ import { describe, it, before, after } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
18
+
19
+ import {
20
+ recordSessionHeartbeat,
21
+ appendTokenSample,
22
+ readActiveSessions,
23
+ readSessionTimeseries,
24
+ pruneStaleSessions,
25
+ pruneTokenSamples,
26
+ clearSessionHeartbeat,
27
+ closeIndexStore,
28
+ } from "./global-index.js";
29
+
30
+ describe("global-index session time-series (S39)", () => {
31
+ let indexDir: string;
32
+ let eventsLogPath: string;
33
+
34
+ before(() => {
35
+ indexDir = mkdtempSync(join(tmpdir(), "mc-global-idx-"));
36
+ eventsLogPath = join(indexDir, "events.log");
37
+ });
38
+
39
+ after(() => {
40
+ closeIndexStore();
41
+ rmSync(indexDir, { recursive: true, force: true });
42
+ });
43
+
44
+ it("recordSessionHeartbeat + readActiveSessions: heartbeat row appears", () => {
45
+ recordSessionHeartbeat(
46
+ 12_345,
47
+ "sess-abc-1",
48
+ "/home/u/repos/proj-a",
49
+ "/home/u/.pi/state-a",
50
+ 200_000,
51
+ indexDir,
52
+ );
53
+ const sessions = readActiveSessions(indexDir);
54
+ assert.equal(sessions.length, 1);
55
+ const s = sessions[0];
56
+ assert.equal(s.pid, 12_345);
57
+ assert.equal(s.sessionId, "sess-abc-1");
58
+ assert.equal(s.repoRoot, "/home/u/repos/proj-a");
59
+ assert.equal(s.stateDir, "/home/u/.pi/state-a");
60
+ assert.equal(s.ctxWindow, 200_000);
61
+ assert.ok(s.lastSeen > 0);
62
+ // No token sample yet → tokens/percent are null.
63
+ assert.equal(s.tokens, null);
64
+ assert.equal(s.percent, null);
65
+ });
66
+
67
+ it("appendTokenSample writes a token_samples row + a session_sample JSONL line", () => {
68
+ appendTokenSample(
69
+ "sess-abc-1",
70
+ "/home/u/repos/proj-a",
71
+ 42_000,
72
+ 21.5,
73
+ 200_000,
74
+ eventsLogPath,
75
+ indexDir,
76
+ );
77
+ // The token_samples row should now be the latest sample for this session.
78
+ const sessions = readActiveSessions(indexDir);
79
+ const s = sessions.find((x) => x.sessionId === "sess-abc-1");
80
+ assert.ok(s, "session must still appear in readActiveSessions");
81
+ assert.equal(s!.tokens, 42_000);
82
+ assert.equal(s!.percent, 21.5);
83
+
84
+ // The events.log JSONL line should exist and match the wire format
85
+ // described by the SseSessionSample contract: an object with `ts`
86
+ // (ISO 8601 string), `type: 'session_sample'`, and the payload fields.
87
+ assert.ok(existsSync(eventsLogPath), "events.log should exist");
88
+ const lines = readFileSync(eventsLogPath, "utf-8")
89
+ .trim()
90
+ .split("\n")
91
+ .filter(Boolean);
92
+ assert.equal(lines.length, 1, "exactly one JSONL line expected");
93
+ const parsed = JSON.parse(lines[0]) as Record<string, unknown>;
94
+ assert.equal(parsed["type"], "session_sample");
95
+ assert.equal(parsed["sessionId"], "sess-abc-1");
96
+ assert.equal(parsed["tokens"], 42_000);
97
+ assert.equal(parsed["percent"], 21.5);
98
+ // Step 5 contract mirroring: `ts` is an ISO 8601 string (matches every
99
+ // other SseEvent variant's `ts: string` and the DashboardEmitter
100
+ // events.log pattern), NOT a numeric ms value.
101
+ assert.equal(
102
+ typeof parsed["ts"],
103
+ "string",
104
+ "ts must be an ISO 8601 string (matches other SseEvent variants)",
105
+ );
106
+ // Must be ISO-parseable (NaN if invalid → typeof won't be number).
107
+ const tsMs = Date.parse(parsed["ts"] as string);
108
+ assert.ok(
109
+ Number.isFinite(tsMs),
110
+ "ts must be a valid ISO 8601 timestamp",
111
+ );
112
+ // And recent (within the last 5s).
113
+ assert.ok(
114
+ Math.abs(Date.now() - tsMs) < 5_000,
115
+ "ts must be within ~5s of now",
116
+ );
117
+ });
118
+
119
+ it("readSessionTimeseries returns per-session series + totals in chronological order", () => {
120
+ // Two sessions, two samples each — fully within the rolling window.
121
+ // sess-xyz-9 needs its own heartbeat row so readActiveSessions can later
122
+ // surface it (heartbeats are the primary key, not the samples).
123
+ recordSessionHeartbeat(
124
+ 67_890,
125
+ "sess-xyz-9",
126
+ "/home/u/repos/proj-b",
127
+ "/home/u/.pi/state-b",
128
+ 200_000,
129
+ indexDir,
130
+ );
131
+ appendTokenSample(
132
+ "sess-abc-1",
133
+ "/home/u/repos/proj-a",
134
+ 50_000,
135
+ 25,
136
+ 200_000,
137
+ null, // no events.log line for this assertion
138
+ indexDir,
139
+ );
140
+ appendTokenSample(
141
+ "sess-xyz-9",
142
+ "/home/u/repos/proj-b",
143
+ 10_000,
144
+ 5,
145
+ 200_000,
146
+ null,
147
+ indexDir,
148
+ );
149
+ appendTokenSample(
150
+ "sess-xyz-9",
151
+ "/home/u/repos/proj-b",
152
+ 30_000,
153
+ 15,
154
+ 200_000,
155
+ null,
156
+ indexDir,
157
+ );
158
+ const since = Date.now() - 60_000;
159
+ const result = readSessionTimeseries(since, indexDir);
160
+
161
+ const seriesIds = result.series.map((s) => s.sessionId).sort();
162
+ assert.deepEqual(seriesIds, ["sess-abc-1", "sess-xyz-9"]);
163
+
164
+ const xyz = result.series.find((s) => s.sessionId === "sess-xyz-9");
165
+ assert.ok(xyz, "sess-xyz-9 series must exist");
166
+ assert.equal(xyz!.data.length, 2);
167
+ assert.equal(xyz!.data[0].tokens, 10_000);
168
+ assert.equal(xyz!.data[1].tokens, 30_000);
169
+ // Stable hash-based color from the SESSION_COLORS palette.
170
+ assert.match(xyz!.color, /^#[0-9a-f]{6}$/i);
171
+
172
+ // Totals: each timestamp summed across sessions.
173
+ assert.ok(result.totals.length >= 2);
174
+ for (const t of result.totals) {
175
+ assert.ok(typeof t.ts === "number" && t.ts > 0);
176
+ assert.ok(typeof t.tokens === "number" && t.tokens >= 0);
177
+ }
178
+ // Totals are sorted ascending by ts.
179
+ for (let i = 1; i < result.totals.length; i++) {
180
+ assert.ok(
181
+ result.totals[i].ts >= result.totals[i - 1].ts,
182
+ "totals must be chronologically ascending",
183
+ );
184
+ }
185
+ });
186
+
187
+ it("pruneStaleSessions clears heartbeats older than the cutoff (and only those)", () => {
188
+ // At this point we have 2 sessions; both are fresh (lastSeen ≈ now).
189
+ const beforeCount = readActiveSessions(indexDir).length;
190
+ assert.equal(beforeCount, 2);
191
+
192
+ // With a 0 ms cutoff, both are stale → pruned.
193
+ const pruned = pruneStaleSessions(0, indexDir);
194
+ assert.equal(pruned, 2, "both heartbeats should be pruned");
195
+ assert.equal(readActiveSessions(indexDir).length, 0);
196
+ });
197
+
198
+ it("pruneTokenSamples clears token_samples older than the cutoff", () => {
199
+ // Re-seed two samples; the previously-written ones were for two
200
+ // sessions (sess-abc-1 + sess-xyz-9). The prunedStaleSessions call
201
+ // above left token_samples untouched (separate table).
202
+ const sinceBefore = Date.now() - 60_000;
203
+ const before = readSessionTimeseries(sinceBefore, indexDir);
204
+ const beforeCount = before.series.reduce((n, s) => n + s.data.length, 0);
205
+ assert.ok(beforeCount >= 3, "expected to find the seeded samples");
206
+
207
+ const npurged = pruneTokenSamples(0, indexDir);
208
+ assert.ok(npurged >= 3, "should have pruned the seeded samples");
209
+
210
+ const after = readSessionTimeseries(sinceBefore, indexDir);
211
+ const afterCount = after.series.reduce((n, s) => n + s.data.length, 0);
212
+ assert.equal(afterCount, 0, "no samples should remain after pruning");
213
+ });
214
+
215
+ it("clearSessionHeartbeat is a no-op on missing rows (never throws)", () => {
216
+ const before = readActiveSessions(indexDir).length;
217
+ assert.doesNotThrow(() =>
218
+ clearSessionHeartbeat(99_999, "nonexistent-session", indexDir),
219
+ );
220
+ assert.equal(readActiveSessions(indexDir).length, before);
221
+ });
222
+ });
@@ -12,7 +12,7 @@
12
12
  * infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
13
13
  */
14
14
  import { DatabaseSync } from "node:sqlite";
15
- import { existsSync, mkdirSync } from "node:fs";
15
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
16
  import { homedir, tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
@@ -70,6 +70,31 @@ export function openIndexStore(indexDir: string = getIndexDir()): DatabaseSync {
70
70
  PRIMARY KEY (checkpoint_id, session_id)
71
71
  );
72
72
  CREATE INDEX IF NOT EXISTS idx_injected_global_cid ON injected_global(checkpoint_id);
73
+ -- S39: machine-wide session heartbeats + token time-series for the live
74
+ -- multi-pi stacked-memory graph. One row per (pid, session_id), updated
75
+ -- on every material snapshot() change (NOT on idle/context-repeat events).
76
+ -- Written by every pi process; read by the dashboard server. WAL-safe.
77
+ -- PRAMETERIZED queries (PREVENT-002); local node:sqlite (PREVENT-PI-004).
78
+ CREATE TABLE IF NOT EXISTS session_heartbeats (
79
+ pid INTEGER NOT NULL,
80
+ session_id TEXT NOT NULL,
81
+ repo_root TEXT,
82
+ state_dir TEXT,
83
+ ctx_window INTEGER DEFAULT 0,
84
+ last_seen INTEGER NOT NULL,
85
+ PRIMARY KEY (pid, session_id)
86
+ );
87
+ CREATE TABLE IF NOT EXISTS token_samples (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ session_id TEXT NOT NULL,
90
+ repo_root TEXT,
91
+ tokens INTEGER NOT NULL,
92
+ percent REAL NOT NULL,
93
+ ctx_window INTEGER DEFAULT 0,
94
+ ts INTEGER NOT NULL
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_token_samples_ts ON token_samples(ts);
97
+ CREATE INDEX IF NOT EXISTS idx_token_samples_sid_ts ON token_samples(session_id, ts);
73
98
  `);
74
99
  indexCache = iddb;
75
100
  indexCacheDir = indexDir;
@@ -303,3 +328,293 @@ export function countInjectedGlobal(indexDir: string = getIndexDir()): number {
303
328
  const row = db.prepare("SELECT COUNT(*) AS n FROM injected_global").get() as { n: number } | undefined;
304
329
  return row?.n ?? 0;
305
330
  }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // S39: session heartbeats + token time-series (multi-pi stacked memory graph)
334
+ //
335
+ // `session_heartbeats`: one row per (pid, session_id), upserted on every
336
+ // material snapshot() change. This is the reliable liveness signal the
337
+ // dashboard /api/sessions endpoint reads (repo_registry.last_seen only updates
338
+ // on repo-switch / bindRepo, so a long-running single-repo process looks
339
+ // inactive; these heartbeats update on every material change).
340
+ //
341
+ // `token_samples`: append-only rows with (session_id, tokens, percent, ts) for
342
+ // the stacked-memory graph. Garbage-collected by pruneTokenSamples.
343
+ //
344
+ // All queries use @named/$named bind parameters (PREVENT-002); local node:sqlite
345
+ // + WAL (PREVENT-PI-004), multi-process safe.
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** A live active session row (joined heartbeat + latest token sample). */
349
+ export interface ActiveSessionRow {
350
+ pid: number;
351
+ sessionId: string;
352
+ repoRoot: string | null;
353
+ stateDir: string | null;
354
+ ctxWindow: number;
355
+ lastSeen: number;
356
+ tokens: number | null;
357
+ percent: number | null;
358
+ }
359
+
360
+ /** A single time-series data point for a session. */
361
+ export interface TokenSamplePoint {
362
+ ts: number;
363
+ tokens: number;
364
+ percent: number;
365
+ }
366
+
367
+ /** A recharts-ready per-session series with a stable color. */
368
+ export interface SessionSeries {
369
+ sessionId: string;
370
+ label: string;
371
+ color: string;
372
+ data: TokenSamplePoint[];
373
+ }
374
+
375
+ /** Timeseries response shape (recharts-ready). */
376
+ export interface SessionTimeseriesResult {
377
+ series: SessionSeries[];
378
+ totals: { ts: number; tokens: number }[];
379
+ }
380
+
381
+ /** Stable color palette for per-session series (hash-based, no randomness). */
382
+ const SESSION_COLORS = [
383
+ "#60a5fa", // blue-400
384
+ "#34d399", // emerald-400
385
+ "#fbbf24", // amber-400
386
+ "#f87171", // red-400
387
+ "#a78bfa", // violet-400
388
+ "#f472b6", // pink-400
389
+ "#22d3ee", // cyan-400
390
+ "#a3e635", // lime-400
391
+ ];
392
+
393
+ /** Hash a sessionId to a stable color index. */
394
+ function sessionColor(sessionId: string): string {
395
+ let h = 0;
396
+ for (let i = 0; i < sessionId.length; i++) {
397
+ h = (h * 31 + sessionId.charCodeAt(i)) | 0;
398
+ }
399
+ return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
400
+ }
401
+
402
+ /**
403
+ * Record (upsert) a session heartbeat. Called from snapshot() on every material
404
+ * change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
405
+ * their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
406
+ */
407
+ export function recordSessionHeartbeat(
408
+ pid: number,
409
+ sessionId: string,
410
+ repoRoot: string,
411
+ stateDir: string,
412
+ ctxWindow: number,
413
+ indexDir: string = getIndexDir(),
414
+ ): void {
415
+ const db = openIndexStore(indexDir);
416
+ const now = Date.now();
417
+ db.prepare(
418
+ `INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
419
+ VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
420
+ ON CONFLICT(pid, session_id) DO UPDATE SET
421
+ repo_root = excluded.repo_root,
422
+ state_dir = excluded.state_dir,
423
+ ctx_window = excluded.ctx_window,
424
+ last_seen = excluded.last_seen`,
425
+ ).run({
426
+ pid,
427
+ session_id: sessionId,
428
+ repo_root: repoRoot,
429
+ state_dir: stateDir,
430
+ ctx_window: ctxWindow,
431
+ last_seen: now,
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Append a token sample row + optionally a session_sample line to events.log
437
+ * (for SSE real-time push via /api/events). The eventsLogPath is optional —
438
+ * callers without an events.log (e.g. tests) can omit it.
439
+ */
440
+ export function appendTokenSample(
441
+ sessionId: string,
442
+ repoRoot: string,
443
+ tokens: number,
444
+ percent: number,
445
+ ctxWindow: number,
446
+ eventsLogPath: string | null,
447
+ indexDir: string = getIndexDir(),
448
+ ): void {
449
+ const db = openIndexStore(indexDir);
450
+ const now = Date.now();
451
+ db.prepare(
452
+ `INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
453
+ VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`,
454
+ ).run({
455
+ session_id: sessionId,
456
+ repo_root: repoRoot,
457
+ tokens,
458
+ percent,
459
+ ctx_window: ctxWindow,
460
+ ts: now,
461
+ });
462
+ // Step 5: also append a session_sample JSON line to events.log so the
463
+ // existing /api/events SSE tail streams it for free (real-time chart push).
464
+ // Mirrors the DashboardEmitter events.log append pattern in
465
+ // extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
466
+ // `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
467
+ // timestamp matches the shape of every other SSE variant (SseSessionSample
468
+ // contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
469
+ // violate the contract union's "every SSE variant has a ts field of type
470
+ // string" invariant and break DashboardEmitter consumers.
471
+ if (eventsLogPath) {
472
+ try {
473
+ const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
474
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
475
+ appendFileSync(
476
+ eventsLogPath,
477
+ JSON.stringify({
478
+ ts: new Date(now).toISOString(),
479
+ type: "session_sample",
480
+ sessionId,
481
+ tokens,
482
+ percent,
483
+ }) + "\n",
484
+ );
485
+ } catch {
486
+ /* non-fatal: SSE push is best-effort */
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Prune stale session heartbeats (sessions not seen within maxAgeMs).
493
+ * Default 30-min retention. Called by /api/sessions.
494
+ */
495
+ export function pruneStaleSessions(
496
+ maxAgeMs: number = 1_800_000,
497
+ indexDir: string = getIndexDir(),
498
+ ): number {
499
+ const db = openIndexStore(indexDir);
500
+ const cutoff = Date.now() - maxAgeMs;
501
+ const result = db.prepare(
502
+ "DELETE FROM session_heartbeats WHERE last_seen < @cutoff",
503
+ ).run({ cutoff });
504
+ return Number(result.changes);
505
+ }
506
+
507
+ /**
508
+ * Prune old token samples older than maxAgeMs. Default 30-min retention.
509
+ * Called by /api/sessions/timeseries.
510
+ */
511
+ export function pruneTokenSamples(
512
+ maxAgeMs: number = 1_800_000,
513
+ indexDir: string = getIndexDir(),
514
+ ): number {
515
+ const db = openIndexStore(indexDir);
516
+ const cutoff = Date.now() - maxAgeMs;
517
+ const result = db.prepare(
518
+ "DELETE FROM token_samples WHERE ts < @cutoff",
519
+ ).run({ cutoff });
520
+ return Number(result.changes);
521
+ }
522
+
523
+ /**
524
+ * Read all active sessions with their latest token sample (if any).
525
+ * JOINs session_heartbeats with the latest token_samples per session_id
526
+ * via a correlated subquery. Returns rows sorted by last_seen descending.
527
+ */
528
+ export function readActiveSessions(
529
+ indexDir: string = getIndexDir(),
530
+ ): ActiveSessionRow[] {
531
+ const db = openIndexStore(indexDir);
532
+ const rows = db.prepare(
533
+ `SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
534
+ s.tokens, s.percent
535
+ FROM session_heartbeats h
536
+ LEFT JOIN token_samples s ON s.id = (
537
+ SELECT id FROM token_samples t
538
+ WHERE t.session_id = h.session_id
539
+ ORDER BY t.ts DESC LIMIT 1
540
+ )
541
+ ORDER BY h.last_seen DESC`,
542
+ ).all() as Array<{
543
+ pid: number;
544
+ session_id: string;
545
+ repo_root: string | null;
546
+ state_dir: string | null;
547
+ ctx_window: number;
548
+ last_seen: number;
549
+ tokens: number | null;
550
+ percent: number | null;
551
+ }>;
552
+ return rows.map((r) => ({
553
+ pid: r.pid,
554
+ sessionId: r.session_id,
555
+ repoRoot: r.repo_root,
556
+ stateDir: r.state_dir,
557
+ ctxWindow: r.ctx_window ?? 0,
558
+ lastSeen: r.last_seen,
559
+ tokens: r.tokens,
560
+ percent: r.percent,
561
+ }));
562
+ }
563
+
564
+ /**
565
+ * Read token samples since sinceMs, returning a recharts-ready stacked shape:
566
+ * per-session `SessionSeries` (with stable color) + a `totals` array
567
+ * [{ts, tokens}] (sum of all sessions at each timestamp).
568
+ */
569
+ export function readSessionTimeseries(
570
+ sinceMs: number,
571
+ indexDir: string = getIndexDir(),
572
+ ): SessionTimeseriesResult {
573
+ const db = openIndexStore(indexDir);
574
+ const rows = db.prepare(
575
+ `SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
576
+ ).all({ since: sinceMs }) as Array<{
577
+ session_id: string;
578
+ tokens: number;
579
+ percent: number;
580
+ ts: number;
581
+ }>;
582
+ // Group by session_id → series; + accumulate totals per timestamp.
583
+ const seriesMap = new Map<string, TokenSamplePoint[]>();
584
+ const totalsMap = new Map<number, number>();
585
+ for (const r of rows) {
586
+ let pts = seriesMap.get(r.session_id);
587
+ if (!pts) {
588
+ pts = [];
589
+ seriesMap.set(r.session_id, pts);
590
+ }
591
+ pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
592
+ totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
593
+ }
594
+ const series: SessionSeries[] = [];
595
+ for (const [sessionId, data] of seriesMap) {
596
+ const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
597
+ series.push({ sessionId, label, color: sessionColor(sessionId), data });
598
+ }
599
+ // Sort series by first-timestamp for stable legend order.
600
+ series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
601
+ const totals = Array.from(totalsMap.entries())
602
+ .sort((a, b) => a[0] - b[0])
603
+ .map(([ts, tokens]) => ({ ts, tokens }));
604
+ return { series, totals };
605
+ }
606
+
607
+ /**
608
+ * Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
609
+ * Non-fatal: no-op if the row doesn't exist.
610
+ */
611
+ export function clearSessionHeartbeat(
612
+ pid: number,
613
+ sessionId: string,
614
+ indexDir: string = getIndexDir(),
615
+ ): void {
616
+ const db = openIndexStore(indexDir);
617
+ db.prepare(
618
+ "DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
619
+ ).run({ pid, session_id: sessionId });
620
+ }
@@ -1,51 +0,0 @@
1
- /**
2
- * dashboard-client/src/hooks/useApi.ts — Generic data fetching hook.
3
- *
4
- * Provides typed fetch with retry, stale detection, and error handling.
5
- * SPRINT-B1: basic fetch. SPRINT-D1: retry + stale.
6
- */
7
- import { useState, useEffect, useCallback, useRef } from 'react';
8
- // SPRINT-D1-REMAINING: integrate retryWithBackoff from utils/retry.ts.
9
- // SPRINT-D1-REMAINING: integrate staleness detection from utils/staleness.ts.
10
- // SPRINT-T1-REMAINING: add Authorization header when auth token present.
11
- export function useApi(fetchFn, options = {}) {
12
- const { pollInterval = 0, maxRetries = 0 } = options;
13
- const [data, setData] = useState(null);
14
- const [error, setError] = useState(null);
15
- const [loading, setLoading] = useState(true);
16
- const [lastFetchedAt, setLastFetchedAt] = useState(null);
17
- const mountedRef = useRef(true);
18
- const doFetch = useCallback(async () => {
19
- setLoading(true);
20
- setError(null);
21
- try {
22
- const result = await fetchFn();
23
- if (mountedRef.current) {
24
- setData(result);
25
- setLastFetchedAt(Date.now());
26
- }
27
- }
28
- catch (err) {
29
- if (mountedRef.current) {
30
- setError(err instanceof Error ? err : new Error(String(err)));
31
- }
32
- }
33
- finally {
34
- if (mountedRef.current) {
35
- setLoading(false);
36
- }
37
- }
38
- }, [fetchFn]);
39
- useEffect(() => {
40
- mountedRef.current = true;
41
- doFetch();
42
- return () => { mountedRef.current = false; };
43
- }, [doFetch]);
44
- useEffect(() => {
45
- if (pollInterval <= 0)
46
- return;
47
- const timer = setInterval(doFetch, pollInterval);
48
- return () => clearInterval(timer);
49
- }, [doFetch, pollInterval]);
50
- return { data, error, loading, refetch: doFetch, lastFetchedAt };
51
- }
@@ -1,63 +0,0 @@
1
- /**
2
- * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.
3
- *
4
- * Connects to /api/events, provides real-time event stream.
5
- * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.
6
- */
7
- import { useState, useEffect, useRef, useCallback } from 'react';
8
- // SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).
9
- // SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.
10
- export function useSSE(options = {}) {
11
- const { maxEvents = 500, maxReconnects = 5 } = options;
12
- const [events, setEvents] = useState([]);
13
- const [status, setStatus] = useState('connecting');
14
- const [eventCount, setEventCount] = useState(0);
15
- const [lastEventAt, setLastEventAt] = useState(null);
16
- const reconnectCount = useRef(0);
17
- const sourceRef = useRef(null);
18
- const connect = useCallback(() => {
19
- if (sourceRef.current) {
20
- sourceRef.current.close();
21
- }
22
- setStatus('connecting');
23
- const source = new EventSource('/api/events');
24
- sourceRef.current = source;
25
- source.onopen = () => {
26
- setStatus('connected');
27
- reconnectCount.current = 0;
28
- };
29
- source.onmessage = (event) => {
30
- try {
31
- const parsed = JSON.parse(event.data);
32
- setEvents(prev => {
33
- const next = [...prev, parsed];
34
- return next.length > maxEvents ? next.slice(-maxEvents) : next;
35
- });
36
- setEventCount(c => c + 1);
37
- setLastEventAt(Date.now());
38
- }
39
- catch {
40
- // non-fatal: skip malformed events
41
- }
42
- };
43
- source.onerror = () => {
44
- source.close();
45
- if (reconnectCount.current < maxReconnects) {
46
- reconnectCount.current += 1;
47
- setStatus('connecting');
48
- // SPRINT-D1: use exponential backoff instead of fixed delay
49
- setTimeout(connect, 1000);
50
- }
51
- else {
52
- setStatus('disconnected');
53
- }
54
- };
55
- }, [maxEvents, maxReconnects]);
56
- useEffect(() => {
57
- connect();
58
- return () => {
59
- sourceRef.current?.close();
60
- };
61
- }, [connect]);
62
- return { events, status, eventCount, lastEventAt };
63
- }