@danielblomma/cortex-mcp 0.4.5 → 0.6.4

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 (61) hide show
  1. package/README.md +38 -42
  2. package/bin/cortex.mjs +32 -60
  3. package/package.json +15 -3
  4. package/scaffold/.context/ontology.cypher +47 -0
  5. package/scaffold/.githooks/post-commit +14 -0
  6. package/scaffold/.githooks/post-rewrite +23 -0
  7. package/scaffold/mcp/package-lock.json +16 -16
  8. package/scaffold/mcp/package.json +3 -1
  9. package/scaffold/mcp/src/contextEntities.ts +311 -0
  10. package/scaffold/mcp/src/defaults.ts +6 -0
  11. package/scaffold/mcp/src/embed.ts +163 -37
  12. package/scaffold/mcp/src/frontmatter.ts +39 -0
  13. package/scaffold/mcp/src/graph.ts +253 -130
  14. package/scaffold/mcp/src/graphMetrics.ts +12 -0
  15. package/scaffold/mcp/src/impactPresentation.ts +202 -0
  16. package/scaffold/mcp/src/impactRanking.ts +237 -0
  17. package/scaffold/mcp/src/impactResponse.ts +47 -0
  18. package/scaffold/mcp/src/impactResults.ts +173 -0
  19. package/scaffold/mcp/src/impactSeed.ts +33 -0
  20. package/scaffold/mcp/src/impactTraversal.ts +83 -0
  21. package/scaffold/mcp/src/jsonl.ts +34 -0
  22. package/scaffold/mcp/src/loadGraph.ts +345 -86
  23. package/scaffold/mcp/src/paths.ts +17 -1
  24. package/scaffold/mcp/src/presets.ts +137 -0
  25. package/scaffold/mcp/src/relatedResponse.ts +30 -0
  26. package/scaffold/mcp/src/relatedTraversal.ts +101 -0
  27. package/scaffold/mcp/src/rules.ts +27 -0
  28. package/scaffold/mcp/src/search.ts +186 -455
  29. package/scaffold/mcp/src/searchCore.ts +274 -0
  30. package/scaffold/mcp/src/searchResults.ts +133 -0
  31. package/scaffold/mcp/src/server.ts +95 -3
  32. package/scaffold/mcp/src/types.ts +82 -3
  33. package/scaffold/scripts/context.sh +12 -46
  34. package/scaffold/scripts/dashboard.mjs +797 -0
  35. package/scaffold/scripts/dashboard.sh +13 -0
  36. package/scaffold/scripts/ingest.mjs +2219 -59
  37. package/scaffold/scripts/install-git-hooks.sh +3 -1
  38. package/scaffold/scripts/memory-compile.mjs +232 -0
  39. package/scaffold/scripts/memory-compile.sh +20 -0
  40. package/scaffold/scripts/memory-lint.mjs +375 -0
  41. package/scaffold/scripts/memory-lint.sh +20 -0
  42. package/scaffold/scripts/parsers/config.mjs +178 -0
  43. package/scaffold/scripts/parsers/cpp.mjs +316 -0
  44. package/scaffold/scripts/parsers/dotnet/VbNetParser/Program.cs +374 -0
  45. package/scaffold/scripts/parsers/dotnet/VbNetParser/VbNetParser.csproj +13 -0
  46. package/scaffold/scripts/parsers/javascript/ast.mjs +61 -0
  47. package/scaffold/scripts/parsers/javascript/calls.mjs +53 -0
  48. package/scaffold/scripts/parsers/javascript/chunks.mjs +388 -0
  49. package/scaffold/scripts/parsers/javascript/imports.mjs +162 -0
  50. package/scaffold/scripts/parsers/javascript/patterns.mjs +82 -0
  51. package/scaffold/scripts/parsers/javascript/scope-analysis.mjs +3 -0
  52. package/scaffold/scripts/parsers/javascript/scope-builder.mjs +305 -0
  53. package/scaffold/scripts/parsers/javascript/scope-resolver.mjs +82 -0
  54. package/scaffold/scripts/parsers/javascript.mjs +27 -350
  55. package/scaffold/scripts/parsers/resources.mjs +166 -0
  56. package/scaffold/scripts/parsers/sql.mjs +137 -0
  57. package/scaffold/scripts/parsers/vbnet.mjs +143 -0
  58. package/scaffold/scripts/status.sh +0 -7
  59. package/scaffold/scripts/capture-note.sh +0 -55
  60. package/scaffold/scripts/plan-state-engine.cjs +0 -310
  61. package/scaffold/scripts/plan-state.sh +0 -71
