engramx 1.0.1 → 2.0.1

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 (32) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +67 -7
  3. package/dist/{aider-context-TNGSXMVY.js → aider-context-BC5R2ZTA.js} +1 -1
  4. package/dist/cache-AK6CF3BC.js +10 -0
  5. package/dist/chunk-22INHMKB.js +31 -0
  6. package/dist/chunk-533LR4I7.js +220 -0
  7. package/dist/{chunk-QOG4K427.js → chunk-C6GBUOAL.js} +1 -1
  8. package/dist/chunk-CIQQ5Y3S.js +338 -0
  9. package/dist/chunk-KL6NSPVA.js +59 -0
  10. package/dist/{chunk-SBHGK5WA.js → chunk-PEH54LYC.js} +85 -3
  11. package/dist/{chunk-6SFMVYUN.js → chunk-SJT7VS2G.js} +127 -23
  12. package/dist/cli.js +383 -258
  13. package/dist/{core-77MHT3QV.js → core-6IY5L6II.js} +2 -2
  14. package/dist/{cursor-mdc-HWVUZUZH.js → cursor-mdc-GJ7E5LDD.js} +1 -1
  15. package/dist/{exporter-A3VSLS4U.js → exporter-GWU2GF23.js} +1 -1
  16. package/dist/grammars/tree-sitter-go.wasm +0 -0
  17. package/dist/grammars/tree-sitter-javascript.wasm +0 -0
  18. package/dist/grammars/tree-sitter-python.wasm +0 -0
  19. package/dist/grammars/tree-sitter-rust.wasm +0 -0
  20. package/dist/grammars/tree-sitter-tsx.wasm +0 -0
  21. package/dist/grammars/tree-sitter-typescript.wasm +0 -0
  22. package/dist/{importer-LU2YFZDY.js → importer-V62NGZRK.js} +1 -1
  23. package/dist/index.js +3 -3
  24. package/dist/{migrate-5ZJWF2HD.js → migrate-UKCO6BUU.js} +3 -1
  25. package/dist/plugin-loader-STTGYIL5.js +106 -0
  26. package/dist/serve.js +2 -2
  27. package/dist/server-6AOI7NQP.js +1370 -0
  28. package/dist/{tuner-2LVIEE5V.js → tuner-KFNNGKG3.js} +4 -2
  29. package/dist/windsurf-rules-C7SVDHBL.js +59 -0
  30. package/package.json +4 -3
  31. package/dist/chunk-CEAANHHX.js +0 -88
  32. package/dist/server-I3C74ZLB.js +0 -193
