pi-mega-compact 0.7.8 → 0.7.9

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 (112) hide show
  1. package/README.md +11 -12
  2. package/dist/extensions/dashboard-server/helpers.js +37 -0
  3. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  4. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  5. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  6. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  7. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  8. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  9. package/dist/extensions/dashboard-server/html/script.js +259 -0
  10. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  11. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  12. package/dist/extensions/dashboard-server/html-template.js +41 -0
  13. package/dist/extensions/dashboard-server/html.js +756 -0
  14. package/dist/extensions/dashboard-server/index-reader.js +133 -0
  15. package/dist/extensions/dashboard-server/server.js +370 -0
  16. package/dist/extensions/dashboard-server/snapshot.js +43 -0
  17. package/dist/extensions/dashboard-server/state.js +30 -0
  18. package/dist/extensions/dashboard-server/types.js +5 -0
  19. package/dist/extensions/dashboard-server.js +7 -1315
  20. package/dist/extensions/mega-commands.js +162 -134
  21. package/dist/extensions/mega-compact.test.js +90 -21
  22. package/dist/extensions/mega-conflict-cmds.js +5 -1
  23. package/dist/extensions/mega-dashboard-cmds.js +29 -22
  24. package/dist/extensions/mega-db-cmds.js +11 -2
  25. package/dist/extensions/mega-events/agent-handlers.js +173 -0
  26. package/dist/extensions/mega-events/compact-handlers.js +133 -0
  27. package/dist/extensions/mega-events/context-handler.js +249 -0
  28. package/dist/extensions/mega-events/register.js +21 -0
  29. package/dist/extensions/mega-events/session-handlers.js +142 -0
  30. package/dist/extensions/mega-events.js +15 -699
  31. package/dist/extensions/mega-pipeline/compact.js +324 -0
  32. package/dist/extensions/mega-pipeline/memory-review.js +38 -0
  33. package/dist/extensions/mega-pipeline/recall.js +147 -0
  34. package/dist/extensions/mega-pipeline.js +9 -480
  35. package/dist/extensions/mega-runtime/helpers.js +40 -0
  36. package/dist/extensions/mega-runtime/query.js +29 -0
  37. package/dist/extensions/mega-runtime/state.js +711 -0
  38. package/dist/extensions/mega-runtime/widget.js +197 -0
  39. package/dist/extensions/mega-runtime.js +15 -947
  40. package/dist/src/store/sqlite/checkpoints.js +145 -0
  41. package/dist/src/store/sqlite/connection.js +35 -0
  42. package/dist/src/store/sqlite/dedup-mirror.js +64 -0
  43. package/dist/src/store/sqlite/foundation.js +38 -0
  44. package/dist/src/store/sqlite/global-index.js +224 -0
  45. package/dist/src/store/sqlite/index-store.js +167 -0
  46. package/dist/src/store/sqlite/maintenance.js +235 -0
  47. package/dist/src/store/sqlite/memories.js +164 -0
  48. package/dist/src/store/sqlite/memory.js +54 -0
  49. package/dist/src/store/sqlite/meta.js +82 -0
  50. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  51. package/dist/src/store/sqlite/model-snapshots.js +47 -0
  52. package/dist/src/store/sqlite/raptor.js +57 -0
  53. package/dist/src/store/sqlite/raw-transcript.js +134 -0
  54. package/dist/src/store/sqlite/schema.js +250 -0
  55. package/dist/src/store/sqlite/session-state.js +28 -0
  56. package/dist/src/store/sqlite/sessions.js +39 -0
  57. package/dist/src/store/sqlite/stats.js +66 -0
  58. package/dist/src/store/sqlite/transaction.js +19 -0
  59. package/dist/src/store/sqlite/utils.js +120 -0
  60. package/dist/src/store/sqlite.js +20 -1607
  61. package/dist/src/vectorStore/add.js +260 -0
  62. package/dist/src/vectorStore/dedup.js +52 -0
  63. package/dist/src/vectorStore/index.js +10 -0
  64. package/dist/src/vectorStore/queries.js +83 -0
  65. package/dist/src/vectorStore/search.js +95 -0
  66. package/dist/src/vectorStore/session.js +19 -0
  67. package/dist/src/vectorStore/store.js +105 -0
  68. package/dist/src/vectorStore/types.js +6 -0
  69. package/dist/src/vectorStore/utils.js +23 -0
  70. package/extensions/dashboard-server/html.ts +758 -0
  71. package/extensions/dashboard-server/index-reader.ts +130 -0
  72. package/extensions/dashboard-server/server.ts +358 -0
  73. package/extensions/dashboard-server/snapshot.ts +44 -0
  74. package/extensions/dashboard-server/state.ts +33 -0
  75. package/extensions/dashboard-server/types.ts +134 -0
  76. package/extensions/dashboard-server.ts +7 -1431
  77. package/extensions/mega-commands.ts +33 -10
  78. package/extensions/mega-compact.test.ts +198 -43
  79. package/extensions/mega-conflict-cmds.ts +6 -2
  80. package/extensions/mega-dashboard-cmds.ts +30 -23
  81. package/extensions/mega-db-cmds.ts +11 -3
  82. package/extensions/mega-events/agent-handlers.ts +214 -0
  83. package/extensions/mega-events/compact-handlers.ts +164 -0
  84. package/extensions/mega-events/context-handler.ts +290 -0
  85. package/extensions/mega-events/register.ts +37 -0
  86. package/extensions/mega-events/session-handlers.ts +165 -0
  87. package/extensions/mega-events.ts +15 -780
  88. package/extensions/mega-pipeline/compact.ts +366 -0
  89. package/extensions/mega-pipeline/memory-review.ts +46 -0
  90. package/extensions/mega-pipeline/recall.ts +165 -0
  91. package/extensions/mega-pipeline.ts +9 -537
  92. package/extensions/mega-runtime/helpers.ts +68 -0
  93. package/extensions/mega-runtime/query.ts +29 -0
  94. package/extensions/mega-runtime/state.ts +797 -0
  95. package/extensions/mega-runtime/widget.ts +258 -0
  96. package/extensions/mega-runtime.ts +15 -1093
  97. package/package.json +4 -3
  98. package/src/store/sqlite/checkpoints.ts +204 -0
  99. package/src/store/sqlite/dedup-mirror.ts +114 -0
  100. package/src/store/sqlite/foundation.ts +63 -0
  101. package/src/store/sqlite/global-index.ts +305 -0
  102. package/src/store/sqlite/maintenance.ts +294 -0
  103. package/src/store/sqlite/memories.ts +217 -0
  104. package/src/store/sqlite/meta.ts +108 -0
  105. package/src/store/sqlite/model-snapshots.ts +83 -0
  106. package/src/store/sqlite/raptor.ts +107 -0
  107. package/src/store/sqlite/raw-transcript.ts +221 -0
  108. package/src/store/sqlite/schema.ts +258 -0
  109. package/src/store/sqlite/session-state.ts +38 -0
  110. package/src/store/sqlite/stats.ts +127 -0
  111. package/src/store/sqlite/utils.ts +125 -0
  112. package/src/store/sqlite.ts +20 -2204
