pi-mega-compact 0.8.16 → 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 (98) hide show
  1. package/README.md +1 -1
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
  4. package/dist/extensions/dashboard-server/helpers.js +37 -0
  5. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  6. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  7. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  8. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  9. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  10. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  11. package/dist/extensions/dashboard-server/html/script.js +259 -0
  12. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  13. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  14. package/dist/extensions/dashboard-server/html-template.js +41 -0
  15. package/dist/extensions/dashboard-server/server.js +83 -0
  16. package/dist/extensions/mega-compact-s38.test.js +33 -0
  17. package/dist/extensions/mega-events/agent-handlers.js +51 -3
  18. package/dist/extensions/mega-events/compact-handlers.js +10 -4
  19. package/dist/extensions/mega-events/error-classifier.js +24 -1
  20. package/dist/extensions/mega-events/send-safe.js +12 -0
  21. package/dist/extensions/mega-events.js +1 -0
  22. package/dist/extensions/mega-runtime/state.js +17 -1
  23. package/dist/src/store/sqlite/connection.js +35 -0
  24. package/dist/src/store/sqlite/global-index.js +199 -1
  25. package/dist/src/store/sqlite/global-index.test.js +136 -0
  26. package/dist/src/store/sqlite/index-store.js +167 -0
  27. package/dist/src/store/sqlite/memory.js +54 -0
  28. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  29. package/dist/src/store/sqlite/sessions.js +39 -0
  30. package/dist/src/store/sqlite/transaction.js +19 -0
  31. package/dist/src/vectorStore/add.js +260 -0
  32. package/dist/src/vectorStore/dedup.js +52 -0
  33. package/dist/src/vectorStore/index.js +10 -0
  34. package/dist/src/vectorStore/queries.js +83 -0
  35. package/dist/src/vectorStore/search.js +95 -0
  36. package/dist/src/vectorStore/session.js +19 -0
  37. package/dist/src/vectorStore/store.js +105 -0
  38. package/dist/src/vectorStore/types.js +6 -0
  39. package/dist/src/vectorStore/utils.js +23 -0
  40. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
  42. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
  48. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
  49. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
  51. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
  52. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
  53. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
  57. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
  59. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
  60. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
  61. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
  62. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
  64. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
  66. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
  67. package/extensions/dashboard-client/dist/index.html +2 -2
  68. package/extensions/dashboard-client/package-lock.json +374 -2
  69. package/extensions/dashboard-client/package.json +2 -1
  70. package/extensions/dashboard-client/src/App.tsx +5 -1
  71. package/extensions/dashboard-client/src/api/client.ts +12 -0
  72. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  73. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  74. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  75. package/extensions/dashboard-client/src/main.tsx +1 -0
  76. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  77. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  78. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  79. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  80. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  81. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  82. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  83. package/extensions/dashboard-server/server.ts +100 -0
  84. package/extensions/mega-compact-s38.test.ts +63 -0
  85. package/extensions/mega-events/agent-handlers.ts +47 -3
  86. package/extensions/mega-events/compact-handlers.ts +11 -4
  87. package/extensions/mega-events/error-classifier.ts +25 -1
  88. package/extensions/mega-events/send-safe.ts +37 -0
  89. package/extensions/mega-events.ts +1 -0
  90. package/extensions/mega-runtime/state.ts +31 -0
  91. package/package.json +1 -1
  92. package/src/store/sqlite/global-index.test.ts +222 -0
  93. package/src/store/sqlite/global-index.ts +316 -1
  94. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  95. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  96. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  97. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  98. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
@@ -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
  /** Resolve the machine-wide index directory (env-overridable). */
@@ -71,6 +71,31 @@ export function openIndexStore(indexDir = getIndexDir()) {
71
71
  PRIMARY KEY (checkpoint_id, session_id)
72
72
  );
73
73
  CREATE INDEX IF NOT EXISTS idx_injected_global_cid ON injected_global(checkpoint_id);