@@ -0,0 +1,797 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execSync } from "node:child_process";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const REPO_ROOT = path.resolve(__dirname, "..");
10
+ const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
11
+ const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
12
+ const CONFIG_PATH = path.join(CONTEXT_DIR, "config.yaml");
13
+
14
+ // Same extensions as ingest.mjs
15
+ const SUPPORTED_TEXT_EXTENSIONS = new Set([
16
+ ".md", ".mdx", ".txt", ".adoc", ".rst",
17
+ ".yaml", ".yml", ".json", ".toml", ".csv",
18
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
19
+ ".py", ".go", ".java", ".cs", ".rb", ".rs", ".php", ".swift", ".kt",
20
+ ".sql", ".sh", ".bash", ".zsh", ".ps1",
21
+ ".c", ".h", ".cpp", ".hpp", ".cc", ".hh"
22
+ ]);
23
+
24
+ // Same skip dirs as ingest.mjs
25
+ const SKIP_DIRECTORIES = new Set([
26
+ ".git", ".idea", ".vscode", "node_modules",
27
+ "dist", "build", "coverage", ".next", ".cache", ".context"
28
+ ]);
29
+
30
+ const MAX_FILE_BYTES = 1024 * 1024;
31
+ const VERSION_CHECK_TTL_MS = 10 * 60 * 1000;
32
+ const VERSION_INSTALL_HINT = "npm i -g github:DanielBlomma/cortex";
33
+
34
+ // ── ANSI helpers ──────────────────────────────────────────────
35
+ const ESC = "\x1b";
36
+ const RESET = `${ESC}[0m`;
37
+ const BOLD = `${ESC}[1m`;
38
+ const DIM = `${ESC}[2m`;
39
+ const HIDE_CURSOR = `${ESC}[?25l`;
40
+ const SHOW_CURSOR = `${ESC}[?25h`;
41
+ const HOME = `${ESC}[H`;
42
+ const CLEAR_DOWN = `${ESC}[J`;
43
+
44
+ const C = {
45
+ gray: `${ESC}[38;5;245m`,
46
+ dimGray: `${ESC}[38;5;239m`,
47
+ white: `${ESC}[37m`,
48
+ green: `${ESC}[38;5;34m`,
49
+ blue: `${ESC}[38;5;33m`,
50
+ orange: `${ESC}[38;5;208m`,
51
+ cyan: `${ESC}[38;5;37m`,
52
+ red: `${ESC}[38;5;196m`,
53
+ yellow: `${ESC}[38;5;220m`,
54
+ purple: `${ESC}[38;5;135m`,
55
+ };
56
+
57
+ const col = (text, color) => `${color}${text}${RESET}`;
58
+ const bold = (text) => `${BOLD}${text}${RESET}`;
59
+ const dim = (text) => `${DIM}${text}${RESET}`;
60
+
61
+ // ── Data: source_paths parsing (same as ingest.mjs) ──────────
62
+ function parseSourcePaths(configText) {
63
+ const sourcePaths = [];
64
+ const lines = configText.split(/\r?\n/);
65
+ let inSourcePaths = false;
66
+ for (const line of lines) {
67
+ if (!inSourcePaths && /^source_paths:\s*$/.test(line.trim())) {
68
+ inSourcePaths = true;
69
+ continue;
70
+ }
71
+ if (!inSourcePaths) continue;
72
+ const m = line.match(/^\s*-\s*(.+?)\s*$/);
73
+ if (m) {
74
+ sourcePaths.push(m[1].replace(/^['"]|['"]$/g, ""));
75
+ continue;
76
+ }
77
+ if (line.trim() !== "" && !/^\s/.test(line)) break;
78
+ }
79
+ return sourcePaths;
80
+ }
81
+
82
+ // ── Data: filesystem walk (same as ingest.mjs) ───────────────
83
+ function walkDirectory(dirPath, files) {
84
+ let entries;
85
+ try {
86
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
87
+ } catch {
88
+ return;
89
+ }
90
+ for (const entry of entries) {
91
+ if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
92
+ const abs = path.join(dirPath, entry.name);
93
+ if (entry.isDirectory()) {
94
+ walkDirectory(abs, files);
95
+ } else if (entry.isFile()) {
96
+ files.push(abs);
97
+ }
98
+ }
99
+ }
100
+
101
+ function toPosixPath(p) {
102
+ return p.split(path.sep).join("/");
103
+ }
104
+
105
+ function hasSourcePrefix(relPath, sourcePaths) {
106
+ return sourcePaths.some((sp) => {
107
+ const s = toPosixPath(sp).replace(/\/+$/, "");
108
+ if (s === "" || s === ".") return true;
109
+ return relPath === s || relPath.startsWith(`${s}/`);
110
+ });
111
+ }
112
+
113
+ // ── Data: baseline scan ──────────────────────────────────────
114
+ function scanBaseline() {
115
+ if (!fs.existsSync(CONFIG_PATH)) return { files: 0, lines: 0, chars: 0, tokens: 0 };
116
+
117
+ const configText = fs.readFileSync(CONFIG_PATH, "utf8");
118
+ const sourcePaths = parseSourcePaths(configText);
119
+ if (sourcePaths.length === 0) return { files: 0, lines: 0, chars: 0, tokens: 0 };
120
+
121
+ const allFiles = [];
122
+ for (const sp of sourcePaths) {
123
+ const abs = path.resolve(REPO_ROOT, sp);
124
+ if (!fs.existsSync(abs)) continue;
125
+ const stat = fs.statSync(abs);
126
+ if (stat.isFile()) {
127
+ allFiles.push(abs);
128
+ } else if (stat.isDirectory()) {
129
+ walkDirectory(abs, allFiles);
130
+ }
131
+ }
132
+
133
+ let fileCount = 0;
134
+ let totalLines = 0;
135
+ let totalChars = 0;
136
+
137
+ for (const filePath of allFiles) {
138
+ const ext = path.extname(filePath).toLowerCase();
139
+ if (!SUPPORTED_TEXT_EXTENSIONS.has(ext)) continue;
140
+ try {
141
+ const stat = fs.statSync(filePath);
142
+ if (stat.size > MAX_FILE_BYTES) continue;
143
+ const content = fs.readFileSync(filePath, "utf8");
144
+ fileCount++;
145
+ totalLines += content.split("\n").length;
146
+ totalChars += content.length;
147
+ } catch {
148
+ // skip unreadable
149
+ }
150
+ }
151
+
152
+ return {
153
+ files: fileCount,
154
+ lines: totalLines,
155
+ chars: totalChars,
156
+ tokens: Math.round(totalChars / 4),
157
+ };
158
+ }
159
+
160
+ // ── Data: read JSONL safely ──────────────────────────────────
161
+ function readJsonlSafe(filePath) {
162
+ try {
163
+ const text = fs.readFileSync(filePath, "utf8").trim();
164
+ if (!text) return [];
165
+ return text.split("\n").map((line) => {
166
+ try { return JSON.parse(line); } catch { return null; }
167
+ }).filter(Boolean);
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
172
+
173
+ // ── Data: read JSON safely ───────────────────────────────────
174
+ function readJsonSafe(filePath) {
175
+ try {
176
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // ── Data: read manifests ─────────────────────────────────────
183
+ function readManifests() {
184
+ return {
185
+ ingest: readJsonSafe(path.join(CACHE_DIR, "manifest.json")),
186
+ graph: readJsonSafe(path.join(CACHE_DIR, "graph-manifest.json")),
187
+ embed: readJsonSafe(path.join(CONTEXT_DIR, "embeddings", "manifest.json")),
188
+ };
189
+ }
190
+
191
+ // ── Data: freshness (same logic as status.sh) ────────────────
192
+ function computeFreshness(manifest) {
193
+ const sourcePaths = Array.isArray(manifest?.source_paths) ? manifest.source_paths : [];
194
+ const indexedFiles = Number(manifest?.counts?.files ?? 0);
195
+
196
+ let relevantChanged = 0;
197
+ let relevantDeleted = 0;
198
+
199
+ try {
200
+ const output = execSync("git status --porcelain", {
201
+ cwd: REPO_ROOT, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 3000,
202
+ });
203
+
204
+ for (const rawLine of output.split(/\r?\n/)) {
205
+ if (!rawLine) continue;
206
+ const status = rawLine.slice(0, 2);
207
+ const payload = rawLine.slice(3).trim();
208
+ if (!payload) continue;
209
+
210
+ const paths = payload.includes(" -> ")
211
+ ? payload.split(" -> ")
212
+ : [payload];
213
+
214
+ for (const p of paths) {
215
+ const relPath = toPosixPath(p);
216
+ if (relPath.startsWith(".context/")) continue;
217
+ if (!hasSourcePrefix(relPath, sourcePaths)) continue;
218
+ if (status.includes("D")) {
219
+ relevantDeleted++;
220
+ } else {
221
+ relevantChanged++;
222
+ }
223
+ }
224
+ }
225
+ } catch {
226
+ return { percent: -1, pending: 0, changed: 0, deleted: 0 };
227
+ }
228
+
229
+ const pending = relevantChanged + relevantDeleted;
230
+ const baseline = Math.max(indexedFiles, pending, 1);
231
+ const freshness = Math.max(0, (baseline - pending) / baseline);
232
+
233
+ return {
234
+ percent: Math.round(freshness * 100),
235
+ pending,
236
+ changed: relevantChanged,
237
+ deleted: relevantDeleted,
238
+ };
239
+ }
240
+
241
+ let versionStatusCache = {
242
+ expiresAt: 0,
243
+ value: null,
244
+ };
245
+
246
+ function parseVersion(value) {
247
+ const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
248
+ if (!match) return null;
249
+ return match.slice(1).map((part) => Number(part));
250
+ }
251
+
252
+ function compareVersions(a, b) {
253
+ for (let i = 0; i < 3; i += 1) {
254
+ if (a[i] > b[i]) return 1;
255
+ if (a[i] < b[i]) return -1;
256
+ }
257
+ return 0;
258
+ }
259
+
260
+ function shorten(text, max = 40) {
261
+ const value = String(text || "").trim();
262
+ if (value.length <= max) return value;
263
+ return `${value.slice(0, max - 1)}…`;
264
+ }
265
+
266
+ function summarizeError(error) {
267
+ const raw = error instanceof Error ? error.message : String(error);
268
+ return shorten(raw.split(/\r?\n/)[0].trim());
269
+ }
270
+
271
+ function getLocalCliVersion() {
272
+ const envVersion = String(process.env.CORTEX_CLI_VERSION || "").trim();
273
+ if (parseVersion(envVersion)) {
274
+ return envVersion;
275
+ }
276
+
277
+ try {
278
+ const output = execSync("cortex --version", {
279
+ cwd: REPO_ROOT,
280
+ stdio: ["ignore", "pipe", "ignore"],
281
+ encoding: "utf8",
282
+ timeout: 1500,
283
+ }).trim();
284
+ if (parseVersion(output)) {
285
+ return output;
286
+ }
287
+ } catch {
288
+ // Ignore and report unavailable below.
289
+ }
290
+
291
+ return "";
292
+ }
293
+
294
+ function getVersionStatus() {
295
+ const now = Date.now();
296
+ if (versionStatusCache.value && versionStatusCache.expiresAt > now) {
297
+ return versionStatusCache.value;
298
+ }
299
+
300
+ const local = getLocalCliVersion();
301
+ let value;
302
+
303
+ if (!local) {
304
+ value = {
305
+ state: "unavailable",
306
+ local: null,
307
+ latest: null,
308
+ message: "local version not detected",
309
+ };
310
+ } else {
311
+ const localParsed = parseVersion(local);
312
+ if (!localParsed) {
313
+ value = {
314
+ state: "unavailable",
315
+ local,
316
+ latest: null,
317
+ message: "unsupported local version format",
318
+ };
319
+ } else {
320
+ try {
321
+ const npmCache = path.join(CACHE_DIR, "npm-cache");
322
+ const latestRaw = execSync("npm view github:DanielBlomma/cortex version --json", {
323
+ cwd: REPO_ROOT,
324
+ stdio: ["ignore", "pipe", "pipe"],
325
+ encoding: "utf8",
326
+ timeout: 2500,
327
+ env: { ...process.env, NPM_CONFIG_CACHE: npmCache },
328
+ }).trim();
329
+ const parsedLatest = JSON.parse(latestRaw);
330
+ const latest = Array.isArray(parsedLatest)
331
+ ? parsedLatest[parsedLatest.length - 1]
332
+ : parsedLatest;
333
+ const latestParsed = parseVersion(latest);
334
+
335
+ if (!latestParsed) {
336
+ value = {
337
+ state: "unavailable",
338
+ local,
339
+ latest,
340
+ message: "unsupported latest version format",
341
+ };
342
+ } else if (compareVersions(latestParsed, localParsed) > 0) {
343
+ value = {
344
+ state: "update-available",
345
+ local,
346
+ latest,
347
+ message: null,
348
+ };
349
+ } else {
350
+ value = {
351
+ state: "current",
352
+ local,
353
+ latest,
354
+ message: null,
355
+ };
356
+ }
357
+ } catch (error) {
358
+ value = {
359
+ state: "unavailable",
360
+ local,
361
+ latest: null,
362
+ message: summarizeError(error),
363
+ };
364
+ }
365
+ }
366
+ }
367
+
368
+ versionStatusCache = {
369
+ expiresAt: now + VERSION_CHECK_TTL_MS,
370
+ value,
371
+ };
372
+
373
+ return value;
374
+ }
375
+
376
+ // ── Data: degree analysis ────────────────────────────────────
377
+ function computeTopConnected() {
378
+ const degree = new Map();
379
+
380
+ const relationFiles = [
381
+ "relations.constrains.jsonl", "relations.implements.jsonl",
382
+ "relations.supersedes.jsonl", "relations.defines.jsonl",
383
+ "relations.calls.jsonl", "relations.imports.jsonl",
384
+ ];
385
+
386
+ for (const file of relationFiles) {
387
+ const records = readJsonlSafe(path.join(CACHE_DIR, file));
388
+ for (const r of records) {
389
+ if (r.from) degree.set(r.from, (degree.get(r.from) || 0) + 1);
390
+ if (r.to) degree.set(r.to, (degree.get(r.to) || 0) + 1);
391
+ }
392
+ }
393
+
394
+ return [...degree.entries()]
395
+ .sort((a, b) => b[1] - a[1])
396
+ .slice(0, 5)
397
+ .map(([id, deg]) => {
398
+ let label = id.includes("/") ? path.basename(id.replace(/^file:/, "")) : id.replace(/^(file|chunk|rule):/, "");
399
+ if (label.length > 18) label = label.slice(0, 17) + "…";
400
+ return { id, label, degree: deg };
401
+ });
402
+ }
403
+
404
+ // ── Data: estimate tokens per task (realistic comparison) ────
405
+ function estimatePerTaskTokens(baseline) {
406
+ // Read all entity types for accurate excerpt averaging
407
+ const entityFiles = [
408
+ "entities.file.jsonl",
409
+ "entities.chunk.jsonl",
410
+ "entities.rule.jsonl",
411
+ "entities.adr.jsonl"
412
+ ];
413
+
414
+ let totalExcerptChars = 0;
415
+ let entityCount = 0;
416
+
417
+ for (const file of entityFiles) {
418
+ const entities = readJsonlSafe(path.join(CACHE_DIR, file));
419
+ for (const e of entities) {
420
+ totalExcerptChars += (e.excerpt || e.body || "").slice(0, 500).length;
421
+ entityCount++;
422
+ }
423
+ }
424
+
425
+ if (entityCount === 0 || baseline.files === 0) {
426
+ return { codebase: baseline.tokens, baselinePerTask: 0, cortexPerTask: 0,
427
+ filesPerTask: 0, queriesPerTask: 0, ratio: 0, reduction: 0 };
428
+ }
429
+
430
+ // --- Without Cortex: LLM reads ~12 files per task for exploration + context ---
431
+ const TYPICAL_FILES_PER_TASK = 12;
432
+ const filesPerTask = Math.min(TYPICAL_FILES_PER_TASK, baseline.files);
433
+ const avgFileTokens = Math.round(baseline.tokens / baseline.files);
434
+ const baselinePerTask = filesPerTask * avgFileTokens;
435
+
436
+ // --- With Cortex: ~3 search queries per task, top 5 results each ---
437
+ const TYPICAL_SEARCHES_PER_TASK = 3;
438
+ const topK = 5;
439
+ const avgExcerptChars = totalExcerptChars / entityCount;
440
+ // Per result: excerpt + JSON metadata (~350 chars for id, type, title, path, scores, etc.)
441
+ const perResultChars = avgExcerptChars + 350;
442
+ // Per query: top-K results + response wrapper (~300 chars for query, ranking, counts)
443
+ const perQueryChars = topK * perResultChars + 300;
444
+ const perQueryTokens = Math.round(perQueryChars / 4);
445
+ const cortexPerTask = TYPICAL_SEARCHES_PER_TASK * perQueryTokens;
446
+
447
+ const ratio = cortexPerTask > 0 ? Math.round(baselinePerTask / cortexPerTask) : 0;
448
+ const reduction = baselinePerTask > 0 ? Math.round((1 - cortexPerTask / baselinePerTask) * 100) : 0;
449
+
450
+ return {
451
+ codebase: baseline.tokens,
452
+ baselinePerTask,
453
+ cortexPerTask,
454
+ filesPerTask,
455
+ queriesPerTask: TYPICAL_SEARCHES_PER_TASK,
456
+ ratio,
457
+ reduction: Math.max(0, reduction)
458
+ };
459
+ }
460
+
461
+ // ── Data: gather all ─────────────────────────────────────────
462
+ function gatherData(baselineCache) {
463
+ const baseline = baselineCache || scanBaseline();
464
+ const manifests = readManifests();
465
+ const gc = manifests.graph?.counts || {};
466
+ const ic = manifests.ingest?.counts || {};
467
+ const ec = manifests.embed?.counts || {};
468
+
469
+ const totalEntities = (gc.files || 0) + (gc.rules || 0) + (gc.adrs || 0) + (gc.chunks || 0);
470
+ const relCalls = gc.calls || ic.relations_calls || 0;
471
+ const relDefines = gc.defines || ic.relations_defines || 0;
472
+ const relConstrains = gc.constrains || ic.relations_constrains || 0;
473
+ const relImplements = gc.implements || ic.relations_implements || 0;
474
+ const relImports = gc.imports || ic.relations_imports || 0;
475
+ const relSupersedes = gc.supersedes || ic.relations_supersedes || 0;
476
+ const totalRelations = relCalls + relDefines + relConstrains + relImplements + relImports + relSupersedes;
477
+
478
+ const tokenEstimate = estimatePerTaskTokens(baseline);
479
+
480
+ const embedCount = ec.output ?? ec.entities ?? 0;
481
+ const embedModel = manifests.embed?.model || null;
482
+ const embedDim = manifests.embed?.dimensions || 0;
483
+
484
+ const freshness = computeFreshness(manifests.ingest);
485
+ const version = getVersionStatus();
486
+ const topConnected = computeTopConnected();
487
+
488
+ const timeAgo = (isoStr) => {
489
+ if (!isoStr) return "never";
490
+ const diff = Date.now() - new Date(isoStr).getTime();
491
+ const mins = Math.floor(diff / 60000);
492
+ if (mins < 1) return "just now";
493
+ if (mins < 60) return `${mins}m ago`;
494
+ const hours = Math.floor(mins / 60);
495
+ if (hours < 24) return `${hours}h ago`;
496
+ return `${Math.floor(hours / 24)}d ago`;
497
+ };
498
+
499
+ return {
500
+ baseline,
501
+ cortex: {
502
+ files: gc.files || ic.files || 0,
503
+ chunks: gc.chunks || ic.chunks || 0,
504
+ rules: gc.rules || ic.rules || 0,
505
+ adrs: gc.adrs || ic.adrs || 0,
506
+ totalEntities,
507
+ relations: { calls: relCalls, defines: relDefines, constrains: relConstrains, implements: relImplements, imports: relImports, supersedes: relSupersedes, total: totalRelations },
508
+ },
509
+ tokens: tokenEstimate,
510
+ embeddings: embedModel ? { model: embedModel, count: embedCount, dimensions: embedDim } : null,
511
+ freshness,
512
+ version,
513
+ topConnected,
514
+ timestamps: {
515
+ lastIngest: timeAgo(manifests.ingest?.generated_at),
516
+ lastGraph: timeAgo(manifests.graph?.generated_at),
517
+ lastEmbed: timeAgo(manifests.embed?.generated_at),
518
+ },
519
+ };
520
+ }
521
+
522
+ // ── Render helpers ────────────────────────────────────────────
523
+ function formatNum(n) {
524
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
525
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
526
+ return String(n);
527
+ }
528
+
529
+ function bar(value, max, width = 16) {
530
+ if (max <= 0) return col("░".repeat(width), C.dimGray);
531
+ const ratio = Math.min(1, value / max);
532
+ const filled = Math.round(ratio * width);
533
+ return col("█".repeat(filled), C.cyan) + col("░".repeat(width - filled), C.dimGray);
534
+ }
535
+
536
+ function freshnessBar(percent, width = 10) {
537
+ const color = percent >= 70 ? C.green : percent >= 40 ? C.yellow : C.red;
538
+ const filled = Math.round((percent / 100) * width);
539
+ return col("█".repeat(filled), color) + col("░".repeat(width - filled), C.dimGray);
540
+ }
541
+
542
+ function padR(str, len) {
543
+ const visible = str.replace(/\x1b\[[0-9;]*m/g, "");
544
+ return str + " ".repeat(Math.max(0, len - visible.length));
545
+ }
546
+
547
+ function padL(str, len) {
548
+ const visible = str.replace(/\x1b\[[0-9;]*m/g, "");
549
+ return " ".repeat(Math.max(0, len - visible.length)) + str;
550
+ }
551
+
552
+ function borderLine(left, fill, right, width) {
553
+ return col(`${left}${fill.repeat(Math.max(0, width - 2))}${right}`, C.gray);
554
+ }
555
+
556
+ function sideBorder(content, width) {
557
+ const visible = content.replace(/\x1b\[[0-9;]*m/g, "");
558
+ const pad = Math.max(0, width - 4 - visible.length);
559
+ return col("│", C.gray) + " " + content + " ".repeat(pad) + " " + col("│", C.gray);
560
+ }
561
+
562
+ function emptyLine(width) {
563
+ return col("│", C.gray) + " ".repeat(width - 2) + col("│", C.gray);
564
+ }
565
+
566
+ // ── Render sections ──────────────────────────────────────────
567
+ function render(data, isTTY) {
568
+ const termWidth = process.stdout.columns || 80;
569
+ const w = Math.min(Math.max(termWidth, 50), 72);
570
+ const lines = [];
571
+
572
+ // Header
573
+ const clock = new Date().toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" });
574
+ const title = "─ cortex dashboard ";
575
+ const clockPart = ` ${clock} ─`;
576
+ const fillLen = w - 2 - title.length - clockPart.length;
577
+ lines.push(col(`┌${title}${"─".repeat(Math.max(0, fillLen))}${clockPart}┐`, C.gray));
578
+ lines.push(emptyLine(w));
579
+
580
+ if (data.version.state === "update-available") {
581
+ lines.push(sideBorder(bold(col("VERSION WARNING", C.red)), w));
582
+ lines.push(sideBorder(
583
+ `${col("Update available:", C.red)} ${bold(col(`${data.version.local} -> ${data.version.latest}`, C.red))}`, w));
584
+ lines.push(sideBorder(
585
+ `${dim("Run:")} ${col(VERSION_INSTALL_HINT, C.yellow)}`, w));
586
+ lines.push(emptyLine(w));
587
+ }
588
+
589
+ // ── WITHOUT vs WITH CORTEX ──
590
+ lines.push(sideBorder(
591
+ `${dim("WITHOUT CORTEX")} ${bold(col("WITH CORTEX", C.green))}`, w));
592
+ lines.push(sideBorder(
593
+ `${dim("───────────────")} ${col("────────────────", C.green)}`, w));
594
+
595
+ const leftCol = 17;
596
+ const compRows = [
597
+ [dim(padR(`${data.baseline.files} raw files`, leftCol + 14)),
598
+ col(`${data.cortex.files} files`, C.green) + ` + ` + col(`${data.cortex.chunks} chunks`, C.green)],
599
+ [dim(padR(`0 relationships`, leftCol + 14)),
600
+ col(`${data.cortex.relations.total} mapped relations`, C.green)],
601
+ [dim(padR(`0 architectural rules`, leftCol + 14)),
602
+ col(`${data.cortex.rules} enforced rules`, C.green)],
603
+ [dim(padR(`0 trust signals`, leftCol + 14)),
604
+ col(`${data.cortex.totalEntities} trust-scored entities`, C.green)],
605
+ [dim(padR(`0 semantic vectors`, leftCol + 14)),
606
+ data.embeddings ? col(`${data.embeddings.count} embedded vectors`, C.green) : col("no embeddings", C.yellow)],
607
+ [dim(padR(`flat file list`, leftCol + 14)),
608
+ col(`ranked hybrid search`, C.green)],
609
+ ];
610
+ for (const [left, right] of compRows) {
611
+ lines.push(sideBorder(`${left} ${right}`, w));
612
+ }
613
+ lines.push(emptyLine(w));
614
+
615
+ // ── TOKENS ──
616
+ lines.push(sideBorder(bold("TOKENS") + dim(" per task estimate"), w));
617
+ lines.push(sideBorder(
618
+ `${dim("Codebase:")} ${col(`~${formatNum(data.tokens.codebase)} tokens`, C.gray)} ${dim(`across ${data.baseline.files} files`)}`, w));
619
+ lines.push(sideBorder(
620
+ `${dim("Without Cortex:")} ${col(`~${formatNum(data.tokens.baselinePerTask)} tokens`, C.gray)} ${dim(`(≈${data.tokens.filesPerTask} file reads)`)}`, w));
621
+ lines.push(sideBorder(
622
+ `${dim("With Cortex:")} ${col(`~${formatNum(data.tokens.cortexPerTask)} tokens`, C.green)} ${dim(`(≈${data.tokens.queriesPerTask} searches)`)}`, w));
623
+ if (data.tokens.ratio > 0) {
624
+ lines.push(sideBorder(
625
+ `${dim("Efficiency:")} ${bold(col(`${data.tokens.ratio}x`, C.green))} ${dim("reduction")}`, w));
626
+ const reductionWidth = Math.min(40, w - 16);
627
+ const filled = Math.round((data.tokens.reduction / 100) * reductionWidth);
628
+ const reductionBar = col("█".repeat(filled), C.green) + col("░".repeat(reductionWidth - filled), C.dimGray);
629
+ lines.push(sideBorder(`${reductionBar} ${col(`${data.tokens.reduction}% less tokens`, C.green)}`, w));
630
+ }
631
+ lines.push(emptyLine(w));
632
+
633
+ // ── CORTEX ADDS ──
634
+ lines.push(sideBorder(bold("CORTEX ADDS"), w));
635
+ const adds = [
636
+ data.cortex.chunks > 0 ? col(`+${data.cortex.chunks} chunks`, C.green) : null,
637
+ data.cortex.relations.total > 0 ? col(`+${data.cortex.relations.total} relations`, C.green) : null,
638
+ data.cortex.rules > 0 ? col(`+${data.cortex.rules} rules`, C.green) : null,
639
+ data.embeddings ? col(`+${data.embeddings.count} embeddings`, C.green) : null,
640
+ ].filter(Boolean);
641
+ if (adds.length > 0) {
642
+ lines.push(sideBorder(adds.join(" "), w));
643
+ }
644
+ const caps = [
645
+ data.embeddings ? "Semantic search" : null,
646
+ data.cortex.relations.total > 0 ? "Graph traversal" : null,
647
+ data.cortex.chunks > 0 ? "Impact analysis" : null,
648
+ ].filter(Boolean);
649
+ if (caps.length > 0) {
650
+ lines.push(sideBorder(dim(caps.join(" • ")), w));
651
+ }
652
+ lines.push(emptyLine(w));
653
+
654
+ // ── RELATIONS ──
655
+ lines.push(sideBorder(bold("RELATIONS"), w));
656
+ const rels = data.cortex.relations;
657
+ const maxRel = Math.max(rels.calls, rels.defines, rels.constrains, rels.implements, rels.imports, rels.supersedes, 1);
658
+ const barW = Math.min(16, w - 30);
659
+ const relRows = [
660
+ ["CALLS", rels.calls, C.cyan],
661
+ ["DEFINES", rels.defines, C.blue],
662
+ ["CONSTRAINS", rels.constrains, C.orange],
663
+ ["IMPLEMENTS", rels.implements, C.green],
664
+ ["IMPORTS", rels.imports, C.purple],
665
+ ["SUPERSEDES", rels.supersedes, C.gray],
666
+ ];
667
+ for (const [label, count, _color] of relRows) {
668
+ const b = bar(count, maxRel, barW);
669
+ lines.push(sideBorder(`${padR(label, 12)} ${b} ${padL(String(count), 4)}`, w));
670
+ }
671
+ lines.push(emptyLine(w));
672
+
673
+ // ── HEALTH ──
674
+ lines.push(sideBorder(bold("HEALTH"), w));
675
+ if (data.freshness.percent >= 0) {
676
+ const fb = freshnessBar(data.freshness.percent);
677
+ lines.push(sideBorder(
678
+ `Freshness ${col("[", C.gray)}${fb}${col("]", C.gray)} ${data.freshness.percent}% ${dim(`Last sync: ${data.timestamps.lastIngest}`)}`, w));
679
+ } else {
680
+ lines.push(sideBorder(dim("Freshness: unavailable (git not accessible)"), w));
681
+ }
682
+ if (data.version.state === "update-available") {
683
+ lines.push(sideBorder(
684
+ `Version: ${bold(col("UPDATE AVAILABLE", C.red))} ${col(`${data.version.local} -> ${data.version.latest}`, C.red)}`, w));
685
+ } else if (data.version.state === "current") {
686
+ lines.push(sideBorder(
687
+ `Version: ${col(data.version.local, C.green)} ${dim(`Latest: ${data.version.latest}`)}`, w));
688
+ } else if (data.version.local) {
689
+ lines.push(sideBorder(
690
+ `Version: ${col(data.version.local, C.yellow)} ${dim(`Check unavailable: ${data.version.message}`)}`, w));
691
+ } else {
692
+ lines.push(sideBorder(
693
+ `Version: ${col("check unavailable", C.yellow)} ${dim(data.version.message)}`, w));
694
+ }
695
+ if (data.embeddings) {
696
+ const check = data.embeddings.count > 0 ? col("✓", C.green) : col("✗", C.red);
697
+ lines.push(sideBorder(
698
+ `Embeddings: ${data.embeddings.count} ${check} ${dim(`Model: ${data.embeddings.model}`)}`, w));
699
+ } else {
700
+ lines.push(sideBorder(`Embeddings: ${col("not generated", C.yellow)} ${dim("Run: cortex embed")}`, w));
701
+ }
702
+ lines.push(emptyLine(w));
703
+
704
+ // ── TOP CONNECTED ──
705
+ if (data.topConnected.length > 0) {
706
+ lines.push(sideBorder(bold("TOP CONNECTED"), w));
707
+ for (const t of data.topConnected) {
708
+ const degStr = padL(String(t.degree), 4);
709
+ lines.push(sideBorder(` ${padR(t.label, 20)} ${dim("───")} ${col(degStr, C.cyan)} edges`, w));
710
+ }
711
+ lines.push(emptyLine(w));
712
+ }
713
+
714
+ // Footer
715
+ const interval = parseInterval();
716
+ const footerLeft = " q quit r refresh ";
717
+ const footerRight = ` ${interval}s auto `;
718
+ const footerFill = w - 2 - footerLeft.length - footerRight.length;
719
+ lines.push(col(`└──${footerLeft}${"─".repeat(Math.max(0, footerFill))}${footerRight}─┘`, C.gray));
720
+
721
+ return lines.join("\n");
722
+ }
723
+
724
+ // ── CLI arg parsing ──────────────────────────────────────────
725
+ function parseInterval() {
726
+ const args = process.argv.slice(2);
727
+ const idx = args.indexOf("--interval");
728
+ if (idx >= 0 && args[idx + 1]) {
729
+ const val = Number(args[idx + 1]);
730
+ if (val > 0) return val;
731
+ }
732
+ return 2;
733
+ }
734
+
735
+ // ── Main ─────────────────────────────────────────────────────
736
+ function main() {
737
+ const interval = parseInterval();
738
+ const isTTY = process.stdout.isTTY;
739
+
740
+ // Non-TTY: one-shot plain output
741
+ if (!isTTY) {
742
+ const baseline = scanBaseline();
743
+ const data = gatherData(baseline);
744
+ // Strip ANSI for pipe output
745
+ const output = render(data, false).replace(/\x1b\[[0-9;]*m/g, "");
746
+ process.stdout.write(output + "\n");
747
+ process.exit(0);
748
+ }
749
+
750
+ // TTY: live TUI
751
+ let baselineCache = scanBaseline();
752
+ let timer = null;
753
+
754
+ function cleanup() {
755
+ if (timer) clearInterval(timer);
756
+ process.stdout.write(SHOW_CURSOR + RESET + "\n");
757
+ if (process.stdin.isTTY) {
758
+ process.stdin.setRawMode(false);
759
+ }
760
+ process.exit(0);
761
+ }
762
+
763
+ process.on("SIGINT", cleanup);
764
+ process.on("SIGTERM", cleanup);
765
+
766
+ process.stdout.write(HIDE_CURSOR);
767
+
768
+ if (process.stdin.isTTY) {
769
+ process.stdin.setRawMode(true);
770
+ process.stdin.resume();
771
+ process.stdin.setEncoding("utf8");
772
+
773
+ process.stdin.on("data", (key) => {
774
+ if (key === "q" || key === "\x03") {
775
+ cleanup();
776
+ } else if (key === "r") {
777
+ baselineCache = scanBaseline();
778
+ renderFrame();
779
+ }
780
+ });
781
+ }
782
+
783
+ function renderFrame() {
784
+ const data = gatherData(baselineCache);
785
+ const output = render(data, true);
786
+ process.stdout.write(HOME + output + CLEAR_DOWN);
787
+ }
788
+
789
+ renderFrame();
790
+ timer = setInterval(renderFrame, interval * 1000);
791
+
792
+ process.stdout.on("resize", () => {
793
+ renderFrame();
794
+ });
795
+ }
796
+
797
+ main();