@@ -0,0 +1,130 @@
1
+ /**
2
+ * dashboard-server/index-reader.ts — machine-wide repo registry reader.
3
+ *
4
+ * The extension writes a machine-wide repo registry into a single SQLite DB
5
+ * (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
6
+ * reads that table directly (one read-only connection, opened per request so a
7
+ * concurrent writer's WAL never blocks the request). All registry data lives in
8
+ * SQLite (the project's one-store invariant) — there is no JSON mirror. Same
9
+ * index-dir resolution as src/store/sqlite.ts getIndexDir().
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { DatabaseSync } from "node:sqlite";
16
+ import type { IndexRepo, IndexIndex } from "./types.js";
17
+
18
+ export function getIndexDir(): string {
19
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
20
+ if (override && override.trim() !== "") return override;
21
+ try {
22
+ return join(homedir(), ".mega-compact-index");
23
+ } catch {
24
+ return join("/tmp", ".mega-compact-index");
25
+ }
26
+ }
27
+
28
+ /** Read the machine-wide repo registry from SQLite (read-only, single shot). */
29
+ export function readIndex(): IndexIndex | null {
30
+ const indexPath = join(getIndexDir(), "index.sqlite");
31
+ if (!existsSync(indexPath)) return null;
32
+ let db: DatabaseSync | undefined;
33
+ try {
34
+ // Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
35
+ db = new DatabaseSync(indexPath, { readOnly: true });
36
+ db.exec("PRAGMA journal_mode = WAL");
37
+ const rows = db
38
+ .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
39
+ .all() as Record<string, unknown>[];
40
+ const mapped: IndexRepo[] = rows.map((r) => ({
41
+ repoRoot: String(r.repo_root ?? ""),
42
+ displayName: String(r.display_name ?? ""),
43
+ stateDir: String(r.state_dir ?? ""),
44
+ checkpointCount: Number(r.checkpoint_count ?? 0),
45
+ tokensSaved: Number(r.tokens_saved ?? 0),
46
+ compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
47
+ lastCompactedAt: (r.last_compacted_at as number | null) ?? null,
48
+ provider: (r.provider as string | null) ?? null,
49
+ providerName: (r.provider_name as string | null) ?? null,
50
+ modelName: (r.model_name as string | null) ?? null,
51
+ inputRate: (r.input_rate as number | null) ?? null,
52
+ outputRate: (r.output_rate as number | null) ?? null,
53
+ lastSeen: Number(r.last_seen ?? 0),
54
+ // Defaults — enriched below from each repo's own store.
55
+ tokensKept: 0,
56
+ tokensDropped: 0,
57
+ sessions: 0,
58
+ contextWindow: null,
59
+ maxTokens: null,
60
+ reasoning: null,
61
+ }));
62
+ // Enrich each repo with per-store token + model detail read directly via
63
+ // node:sqlite (same zero-dependency invariant as readIndex; no store graph
64
+ // import). Best-effort: a missing/corrupt store degrades to the defaults
65
+ // above so the dashboard never fails to render.
66
+ for (const repo of mapped) {
67
+ try {
68
+ const storePath = join(repo.stateDir, "sqlite.db");
69
+ if (existsSync(storePath)) {
70
+ const sdb = new DatabaseSync(storePath, { readOnly: true });
71
+ try {
72
+ const tok = sdb
73
+ .prepare(
74
+ `SELECT COALESCE(SUM(token_estimate),0) AS kept,
75
+ COALESCE(SUM(original_token_estimate),0) AS dropped,
76
+ COUNT(DISTINCT session_id) AS sess
77
+ FROM context_chunks WHERE dedup_status != 'removed'`,
78
+ )
79
+ .get() as { kept: number; dropped: number; sess: number };
80
+ repo.tokensKept = Number(tok.kept ?? 0);
81
+ repo.tokensDropped = Number(tok.dropped ?? 0);
82
+ repo.sessions = Number(tok.sess ?? 0);
83
+ const mrow = sdb
84
+ .prepare(
85
+ `SELECT context_window, max_tokens, reasoning
86
+ FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`,
87
+ )
88
+ .get() as { context_window: number; max_tokens: number; reasoning: number } | undefined;
89
+ if (mrow) {
90
+ repo.contextWindow = Number(mrow.context_window ?? 0) || null;
91
+ repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
92
+ repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
93
+ }
94
+ } finally {
95
+ sdb.close();
96
+ }
97
+ }
98
+ } catch {
99
+ /* best-effort — keep the defaults */
100
+ }
101
+ }
102
+ // Defensive display hygiene (belt-and-suspenders — the real fix is that
103
+ // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
104
+ // paths that should never have been real repos, and collapse duplicate
105
+ // display names to the most-recently-seen row (rows are last_seen DESC, so
106
+ // the first occurrence wins). Keeps the All-repos list readable.
107
+ const isTransient = (p: string) =>
108
+ /^\/tmp\//.test(p) || /^\/private\/tmp\//.test(p) || /^\/var\/folders\//.test(p) ||
109
+ /\/mc-(ext|e2e|resume|recall)-/.test(p);
110
+ const seenName = new Set<string>();
111
+ const repos: IndexRepo[] = [];
112
+ for (const r of mapped) {
113
+ if (isTransient(r.repoRoot)) continue;
114
+ if (seenName.has(r.displayName)) continue;
115
+ seenName.add(r.displayName);
116
+ repos.push(r);
117
+ }
118
+ const summary = {
119
+ totalRepos: repos.length,
120
+ totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
121
+ totalTokensSaved: repos.reduce((a, r) => a + r.tokensSaved, 0),
122
+ totalCompressedOriginalBytes: repos.reduce((a, r) => a + r.compressedOriginalBytes, 0),
123
+ };
124
+ return { updatedAt: new Date().toISOString(), summary, repos };
125
+ } catch {
126
+ return null;
127
+ } finally {
128
+ try { db?.close(); } catch { /* ignore */ }
129
+ }
130
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
3
+ */
4
+
5
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { createRequire } from "node:module";
10
+
11
+ import { log, setLogPath, setDashboardServerVersion } from "./state.js";
12
+ import { readIndex, getIndexDir } from "./index-reader.js";
13
+ import { readSnapshot, readFrom } from "./snapshot.js";
14
+ import { dashboardHtml } from "./html.js";
15
+ import { ACTIVE_WINDOW_SEC } from "./types.js";
16
+ import type { IndexIndex, Snapshot, LiveSnapshot } from "./types.js";
17
+
18
+ export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
19
+ // Our own package version — exposed at /api/version so the launcher can
20
+ // detect a stale server (started by an older build) and replace it on
21
+ // upgrade instead of reuse it.
22
+ let SERVER_VERSION = "0.0.0";
23
+ try {
24
+ // dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
25
+ // two levels up. Guard each candidate so a dev-checkout layout still works.
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
28
+ for (const p of candidates) {
29
+ if (!existsSync(p)) continue;
30
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
31
+ if (pkg.version) { SERVER_VERSION = pkg.version; setDashboardServerVersion(pkg.version); break; }
32
+ }
33
+ } catch { /* non-fatal */ }
34
+
35
+ // Lazy-loaded via require so the dashboard stays cheap to boot and we don't
36
+ // need a top-level await in the handler.
37
+ const driftReq = createRequire(import.meta.url);
38
+ const detectCrossRepoDrift = (idxDir: string) =>
39
+ (driftReq("../../src/driftDetection.js") as typeof import("../../src/driftDetection.js"))
40
+ .detectCrossRepoDrift(idxDir);
41
+ const portFile = join(stateDir, "port.pid");
42
+ const snapshotPath = join(stateDir, "dashboard.json");
43
+ const eventsPath = join(stateDir, "events.log");
44
+ setLogPath(join(stateDir, "dashboard.log"));
45
+ log("launch invoked", { stateDir });
46
+
47
+ // ── Existing server? ───────────────────────────────────────────────────────
48
+ // A stale port.pid pointing at a dead/competing process is the classic cause
49
+ // of "dashboard failed to start" — we return a port that is NOT actually
50
+ // serving. Probe for a live server on that port first; only reuse the marker
51
+ // when something real answers /api/version. Otherwise drop it and start fresh.
52
+ if (existsSync(portFile)) {
53
+ try {
54
+ const info = JSON.parse(readFileSync(portFile, "utf-8"));
55
+ if (info && info.port) {
56
+ let live = false;
57
+ try {
58
+ const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: optional localhost dashboard server probe (loopback-only)
59
+ live = probe.ok;
60
+ } catch {
61
+ live = false;
62
+ }
63
+ if (live) {
64
+ log("reusing live server from port.pid", { port: info.port });
65
+ return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
66
+ }
67
+ log("port.pid present but no live server — treating as stale", { port: info.port });
68
+ }
69
+ } catch {
70
+ log("port.pid unparseable — treating as stale");
71
+ }
72
+ // stale file, remove so the fresh bind does not collide with a lingering
73
+ // process that still holds the port
74
+ try { unlinkSync(portFile); } catch { /* ignore */ }
75
+ }
76
+
77
+ // ── New server ────────────────────────────────────────────────────────────
78
+ mkdirSync(stateDir, { recursive: true });
79
+
80
+ let eventOffset = 0;
81
+
82
+ // Overlay the live current-repo snapshot (snapshot.json, rewritten every
83
+ // context event) onto its registry row so the All-repos / Summary views stay
84
+ // in sync with the live menu bar + Current-repo card in real time. The
85
+ // registry (index.sqlite) is only written on repo-switch (bindRepo), so
86
+ // without this the current repo's row freezes between switches. Read-only —
87
+ // no extra writes to index.sqlite. Matched by stateDir, which equals the
88
+ // value this server was launched with (runtime.currentStateDir).
89
+ function overlayCurrentRepo(idx: IndexIndex | null): void {
90
+ if (!idx || !idx.repos.length) return;
91
+ let snap: Snapshot | null = null;
92
+ try { snap = readSnapshot(snapshotPath); } catch { return; }
93
+ if (!snap || !snap.repo) return;
94
+ const cur = idx.repos.find((r) => r.stateDir === stateDir);
95
+ if (!cur) return;
96
+ const prevSaved = cur.tokensSaved;
97
+ const prevCp = cur.checkpointCount;
98
+ const prevBytes = cur.compressedOriginalBytes;
99
+ const comp = snap.compression?.repo;
100
+ const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
101
+ const liveCp = snap.repo.checkpointCount ?? prevCp;
102
+ const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
103
+ cur.tokensSaved = liveSaved;
104
+ cur.checkpointCount = liveCp;
105
+ cur.compressedOriginalBytes = liveBytes;
106
+ if (idx.summary) {
107
+ idx.summary.totalTokensSaved += liveSaved - prevSaved;
108
+ idx.summary.totalCheckpoints += liveCp - prevCp;
109
+ idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
110
+ }
111
+ idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
112
+ }
113
+
114
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
115
+ // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
116
+ // CORS for local access
117
+ res.setHeader("Access-Control-Allow-Origin", "*");
118
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
119
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
120
+
121
+ if (req.method === "OPTIONS") {
122
+ res.writeHead(204);
123
+ res.end();
124
+ return;
125
+ }
126
+
127
+ if (req.url === "/" || req.url === "/index.html") {
128
+ const tier = readSnapshot(snapshotPath).tier;
129
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
130
+ res.end(dashboardHtml(tier));
131
+ return;
132
+ }
133
+
134
+ if (req.url === "/api/snapshot") {
135
+ const snap = readSnapshot(snapshotPath);
136
+ res.writeHead(200, { "Content-Type": "application/json" });
137
+ res.end(JSON.stringify(snap));
138
+ return;
139
+ }
140
+
141
+ // Server version — lets the /dashboard launcher detect a stale server from
142
+ // an older build and replace it on upgrade rather than reuse it.
143
+ if (req.url === "/api/version") {
144
+ res.writeHead(200, { "Content-Type": "application/json" });
145
+ res.end(JSON.stringify({ version: SERVER_VERSION }));
146
+ return;
147
+ }
148
+
149
+ // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
150
+ // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
151
+ // checkpoints, tokens saved, and active model. Read-only.
152
+ if (req.url === "/api/index") {
153
+ const idx = readIndex();
154
+ if (idx) overlayCurrentRepo(idx);
155
+ res.writeHead(200, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
157
+ return;
158
+ }
159
+
160
+ // /api/repos — registry list. Optional `?active=24h` filters to repos
161
+ // seen within the last N hours (default: all). The dashboard uses this to
162
+ // drive its "active vs archived" badge without refetching /api/index.
163
+ if (req.url?.startsWith("/api/repos")) {
164
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
165
+ const activeParam = url.searchParams.get("active");
166
+ const idx = readIndex();
167
+ if (idx) overlayCurrentRepo(idx);
168
+ let repos = idx?.repos ?? [];
169
+ if (activeParam) {
170
+ const m = /^(\d+)h$/.exec(activeParam);
171
+ if (m) {
172
+ const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
173
+ repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
174
+ }
175
+ }
176
+ res.writeHead(200, { "Content-Type": "application/json" });
177
+ res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
178
+ return;
179
+ }
180
+
181
+ // /api/summary — header tiles without the full repo list (keeps payload
182
+ // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
183
+ // count so the dashboard can render the active badge alongside totals.
184
+ if (req.url?.startsWith("/api/summary")) {
185
+ const idx = readIndex();
186
+ if (idx) overlayCurrentRepo(idx);
187
+ const repos = idx?.repos ?? [];
188
+ const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
189
+ const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
190
+ res.writeHead(200, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify({
192
+ updatedAt: idx?.updatedAt ?? null,
193
+ summary: idx?.summary ?? null,
194
+ activeRepos,
195
+ totalRepos: repos.length,
196
+ }));
197
+ return;
198
+ }
199
+
200
+ // /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
201
+ // repos (>30d idle), compaction lag (active but >24h since last
202
+ // compaction), and recent model churn. Read-only.
203
+ if (req.url?.startsWith("/api/drift")) {
204
+ const report = detectCrossRepoDrift(getIndexDir());
205
+ res.writeHead(200, { "Content-Type": "application/json" });
206
+ res.end(JSON.stringify(report));
207
+ return;
208
+ }
209
+
210
+ if (req.url === "/api/servers") {
211
+ try {
212
+ const idx = readIndex();
213
+ const nowSec = Math.floor(Date.now() / 1000);
214
+ const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
215
+ const out: Record<string, unknown> = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
216
+ try { const p = join(r.stateDir, "dashboard.json"); if (existsSync(p)) { const snap = JSON.parse(readFileSync(p, "utf-8")) as LiveSnapshot; out.tier = snap.tier ?? null; out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null; out.state = (snap.session && snap.session.state) || null; out.cacheHits = snap.cacheHits ?? null; out.compacts = snap.compacts ?? null; out.timeSaved = snap.timeSaved ?? null; out.updatedAt = snap.updatedAt ?? null; } } catch { /* best-effort */ }
217
+ return out;
218
+ }).sort((a, b) => (b.lastSeen as number) - (a.lastSeen as number));
219
+ res.writeHead(200, { "Content-Type": "application/json" });
220
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
221
+ } catch {
222
+ res.writeHead(500, { "Content-Type": "application/json" });
223
+ res.end(JSON.stringify({ error: "servers_unavailable" }));
224
+ }
225
+ return;
226
+ }
227
+
228
+ if (req.url === "/api/events") {
229
+ res.writeHead(200, {
230
+ "Content-Type": "text/event-stream",
231
+ "Cache-Control": "no-cache",
232
+ "Connection": "keep-alive",
233
+ });
234
+
235
+ // Drain existing events so the client starts with history
236
+ const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
237
+ eventOffset = initialOffset;
238
+ const lines = existing.split("\n").filter((l: string) => l.trim());
239
+ for (const line of lines) {
240
+ res.write(`data: ${line}\n\n`);
241
+ }
242
+
243
+ // Tail new events via fs.watch (coalesced with 100ms debounce)
244
+ let watchTimer: ReturnType<typeof setTimeout> | null = null;
245
+ const onWatch = () => {
246
+ if (watchTimer) return;
247
+ watchTimer = setTimeout(() => {
248
+ watchTimer = null;
249
+ const { data, offset } = readFrom(eventsPath, eventOffset);
250
+ eventOffset = offset;
251
+ const newLines = data.split("\n").filter((l: string) => l.trim());
252
+ for (const line of newLines) {
253
+ res.write(`data: ${line}\n\n`);
254
+ }
255
+ }, 100);
256
+ };
257
+
258
+ // Set up file watching: if file exists, watch it directly;
259
+ // otherwise poll for creation every 1s then switch to fs.watch.
260
+ let watcher: ReturnType<typeof watch> | null = null;
261
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
262
+
263
+ function startFileWatch(): void {
264
+ try {
265
+ watcher = watch(eventsPath, onWatch);
266
+ } catch { /* give up */ }
267
+ }
268
+
269
+ if (existsSync(eventsPath)) {
270
+ startFileWatch();
271
+ } else {
272
+ pollInterval = setInterval(() => {
273
+ if (existsSync(eventsPath)) {
274
+ if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
275
+ startFileWatch();
276
+ }
277
+ }, 1000);
278
+ }
279
+
280
+ req.on("close", () => {
281
+ if (watchTimer) clearTimeout(watchTimer);
282
+ if (pollInterval) clearInterval(pollInterval);
283
+ watcher?.close();
284
+ });
285
+ return;
286
+ }
287
+
288
+ // Fallback — serve the dashboard
289
+ const tier = readSnapshot(snapshotPath).tier;
290
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
291
+ res.end(dashboardHtml(tier));
292
+ });
293
+
294
+ // Bind base + range are env-configurable so tests can use a private,
295
+ // non-colliding range (parallel runs / leftover servers from killed runs
296
+ // would otherwise EADDRINUSE on the machine-global 9320 range). Default
297
+ // MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
298
+ // production behavior.
299
+ const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
300
+ const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
301
+
302
+ return new Promise((resolve, reject) => {
303
+ function tryPort(port: number) {
304
+ server.once("error", (err: NodeJS.ErrnoException) => {
305
+ if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
306
+ log("port in use, trying next", { port });
307
+ tryPort(port + 1);
308
+ } else {
309
+ log("listen failed", { port, code: err.code, message: err.message });
310
+ reject(err);
311
+ }
312
+ });
313
+
314
+ server.listen(port, "127.0.0.1", () => {
315
+ const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
316
+ log("server running", { url });
317
+ // eslint-disable-next-line no-console
318
+ console.log(`[mega-compact] dashboard server running: ${url}`);
319
+
320
+ // Write port.pid
321
+ try {
322
+ writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
323
+ } catch (e) {
324
+ log("could not write port.pid", { error: String(e) });
325
+ }
326
+
327
+ // Graceful cleanup
328
+ const cleanup = () => {
329
+ try { unlinkSync(portFile); } catch { /* already gone */ }
330
+ server.close();
331
+ process.exit(0);
332
+ };
333
+ process.on("SIGTERM", cleanup);
334
+ process.on("SIGINT", cleanup);
335
+
336
+ resolve({ port, url });
337
+ });
338
+ }
339
+
340
+ tryPort(TARGET_PORT);
341
+ });
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // CLI entry point — when run directly as `node dashboard-server.js <stateDir>`
346
+ // ---------------------------------------------------------------------------
347
+
348
+ if (process.argv[1] && process.argv[1].includes("dashboard-server")) {
349
+ const stateDir = process.argv[2];
350
+ if (!stateDir) {
351
+ console.error("Usage: node dashboard-server.js <stateDir>");
352
+ process.exit(1);
353
+ }
354
+ launchDashboardServer(stateDir).catch((err) => {
355
+ console.error("[mega-compact] dashboard server failed:", err);
356
+ process.exit(1);
357
+ });
358
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * dashboard-server/snapshot.ts — snapshot + events.log file readers.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import type { Snapshot } from "./types.js";
7
+
8
+ export function readSnapshot(snapshotPath: string) {
9
+ try {
10
+ const raw = readFileSync(snapshotPath, "utf-8");
11
+ return JSON.parse(raw) as Snapshot;
12
+ } catch {
13
+ return {
14
+ version: 1,
15
+ updatedAt: null,
16
+ tier: "unknown",
17
+ presetTier: "unknown",
18
+ pressure: 0,
19
+ config: { fastGatePct: 80, thresholdTokens: 100_000, tierPct: null, effectiveThresholdPct: null, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
20
+ session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
21
+ context: { tokens: null, percent: null, contextWindow: 0 },
22
+ trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80, tierPct: null, effectiveThresholdPct: null },
23
+ store: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
24
+ crew: { activeAgents: 0, currentTurn: 0 },
25
+ repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
26
+ integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
27
+ cacheHits: { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 },
28
+ compacts: { session: 0, total: 0 },
29
+ timeSaved: { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } },
30
+ compression: { session: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 }, repo: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 } },
31
+ model: undefined,
32
+ } as Snapshot;
33
+ }
34
+ }
35
+
36
+ export function readFrom(path: string, charOffset: number): { data: string; offset: number } {
37
+ try {
38
+ const content = readFileSync(path, "utf-8");
39
+ if (content.length <= charOffset) return { data: "", offset: charOffset };
40
+ return { data: content.slice(charOffset), offset: content.length };
41
+ } catch {
42
+ return { data: "", offset: charOffset };
43
+ }
44
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * dashboard-server/state.ts — local runtime log + version state.
3
+ *
4
+ * The dashboard server is spawned as a DETACHED child. When it is launched with
5
+ * `stdio: "ignore"` (the old default) any crash before the first console.log is
6
+ * invisible — there is no log to "check". We therefore mirror every lifecycle
7
+ * line to a file in the state dir so a failed start is always diagnosable. The
8
+ * launcher also captures stderr, so this doubles as defense-in-depth.
9
+ */
10
+
11
+ import { appendFileSync } from "node:fs";
12
+
13
+ let LOG_PATH: string | null = null;
14
+
15
+ export function setLogPath(path: string | null): void {
16
+ LOG_PATH = path;
17
+ }
18
+
19
+ export function log(...parts: unknown[]): void {
20
+ const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
21
+ // eslint-disable-next-line no-console
22
+ console.error(line); // stderr — captured by the launcher pipe
23
+ if (LOG_PATH) {
24
+ try { appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n"); } catch { /* non-fatal */ }
25
+ }
26
+ }
27
+
28
+ /** Package version of this extension, surfaced in the dashboard header. */
29
+ export let dashboardServerVersion = "0.0.0";
30
+
31
+ export function setDashboardServerVersion(v: string): void {
32
+ dashboardServerVersion = v;
33
+ }