74
+ -- S39: machine-wide session heartbeats + token time-series for the live
75
+ -- multi-pi stacked-memory graph. One row per (pid, session_id), updated
76
+ -- on every material snapshot() change (NOT on idle/context-repeat events).
77
+ -- Written by every pi process; read by the dashboard server. WAL-safe.
78
+ -- PRAMETERIZED queries (PREVENT-002); local node:sqlite (PREVENT-PI-004).
79
+ CREATE TABLE IF NOT EXISTS session_heartbeats (
80
+ pid INTEGER NOT NULL,
81
+ session_id TEXT NOT NULL,
82
+ repo_root TEXT,
83
+ state_dir TEXT,
84
+ ctx_window INTEGER DEFAULT 0,
85
+ last_seen INTEGER NOT NULL,
86
+ PRIMARY KEY (pid, session_id)
87
+ );
88
+ CREATE TABLE IF NOT EXISTS token_samples (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ session_id TEXT NOT NULL,
91
+ repo_root TEXT,
92
+ tokens INTEGER NOT NULL,
93
+ percent REAL NOT NULL,
94
+ ctx_window INTEGER DEFAULT 0,
95
+ ts INTEGER NOT NULL
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_token_samples_ts ON token_samples(ts);
98
+ CREATE INDEX IF NOT EXISTS idx_token_samples_sid_ts ON token_samples(session_id, ts);
74
99
  `);
75
100
  indexCache = iddb;
76
101
  indexCacheDir = indexDir;
@@ -222,3 +247,176 @@ export function countInjectedGlobal(indexDir = getIndexDir()) {
222
247
  const row = db.prepare("SELECT COUNT(*) AS n FROM injected_global").get();
223
248
  return row?.n ?? 0;
224
249
  }
250
+ /** Stable color palette for per-session series (hash-based, no randomness). */
251
+ const SESSION_COLORS = [
252
+ "#60a5fa", // blue-400
253
+ "#34d399", // emerald-400
254
+ "#fbbf24", // amber-400
255
+ "#f87171", // red-400
256
+ "#a78bfa", // violet-400
257
+ "#f472b6", // pink-400
258
+ "#22d3ee", // cyan-400
259
+ "#a3e635", // lime-400
260
+ ];
261
+ /** Hash a sessionId to a stable color index. */
262
+ function sessionColor(sessionId) {
263
+ let h = 0;
264
+ for (let i = 0; i < sessionId.length; i++) {
265
+ h = (h * 31 + sessionId.charCodeAt(i)) | 0;
266
+ }
267
+ return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
268
+ }
269
+ /**
270
+ * Record (upsert) a session heartbeat. Called from snapshot() on every material
271
+ * change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
272
+ * their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
273
+ */
274
+ export function recordSessionHeartbeat(pid, sessionId, repoRoot, stateDir, ctxWindow, indexDir = getIndexDir()) {
275
+ const db = openIndexStore(indexDir);
276
+ const now = Date.now();
277
+ db.prepare(`INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
278
+ VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
279
+ ON CONFLICT(pid, session_id) DO UPDATE SET
280
+ repo_root = excluded.repo_root,
281
+ state_dir = excluded.state_dir,
282
+ ctx_window = excluded.ctx_window,
283
+ last_seen = excluded.last_seen`).run({
284
+ pid,
285
+ session_id: sessionId,
286
+ repo_root: repoRoot,
287
+ state_dir: stateDir,
288
+ ctx_window: ctxWindow,
289
+ last_seen: now,
290
+ });
291
+ }
292
+ /**
293
+ * Append a token sample row + optionally a session_sample line to events.log
294
+ * (for SSE real-time push via /api/events). The eventsLogPath is optional —
295
+ * callers without an events.log (e.g. tests) can omit it.
296
+ */
297
+ export function appendTokenSample(sessionId, repoRoot, tokens, percent, ctxWindow, eventsLogPath, indexDir = getIndexDir()) {
298
+ const db = openIndexStore(indexDir);
299
+ const now = Date.now();
300
+ db.prepare(`INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
301
+ VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`).run({
302
+ session_id: sessionId,
303
+ repo_root: repoRoot,
304
+ tokens,
305
+ percent,
306
+ ctx_window: ctxWindow,
307
+ ts: now,
308
+ });
309
+ // Step 5: also append a session_sample JSON line to events.log so the
310
+ // existing /api/events SSE tail streams it for free (real-time chart push).
311
+ // Mirrors the DashboardEmitter events.log append pattern in
312
+ // extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
313
+ // `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
314
+ // timestamp matches the shape of every other SSE variant (SseSessionSample
315
+ // contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
316
+ // violate the contract union's "every SSE variant has a ts field of type
317
+ // string" invariant and break DashboardEmitter consumers.
318
+ if (eventsLogPath) {
319
+ try {
320
+ const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
321
+ if (!existsSync(dir))
322
+ mkdirSync(dir, { recursive: true });
323
+ appendFileSync(eventsLogPath, JSON.stringify({
324
+ ts: new Date(now).toISOString(),
325
+ type: "session_sample",
326
+ sessionId,
327
+ tokens,
328
+ percent,
329
+ }) + "\n");
330
+ }
331
+ catch {
332
+ /* non-fatal: SSE push is best-effort */
333
+ }
334
+ }
335
+ }
336
+ /**
337
+ * Prune stale session heartbeats (sessions not seen within maxAgeMs).
338
+ * Default 30-min retention. Called by /api/sessions.
339
+ */
340
+ export function pruneStaleSessions(maxAgeMs = 1_800_000, indexDir = getIndexDir()) {
341
+ const db = openIndexStore(indexDir);
342
+ const cutoff = Date.now() - maxAgeMs;
343
+ const result = db.prepare("DELETE FROM session_heartbeats WHERE last_seen < @cutoff").run({ cutoff });
344
+ return Number(result.changes);
345
+ }
346
+ /**
347
+ * Prune old token samples older than maxAgeMs. Default 30-min retention.
348
+ * Called by /api/sessions/timeseries.
349
+ */
350
+ export function pruneTokenSamples(maxAgeMs = 1_800_000, indexDir = getIndexDir()) {
351
+ const db = openIndexStore(indexDir);
352
+ const cutoff = Date.now() - maxAgeMs;
353
+ const result = db.prepare("DELETE FROM token_samples WHERE ts < @cutoff").run({ cutoff });
354
+ return Number(result.changes);
355
+ }
356
+ /**
357
+ * Read all active sessions with their latest token sample (if any).
358
+ * JOINs session_heartbeats with the latest token_samples per session_id
359
+ * via a correlated subquery. Returns rows sorted by last_seen descending.
360
+ */
361
+ export function readActiveSessions(indexDir = getIndexDir()) {
362
+ const db = openIndexStore(indexDir);
363
+ const rows = db.prepare(`SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
364
+ s.tokens, s.percent
365
+ FROM session_heartbeats h
366
+ LEFT JOIN token_samples s ON s.id = (
367
+ SELECT id FROM token_samples t
368
+ WHERE t.session_id = h.session_id
369
+ ORDER BY t.ts DESC LIMIT 1
370
+ )
371
+ ORDER BY h.last_seen DESC`).all();
372
+ return rows.map((r) => ({
373
+ pid: r.pid,
374
+ sessionId: r.session_id,
375
+ repoRoot: r.repo_root,
376
+ stateDir: r.state_dir,
377
+ ctxWindow: r.ctx_window ?? 0,
378
+ lastSeen: r.last_seen,
379
+ tokens: r.tokens,
380
+ percent: r.percent,
381
+ }));
382
+ }
383
+ /**
384
+ * Read token samples since sinceMs, returning a recharts-ready stacked shape:
385
+ * per-session `SessionSeries` (with stable color) + a `totals` array
386
+ * [{ts, tokens}] (sum of all sessions at each timestamp).
387
+ */
388
+ export function readSessionTimeseries(sinceMs, indexDir = getIndexDir()) {
389
+ const db = openIndexStore(indexDir);
390
+ const rows = db.prepare(`SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`).all({ since: sinceMs });
391
+ // Group by session_id → series; + accumulate totals per timestamp.
392
+ const seriesMap = new Map();
393
+ const totalsMap = new Map();
394
+ for (const r of rows) {
395
+ let pts = seriesMap.get(r.session_id);
396
+ if (!pts) {
397
+ pts = [];
398
+ seriesMap.set(r.session_id, pts);
399
+ }
400
+ pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
401
+ totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
402
+ }
403
+ const series = [];
404
+ for (const [sessionId, data] of seriesMap) {
405
+ const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
406
+ series.push({ sessionId, label, color: sessionColor(sessionId), data });
407
+ }
408
+ // Sort series by first-timestamp for stable legend order.
409
+ series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
410
+ const totals = Array.from(totalsMap.entries())
411
+ .sort((a, b) => a[0] - b[0])
412
+ .map(([ts, tokens]) => ({ ts, tokens }));
413
+ return { series, totals };
414
+ }
415
+ /**
416
+ * Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
417
+ * Non-fatal: no-op if the row doesn't exist.
418
+ */
419
+ export function clearSessionHeartbeat(pid, sessionId, indexDir = getIndexDir()) {
420
+ const db = openIndexStore(indexDir);
421
+ db.prepare("DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id").run({ pid, session_id: sessionId });
422
+ }
@@ -0,0 +1,136 @@
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
+ import { recordSessionHeartbeat, appendTokenSample, readActiveSessions, readSessionTimeseries, pruneStaleSessions, pruneTokenSamples, clearSessionHeartbeat, closeIndexStore, } from "./global-index.js";
19
+ describe("global-index session time-series (S39)", () => {
20
+ let indexDir;
21
+ let eventsLogPath;
22
+ before(() => {
23
+ indexDir = mkdtempSync(join(tmpdir(), "mc-global-idx-"));
24
+ eventsLogPath = join(indexDir, "events.log");
25
+ });
26
+ after(() => {
27
+ closeIndexStore();
28
+ rmSync(indexDir, { recursive: true, force: true });
29
+ });
30
+ it("recordSessionHeartbeat + readActiveSessions: heartbeat row appears", () => {
31
+ recordSessionHeartbeat(12_345, "sess-abc-1", "/home/u/repos/proj-a", "/home/u/.pi/state-a", 200_000, indexDir);
32
+ const sessions = readActiveSessions(indexDir);
33
+ assert.equal(sessions.length, 1);
34
+ const s = sessions[0];
35
+ assert.equal(s.pid, 12_345);
36
+ assert.equal(s.sessionId, "sess-abc-1");
37
+ assert.equal(s.repoRoot, "/home/u/repos/proj-a");
38
+ assert.equal(s.stateDir, "/home/u/.pi/state-a");
39
+ assert.equal(s.ctxWindow, 200_000);
40
+ assert.ok(s.lastSeen > 0);
41
+ // No token sample yet → tokens/percent are null.
42
+ assert.equal(s.tokens, null);
43
+ assert.equal(s.percent, null);
44
+ });
45
+ it("appendTokenSample writes a token_samples row + a session_sample JSONL line", () => {
46
+ appendTokenSample("sess-abc-1", "/home/u/repos/proj-a", 42_000, 21.5, 200_000, eventsLogPath, indexDir);
47
+ // The token_samples row should now be the latest sample for this session.
48
+ const sessions = readActiveSessions(indexDir);
49
+ const s = sessions.find((x) => x.sessionId === "sess-abc-1");
50
+ assert.ok(s, "session must still appear in readActiveSessions");
51
+ assert.equal(s.tokens, 42_000);
52
+ assert.equal(s.percent, 21.5);
53
+ // The events.log JSONL line should exist and match the wire format
54
+ // described by the SseSessionSample contract: an object with `ts`
55
+ // (ISO 8601 string), `type: 'session_sample'`, and the payload fields.
56
+ assert.ok(existsSync(eventsLogPath), "events.log should exist");
57
+ const lines = readFileSync(eventsLogPath, "utf-8")
58
+ .trim()
59
+ .split("\n")
60
+ .filter(Boolean);
61
+ assert.equal(lines.length, 1, "exactly one JSONL line expected");
62
+ const parsed = JSON.parse(lines[0]);
63
+ assert.equal(parsed["type"], "session_sample");
64
+ assert.equal(parsed["sessionId"], "sess-abc-1");
65
+ assert.equal(parsed["tokens"], 42_000);
66
+ assert.equal(parsed["percent"], 21.5);
67
+ // Step 5 contract mirroring: `ts` is an ISO 8601 string (matches every
68
+ // other SseEvent variant's `ts: string` and the DashboardEmitter
69
+ // events.log pattern), NOT a numeric ms value.
70
+ assert.equal(typeof parsed["ts"], "string", "ts must be an ISO 8601 string (matches other SseEvent variants)");
71
+ // Must be ISO-parseable (NaN if invalid → typeof won't be number).
72
+ const tsMs = Date.parse(parsed["ts"]);
73
+ assert.ok(Number.isFinite(tsMs), "ts must be a valid ISO 8601 timestamp");
74
+ // And recent (within the last 5s).
75
+ assert.ok(Math.abs(Date.now() - tsMs) < 5_000, "ts must be within ~5s of now");
76
+ });
77
+ it("readSessionTimeseries returns per-session series + totals in chronological order", () => {
78
+ // Two sessions, two samples each — fully within the rolling window.
79
+ // sess-xyz-9 needs its own heartbeat row so readActiveSessions can later
80
+ // surface it (heartbeats are the primary key, not the samples).
81
+ recordSessionHeartbeat(67_890, "sess-xyz-9", "/home/u/repos/proj-b", "/home/u/.pi/state-b", 200_000, indexDir);
82
+ appendTokenSample("sess-abc-1", "/home/u/repos/proj-a", 50_000, 25, 200_000, null, // no events.log line for this assertion
83
+ indexDir);
84
+ appendTokenSample("sess-xyz-9", "/home/u/repos/proj-b", 10_000, 5, 200_000, null, indexDir);
85
+ appendTokenSample("sess-xyz-9", "/home/u/repos/proj-b", 30_000, 15, 200_000, null, indexDir);
86
+ const since = Date.now() - 60_000;
87
+ const result = readSessionTimeseries(since, indexDir);
88
+ const seriesIds = result.series.map((s) => s.sessionId).sort();
89
+ assert.deepEqual(seriesIds, ["sess-abc-1", "sess-xyz-9"]);
90
+ const xyz = result.series.find((s) => s.sessionId === "sess-xyz-9");
91
+ assert.ok(xyz, "sess-xyz-9 series must exist");
92
+ assert.equal(xyz.data.length, 2);
93
+ assert.equal(xyz.data[0].tokens, 10_000);
94
+ assert.equal(xyz.data[1].tokens, 30_000);
95
+ // Stable hash-based color from the SESSION_COLORS palette.
96
+ assert.match(xyz.color, /^#[0-9a-f]{6}$/i);
97
+ // Totals: each timestamp summed across sessions.
98
+ assert.ok(result.totals.length >= 2);
99
+ for (const t of result.totals) {
100
+ assert.ok(typeof t.ts === "number" && t.ts > 0);
101
+ assert.ok(typeof t.tokens === "number" && t.tokens >= 0);
102
+ }
103
+ // Totals are sorted ascending by ts.
104
+ for (let i = 1; i < result.totals.length; i++) {
105
+ assert.ok(result.totals[i].ts >= result.totals[i - 1].ts, "totals must be chronologically ascending");
106
+ }
107
+ });
108
+ it("pruneStaleSessions clears heartbeats older than the cutoff (and only those)", () => {
109
+ // At this point we have 2 sessions; both are fresh (lastSeen ≈ now).
110
+ const beforeCount = readActiveSessions(indexDir).length;
111
+ assert.equal(beforeCount, 2);
112
+ // With a 0 ms cutoff, both are stale → pruned.
113
+ const pruned = pruneStaleSessions(0, indexDir);
114
+ assert.equal(pruned, 2, "both heartbeats should be pruned");
115
+ assert.equal(readActiveSessions(indexDir).length, 0);
116
+ });
117
+ it("pruneTokenSamples clears token_samples older than the cutoff", () => {
118
+ // Re-seed two samples; the previously-written ones were for two
119
+ // sessions (sess-abc-1 + sess-xyz-9). The prunedStaleSessions call
120
+ // above left token_samples untouched (separate table).
121
+ const sinceBefore = Date.now() - 60_000;
122
+ const before = readSessionTimeseries(sinceBefore, indexDir);
123
+ const beforeCount = before.series.reduce((n, s) => n + s.data.length, 0);
124
+ assert.ok(beforeCount >= 3, "expected to find the seeded samples");
125
+ const npurged = pruneTokenSamples(0, indexDir);
126
+ assert.ok(npurged >= 3, "should have pruned the seeded samples");
127
+ const after = readSessionTimeseries(sinceBefore, indexDir);
128
+ const afterCount = after.series.reduce((n, s) => n + s.data.length, 0);
129
+ assert.equal(afterCount, 0, "no samples should remain after pruning");
130
+ });
131
+ it("clearSessionHeartbeat is a no-op on missing rows (never throws)", () => {
132
+ const before = readActiveSessions(indexDir).length;
133
+ assert.doesNotThrow(() => clearSessionHeartbeat(99_999, "nonexistent-session", indexDir));
134
+ assert.equal(readActiveSessions(indexDir).length, before);
135
+ });
136
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Global machine-wide index store (Phase 5b).
3
+ *
4
+ * A single SQLite DB, separate from every per-repo store, that aggregates one
5
+ * row per repo this machine has run on. The multi-repo dashboard (Summary /
6
+ * All-repos tabs) reads it so ONE dashboard can show every repo's checkpoints,
7
+ * tokens saved, and active model — instead of a per-repo dashboard that only
8
+ * ever sees the repo it was launched from.
9
+ *
10
+ * Written by every pi process on repo-switch (bindRepo) + model capture; read by
11
+ * the dashboard server. Concurrency across 10+ pi processes is handled by WAL +
12
+ * infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
13
+ */
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { existsSync, mkdirSync } from "node:fs";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ /** Resolve the machine-wide index directory (env-overridable). */
19
+ export function getIndexDir() {
20
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
21
+ if (override && override.trim() !== "")
22
+ return override;
23
+ // homedir() can throw in exotic sandboxes; fall back to tmpdir.
24
+ try {
25
+ return join(homedir(), ".mega-compact-index");
26
+ }
27
+ catch {
28
+ return join(tmpdir(), ".mega-compact-index");
29
+ }
30
+ }
31
+ let indexCache;
32
+ let indexCacheDir;
33
+ /** Open (or reuse) the machine-wide index DB. WAL for concurrent writers. */
34
+ export function openIndexStore(indexDir = getIndexDir()) {
35
+ if (indexCache && indexCacheDir === indexDir)
36
+ return indexCache;
37
+ if (!existsSync(indexDir))
38
+ mkdirSync(indexDir, { recursive: true });
39
+ const iddb = new DatabaseSync(join(indexDir, "index.sqlite"));
40
+ iddb.exec("PRAGMA journal_mode = WAL");
41
+ iddb.exec("PRAGMA busy_timeout = 3000"); // tolerate brief cross-process write contention
42
+ iddb.exec(`
43
+ CREATE TABLE IF NOT EXISTS repo_registry (
44
+ repo_root TEXT PRIMARY KEY,
45
+ display_name TEXT,
46
+ state_dir TEXT NOT NULL,
47
+ first_seen INTEGER,
48
+ last_seen INTEGER,
49
+ last_compacted_at INTEGER,
50
+ checkpoint_count INTEGER DEFAULT 0,
51
+ tokens_saved INTEGER DEFAULT 0,
52
+ compressed_original_bytes INTEGER DEFAULT 0,
53
+ provider TEXT,
54
+ provider_name TEXT,
55
+ model_name TEXT,
56
+ input_rate REAL,
57
+ output_rate REAL,
58
+ model_captured_at INTEGER
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
61
+ `);
62
+ indexCache = iddb;
63
+ indexCacheDir = indexDir;
64
+ return iddb;
65
+ }
66
+ /**
67
+ * Upsert a repo's aggregate stats into the global index. Called on repo-switch
68
+ * (infrequent). Preserves first_seen + the model columns on update (model is
69
+ * written separately by recordRepoModel so we never clobber it here with nulls).
70
+ */
71
+ export function upsertRepoRegistry(row, indexDir = getIndexDir()) {
72
+ const db = openIndexStore(indexDir);
73
+ const now = Date.now();
74
+ db.prepare(`INSERT INTO repo_registry
75
+ (repo_root, display_name, state_dir, first_seen, last_seen, last_compacted_at,
76
+ checkpoint_count, tokens_saved, compressed_original_bytes)
77
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now, @last_compacted_at,
78
+ @checkpoint_count, @tokens_saved, @compressed_original_bytes)
79
+ ON CONFLICT(repo_root) DO UPDATE SET
80
+ display_name = excluded.display_name,
81
+ state_dir = excluded.state_dir,
82
+ last_seen = excluded.last_seen,
83
+ last_compacted_at = COALESCE(excluded.last_compacted_at, repo_registry.last_compacted_at),
84
+ checkpoint_count = excluded.checkpoint_count,
85
+ tokens_saved = excluded.tokens_saved,
86
+ compressed_original_bytes = excluded.compressed_original_bytes`).run({
87
+ repo_root: row.repoRoot,
88
+ display_name: row.displayName,
89
+ state_dir: row.stateDir,
90
+ now,
91
+ last_compacted_at: row.lastCompactedAt ?? null,
92
+ checkpoint_count: row.checkpointCount,
93
+ tokens_saved: row.tokensSaved,
94
+ compressed_original_bytes: row.compressedOriginalBytes,
95
+ });
96
+ }
97
+ /**
98
+ * Record the active model/provider for a repo in the global index (denormalized
99
+ * so the All-repos table shows model without opening each repo's DB). Upserts a
100
+ * bare registry row if the repo isn't registered yet.
101
+ */
102
+ export function recordRepoModel(repoRoot, model, indexDir = getIndexDir()) {
103
+ const db = openIndexStore(indexDir);
104
+ const now = Date.now();
105
+ db.prepare(`INSERT INTO repo_registry
106
+ (repo_root, display_name, state_dir, first_seen, last_seen,
107
+ provider, provider_name, model_name, input_rate, output_rate, model_captured_at)
108
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now,
109
+ @provider, @provider_name, @model_name, @input_rate, @output_rate, @now)
110
+ ON CONFLICT(repo_root) DO UPDATE SET
111
+ last_seen = excluded.last_seen,
112
+ provider = excluded.provider,
113
+ provider_name = excluded.provider_name,
114
+ model_name = excluded.model_name,
115
+ input_rate = excluded.input_rate,
116
+ output_rate = excluded.output_rate,
117
+ model_captured_at = excluded.model_captured_at`).run({
118
+ repo_root: repoRoot,
119
+ display_name: model.displayName,
120
+ state_dir: model.stateDir,
121
+ now,
122
+ provider: model.provider,
123
+ provider_name: model.providerName,
124
+ model_name: model.modelName,
125
+ input_rate: model.inputRate,
126
+ output_rate: model.outputRate,
127
+ });
128
+ }
129
+ function mapRegistryRow(row) {
130
+ return {
131
+ repoRoot: row.repo_root,
132
+ displayName: row.display_name ?? "",
133
+ stateDir: row.state_dir,
134
+ firstSeen: row.first_seen ?? 0,
135
+ lastSeen: row.last_seen ?? 0,
136
+ lastCompactedAt: row.last_compacted_at ?? null,
137
+ checkpointCount: row.checkpoint_count ?? 0,
138
+ tokensSaved: row.tokens_saved ?? 0,
139
+ compressedOriginalBytes: row.compressed_original_bytes ?? 0,
140
+ provider: row.provider ?? null,
141
+ providerName: row.provider_name ?? null,
142
+ modelName: row.model_name ?? null,
143
+ inputRate: row.input_rate ?? null,
144
+ outputRate: row.output_rate ?? null,
145
+ modelCapturedAt: row.model_captured_at ?? null,
146
+ };
147
+ }
148
+ /** All registered repos, most-recently-seen first. */
149
+ export function listRepoRegistry(indexDir = getIndexDir()) {
150
+ const db = openIndexStore(indexDir);
151
+ const rows = db.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC").all();
152
+ return rows.map(mapRegistryRow);
153
+ }
154
+ /** A single repo's registry row, or undefined. */
155
+ export function getRepoRegistry(repoRoot, indexDir = getIndexDir()) {
156
+ const db = openIndexStore(indexDir);
157
+ const row = db.prepare("SELECT * FROM repo_registry WHERE repo_root = ?").get(repoRoot);
158
+ return row ? mapRegistryRow(row) : undefined;
159
+ }
160
+ /** Close the cached index connection (test teardown only). */
161
+ export function closeIndexStore() {
162
+ if (indexCache) {
163
+ indexCache.close();
164
+ indexCache = undefined;
165
+ indexCacheDir = undefined;
166
+ }
167
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Durable "save to memory" store (taken over from memory extensions).
3
+ *
4
+ * One SQLite store for user-saved memories, scoped by repo. Mirrors the
5
+ * lessons/sessions pattern: all state lives in SQLite from day one. All queries
6
+ * are parameterized (PREVENT-002).
7
+ */
8
+ import { getStateDir } from "../../store.js";
9
+ import { openStore } from "./connection.js";
10
+ /** Save a memory to the current repo's store. Returns the new row id. */
11
+ export function addMemory(memory, repo, stateDir = getStateDir()) {
12
+ const db = openStore(stateDir);
13
+ const now = Math.floor(Date.now() / 1000);
14
+ const res = db
15
+ .prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
16
+ VALUES(?, ?, ?, ?, ?, NULL)`)
17
+ .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
18
+ return Number(res.lastInsertRowid);
19
+ }
20
+ /** List recent memories for a repo (or all repos when repo is null). */
21
+ export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
22
+ const db = openStore(stateDir);
23
+ const rows = repo
24
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
25
+ : db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
26
+ return rows.map(mapMemoryRow);
27
+ }
28
+ /** Substring search across content + tags. */
29
+ export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
30
+ const db = openStore(stateDir);
31
+ const like = `%${query}%`;
32
+ const rows = repo
33
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
34
+ : db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
35
+ return rows.map(mapMemoryRow);
36
+ }
37
+ /** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
38
+ export function recallMemory(id, stateDir = getStateDir()) {
39
+ const db = openStore(stateDir);
40
+ const now = Math.floor(Date.now() / 1000);
41
+ const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
42
+ return res.changes > 0;
43
+ }
44
+ function mapMemoryRow(row) {
45
+ return {
46
+ id: row.id,
47
+ repo: row.repo ?? null,
48
+ kind: row.kind ?? "note",
49
+ content: row.content ?? "",
50
+ tags: row.tags ? JSON.parse(row.tags) : [],
51
+ createdAt: row.created_at ?? 0,
52
+ lastRecalledAt: row.last_recalled_at ?? null,
53
+ };
54
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Sprint 11: MinHash signatures + LSH bucket persistence and candidate lookup.
3
+ *
4
+ * All queries are parameterized (PREVENT-002) — never string-concatenated.
5
+ */
6
+ import { getStateDir, normalizeSessionId } from "../../store.js";
7
+ import { openStore } from "./connection.js";
8
+ import { withTx } from "./transaction.js";
9
+ /** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
10
+ export function upsertMinhashSignature(chunkId, sessionId, signatureVersion, signatures, stateDir = getStateDir()) {
11
+ const db = openStore(stateDir);
12
+ const sid = normalizeSessionId(sessionId);
13
+ db.prepare(`INSERT INTO minhash_signatures(chunk_id, session_id, signature_version, signatures)
14
+ VALUES(?, ?, ?, ?)
15
+ ON CONFLICT(chunk_id, signature_version) DO UPDATE SET
16
+ session_id=excluded.session_id, signatures=excluded.signatures`).run(chunkId, sid, signatureVersion, JSON.stringify(signatures));
17
+ }
18
+ /** Persist LSH bucket memberships for a chunk (one row per bucket key). */
19
+ export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKeys, stateDir = getStateDir()) {
20
+ const db = openStore(stateDir);
21
+ const sid = normalizeSessionId(sessionId);
22
+ const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
23
+ const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
24
+ withTx(db, () => {
25
+ del.run(chunkId);
26
+ for (const key of bucketKeys)
27
+ ins.run(key, chunkId, sid, signatureVersion);
28
+ });
29
+ }
30
+ /**
31
+ * Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
32
+ * session, capped at `limit`. Single query (no N loops) — QA #15 amplification
33
+ * guard. Returns DISTINCT chunk_ids excluding `excludeChunkId` (the new row).
34
+ */
35
+ export function lshCandidateChunks(bucketKeys, sessionId, excludeChunkId, stateDir = getStateDir(), limit = 100) {
36
+ if (bucketKeys.length === 0)
37
+ return [];
38
+ const db = openStore(stateDir);
39
+ const sid = normalizeSessionId(sessionId);
40
+ const placeholders = bucketKeys.map(() => "?").join(",");
41
+ const rows = db
42
+ .prepare(`SELECT DISTINCT chunk_id FROM dedup_lsh_buckets
43
+ WHERE bucket_key IN (${placeholders}) AND session_id = ? AND chunk_id != ?
44
+ LIMIT ?`)
45
+ .all(...bucketKeys, sid, excludeChunkId, limit);
46
+ return rows.map((r) => r.chunk_id);
47
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Future-feature foundation: resume sessions, daily activity log, lessons learned.
3
+ *
4
+ * Scaffolded tables + minimal helpers so all store data lives in SQLite from
5
+ * day one. Full UI/recall for these lands in later sprints. All queries are
6
+ * parameterized (PREVENT-002).
7
+ */
8
+ import { getStateDir, normalizeSessionId } from "../../store.js";
9
+ import { openStore } from "./connection.js";
10
+ /** Upsert a `sessions` row (resume + per-repo session history). */
11
+ export function touchSession(sessionId, repo, stateDir = getStateDir()) {
12
+ const db = openStore(stateDir);
13
+ const sid = normalizeSessionId(sessionId);
14
+ const existing = db
15
+ .prepare("SELECT started_at FROM sessions WHERE session_id = ?")
16
+ .get(sid);
17
+ const now = Math.floor(Date.now() / 1000);
18
+ if (!existing) {
19
+ db.prepare(`INSERT INTO sessions(session_id, repo, started_at, last_compacted_at, status)
20
+ VALUES(?, ?, ?, ?, 'active')`).run(sid, repo ?? null, now, now);
21
+ }
22
+ else {
23
+ db.prepare("UPDATE sessions SET last_compacted_at = ?, repo = COALESCE(?, repo), status = 'active' WHERE session_id = ?").run(now, repo ?? null, sid);
24
+ }
25
+ }
26
+ /** Append a `daily_log` entry (day = YYYY-MM-DD, local-naive from Date). */
27
+ export function logDaily(sessionId, event, detail, tokensSaved, stateDir = getStateDir()) {
28
+ const db = openStore(stateDir);
29
+ const day = new Date().toISOString().slice(0, 10);
30
+ const now = Math.floor(Date.now() / 1000);
31
+ db.prepare(`INSERT INTO daily_log(day, session_id, event, detail, tokens_saved, ts)
32
+ VALUES(?, ?, ?, ?, ?, ?)`).run(day, normalizeSessionId(sessionId), event, detail ?? null, tokensSaved, now);
33
+ }
34
+ /** Append a `lessons` entry (future lessons-learned browse/recall). */
35
+ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
36
+ const db = openStore(stateDir);
37
+ const now = Math.floor(Date.now() / 1000);
38
+ db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
39
+ }