@@ -0,0 +1,1370 @@
1
+ import {
2
+ ContextCache,
3
+ getContextCache
4
+ } from "./chunk-CIQQ5Y3S.js";
5
+ import {
6
+ getComponentStatus,
7
+ summarizeHookLog
8
+ } from "./chunk-533LR4I7.js";
9
+ import {
10
+ readHookLog
11
+ } from "./chunk-KL6NSPVA.js";
12
+ import {
13
+ getStore,
14
+ learn,
15
+ query,
16
+ stats
17
+ } from "./chunk-SJT7VS2G.js";
18
+ import "./chunk-PEH54LYC.js";
19
+
20
+ // src/server/http.ts
21
+ import { createServer } from "http";
22
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync, statSync } from "fs";
23
+ import { join } from "path";
24
+
25
+ // src/intelligence/token-tracker.ts
26
+ var COST_PER_MILLION_TOKENS = 3;
27
+ function getCumulativeStats(store) {
28
+ const totalSessions = store.getStatNum("total_sessions");
29
+ const totalNaiveTokens = store.getStatNum("total_naive_tokens");
30
+ const totalGraphTokens = store.getStatNum("total_graph_tokens");
31
+ const totalSaved = store.getStatNum("total_tokens_saved");
32
+ const avgReduction = totalNaiveTokens > 0 ? Math.round(totalSaved / totalNaiveTokens * 1e3) / 10 : 0;
33
+ const estimatedCostSaved = Math.round(totalSaved / 1e6 * COST_PER_MILLION_TOKENS * 100) / 100;
34
+ return { totalSessions, totalNaiveTokens, totalGraphTokens, totalSaved, avgReduction, estimatedCostSaved };
35
+ }
36
+
37
+ // src/server/ui-components.ts
38
+ function buildComponents() {
39
+ return `
40
+ // Colors pulled from CSS custom properties via getComputedStyle would
41
+ // hit a FOUC at load \u2014 inline them to match the CSS :root values.
42
+ const COLOR_ACCENT = "#10b981";
43
+ const COLOR_ACCENT_DIM = "#047857";
44
+ const COLOR_BLUE = "#3b82f6";
45
+ const COLOR_PURPLE = "#a855f7";
46
+ const COLOR_DIM = "#71717a";
47
+ const COLOR_BG_HOVER = "#1a1a1c";
48
+
49
+ /**
50
+ * Donut chart showing hit rate as a ratio (0-1).
51
+ * Renders a SVG ring with the percentage centered.
52
+ */
53
+ function renderDonut(ratio) {
54
+ const safe = Math.max(0, Math.min(1, Number(ratio) || 0));
55
+ const r = 60;
56
+ const circumference = 2 * Math.PI * r;
57
+ const dashArray = safe * circumference;
58
+ const gapArray = circumference - dashArray;
59
+ const pct = (safe * 100).toFixed(1);
60
+
61
+ return '<svg viewBox="0 0 160 160" width="100%" style="max-width: 200px; display: block; margin: 0 auto;">' +
62
+ '<circle cx="80" cy="80" r="' + r + '" fill="none" stroke="' + COLOR_BG_HOVER + '" stroke-width="14" />' +
63
+ '<circle cx="80" cy="80" r="' + r + '" fill="none" stroke="' + COLOR_ACCENT + '" stroke-width="14" ' +
64
+ 'stroke-dasharray="' + dashArray.toFixed(2) + ' ' + gapArray.toFixed(2) + '" ' +
65
+ 'stroke-linecap="round" transform="rotate(-90 80 80)" />' +
66
+ '<text x="80" y="80" text-anchor="middle" dominant-baseline="central" ' +
67
+ 'fill="' + COLOR_ACCENT + '" font-size="24" font-weight="700" ' +
68
+ 'font-family="SF Mono, Monaco, monospace">' + pct + '%</text>' +
69
+ '<text x="80" y="105" text-anchor="middle" fill="' + COLOR_DIM + '" font-size="10" ' +
70
+ 'font-family="SF Mono, Monaco, monospace" letter-spacing="1">HIT RATE</text>' +
71
+ '</svg>';
72
+ }
73
+
74
+ /**
75
+ * Horizontal stacked bar showing decision distribution.
76
+ * Input: { deny: number, allow: number, passthrough: number }
77
+ */
78
+ function renderDecisionBars(byDecision) {
79
+ const d = Number(byDecision.deny) || 0;
80
+ const a = Number(byDecision.allow) || 0;
81
+ const p = Number(byDecision.passthrough) || 0;
82
+ const total = d + a + p;
83
+
84
+ if (total === 0) {
85
+ return '<div style="color: ' + COLOR_DIM + '; font-family: SF Mono, Monaco, monospace; font-size: 12px; text-align: center; padding: 24px;">No events yet</div>';
86
+ }
87
+
88
+ const dPct = (d / total) * 100;
89
+ const aPct = (a / total) * 100;
90
+ const pPct = (p / total) * 100;
91
+
92
+ return '<div style="margin-bottom: 16px;">' +
93
+ '<div style="display: flex; height: 32px; border-radius: 4px; overflow: hidden; background: ' + COLOR_BG_HOVER + ';">' +
94
+ '<div style="background: ' + COLOR_ACCENT + '; width: ' + dPct + '%;" title="deny"></div>' +
95
+ '<div style="background: ' + COLOR_BLUE + '; width: ' + aPct + '%;" title="allow"></div>' +
96
+ '<div style="background: ' + COLOR_DIM + '; width: ' + pPct + '%;" title="passthrough"></div>' +
97
+ '</div></div>' +
98
+ '<div style="display: flex; justify-content: space-between; font-family: SF Mono, Monaco, monospace; font-size: 11px;">' +
99
+ '<div><span style="display:inline-block; width:10px; height:10px; background:' + COLOR_ACCENT + '; border-radius:2px; vertical-align: middle; margin-right: 6px;"></span>deny <span style="color: ' + COLOR_DIM + '">' + d + '</span></div>' +
100
+ '<div><span style="display:inline-block; width:10px; height:10px; background:' + COLOR_BLUE + '; border-radius:2px; vertical-align: middle; margin-right: 6px;"></span>allow <span style="color: ' + COLOR_DIM + '">' + a + '</span></div>' +
101
+ '<div><span style="display:inline-block; width:10px; height:10px; background:' + COLOR_DIM + '; border-radius:2px; vertical-align: middle; margin-right: 6px;"></span>passthrough <span style="color: ' + COLOR_DIM + '">' + p + '</span></div>' +
102
+ '</div>';
103
+ }
104
+
105
+ /**
106
+ * Sparkline SVG path from numeric array. Handles single-point gracefully.
107
+ */
108
+ function renderSparkline(values) {
109
+ const nums = (values || []).map(Number).filter((n) => !isNaN(n));
110
+ if (nums.length === 0) {
111
+ return '<div style="color: ' + COLOR_DIM + '; font-family: SF Mono, Monaco, monospace; font-size: 12px; text-align: center; padding: 24px;">No data yet</div>';
112
+ }
113
+
114
+ const width = 800;
115
+ const height = 120;
116
+ const padding = 10;
117
+ const max = Math.max(...nums, 1);
118
+ const min = 0;
119
+ const range = max - min;
120
+
121
+ const innerW = width - padding * 2;
122
+ const innerH = height - padding * 2;
123
+
124
+ const points = nums.length === 1
125
+ ? [[width / 2, height / 2]]
126
+ : nums.map((n, i) => {
127
+ const x = padding + (i / (nums.length - 1)) * innerW;
128
+ const y = padding + innerH - ((n - min) / range) * innerH;
129
+ return [x, y];
130
+ });
131
+
132
+ const pathD = nums.length === 1
133
+ ? 'M' + points[0][0] + ' ' + points[0][1] + ' l 0 0'
134
+ : 'M' + points.map((p) => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ');
135
+
136
+ // Fill area under curve
137
+ const fillD = nums.length === 1
138
+ ? ''
139
+ : pathD + ' L ' + points[points.length - 1][0].toFixed(1) + ' ' + (height - padding) +
140
+ ' L ' + points[0][0].toFixed(1) + ' ' + (height - padding) + ' Z';
141
+
142
+ return '<svg viewBox="0 0 ' + width + ' ' + height + '" width="100%" style="height: 120px;">' +
143
+ (fillD ? '<path d="' + fillD + '" fill="' + COLOR_ACCENT + '" fill-opacity="0.1" />' : '') +
144
+ '<path d="' + pathD + '" stroke="' + COLOR_ACCENT + '" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" />' +
145
+ points.map((p) => '<circle cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" r="3" fill="' + COLOR_ACCENT + '" />').join('') +
146
+ '</svg>';
147
+ }
148
+
149
+ /**
150
+ * Cache performance block. Input shape from /api/cache/stats.
151
+ * All values are numbers from our own DB \u2014 no escape needed.
152
+ */
153
+ function renderCacheStats(stats) {
154
+ const hitRate = Number(stats.hitRate) || 0;
155
+ const total = (Number(stats.totalHits) || 0) + (Number(stats.totalMisses) || 0);
156
+ const queryEntries = Number(stats.queryEntries) || 0;
157
+ const patternEntries = Number(stats.patternEntries) || 0;
158
+ const hotFiles = Number(stats.hotFileCount) || 0;
159
+
160
+ return '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-family: SF Mono, Monaco, monospace; font-size: 12px;">' +
161
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Hit Rate</div>' +
162
+ '<div style="font-size: 20px; font-weight: 600; color: ' + COLOR_ACCENT + ';">' + (hitRate * 100).toFixed(1) + '%</div></div>' +
163
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Total Ops</div>' +
164
+ '<div style="font-size: 20px; font-weight: 600;">' + total + '</div></div>' +
165
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Query Entries</div>' +
166
+ '<div style="font-size: 20px; font-weight: 600;">' + queryEntries + '</div></div>' +
167
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Pattern Entries</div>' +
168
+ '<div style="font-size: 20px; font-weight: 600;">' + patternEntries + '</div></div>' +
169
+ '<div style="grid-column: span 2;"><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Hot Files Warmed</div>' +
170
+ '<div style="font-size: 20px; font-weight: 600;">' + hotFiles + '</div></div>' +
171
+ '</div>';
172
+ }
173
+
174
+ /**
175
+ * Graph stats block. Input from /stats endpoint.
176
+ * All values numeric from our DB.
177
+ */
178
+ function renderGraphStats(stats) {
179
+ // The /stats API returns { nodes, edges, extractedPct, inferredPct, ambiguousPct, ... }
180
+ const nodes = Number(stats.nodes ?? stats.nodeCount) || 0;
181
+ const edges = Number(stats.edges ?? stats.edgeCount) || 0;
182
+ const extracted = Number(stats.extractedPct) || 0;
183
+ const inferred = Number(stats.inferredPct) || 0;
184
+ const ambiguous = Number(stats.ambiguousPct) || 0;
185
+
186
+ return '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-family: SF Mono, Monaco, monospace; font-size: 12px;">' +
187
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Nodes</div>' +
188
+ '<div style="font-size: 20px; font-weight: 600; color: ' + COLOR_BLUE + ';">' + nodes + '</div></div>' +
189
+ '<div><div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;">Edges</div>' +
190
+ '<div style="font-size: 20px; font-weight: 600; color: ' + COLOR_PURPLE + ';">' + edges + '</div></div>' +
191
+ '<div style="grid-column: span 2; margin-top: 8px;">' +
192
+ '<div style="color: ' + COLOR_DIM + '; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Confidence Distribution</div>' +
193
+ '<div style="display: flex; gap: 8px; font-size: 11px;">' +
194
+ '<span>extracted: <b style="color: ' + COLOR_ACCENT + '">' + extracted + '%</b></span>' +
195
+ '<span>inferred: <b style="color: ' + COLOR_BLUE + '">' + inferred + '%</b></span>' +
196
+ '<span>ambiguous: <b style="color: ' + COLOR_PURPLE + '">' + ambiguous + '%</b></span>' +
197
+ '</div></div>' +
198
+ '</div>';
199
+ }
200
+ `;
201
+ }
202
+
203
+ // src/server/ui-graph.ts
204
+ function buildGraphScript() {
205
+ return `
206
+ // \u2500\u2500\u2500 Node color by kind (match the graph schema) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
207
+ const NODE_COLORS = {
208
+ file: "#3b82f6",
209
+ function: "#10b981",
210
+ class: "#a855f7",
211
+ concept: "#f59e0b",
212
+ mistake: "#ef4444",
213
+ decision: "#eab308",
214
+ default: "#71717a",
215
+ };
216
+
217
+ /**
218
+ * Main entry point. Given a canvas element and node/edge data from
219
+ * /api/graph/nodes and /api/graph/god-nodes, starts the simulation.
220
+ */
221
+ function renderGraph(canvas, nodes, godNodes) {
222
+ const ctx = canvas.getContext("2d");
223
+ const rect = canvas.getBoundingClientRect();
224
+ canvas.width = rect.width * window.devicePixelRatio;
225
+ canvas.height = rect.height * window.devicePixelRatio;
226
+ ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
227
+
228
+ const W = rect.width;
229
+ const H = rect.height;
230
+
231
+ // God node IDs (for emphasis). godNodes shape: [{node, degree}]
232
+ const godIds = new Set((godNodes || []).map((g) => g.node?.id).filter(Boolean));
233
+
234
+ // Build simulation nodes with random starting positions near center
235
+ const sim = (nodes || []).slice(0, 300).map((n) => ({
236
+ id: n.id,
237
+ label: n.label || n.id,
238
+ kind: n.kind || "default",
239
+ isGod: godIds.has(n.id),
240
+ x: W / 2 + (Math.random() - 0.5) * 400,
241
+ y: H / 2 + (Math.random() - 0.5) * 400,
242
+ vx: 0, vy: 0,
243
+ }));
244
+
245
+ if (sim.length === 0) {
246
+ ctx.fillStyle = "#71717a";
247
+ ctx.font = "13px SF Mono, Monaco, monospace";
248
+ ctx.textAlign = "center";
249
+ ctx.fillText("No graph data yet", W / 2, H / 2);
250
+ return;
251
+ }
252
+
253
+ // Viewport transform (pan + zoom)
254
+ let viewX = 0, viewY = 0, zoom = 1;
255
+ let draggingView = false, dragStartX = 0, dragStartY = 0;
256
+ let selectedId = null;
257
+
258
+ // \u2500\u2500\u2500 Physics step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
259
+ const REPULSION = 1200;
260
+ const SPRING_K = 0.02;
261
+ const SPRING_LENGTH = 80;
262
+ const DAMPING = 0.85;
263
+ const CENTER_GRAVITY = 0.003;
264
+
265
+ function step() {
266
+ // Pairwise repulsion (O(n^2) but fine up to ~500 nodes)
267
+ for (let i = 0; i < sim.length; i++) {
268
+ const a = sim[i];
269
+ for (let j = i + 1; j < sim.length; j++) {
270
+ const b = sim[j];
271
+ const dx = b.x - a.x;
272
+ const dy = b.y - a.y;
273
+ const dist2 = dx * dx + dy * dy + 1;
274
+ const force = REPULSION / dist2;
275
+ const dist = Math.sqrt(dist2);
276
+ const fx = (dx / dist) * force;
277
+ const fy = (dy / dist) * force;
278
+ a.vx -= fx; a.vy -= fy;
279
+ b.vx += fx; b.vy += fy;
280
+ }
281
+ }
282
+
283
+ // Gravity toward viewport center \u2014 keeps disconnected components visible
284
+ for (const n of sim) {
285
+ n.vx += (W / 2 - n.x) * CENTER_GRAVITY;
286
+ n.vy += (H / 2 - n.y) * CENTER_GRAVITY;
287
+ }
288
+
289
+ // Apply velocity with damping
290
+ for (const n of sim) {
291
+ n.vx *= DAMPING;
292
+ n.vy *= DAMPING;
293
+ n.x += n.vx;
294
+ n.y += n.vy;
295
+ }
296
+ }
297
+
298
+ // \u2500\u2500\u2500 Render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
299
+ function draw() {
300
+ ctx.clearRect(0, 0, W, H);
301
+ ctx.save();
302
+ ctx.translate(viewX, viewY);
303
+ ctx.scale(zoom, zoom);
304
+
305
+ // Draw nodes
306
+ for (const n of sim) {
307
+ const radius = n.isGod ? 7 : 4;
308
+ const color = NODE_COLORS[n.kind] || NODE_COLORS.default;
309
+
310
+ ctx.beginPath();
311
+ ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
312
+ ctx.fillStyle = color;
313
+ ctx.globalAlpha = n.id === selectedId ? 1.0 : (n.isGod ? 0.95 : 0.75);
314
+ ctx.fill();
315
+
316
+ if (n.id === selectedId) {
317
+ ctx.strokeStyle = "#ffffff";
318
+ ctx.lineWidth = 2 / zoom;
319
+ ctx.stroke();
320
+ }
321
+ }
322
+
323
+ // Labels for god nodes and selected
324
+ ctx.globalAlpha = 1.0;
325
+ ctx.fillStyle = "#e4e4e7";
326
+ ctx.font = (11 / zoom) + "px SF Mono, Monaco, monospace";
327
+ ctx.textAlign = "center";
328
+ ctx.textBaseline = "top";
329
+
330
+ for (const n of sim) {
331
+ if (n.isGod || n.id === selectedId) {
332
+ const label = n.label.length > 30 ? n.label.slice(0, 27) + "..." : n.label;
333
+ ctx.fillText(label, n.x, n.y + 10);
334
+ }
335
+ }
336
+
337
+ ctx.restore();
338
+ }
339
+
340
+ // \u2500\u2500\u2500 Animation loop \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
341
+ let frames = 0;
342
+ let running = true;
343
+
344
+ function tick() {
345
+ if (!running) return;
346
+ if (frames < 300) step(); // run physics until settled
347
+ draw();
348
+ frames++;
349
+ requestAnimationFrame(tick);
350
+ }
351
+ tick();
352
+
353
+ // \u2500\u2500\u2500 Interaction: pan \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
354
+ canvas.addEventListener("mousedown", (e) => {
355
+ const x = e.offsetX;
356
+ const y = e.offsetY;
357
+
358
+ // Hit test for node click (world coords)
359
+ const worldX = (x - viewX) / zoom;
360
+ const worldY = (y - viewY) / zoom;
361
+ let clicked = null;
362
+ for (const n of sim) {
363
+ const dx = n.x - worldX;
364
+ const dy = n.y - worldY;
365
+ const radius = n.isGod ? 7 : 4;
366
+ if (dx * dx + dy * dy < (radius + 3) * (radius + 3)) {
367
+ clicked = n;
368
+ break;
369
+ }
370
+ }
371
+
372
+ if (clicked) {
373
+ selectedId = clicked.id;
374
+ const info = document.getElementById("graph-info");
375
+ if (info) {
376
+ info.textContent = clicked.kind + " \xB7 " + clicked.label + (clicked.isGod ? " (god node)" : "");
377
+ }
378
+ } else {
379
+ draggingView = true;
380
+ dragStartX = x - viewX;
381
+ dragStartY = y - viewY;
382
+ }
383
+ });
384
+
385
+ canvas.addEventListener("mousemove", (e) => {
386
+ if (draggingView) {
387
+ viewX = e.offsetX - dragStartX;
388
+ viewY = e.offsetY - dragStartY;
389
+ }
390
+ });
391
+
392
+ canvas.addEventListener("mouseup", () => { draggingView = false; });
393
+ canvas.addEventListener("mouseleave", () => { draggingView = false; });
394
+
395
+ // \u2500\u2500\u2500 Interaction: zoom \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
396
+ canvas.addEventListener("wheel", (e) => {
397
+ e.preventDefault();
398
+ const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
399
+ const newZoom = Math.max(0.2, Math.min(3, zoom * zoomDelta));
400
+
401
+ // Anchor zoom on cursor
402
+ const mx = e.offsetX;
403
+ const my = e.offsetY;
404
+ viewX = mx - ((mx - viewX) * newZoom) / zoom;
405
+ viewY = my - ((my - viewY) * newZoom) / zoom;
406
+ zoom = newZoom;
407
+ }, { passive: false });
408
+ }
409
+ `;
410
+ }
411
+
412
+ // src/server/ui.ts
413
+ var CSS = `
414
+ :root {
415
+ --bg: #0a0a0b;
416
+ --bg-panel: #121214;
417
+ --bg-hover: #1a1a1c;
418
+ --border: #2a2a2e;
419
+ --text: #e4e4e7;
420
+ --text-dim: #71717a;
421
+ --accent: #10b981;
422
+ --accent-dim: #047857;
423
+ --warn: #f59e0b;
424
+ --error: #ef4444;
425
+ --blue: #3b82f6;
426
+ --purple: #a855f7;
427
+ --mono: "SF Mono", "Monaco", "Menlo", monospace;
428
+ }
429
+
430
+ * { box-sizing: border-box; margin: 0; padding: 0; }
431
+
432
+ body {
433
+ background: var(--bg);
434
+ color: var(--text);
435
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
436
+ font-size: 14px;
437
+ line-height: 1.5;
438
+ min-height: 100vh;
439
+ }
440
+
441
+ header {
442
+ display: flex;
443
+ align-items: center;
444
+ justify-content: space-between;
445
+ padding: 18px 32px;
446
+ border-bottom: 1px solid var(--border);
447
+ background: var(--bg-panel);
448
+ }
449
+
450
+ header .brand {
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 10px;
454
+ font-family: var(--mono);
455
+ font-weight: 600;
456
+ font-size: 16px;
457
+ }
458
+
459
+ header .brand .diamond { color: var(--accent); font-size: 18px; }
460
+ header .brand .version { color: var(--text-dim); font-size: 12px; font-weight: 400; }
461
+
462
+ header .status {
463
+ display: flex; align-items: center; gap: 16px;
464
+ font-family: var(--mono); font-size: 12px; color: var(--text-dim);
465
+ }
466
+
467
+ header .status .dot {
468
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
469
+ background: var(--accent); margin-right: 6px;
470
+ }
471
+
472
+ nav {
473
+ display: flex;
474
+ padding: 0 32px;
475
+ border-bottom: 1px solid var(--border);
476
+ background: var(--bg-panel);
477
+ }
478
+
479
+ nav button {
480
+ background: none; border: none; color: var(--text-dim);
481
+ padding: 14px 20px; cursor: pointer;
482
+ font-size: 13px; font-family: var(--mono);
483
+ border-bottom: 2px solid transparent;
484
+ transition: color 0.15s, border-color 0.15s;
485
+ }
486
+
487
+ nav button:hover { color: var(--text); }
488
+
489
+ nav button.active {
490
+ color: var(--accent);
491
+ border-bottom-color: var(--accent);
492
+ }
493
+
494
+ main { padding: 32px; max-width: 1400px; margin: 0 auto; }
495
+
496
+ .tab { display: none; }
497
+ .tab.active { display: block; }
498
+
499
+ .grid { display: grid; gap: 16px; margin-bottom: 24px; }
500
+ .grid-2 { grid-template-columns: repeat(2, 1fr); }
501
+ .grid-3 { grid-template-columns: repeat(3, 1fr); }
502
+ .grid-4 { grid-template-columns: repeat(4, 1fr); }
503
+
504
+ @media (max-width: 900px) {
505
+ .grid-3, .grid-4 { grid-template-columns: repeat(2, 1fr); }
506
+ .grid-2 { grid-template-columns: 1fr; }
507
+ }
508
+
509
+ .card {
510
+ background: var(--bg-panel);
511
+ border: 1px solid var(--border);
512
+ border-radius: 8px;
513
+ padding: 20px;
514
+ }
515
+
516
+ .card h3 {
517
+ font-size: 11px; font-weight: 500;
518
+ text-transform: uppercase; letter-spacing: 0.08em;
519
+ color: var(--text-dim); margin-bottom: 12px;
520
+ font-family: var(--mono);
521
+ }
522
+
523
+ .card h2 { font-size: 14px; font-weight: 600; margin-bottom: 16px; }
524
+
525
+ .big-number {
526
+ font-size: 32px; font-weight: 700;
527
+ font-family: var(--mono); color: var(--text); line-height: 1;
528
+ }
529
+
530
+ .big-number.accent { color: var(--accent); }
531
+
532
+ .subtext {
533
+ font-size: 12px; color: var(--text-dim);
534
+ margin-top: 6px; font-family: var(--mono);
535
+ }
536
+
537
+ table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
538
+
539
+ th, td {
540
+ text-align: left; padding: 10px 12px;
541
+ border-bottom: 1px solid var(--border);
542
+ }
543
+
544
+ th {
545
+ color: var(--text-dim); font-weight: 500;
546
+ text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em;
547
+ }
548
+
549
+ tr:hover td { background: var(--bg-hover); }
550
+
551
+ td.num { text-align: right; color: var(--accent); }
552
+ td.dim { color: var(--text-dim); }
553
+
554
+ .activity-row {
555
+ display: flex; align-items: center;
556
+ padding: 8px 0; border-bottom: 1px solid var(--border);
557
+ font-family: var(--mono); font-size: 12px; gap: 10px;
558
+ }
559
+
560
+ .activity-row .badge {
561
+ padding: 2px 6px; border-radius: 3px;
562
+ font-size: 10px; font-weight: 600;
563
+ text-transform: uppercase; letter-spacing: 0.05em;
564
+ }
565
+
566
+ .badge.deny { background: var(--accent-dim); color: var(--accent); }
567
+ .badge.allow { background: #1e3a8a; color: var(--blue); }
568
+ .badge.passthrough { background: var(--bg-hover); color: var(--text-dim); }
569
+
570
+ .empty-state {
571
+ text-align: center; padding: 48px 24px;
572
+ color: var(--text-dim); font-family: var(--mono); font-size: 13px;
573
+ }
574
+
575
+ .provider-card {
576
+ display: flex; justify-content: space-between; align-items: center;
577
+ padding: 14px 16px;
578
+ background: var(--bg-panel);
579
+ border: 1px solid var(--border);
580
+ border-radius: 6px;
581
+ margin-bottom: 8px;
582
+ font-family: var(--mono); font-size: 12px;
583
+ }
584
+
585
+ .provider-card .name { color: var(--text); font-weight: 500; }
586
+
587
+ .provider-card .indicator {
588
+ display: inline-block; width: 8px; height: 8px;
589
+ border-radius: 50%; margin-right: 8px;
590
+ }
591
+
592
+ .provider-card .indicator.ok { background: var(--accent); }
593
+ .provider-card .indicator.down { background: var(--error); }
594
+
595
+ #graph-canvas {
596
+ width: 100%; height: 600px;
597
+ background: var(--bg);
598
+ border: 1px solid var(--border);
599
+ border-radius: 8px;
600
+ cursor: grab;
601
+ }
602
+
603
+ #graph-canvas:active { cursor: grabbing; }
604
+ `;
605
+ var HTML_HEAD = `<!DOCTYPE html>
606
+ <html lang="en">
607
+ <head>
608
+ <meta charset="utf-8" />
609
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
610
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:;" />
611
+ <title>engram dashboard</title>
612
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230a0a0b'/%3E%3Ctext x='50' y='62' font-size='56' text-anchor='middle' fill='%2310b981' font-family='Menlo,monospace'%3E%E2%97%86%3C/text%3E%3C/svg%3E" />
613
+ <style>${CSS}</style>
614
+ </head>`;
615
+ var HTML_BODY = `
616
+ <body>
617
+ <header>
618
+ <div class="brand">
619
+ <span class="diamond">&#9670;</span>
620
+ <span>engram</span>
621
+ <span class="version" id="version">loading...</span>
622
+ </div>
623
+ <div class="status">
624
+ <span><span class="dot"></span>connected</span>
625
+ <span id="uptime">&mdash;</span>
626
+ </div>
627
+ </header>
628
+
629
+ <nav>
630
+ <button class="tab-btn active" data-tab="overview">Overview</button>
631
+ <button class="tab-btn" data-tab="sessions">Sessions</button>
632
+ <button class="tab-btn" data-tab="activity">Activity</button>
633
+ <button class="tab-btn" data-tab="files">Files</button>
634
+ <button class="tab-btn" data-tab="graph">Graph</button>
635
+ <button class="tab-btn" data-tab="providers">Providers</button>
636
+ </nav>
637
+
638
+ <main>
639
+ <section class="tab active" id="tab-overview">
640
+ <div class="grid grid-4">
641
+ <div class="card"><h3>Tokens Saved</h3><div class="big-number accent" id="ov-tokens">&mdash;</div><div class="subtext" id="ov-tokens-sub">cumulative</div></div>
642
+ <div class="card"><h3>Cost Saved</h3><div class="big-number" id="ov-cost">&mdash;</div><div class="subtext">at $3/M tokens</div></div>
643
+ <div class="card"><h3>Hit Rate</h3><div class="big-number" id="ov-hitrate">&mdash;</div><div class="subtext" id="ov-hitrate-sub">hook interceptions</div></div>
644
+ <div class="card"><h3>Sessions</h3><div class="big-number" id="ov-sessions">&mdash;</div><div class="subtext">tracked</div></div>
645
+ </div>
646
+ <div class="grid grid-2">
647
+ <div class="card"><h2>Decision Distribution</h2><div id="ov-decisions-chart"></div></div>
648
+ <div class="card"><h2>Hit Rate</h2><div id="ov-donut"></div></div>
649
+ </div>
650
+ <div class="grid grid-2">
651
+ <div class="card"><h2>Cache Performance</h2><div id="ov-cache"></div></div>
652
+ <div class="card"><h2>Graph Health</h2><div id="ov-graph-stats"></div></div>
653
+ </div>
654
+ </section>
655
+
656
+ <section class="tab" id="tab-sessions">
657
+ <div class="card"><h2>Token Savings Over Time</h2><div id="sessions-sparkline"></div></div>
658
+ <div class="card" style="margin-top: 16px;"><h2>Session Breakdown</h2><div id="sessions-table"></div></div>
659
+ </section>
660
+
661
+ <section class="tab" id="tab-activity">
662
+ <div class="grid grid-2">
663
+ <div class="card">
664
+ <h2>Live Hook Events</h2>
665
+ <div id="activity-stream" style="max-height: 500px; overflow-y: auto;">
666
+ <div class="empty-state">Listening for events...</div>
667
+ </div>
668
+ </div>
669
+ <div class="card"><h2>Per-Tool Breakdown</h2><div id="activity-tools"></div></div>
670
+ </div>
671
+ </section>
672
+
673
+ <section class="tab" id="tab-files">
674
+ <div class="card"><h2>Most-Intercepted Files</h2><div id="files-table"></div></div>
675
+ </section>
676
+
677
+ <section class="tab" id="tab-graph">
678
+ <div class="card">
679
+ <h2>Knowledge Graph Visualization</h2>
680
+ <div class="subtext" style="margin-bottom: 12px;">Drag to pan &middot; Scroll to zoom &middot; Click nodes for details</div>
681
+ <canvas id="graph-canvas"></canvas>
682
+ <div id="graph-info" class="subtext" style="margin-top: 10px;"></div>
683
+ </div>
684
+ </section>
685
+
686
+ <section class="tab" id="tab-providers">
687
+ <div class="card"><h2>Component Health</h2><div id="providers-list"></div></div>
688
+ </section>
689
+ </main>
690
+
691
+ <script>
692
+ __APP_JS__
693
+ </script>
694
+ </body>
695
+ </html>
696
+ `;
697
+ var APP_JS = `
698
+ // \u2500\u2500\u2500 HTML escape (single source of truth for XSS defense) \u2500\u2500\u2500\u2500\u2500
699
+ function esc(s) {
700
+ if (s === null || s === undefined) return "";
701
+ return String(s)
702
+ .replaceAll("&", "&amp;")
703
+ .replaceAll("<", "&lt;")
704
+ .replaceAll(">", "&gt;")
705
+ .replaceAll('"', "&quot;")
706
+ .replaceAll("'", "&#39;");
707
+ }
708
+
709
+ function setText(id, value) {
710
+ const el = document.getElementById(id);
711
+ if (el) el.textContent = value;
712
+ }
713
+
714
+ // \u2500\u2500\u2500 Tab navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
715
+ const tabs = document.querySelectorAll(".tab-btn");
716
+ const panels = document.querySelectorAll(".tab");
717
+
718
+ tabs.forEach((btn) => {
719
+ btn.addEventListener("click", () => {
720
+ const target = btn.dataset.tab;
721
+ tabs.forEach((b) => b.classList.toggle("active", b === btn));
722
+ panels.forEach((p) => p.classList.toggle("active", p.id === "tab-" + target));
723
+ if (target === "graph") loadGraph();
724
+ if (target === "sessions") loadSessions();
725
+ if (target === "files") loadFiles();
726
+ if (target === "providers") loadProviders();
727
+ if (target === "activity") loadActivity();
728
+ });
729
+ });
730
+
731
+ // \u2500\u2500\u2500 API helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
732
+ async function api(path) {
733
+ try {
734
+ const r = await fetch(path);
735
+ if (!r.ok) return null;
736
+ return await r.json();
737
+ } catch { return null; }
738
+ }
739
+
740
+ function formatNumber(n) {
741
+ if (n === null || n === undefined) return "\u2014";
742
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
743
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
744
+ return String(Math.round(n));
745
+ }
746
+
747
+ function formatCost(tokens) {
748
+ return "$" + ((tokens / 1_000_000) * 3).toFixed(2);
749
+ }
750
+
751
+ function formatPercent(n) { return (n * 100).toFixed(1) + "%"; }
752
+
753
+ function formatUptime(seconds) {
754
+ if (seconds < 60) return seconds + "s";
755
+ if (seconds < 3600) return Math.floor(seconds / 60) + "m";
756
+ return Math.floor(seconds / 3600) + "h " + Math.floor((seconds % 3600) / 60) + "m";
757
+ }
758
+
759
+ // \u2500\u2500\u2500 Components library (SVG charts \u2014 data-agnostic) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
760
+ __COMPONENTS__
761
+
762
+ // \u2500\u2500\u2500 Graph canvas module \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
763
+ __GRAPH__
764
+
765
+ // \u2500\u2500\u2500 Tab: Overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
766
+ async function loadOverview() {
767
+ const [tokens, summary, cache, graphStats, health] = await Promise.all([
768
+ api("/api/tokens"),
769
+ api("/api/hook-log/summary"),
770
+ api("/api/cache/stats"),
771
+ api("/stats"),
772
+ api("/health"),
773
+ ]);
774
+
775
+ if (tokens) {
776
+ setText("ov-tokens", formatNumber(tokens.totalSaved ?? 0));
777
+ setText("ov-cost", formatCost(tokens.totalSaved ?? 0));
778
+ setText("ov-sessions", formatNumber(tokens.totalSessions ?? 0));
779
+ setText("ov-tokens-sub", (Number(tokens.avgReduction ?? 0).toFixed(1) + "%") + " avg reduction");
780
+ }
781
+
782
+ if (summary) {
783
+ const d = summary.byDecision ?? {};
784
+ const total = (d.deny ?? 0) + (d.allow ?? 0) + (d.passthrough ?? 0);
785
+ const deny = d.deny ?? 0;
786
+ const hitRate = total > 0 ? deny / total : 0;
787
+ setText("ov-hitrate", formatPercent(hitRate));
788
+ setText("ov-hitrate-sub", deny + " / " + total + " intercepted");
789
+ // Safe: renderDonut/renderDecisionBars output is SVG with numeric values only
790
+ const donut = document.getElementById("ov-donut");
791
+ if (donut) donut.innerHTML = renderDonut(hitRate);
792
+ const bars = document.getElementById("ov-decisions-chart");
793
+ if (bars) bars.innerHTML = renderDecisionBars(d);
794
+ }
795
+
796
+ if (cache) {
797
+ const el = document.getElementById("ov-cache");
798
+ if (el) el.innerHTML = renderCacheStats(cache);
799
+ }
800
+
801
+ if (graphStats) {
802
+ const el = document.getElementById("ov-graph-stats");
803
+ if (el) el.innerHTML = renderGraphStats(graphStats);
804
+ }
805
+
806
+ if (health) {
807
+ setText("version", "v" + health.version);
808
+ setText("uptime", formatUptime(health.uptime));
809
+ }
810
+ }
811
+
812
+ // \u2500\u2500\u2500 Tab: Sessions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
813
+ async function loadSessions() {
814
+ const tokens = await api("/api/tokens");
815
+ if (!tokens) return;
816
+
817
+ const sparkline = document.getElementById("sessions-sparkline");
818
+ if (sparkline) sparkline.innerHTML = renderSparkline([tokens.totalSaved ?? 0]);
819
+
820
+ // Build table with data from trusted source (our own DB)
821
+ // Still using esc() for numbers to be defensive about type assumptions
822
+ const rows = [
823
+ ["Total Sessions", formatNumber(tokens.totalSessions)],
824
+ ["Total Naive Tokens", formatNumber(tokens.totalNaiveTokens)],
825
+ ["Total Graph Tokens", formatNumber(tokens.totalGraphTokens)],
826
+ ["Total Saved", formatNumber(tokens.totalSaved)],
827
+ ["Avg Reduction", (Number(tokens.avgReduction ?? 0).toFixed(1) + "%")],
828
+ ["Estimated Cost Saved", formatCost(tokens.totalSaved ?? 0)],
829
+ ];
830
+
831
+ const html = '<table><thead><tr><th>Metric</th><th style="text-align:right">Value</th></tr></thead><tbody>' +
832
+ rows.map(([k, v]) => '<tr><td>' + esc(k) + '</td><td class="num">' + esc(v) + '</td></tr>').join('') +
833
+ '</tbody></table>';
834
+
835
+ const table = document.getElementById("sessions-table");
836
+ if (table) table.innerHTML = html;
837
+ }
838
+
839
+ // \u2500\u2500\u2500 Tab: Activity (live via SSE) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
840
+ let sseSource = null;
841
+
842
+ async function loadActivity() {
843
+ if (sseSource) return;
844
+
845
+ const log = await api("/api/hook-log?limit=20");
846
+ const streamEl = document.getElementById("activity-stream");
847
+ if (streamEl) {
848
+ if (log && log.entries && log.entries.length > 0) {
849
+ streamEl.innerHTML = log.entries.slice().reverse().map(renderActivityRow).join("");
850
+ } else {
851
+ streamEl.innerHTML = '<div class="empty-state">No events yet</div>';
852
+ }
853
+ }
854
+
855
+ const summary = await api("/api/hook-log/summary");
856
+ const toolsEl = document.getElementById("activity-tools");
857
+ if (toolsEl) {
858
+ toolsEl.innerHTML = renderToolBreakdown((summary && summary.byTool) || {});
859
+ }
860
+
861
+ try {
862
+ sseSource = new EventSource("/api/sse");
863
+ sseSource.addEventListener("message", async () => {
864
+ const fresh = await api("/api/hook-log?limit=20");
865
+ if (fresh && fresh.entries && streamEl) {
866
+ streamEl.innerHTML = fresh.entries.slice().reverse().map(renderActivityRow).join("");
867
+ }
868
+ });
869
+ } catch (e) {
870
+ console.warn("SSE failed", e);
871
+ }
872
+ }
873
+
874
+ function renderActivityRow(entry) {
875
+ const decision = esc(entry.decision || "passthrough");
876
+ const tool = esc(entry.tool || "?");
877
+ const path = entry.path || "";
878
+ const shortPath = path.length > 60 ? "..." + path.slice(-57) : path;
879
+ return '<div class="activity-row">' +
880
+ '<span class="badge ' + decision + '">' + decision + '</span>' +
881
+ '<span style="color: var(--text)">' + tool + '</span>' +
882
+ '<span style="color: var(--text-dim); flex: 1;">' + esc(shortPath) + '</span>' +
883
+ '</div>';
884
+ }
885
+
886
+ function renderToolBreakdown(byTool) {
887
+ const total = Object.values(byTool).reduce((a, b) => a + b, 0);
888
+ if (total === 0) return '<div class="empty-state">No tool events yet</div>';
889
+ return Object.entries(byTool)
890
+ .sort((a, b) => b[1] - a[1])
891
+ .map(([tool, count]) => {
892
+ const pct = ((count / total) * 100).toFixed(1);
893
+ return '<div style="margin-bottom: 10px;">' +
894
+ '<div style="display: flex; justify-content: space-between; font-family: var(--mono); font-size: 12px; margin-bottom: 4px;">' +
895
+ '<span>' + esc(tool) + '</span><span style="color: var(--accent)">' + count + ' (' + pct + '%)</span></div>' +
896
+ '<div style="background: var(--bg-hover); height: 6px; border-radius: 3px; overflow: hidden;">' +
897
+ '<div style="background: var(--accent); height: 100%; width: ' + pct + '%;"></div></div></div>';
898
+ })
899
+ .join("");
900
+ }
901
+
902
+ // \u2500\u2500\u2500 Tab: Files \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
903
+ async function loadFiles() {
904
+ const heatmap = await api("/api/files/heatmap?limit=30");
905
+ const tableEl = document.getElementById("files-table");
906
+ if (!tableEl) return;
907
+
908
+ if (!heatmap || heatmap.length === 0) {
909
+ tableEl.innerHTML = '<div class="empty-state">No file interceptions yet</div>';
910
+ return;
911
+ }
912
+
913
+ const rows = heatmap.map((f) =>
914
+ '<tr>' +
915
+ '<td class="dim">' + esc(f.path) + '</td>' +
916
+ '<td class="num">' + formatNumber(f.count) + '</td>' +
917
+ '<td class="num">' + formatNumber(f.tokensSaved) + '</td>' +
918
+ '</tr>'
919
+ ).join("");
920
+
921
+ tableEl.innerHTML =
922
+ '<table><thead><tr><th>File</th><th style="text-align:right">Interceptions</th>' +
923
+ '<th style="text-align:right">Tokens Saved</th></tr></thead><tbody>' + rows + '</tbody></table>';
924
+ }
925
+
926
+ // \u2500\u2500\u2500 Tab: Graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
927
+ let graphLoaded = false;
928
+
929
+ async function loadGraph() {
930
+ if (graphLoaded) return;
931
+ const [nodes, godNodes] = await Promise.all([
932
+ api("/api/graph/nodes?limit=300"),
933
+ api("/api/graph/god-nodes"),
934
+ ]);
935
+ if (!nodes) return;
936
+ graphLoaded = true;
937
+ const canvas = document.getElementById("graph-canvas");
938
+ if (canvas) renderGraph(canvas, nodes.nodes ?? [], godNodes ?? []);
939
+ setText("graph-info", (nodes.nodes?.length ?? 0) + " of " + (nodes.total ?? 0) + " nodes shown");
940
+ }
941
+
942
+ // \u2500\u2500\u2500 Tab: Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
943
+ async function loadProviders() {
944
+ const [health, cache] = await Promise.all([
945
+ api("/api/providers/health"),
946
+ api("/api/cache/stats"),
947
+ ]);
948
+
949
+ let html = "";
950
+ if (health) {
951
+ const rows = [
952
+ ["HTTP Server", !!health.httpRunning, health.httpRunning ? "active" : "down"],
953
+ ["LSP Provider", !!health.lspAvailable, health.lspAvailable ? "active" : "down"],
954
+ ["AST Provider", !!health.astAvailable, health.astAvailable ? "active" : "down"],
955
+ ["IDE Integrations", (health.ideCount || 0) > 0, (health.ideCount || 0) + " active"],
956
+ ];
957
+ html = rows.map((r) =>
958
+ '<div class="provider-card">' +
959
+ '<div><span class="indicator ' + (r[1] ? "ok" : "down") + '"></span>' + esc(r[0]) + '</div>' +
960
+ '<div style="color: var(--text-dim)">' + esc(r[2]) + '</div>' +
961
+ '</div>'
962
+ ).join("");
963
+ }
964
+
965
+ if (cache) {
966
+ html += '<h3 style="margin-top: 24px; margin-bottom: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-family: var(--mono);">Cache</h3>';
967
+ html += '<div class="provider-card"><div>Query Cache</div><div style="color: var(--text-dim)">' +
968
+ cache.queryEntries + ' entries &middot; ' + cache.queryHits + ' hits</div></div>';
969
+ html += '<div class="provider-card"><div>Pattern Cache</div><div style="color: var(--text-dim)">' +
970
+ cache.patternEntries + ' entries &middot; ' + cache.patternHits + ' hits</div></div>';
971
+ html += '<div class="provider-card"><div>Hot Files</div><div style="color: var(--text-dim)">' +
972
+ cache.hotFileCount + ' warmed</div></div>';
973
+ }
974
+
975
+ const listEl = document.getElementById("providers-list");
976
+ if (listEl) listEl.innerHTML = html;
977
+ }
978
+
979
+ // \u2500\u2500\u2500 Initial load \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
980
+ loadOverview();
981
+ setInterval(loadOverview, 5000);
982
+ `;
983
+ function buildDashboardHtml() {
984
+ const fullJs = APP_JS.replace("__COMPONENTS__", buildComponents()).replace("__GRAPH__", buildGraphScript());
985
+ const body = HTML_BODY.replace("__APP_JS__", fullJs);
986
+ return HTML_HEAD + body;
987
+ }
988
+
989
+ // src/server/http.ts
990
+ import { createRequire } from "module";
991
+ var require2 = createRequire(import.meta.url);
992
+ var PKG_VERSION = (() => {
993
+ for (const p of ["../package.json", "../../package.json"]) {
994
+ try {
995
+ return require2(p).version;
996
+ } catch {
997
+ }
998
+ }
999
+ return "0.0.0";
1000
+ })();
1001
+ var PROVIDERS = [
1002
+ "structure",
1003
+ "mistakes",
1004
+ "git",
1005
+ "mempalace",
1006
+ "context7",
1007
+ "obsidian"
1008
+ ];
1009
+ function parseUrl(req) {
1010
+ return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1011
+ }
1012
+ async function readBody(req) {
1013
+ const chunks = [];
1014
+ for await (const chunk of req) chunks.push(chunk);
1015
+ return Buffer.concat(chunks).toString("utf-8");
1016
+ }
1017
+ function json(res, status, data) {
1018
+ res.writeHead(status, {
1019
+ "Content-Type": "application/json",
1020
+ "Access-Control-Allow-Origin": "*",
1021
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1022
+ "Access-Control-Allow-Headers": "Authorization, Content-Type"
1023
+ });
1024
+ res.end(JSON.stringify(data));
1025
+ }
1026
+ function checkAuth(req, res) {
1027
+ const token = process.env.ENGRAM_API_TOKEN;
1028
+ if (!token) return true;
1029
+ const header = req.headers.authorization ?? "";
1030
+ if (header === `Bearer ${token}`) return true;
1031
+ json(res, 401, { error: "Unauthorized" });
1032
+ return false;
1033
+ }
1034
+ function handleHealth(_req, res, startedAt) {
1035
+ json(res, 200, {
1036
+ ok: true,
1037
+ version: PKG_VERSION,
1038
+ uptime: Math.floor((Date.now() - startedAt) / 1e3)
1039
+ });
1040
+ }
1041
+ async function handleQuery(req, res, projectRoot) {
1042
+ const url = parseUrl(req);
1043
+ const q = url.searchParams.get("q");
1044
+ if (!q) {
1045
+ json(res, 400, { error: "Missing query parameter 'q'" });
1046
+ return;
1047
+ }
1048
+ const budget = parseInt(url.searchParams.get("budget") ?? "2000", 10);
1049
+ try {
1050
+ const result = await query(projectRoot, q, { tokenBudget: isNaN(budget) ? 2e3 : budget });
1051
+ json(res, 200, {
1052
+ text: result.text,
1053
+ estimatedTokens: result.estimatedTokens,
1054
+ providers: [...PROVIDERS]
1055
+ });
1056
+ } catch (err) {
1057
+ json(res, 500, { error: "Query failed", detail: String(err) });
1058
+ }
1059
+ }
1060
+ async function handleStats(_req, res, projectRoot) {
1061
+ try {
1062
+ const result = await stats(projectRoot);
1063
+ json(res, 200, result);
1064
+ } catch (err) {
1065
+ json(res, 500, { error: "Stats failed", detail: String(err) });
1066
+ }
1067
+ }
1068
+ function handleProviders(_req, res) {
1069
+ const list = PROVIDERS.map((name) => ({ name, available: true }));
1070
+ json(res, 200, list);
1071
+ }
1072
+ async function handleLearn(req, res, projectRoot) {
1073
+ let body;
1074
+ try {
1075
+ body = await readBody(req);
1076
+ } catch {
1077
+ json(res, 400, { error: "Failed to read request body" });
1078
+ return;
1079
+ }
1080
+ let parsed;
1081
+ try {
1082
+ parsed = JSON.parse(body);
1083
+ } catch {
1084
+ json(res, 400, { error: "Invalid JSON body" });
1085
+ return;
1086
+ }
1087
+ if (!parsed.content || typeof parsed.content !== "string") {
1088
+ json(res, 400, { error: "Missing 'content' in request body" });
1089
+ return;
1090
+ }
1091
+ try {
1092
+ await learn(projectRoot, parsed.content, parsed.file ?? "http-api");
1093
+ json(res, 201, { ok: true });
1094
+ } catch (err) {
1095
+ json(res, 500, { error: "Learn failed", detail: String(err) });
1096
+ }
1097
+ }
1098
+ async function handleHookLog(req, res, projectRoot) {
1099
+ try {
1100
+ const url = parseUrl(req);
1101
+ const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
1102
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
1103
+ const entries = readHookLog(projectRoot);
1104
+ const paginated = entries.slice(offset, offset + limit);
1105
+ json(res, 200, { entries: paginated, total: entries.length });
1106
+ } catch (err) {
1107
+ json(res, 500, { error: "Hook log read failed", detail: String(err) });
1108
+ }
1109
+ }
1110
+ function handleHookLogSummary(_req, res, projectRoot) {
1111
+ try {
1112
+ const entries = readHookLog(projectRoot);
1113
+ const summary = summarizeHookLog(entries);
1114
+ json(res, 200, summary);
1115
+ } catch (err) {
1116
+ json(res, 500, { error: "Summary failed", detail: String(err) });
1117
+ }
1118
+ }
1119
+ async function handleTokens(_req, res, projectRoot) {
1120
+ try {
1121
+ const store = await getStore(projectRoot);
1122
+ try {
1123
+ const tokenStats = getCumulativeStats(store);
1124
+ json(res, 200, tokenStats);
1125
+ } finally {
1126
+ store.close();
1127
+ }
1128
+ } catch (err) {
1129
+ json(res, 500, { error: "Token stats failed", detail: String(err) });
1130
+ }
1131
+ }
1132
+ async function handleFilesHeatmap(req, res, projectRoot) {
1133
+ try {
1134
+ const url = parseUrl(req);
1135
+ const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
1136
+ const entries = readHookLog(projectRoot);
1137
+ const fileMap = /* @__PURE__ */ new Map();
1138
+ for (const entry of entries) {
1139
+ if (!entry.path) continue;
1140
+ const existing = fileMap.get(entry.path) ?? { count: 0, tokensSaved: 0 };
1141
+ fileMap.set(entry.path, {
1142
+ count: existing.count + 1,
1143
+ tokensSaved: existing.tokensSaved + (entry.tokensSaved ?? 0)
1144
+ });
1145
+ }
1146
+ const sorted = [...fileMap.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, limit).map(([path, data]) => ({ path, ...data }));
1147
+ json(res, 200, sorted);
1148
+ } catch (err) {
1149
+ json(res, 500, { error: "Heatmap failed", detail: String(err) });
1150
+ }
1151
+ }
1152
+ function handleProvidersHealth(_req, res, projectRoot) {
1153
+ try {
1154
+ const status = getComponentStatus(projectRoot);
1155
+ const httpComp = status.components.find((c) => c.name === "http");
1156
+ const lspComp = status.components.find((c) => c.name === "lsp");
1157
+ const astComp = status.components.find((c) => c.name === "ast");
1158
+ json(res, 200, {
1159
+ httpRunning: true,
1160
+ // we're literally responding — it's up
1161
+ lspAvailable: !!lspComp?.available,
1162
+ astAvailable: !!astComp?.available,
1163
+ ideCount: status.ideCount,
1164
+ // Also expose the raw report for advanced consumers
1165
+ components: status.components,
1166
+ generatedAt: status.generatedAt,
1167
+ // Expose the httpComp flag separately in case callers want to know
1168
+ // whether the PID file was found (vs inferred from this response)
1169
+ httpPidDetected: !!httpComp?.available
1170
+ });
1171
+ } catch (err) {
1172
+ json(res, 500, { error: "Provider health failed", detail: String(err) });
1173
+ }
1174
+ }
1175
+ async function handleCacheStats(_req, res, projectRoot) {
1176
+ try {
1177
+ const store = await getStore(projectRoot);
1178
+ try {
1179
+ ContextCache.ensureTables(store);
1180
+ const cache = getContextCache();
1181
+ const cacheStats = cache.getStats(store);
1182
+ json(res, 200, cacheStats);
1183
+ } finally {
1184
+ store.close();
1185
+ }
1186
+ } catch (err) {
1187
+ json(res, 500, { error: "Cache stats failed", detail: String(err) });
1188
+ }
1189
+ }
1190
+ async function handleGraphNodes(req, res, projectRoot) {
1191
+ try {
1192
+ const url = parseUrl(req);
1193
+ const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
1194
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
1195
+ const store = await getStore(projectRoot);
1196
+ try {
1197
+ const allNodes = store.getAllNodes();
1198
+ const paginated = allNodes.slice(offset, offset + limit);
1199
+ json(res, 200, { nodes: paginated, total: allNodes.length });
1200
+ } finally {
1201
+ store.close();
1202
+ }
1203
+ } catch (err) {
1204
+ json(res, 500, { error: "Graph nodes failed", detail: String(err) });
1205
+ }
1206
+ }
1207
+ async function handleGraphGodNodes(_req, res, projectRoot) {
1208
+ try {
1209
+ const store = await getStore(projectRoot);
1210
+ try {
1211
+ const godNodes = store.getGodNodes(10);
1212
+ json(res, 200, godNodes);
1213
+ } finally {
1214
+ store.close();
1215
+ }
1216
+ } catch (err) {
1217
+ json(res, 500, { error: "God nodes failed", detail: String(err) });
1218
+ }
1219
+ }
1220
+ var sseClients = /* @__PURE__ */ new Set();
1221
+ var hookLogWatcher = null;
1222
+ function handleSSE(_req, res, projectRoot) {
1223
+ res.writeHead(200, {
1224
+ "Content-Type": "text/event-stream",
1225
+ "Cache-Control": "no-cache",
1226
+ "Connection": "keep-alive",
1227
+ "Access-Control-Allow-Origin": "*"
1228
+ });
1229
+ res.write('data: {"type":"connected"}\n\n');
1230
+ sseClients.add(res);
1231
+ if (!hookLogWatcher) {
1232
+ const logPath = join(projectRoot, ".engram", "hook-log.jsonl");
1233
+ if (existsSync(logPath)) {
1234
+ let lastSize = statSync(logPath).size;
1235
+ const checkFile = () => {
1236
+ try {
1237
+ const currentSize = statSync(logPath).size;
1238
+ if (currentSize > lastSize) {
1239
+ lastSize = currentSize;
1240
+ const msg = JSON.stringify({ type: "hook-event", timestamp: Date.now() });
1241
+ for (const client of sseClients) {
1242
+ try {
1243
+ client.write(`data: ${msg}
1244
+
1245
+ `);
1246
+ } catch {
1247
+ sseClients.delete(client);
1248
+ }
1249
+ }
1250
+ }
1251
+ } catch {
1252
+ }
1253
+ };
1254
+ const interval = setInterval(checkFile, 1e3);
1255
+ hookLogWatcher = () => clearInterval(interval);
1256
+ }
1257
+ }
1258
+ res.on("close", () => {
1259
+ sseClients.delete(res);
1260
+ if (sseClients.size === 0 && hookLogWatcher) {
1261
+ hookLogWatcher();
1262
+ hookLogWatcher = null;
1263
+ }
1264
+ });
1265
+ }
1266
+ function writePid(projectRoot) {
1267
+ const dir = join(projectRoot, ".engram");
1268
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1269
+ writeFileSync(join(dir, "http-server.pid"), String(process.pid), "utf-8");
1270
+ }
1271
+ function removePid(projectRoot) {
1272
+ try {
1273
+ unlinkSync(join(projectRoot, ".engram", "http-server.pid"));
1274
+ } catch {
1275
+ }
1276
+ }
1277
+ function createHttpServer(projectRoot, port) {
1278
+ return new Promise((resolve, reject) => {
1279
+ const startedAt = Date.now();
1280
+ const server = createServer(async (req, res) => {
1281
+ if (req.method === "OPTIONS") {
1282
+ res.writeHead(204, {
1283
+ "Access-Control-Allow-Origin": "*",
1284
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1285
+ "Access-Control-Allow-Headers": "Authorization, Content-Type"
1286
+ });
1287
+ res.end();
1288
+ return;
1289
+ }
1290
+ if (!checkAuth(req, res)) return;
1291
+ const url = parseUrl(req);
1292
+ const path = url.pathname;
1293
+ try {
1294
+ if (req.method === "GET" && path === "/health") {
1295
+ handleHealth(req, res, startedAt);
1296
+ } else if (req.method === "GET" && path === "/query") {
1297
+ await handleQuery(req, res, projectRoot);
1298
+ } else if (req.method === "GET" && path === "/stats") {
1299
+ await handleStats(req, res, projectRoot);
1300
+ } else if (req.method === "GET" && path === "/providers") {
1301
+ handleProviders(req, res);
1302
+ } else if (req.method === "POST" && path === "/learn") {
1303
+ await handleLearn(req, res, projectRoot);
1304
+ } else if (req.method === "GET" && path === "/api/hook-log") {
1305
+ await handleHookLog(req, res, projectRoot);
1306
+ } else if (req.method === "GET" && path === "/api/hook-log/summary") {
1307
+ handleHookLogSummary(req, res, projectRoot);
1308
+ } else if (req.method === "GET" && path === "/api/tokens") {
1309
+ await handleTokens(req, res, projectRoot);
1310
+ } else if (req.method === "GET" && path === "/api/files/heatmap") {
1311
+ await handleFilesHeatmap(req, res, projectRoot);
1312
+ } else if (req.method === "GET" && path === "/api/providers/health") {
1313
+ handleProvidersHealth(req, res, projectRoot);
1314
+ } else if (req.method === "GET" && path === "/api/cache/stats") {
1315
+ await handleCacheStats(req, res, projectRoot);
1316
+ } else if (req.method === "GET" && path === "/api/graph/nodes") {
1317
+ await handleGraphNodes(req, res, projectRoot);
1318
+ } else if (req.method === "GET" && path === "/api/graph/god-nodes") {
1319
+ await handleGraphGodNodes(req, res, projectRoot);
1320
+ } else if (req.method === "GET" && path === "/api/sse") {
1321
+ handleSSE(req, res, projectRoot);
1322
+ } else if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1323
+ res.writeHead(200, {
1324
+ "Content-Type": "text/html; charset=utf-8",
1325
+ "Cache-Control": "no-cache"
1326
+ });
1327
+ res.end(buildDashboardHtml());
1328
+ } else if (req.method === "GET" && path === "/favicon.ico") {
1329
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0a0a0b"/><text x="50" y="62" font-size="56" text-anchor="middle" fill="#10b981" font-family="Menlo,monospace">&#9670;</text></svg>';
1330
+ res.writeHead(200, {
1331
+ "Content-Type": "image/svg+xml",
1332
+ "Cache-Control": "public, max-age=86400"
1333
+ });
1334
+ res.end(svg);
1335
+ } else {
1336
+ json(res, 404, { error: "Not found" });
1337
+ }
1338
+ } catch (err) {
1339
+ json(res, 500, { error: "Internal server error", detail: String(err) });
1340
+ }
1341
+ });
1342
+ server.on("error", (err) => {
1343
+ removePid(projectRoot);
1344
+ reject(err);
1345
+ });
1346
+ server.listen(port, "127.0.0.1", () => {
1347
+ writePid(projectRoot);
1348
+ const cleanup = () => {
1349
+ removePid(projectRoot);
1350
+ server.close(() => process.exit(0));
1351
+ };
1352
+ process.on("SIGINT", cleanup);
1353
+ process.on("SIGTERM", cleanup);
1354
+ resolve();
1355
+ });
1356
+ });
1357
+ }
1358
+
1359
+ // src/server/index.ts
1360
+ var DEFAULT_PORT = 7337;
1361
+ async function startHttpServer(projectRoot, port = DEFAULT_PORT) {
1362
+ await createHttpServer(projectRoot, port);
1363
+ process.stdout.write(
1364
+ `engram HTTP server listening on http://127.0.0.1:${port}
1365
+ `
1366
+ );
1367
+ }
1368
+ export {
1369
+ startHttpServer
1370
+ };