@jefuriiij/synthra 0.1.0

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.
@@ -0,0 +1,4284 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // package.json
12
+ var package_exports = {};
13
+ __export(package_exports, {
14
+ default: () => package_default
15
+ });
16
+ var package_default;
17
+ var init_package = __esm({
18
+ "package.json"() {
19
+ package_default = {
20
+ name: "@jefuriiij/synthra",
21
+ version: "0.1.0",
22
+ publishConfig: {
23
+ access: "public"
24
+ },
25
+ description: "Local context engine for AI coding assistants \u2014 graph-based context, branch-aware memory, real-time human-activity awareness, deterministic Grep/Glob gating, and a live token dashboard.",
26
+ type: "module",
27
+ bin: {
28
+ syn: "./bin/syn",
29
+ synthra: "./bin/syn"
30
+ },
31
+ scripts: {
32
+ build: "tsup",
33
+ dev: "tsup --watch",
34
+ test: "vitest run",
35
+ "test:watch": "vitest",
36
+ typecheck: "tsc --noEmit"
37
+ },
38
+ files: [
39
+ "dist",
40
+ "bin",
41
+ "README.md",
42
+ "LICENSE",
43
+ "ROADMAP.md"
44
+ ],
45
+ keywords: [
46
+ "claude-code",
47
+ "mcp",
48
+ "context-engine",
49
+ "code-graph",
50
+ "ai-coding",
51
+ "token-savings"
52
+ ],
53
+ author: "Jeff (@jefuriiij)",
54
+ license: "MIT",
55
+ homepage: "https://github.com/jefuriiij/synthra#readme",
56
+ repository: {
57
+ type: "git",
58
+ url: "git+https://github.com/jefuriiij/synthra.git"
59
+ },
60
+ bugs: {
61
+ url: "https://github.com/jefuriiij/synthra/issues"
62
+ },
63
+ engines: {
64
+ node: ">=18"
65
+ },
66
+ dependencies: {
67
+ "@hono/node-server": "^1.18.0",
68
+ chokidar: "^5.0.0",
69
+ hono: "^4.12.23",
70
+ ignore: "^7.0.0",
71
+ sade: "^1.8.1",
72
+ "tree-sitter-wasms": "^0.1.12",
73
+ "web-tree-sitter": "~0.22.6"
74
+ },
75
+ devDependencies: {
76
+ "@types/node": "^25.9.1",
77
+ tsup: "^8.5.1",
78
+ typescript: "^6.0.3",
79
+ vitest: "^4.1.7"
80
+ }
81
+ };
82
+ }
83
+ });
84
+
85
+ // src/cli/index.ts
86
+ import sade from "sade";
87
+ import { resolve as resolve4 } from "path";
88
+
89
+ // src/dashboard/server.ts
90
+ import { serve } from "@hono/node-server";
91
+ import { Hono } from "hono";
92
+
93
+ // src/shared/logger.ts
94
+ var LEVEL_PRIORITY = {
95
+ debug: 10,
96
+ info: 20,
97
+ warn: 30,
98
+ error: 40
99
+ };
100
+ var activeLevel = process.env.SYN_LOG_LEVEL ?? "info";
101
+ function shouldLog(level) {
102
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];
103
+ }
104
+ function emit(level, msg, ...args) {
105
+ if (!shouldLog(level)) return;
106
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
107
+ stream.write(`[syn] ${msg}${args.length ? " " + args.map(String).join(" ") : ""}
108
+ `);
109
+ }
110
+ var log = {
111
+ debug: (m, ...a) => emit("debug", m, ...a),
112
+ info: (m, ...a) => emit("info", m, ...a),
113
+ warn: (m, ...a) => emit("warn", m, ...a),
114
+ error: (m, ...a) => emit("error", m, ...a)
115
+ };
116
+
117
+ // src/server/port.ts
118
+ import { createServer } from "net";
119
+ var PORT_RANGE_START = 8080;
120
+ var PORT_RANGE_END = 8099;
121
+ async function findFreePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
122
+ for (let port = start; port <= end; port++) {
123
+ if (await isFree(port)) return port;
124
+ }
125
+ throw new Error(`Synthra: no free port in ${start}-${end}`);
126
+ }
127
+ function isFree(port) {
128
+ return new Promise((resolve5) => {
129
+ const s = createServer();
130
+ s.once("error", () => resolve5(false));
131
+ s.once("listening", () => s.close(() => resolve5(true)));
132
+ s.listen(port, "127.0.0.1");
133
+ });
134
+ }
135
+
136
+ // src/dashboard/delta.ts
137
+ import { readFile as readFile2 } from "fs/promises";
138
+
139
+ // src/shared/paths.ts
140
+ import { join } from "path";
141
+ function resolvePaths(projectRoot) {
142
+ const graphDir = join(projectRoot, ".synthra-graph");
143
+ const contextDir = join(projectRoot, ".synthra");
144
+ const claudeDir = join(projectRoot, ".claude");
145
+ return {
146
+ projectRoot,
147
+ graphDir,
148
+ contextDir,
149
+ infoGraph: join(graphDir, "info_graph.json"),
150
+ symbolIndex: join(graphDir, "symbol_index.json"),
151
+ sessionState: join(graphDir, "session.json"),
152
+ activityLog: join(graphDir, "activity.jsonl"),
153
+ tokenLog: join(graphDir, "token_log.jsonl"),
154
+ gateLog: join(graphDir, "gate_log.jsonl"),
155
+ mcpPort: join(graphDir, "mcp_port"),
156
+ mcpServerLog: join(graphDir, "mcp_server.log"),
157
+ mcpServerErrLog: join(graphDir, "mcp_server.err.log"),
158
+ contextStore: join(contextDir, "context-store.json"),
159
+ contextMd: join(contextDir, "CONTEXT.md"),
160
+ branchesDir: join(contextDir, "branches"),
161
+ claudeDir,
162
+ claudeSettings: join(claudeDir, "settings.local.json"),
163
+ claudeHooksDir: join(claudeDir, "hooks"),
164
+ claudeMd: join(projectRoot, "CLAUDE.md"),
165
+ gitignore: join(projectRoot, ".gitignore")
166
+ };
167
+ }
168
+
169
+ // src/shared/pricing.ts
170
+ var PRICING = {
171
+ // Opus-class models — premium tier
172
+ "claude-opus-4-7": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
173
+ "claude-opus-4-6": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
174
+ "claude-opus-4-5": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
175
+ // Sonnet-class — workhorse
176
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
177
+ "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
178
+ // Haiku-class — fast and cheap
179
+ "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 }
180
+ };
181
+ var FALLBACK = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };
182
+ function pricingFor(model) {
183
+ if (!model) return FALLBACK;
184
+ const direct = PRICING[model];
185
+ if (direct) return direct;
186
+ if (model.includes("opus")) return PRICING["claude-opus-4-7"] ?? FALLBACK;
187
+ if (model.includes("sonnet")) return PRICING["claude-sonnet-4-6"] ?? FALLBACK;
188
+ if (model.includes("haiku")) return PRICING["claude-haiku-4-5"] ?? FALLBACK;
189
+ return FALLBACK;
190
+ }
191
+ function estimateCostUsd(usage) {
192
+ const p = pricingFor(usage.model);
193
+ return usage.input_tokens / 1e6 * p.input + usage.output_tokens / 1e6 * p.output + (usage.cache_read_input_tokens ?? 0) / 1e6 * p.cacheRead + (usage.cache_creation_input_tokens ?? 0) / 1e6 * p.cacheCreate;
194
+ }
195
+
196
+ // src/shared/project-registry.ts
197
+ import { mkdir, readFile, writeFile } from "fs/promises";
198
+ import { homedir } from "os";
199
+ import { basename, dirname, join as join2 } from "path";
200
+ var REGISTRY_DIR = join2(homedir(), ".synthra");
201
+ var REGISTRY_PATH = join2(REGISTRY_DIR, "projects.json");
202
+ var SCHEMA_VERSION = 1;
203
+ async function readRegistry() {
204
+ try {
205
+ const raw = await readFile(REGISTRY_PATH, "utf8");
206
+ const parsed = JSON.parse(raw);
207
+ if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };
208
+ return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };
209
+ } catch {
210
+ return { schema_version: SCHEMA_VERSION, projects: [] };
211
+ }
212
+ }
213
+ async function writeRegistry(registry) {
214
+ await mkdir(dirname(REGISTRY_PATH), { recursive: true });
215
+ await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf8");
216
+ }
217
+ async function recordProject(projectRoot) {
218
+ const now = (/* @__PURE__ */ new Date()).toISOString();
219
+ const registry = await readRegistry();
220
+ const existing = registry.projects.find((p) => p.path === projectRoot);
221
+ if (existing) {
222
+ existing.last_seen = now;
223
+ existing.name = basename(projectRoot);
224
+ } else {
225
+ registry.projects.push({
226
+ path: projectRoot,
227
+ name: basename(projectRoot),
228
+ first_seen: now,
229
+ last_seen: now
230
+ });
231
+ }
232
+ try {
233
+ await writeRegistry(registry);
234
+ } catch {
235
+ }
236
+ }
237
+ async function listProjects() {
238
+ const registry = await readRegistry();
239
+ return registry.projects.slice().sort((a, b) => a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0);
240
+ }
241
+
242
+ // src/dashboard/delta.ts
243
+ var AVG_TOKENS_PER_BLOCKED_GREP = 500;
244
+ async function readJsonl(path) {
245
+ try {
246
+ const text = await readFile2(path, "utf8");
247
+ return text.split(/\r?\n/).filter((l) => l.length > 0).map((l) => {
248
+ try {
249
+ return JSON.parse(l);
250
+ } catch {
251
+ return null;
252
+ }
253
+ }).filter((v) => v !== null);
254
+ } catch {
255
+ return [];
256
+ }
257
+ }
258
+ function basename2(p) {
259
+ const parts = p.split(/[\\/]/);
260
+ return parts[parts.length - 1] || p;
261
+ }
262
+ function summarize(p) {
263
+ let totalIn = 0;
264
+ let totalOut = 0;
265
+ let totalCacheRead = 0;
266
+ let totalCacheCreate = 0;
267
+ let costUsd = 0;
268
+ const models = {};
269
+ for (const t of p.tokens) {
270
+ totalIn += t.input_tokens ?? 0;
271
+ totalOut += t.output_tokens ?? 0;
272
+ totalCacheRead += t.cache_read_input_tokens ?? 0;
273
+ totalCacheCreate += t.cache_creation_input_tokens ?? 0;
274
+ costUsd += estimateCostUsd(t);
275
+ if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;
276
+ }
277
+ const blocked = p.gates.filter((g) => g.decision === "block").length;
278
+ const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;
279
+ return {
280
+ path: p.path,
281
+ name: p.name,
282
+ last_seen: p.last_seen,
283
+ total_turns: p.tokens.length,
284
+ total_input_tokens: totalIn,
285
+ total_output_tokens: totalOut,
286
+ total_cache_read: totalCacheRead,
287
+ total_cache_create: totalCacheCreate,
288
+ total_gate_calls: p.gates.length,
289
+ blocked_count: blocked,
290
+ estimated_tokens_saved: saved,
291
+ estimated_cost_usd: Math.round(costUsd * 100) / 100,
292
+ models
293
+ };
294
+ }
295
+ async function loadProjectFiles(path, name, lastSeen) {
296
+ const paths = resolvePaths(path);
297
+ const [rawTokens, gates] = await Promise.all([
298
+ readJsonl(paths.tokenLog),
299
+ readJsonl(paths.gateLog)
300
+ ]);
301
+ return { path, name, last_seen: lastSeen, tokens: dedupeTokens(rawTokens), gates };
302
+ }
303
+ function dedupeTokens(entries) {
304
+ const score2 = (model) => {
305
+ if (!model) return 0;
306
+ if (model === "<synthetic>") return 1;
307
+ return 2;
308
+ };
309
+ const groups = /* @__PURE__ */ new Map();
310
+ for (const e of entries) {
311
+ const ts = e.ts ?? e.written_at ?? "";
312
+ const second = ts.slice(0, 19);
313
+ const key = [
314
+ e.project ?? "",
315
+ e.input_tokens ?? 0,
316
+ e.output_tokens ?? 0,
317
+ e.cache_creation_input_tokens ?? 0,
318
+ e.cache_read_input_tokens ?? 0,
319
+ second
320
+ ].join("|");
321
+ const arr = groups.get(key) ?? [];
322
+ arr.push(e);
323
+ groups.set(key, arr);
324
+ }
325
+ const out = [];
326
+ for (const arr of groups.values()) {
327
+ if (arr.length === 1) {
328
+ out.push(arr[0]);
329
+ continue;
330
+ }
331
+ arr.sort((a, b) => score2(b.model) - score2(a.model));
332
+ out.push(arr[0]);
333
+ }
334
+ out.sort((a, b) => {
335
+ const at = a.ts ?? a.written_at ?? "";
336
+ const bt = b.ts ?? b.written_at ?? "";
337
+ return at.localeCompare(bt);
338
+ });
339
+ return out;
340
+ }
341
+ async function computeDashboardData(activePaths, recentN = 25) {
342
+ const registered = await listProjects();
343
+ const activePath = activePaths.projectRoot;
344
+ const activeName = basename2(activePath);
345
+ const knownPaths = new Set(registered.map((p) => p.path));
346
+ const allEntries = [
347
+ ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen }))
348
+ ];
349
+ if (!knownPaths.has(activePath)) {
350
+ allEntries.unshift({ path: activePath, name: activeName, last_seen: null });
351
+ }
352
+ const loaded = await Promise.all(
353
+ allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen))
354
+ );
355
+ const projects = loaded.map(summarize).sort((a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens));
356
+ const activeFiles = loaded.find((p) => p.path === activePath) ?? {
357
+ path: activePath,
358
+ name: activeName,
359
+ last_seen: null,
360
+ tokens: [],
361
+ gates: []
362
+ };
363
+ const activeStats = summarize(activeFiles);
364
+ let g_in = 0, g_out = 0, g_cr = 0, g_cc = 0, g_gate = 0, g_block = 0, g_cost = 0, g_turns = 0;
365
+ for (const s of projects) {
366
+ g_turns += s.total_turns;
367
+ g_in += s.total_input_tokens;
368
+ g_out += s.total_output_tokens;
369
+ g_cr += s.total_cache_read;
370
+ g_cc += s.total_cache_create;
371
+ g_gate += s.total_gate_calls;
372
+ g_block += s.blocked_count;
373
+ g_cost += s.estimated_cost_usd;
374
+ }
375
+ const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;
376
+ const g_used = g_in + g_out + g_cc;
377
+ const g_saved_pct = g_used + g_saved > 0 ? g_saved / (g_used + g_saved) * 100 : 0;
378
+ const allTurns = [];
379
+ const allGates = [];
380
+ for (const p of loaded) {
381
+ for (const t of p.tokens) {
382
+ allTurns.push({
383
+ // Fall back to written_at — the Stop hook today posts entries without
384
+ // a `ts` field, and the server tags them with written_at on receive.
385
+ ts: t.ts ?? t.written_at ?? "",
386
+ project_name: p.name,
387
+ project_path: p.path,
388
+ input: t.input_tokens ?? 0,
389
+ output: t.output_tokens ?? 0,
390
+ cache_read: t.cache_read_input_tokens ?? 0,
391
+ cache_create: t.cache_creation_input_tokens ?? 0,
392
+ model: t.model ?? "",
393
+ cost_usd: Math.round(estimateCostUsd(t) * 1e3) / 1e3
394
+ });
395
+ }
396
+ for (const gate of p.gates) {
397
+ allGates.push({
398
+ ts: gate.ts,
399
+ project_name: p.name,
400
+ project_path: p.path,
401
+ tool: gate.tool,
402
+ decision: gate.decision,
403
+ query: gate.query
404
+ });
405
+ }
406
+ }
407
+ allTurns.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
408
+ allGates.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
409
+ return {
410
+ active: {
411
+ project_root: activePath,
412
+ project_name: activeName,
413
+ stats: activeStats
414
+ },
415
+ global: {
416
+ project_count: projects.length,
417
+ total_turns: g_turns,
418
+ total_input_tokens: g_in,
419
+ total_output_tokens: g_out,
420
+ total_cache_read: g_cr,
421
+ total_cache_create: g_cc,
422
+ total_gate_calls: g_gate,
423
+ blocked_count: g_block,
424
+ estimated_tokens_saved: g_saved,
425
+ saved_percent: Math.round(g_saved_pct * 10) / 10,
426
+ estimated_cost_usd: Math.round(g_cost * 100) / 100
427
+ },
428
+ projects,
429
+ recent_turns: allTurns.slice(0, recentN),
430
+ recent_gates: allGates.slice(0, recentN)
431
+ };
432
+ }
433
+
434
+ // src/dashboard/public/index.html
435
+ var public_default = `<!doctype html>
436
+ <html lang="en">
437
+ <head>
438
+ <meta charset="UTF-8" />
439
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
440
+ <title>Synthra \u2014 Token Dashboard</title>
441
+ <link rel="stylesheet" href="./style.css" />
442
+ </head>
443
+ <body>
444
+ <header>
445
+ <div class="brand">
446
+ <h1>Synthra</h1>
447
+ <span class="tag">Token Dashboard</span>
448
+ </div>
449
+ <div class="meta">
450
+ <span class="active-project" id="active-project">\u2026</span>
451
+ <span class="dot" id="dot"></span>
452
+ <span id="status">connecting\u2026</span>
453
+ </div>
454
+ </header>
455
+
456
+ <main>
457
+ <section>
458
+ <h2>Global totals <span class="muted">(all projects)</span></h2>
459
+ <div class="cards" id="cards"></div>
460
+ </section>
461
+
462
+ <section>
463
+ <h2>Projects</h2>
464
+ <div class="projects" id="projects"></div>
465
+ <p class="empty hidden" id="projects-empty">No projects registered yet. Run <code>syn .</code> in any project to add it.</p>
466
+ </section>
467
+
468
+ <section>
469
+ <h2>Recent calls <span class="muted">(across all projects)</span></h2>
470
+ <table id="turns">
471
+ <thead>
472
+ <tr>
473
+ <th>Time</th>
474
+ <th>Project</th>
475
+ <th>Model</th>
476
+ <th class="num">Input</th>
477
+ <th class="num">Output</th>
478
+ <th class="num">Cache R / W</th>
479
+ <th class="num">Cost</th>
480
+ </tr>
481
+ </thead>
482
+ <tbody></tbody>
483
+ </table>
484
+ <p class="empty hidden" id="turns-empty">No turns logged yet. Use Claude via the IDE extension or <code>claude</code> CLI while <code>syn .</code> is running.</p>
485
+ </section>
486
+
487
+ <section>
488
+ <h2>Recent gate decisions</h2>
489
+ <table id="gates">
490
+ <thead>
491
+ <tr>
492
+ <th>Time</th>
493
+ <th>Project</th>
494
+ <th>Tool</th>
495
+ <th>Decision</th>
496
+ <th>Query</th>
497
+ </tr>
498
+ </thead>
499
+ <tbody></tbody>
500
+ </table>
501
+ <p class="empty hidden" id="gates-empty">No gate decisions yet.</p>
502
+ </section>
503
+ </main>
504
+
505
+ <footer>
506
+ <span>Token Counter MCP \xB7 live polling every 2s</span>
507
+ <span class="muted">Cost figures are approximate \u2014 see /docs/PROTOCOL.md</span>
508
+ </footer>
509
+
510
+ <script>
511
+ const $ = (sel) => document.querySelector(sel);
512
+ const cardsEl = $("#cards");
513
+ const projectsEl = $("#projects");
514
+ const turnsBody = $("#turns tbody");
515
+ const gatesBody = $("#gates tbody");
516
+ const turnsEmpty = $("#turns-empty");
517
+ const gatesEmpty = $("#gates-empty");
518
+ const projectsEmpty = $("#projects-empty");
519
+ const statusEl = $("#status");
520
+ const dotEl = $("#dot");
521
+ const activeProjectEl = $("#active-project");
522
+
523
+ function fmt(n) {
524
+ if (typeof n !== "number" || !Number.isFinite(n)) return "0";
525
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
526
+ if (n >= 1000) return (n / 1000).toFixed(1) + "K";
527
+ return n.toLocaleString();
528
+ }
529
+
530
+ function fmtFull(n) {
531
+ return (typeof n === "number" ? n : 0).toLocaleString();
532
+ }
533
+
534
+ function fmtCost(usd) {
535
+ if (typeof usd !== "number") return "~$0.00";
536
+ if (usd >= 1) return "~$" + usd.toFixed(2);
537
+ if (usd >= 0.01) return "~$" + usd.toFixed(3);
538
+ return "~$" + usd.toFixed(4);
539
+ }
540
+
541
+ function fmtTs(iso) {
542
+ try {
543
+ const d = new Date(iso);
544
+ const today = new Date();
545
+ const isToday = d.toDateString() === today.toDateString();
546
+ if (isToday) return d.toLocaleTimeString();
547
+ return d.toLocaleString();
548
+ } catch {
549
+ return iso;
550
+ }
551
+ }
552
+
553
+ function renderCards(g) {
554
+ cardsEl.innerHTML = "";
555
+ const cards = [
556
+ { label: "Total cost", value: fmtCost(g.estimated_cost_usd), accent: true },
557
+ { label: "Turns", value: fmt(g.total_turns) },
558
+ { label: "Input", value: fmt(g.total_input_tokens) },
559
+ { label: "Output", value: fmt(g.total_output_tokens) },
560
+ { label: "Cache read", value: fmt(g.total_cache_read) },
561
+ { label: "Cache write", value: fmt(g.total_cache_create) },
562
+ { label: "Projects", value: fmt(g.project_count) },
563
+ { label: "Blocked Grep / Glob", value: fmt(g.blocked_count), accent: true },
564
+ { label: "Tokens saved", value: fmt(g.estimated_tokens_saved), accent: true },
565
+ ];
566
+ for (const c of cards) {
567
+ const el = document.createElement("div");
568
+ el.className = "card" + (c.accent ? " accent" : "");
569
+ el.innerHTML = '<div class="card-label">' + c.label + '</div><div class="card-value">' + c.value + '</div>';
570
+ cardsEl.appendChild(el);
571
+ }
572
+ }
573
+
574
+ function renderProjects(projects, globalCost) {
575
+ projectsEl.innerHTML = "";
576
+ if (!projects.length) {
577
+ projectsEmpty.classList.remove("hidden");
578
+ return;
579
+ }
580
+ projectsEmpty.classList.add("hidden");
581
+ const maxTokens = Math.max(1, ...projects.map((p) => p.total_input_tokens + p.total_output_tokens));
582
+ for (const p of projects) {
583
+ const total = p.total_input_tokens + p.total_output_tokens;
584
+ const pct = Math.round((total / maxTokens) * 100);
585
+ const sharePct = globalCost > 0 ? ((p.estimated_cost_usd / globalCost) * 100).toFixed(1) : "0.0";
586
+ const row = document.createElement("div");
587
+ row.className = "project-row";
588
+ row.innerHTML =
589
+ '<div class="project-name">' +
590
+ '<strong>' + p.name + '</strong>' +
591
+ '<code class="project-path">' + p.path + '</code>' +
592
+ '</div>' +
593
+ '<div class="project-stats">' +
594
+ '<div class="stat"><span class="stat-value cost">' + fmtCost(p.estimated_cost_usd) + '</span><span class="stat-label">cost (' + sharePct + '%)</span></div>' +
595
+ '<div class="stat"><span class="stat-value">' + fmt(total) + '</span><span class="stat-label">tokens</span></div>' +
596
+ '<div class="stat"><span class="stat-value">' + fmt(p.total_turns) + '</span><span class="stat-label">turns</span></div>' +
597
+ '<div class="stat"><span class="stat-value">' + fmt(p.blocked_count) + '</span><span class="stat-label">blocks</span></div>' +
598
+ '</div>' +
599
+ '<div class="bar"><div class="bar-fill" style="width:' + pct + '%"></div></div>';
600
+ projectsEl.appendChild(row);
601
+ }
602
+ }
603
+
604
+ function renderTurns(turns) {
605
+ turnsBody.innerHTML = "";
606
+ if (!turns.length) {
607
+ turnsEmpty.classList.remove("hidden");
608
+ return;
609
+ }
610
+ turnsEmpty.classList.add("hidden");
611
+ for (const t of turns) {
612
+ const tr = document.createElement("tr");
613
+ const modelCell =
614
+ t.model && t.model !== "<synthetic>"
615
+ ? "<code>" + t.model + "</code>"
616
+ : '<span class="muted">' + (t.model === "<synthetic>" ? "synthetic" : "unknown") + "</span>";
617
+ tr.innerHTML =
618
+ "<td>" + fmtTs(t.ts) + "</td>" +
619
+ "<td><code>" + t.project_name + "</code></td>" +
620
+ "<td>" + modelCell + "</td>" +
621
+ '<td class="num">' + fmtFull(t.input) + "</td>" +
622
+ '<td class="num">' + fmtFull(t.output) + "</td>" +
623
+ '<td class="num">' + fmt(t.cache_read) + " / " + fmt(t.cache_create) + "</td>" +
624
+ '<td class="num cost">' + fmtCost(t.cost_usd) + "</td>";
625
+ turnsBody.appendChild(tr);
626
+ }
627
+ }
628
+
629
+ function renderGates(gates) {
630
+ gatesBody.innerHTML = "";
631
+ if (!gates.length) {
632
+ gatesEmpty.classList.remove("hidden");
633
+ return;
634
+ }
635
+ gatesEmpty.classList.add("hidden");
636
+ for (const g of gates) {
637
+ const tr = document.createElement("tr");
638
+ const cls = g.decision === "block" ? "decision-block" : "decision-allow";
639
+ tr.innerHTML =
640
+ "<td>" + fmtTs(g.ts) + "</td>" +
641
+ "<td><code>" + g.project_name + "</code></td>" +
642
+ "<td><code>" + g.tool + "</code></td>" +
643
+ '<td class="' + cls + '">' + g.decision + "</td>" +
644
+ "<td><code>" + (g.query || "") + "</code></td>";
645
+ gatesBody.appendChild(tr);
646
+ }
647
+ }
648
+
649
+ async function tick() {
650
+ try {
651
+ const res = await fetch("/data");
652
+ if (!res.ok) throw new Error("HTTP " + res.status);
653
+ const data = await res.json();
654
+ activeProjectEl.textContent = data.active.project_root;
655
+ renderCards(data.global);
656
+ renderProjects(data.projects, data.global.estimated_cost_usd);
657
+ renderTurns(data.recent_turns);
658
+ renderGates(data.recent_gates);
659
+ statusEl.textContent = "live \xB7 " + new Date().toLocaleTimeString();
660
+ dotEl.classList.add("live");
661
+ dotEl.classList.remove("dead");
662
+ } catch (e) {
663
+ statusEl.textContent = "disconnected \xB7 " + e.message;
664
+ dotEl.classList.add("dead");
665
+ dotEl.classList.remove("live");
666
+ }
667
+ }
668
+
669
+ tick();
670
+ setInterval(tick, 2000);
671
+ </script>
672
+ </body>
673
+ </html>
674
+ `;
675
+
676
+ // src/dashboard/public/style.css
677
+ var style_default = '/* Synthra token dashboard \u2014 palette per project brief. */\n\n:root {\n --color-heading: #ECEBD8;\n --color-body: #EDECD9;\n --color-bg: #000000;\n --color-surface: #140009;\n --color-surface-raised: #1E000D;\n --color-border: #4D0020;\n --color-accent: #FF0073;\n --color-accent-darker: #EB006A;\n --color-form-bg: #2E0014;\n --color-muted: rgba(237, 236, 217, 0.55);\n --color-very-muted: rgba(237, 236, 217, 0.35);\n --color-block: #FF0073;\n --color-allow: #ECEBD8;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n background: var(--color-bg);\n color: var(--color-body);\n font-family:\n ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",\n system-ui, sans-serif;\n font-size: 14px;\n line-height: 1.5;\n min-height: 100vh;\n}\n\ncode, .num, table, .project-path {\n font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\nheader {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n padding: 1.1rem 2rem;\n background: var(--color-surface);\n border-bottom: 1px solid var(--color-border);\n}\n\nheader .brand {\n display: flex;\n align-items: baseline;\n gap: 0.75rem;\n}\n\nheader h1 {\n color: var(--color-heading);\n font-size: 1.35rem;\n font-weight: 700;\n letter-spacing: 0.02em;\n}\n\nheader .tag {\n color: var(--color-muted);\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n}\n\nheader .meta {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n font-size: 0.78rem;\n font-family: ui-monospace, monospace;\n color: var(--color-muted);\n}\n\nheader .active-project {\n max-width: 480px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nmain {\n padding: 2rem;\n display: flex;\n flex-direction: column;\n gap: 2rem;\n max-width: 1400px;\n margin: 0 auto;\n}\n\nsection {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\nh2 {\n color: var(--color-heading);\n font-size: 0.85rem;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\nh2 .muted {\n color: var(--color-very-muted);\n font-size: 0.78rem;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: none;\n margin-left: 0.5rem;\n}\n\n/* Cards row */\n.cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));\n gap: 0.7rem;\n}\n\n.card {\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.85rem 1rem;\n}\n\n.card.accent {\n background: var(--color-surface-raised);\n border-color: var(--color-accent);\n}\n\n.card-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n margin-bottom: 0.35rem;\n}\n\n.card-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n.card.accent .card-value {\n color: var(--color-accent);\n}\n\n/* Projects list */\n.projects {\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.project-row {\n display: grid;\n grid-template-columns: minmax(220px, 1fr) auto;\n grid-template-rows: auto auto;\n gap: 0.6rem 1.25rem;\n align-items: center;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.9rem 1.2rem;\n}\n\n.project-row:hover {\n background: var(--color-surface-raised);\n}\n\n.project-name {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n overflow: hidden;\n}\n\n.project-name strong {\n color: var(--color-heading);\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.project-name .project-path {\n color: var(--color-very-muted);\n font-size: 0.72rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.project-stats {\n display: flex;\n gap: 1.6rem;\n justify-self: end;\n text-align: right;\n}\n\n.stat {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n min-width: 70px;\n}\n\n.stat-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.stat-value.cost {\n color: var(--color-accent);\n}\n\n.stat-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.bar {\n grid-column: 1 / -1;\n height: 3px;\n background: var(--color-form-bg);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.bar-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--color-accent-darker), var(--color-accent));\n border-radius: 2px;\n transition: width 400ms ease;\n}\n\n/* Tables */\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.83rem;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n overflow: hidden;\n}\n\ntable thead th {\n text-align: left;\n color: var(--color-muted);\n text-transform: uppercase;\n font-size: 0.66rem;\n letter-spacing: 0.08em;\n font-weight: 600;\n padding: 0.65rem 0.85rem;\n border-bottom: 1px solid var(--color-border);\n background: var(--color-surface);\n}\n\ntable thead th.num {\n text-align: right;\n}\n\ntable tbody td {\n padding: 0.55rem 0.85rem;\n border-bottom: 1px solid rgba(77, 0, 32, 0.4);\n color: var(--color-body);\n}\n\ntable tbody td.num {\n text-align: right;\n}\n\ntable tbody td.cost {\n color: var(--color-accent);\n font-weight: 600;\n}\n\ntable tbody tr:last-child td {\n border-bottom: none;\n}\n\ntable tbody tr:hover {\n background: var(--color-surface-raised);\n}\n\ncode {\n color: var(--color-heading);\n background: var(--color-form-bg);\n padding: 0.1rem 0.4rem;\n border-radius: 3px;\n font-size: 0.85em;\n}\n\n.decision-block {\n color: var(--color-block);\n font-weight: 700;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.decision-allow {\n color: var(--color-allow);\n opacity: 0.7;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.empty {\n color: var(--color-muted);\n font-style: italic;\n padding: 1rem;\n background: var(--color-surface);\n border: 1px dashed var(--color-border);\n border-radius: 6px;\n}\n\n.hidden {\n display: none;\n}\n\nfooter {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 0.85rem 2rem;\n background: var(--color-surface);\n border-top: 1px solid var(--color-border);\n color: var(--color-muted);\n font-size: 0.72rem;\n font-family: ui-monospace, monospace;\n}\n\nfooter .muted {\n color: var(--color-very-muted);\n}\n\n.dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--color-muted);\n transition: background 200ms ease, box-shadow 200ms ease;\n}\n\n.dot.live {\n background: var(--color-accent);\n box-shadow: 0 0 8px var(--color-accent-darker);\n}\n\n.dot.dead {\n background: var(--color-border);\n box-shadow: none;\n}\n';
678
+
679
+ // src/dashboard/server.ts
680
+ var FALLBACK_RANGE = 9;
681
+ async function startDashboard(paths, preferredPort = 8901) {
682
+ const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);
683
+ if (port !== preferredPort) {
684
+ log.info(
685
+ `dashboard port ${preferredPort} was busy \u2014 bound to ${port} instead (likely another dashboard from a coexisting tool).`
686
+ );
687
+ }
688
+ const app = new Hono();
689
+ app.get("/", (c) => c.html(public_default));
690
+ app.get("/style.css", (c) => {
691
+ c.header("Content-Type", "text/css; charset=utf-8");
692
+ c.header("Cache-Control", "no-cache");
693
+ return c.body(style_default);
694
+ });
695
+ app.get("/health", (c) => c.json({ ok: true }));
696
+ app.get("/data", async (c) => {
697
+ const data = await computeDashboardData(paths);
698
+ return c.json(data);
699
+ });
700
+ const nodeServer = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
701
+ return {
702
+ port,
703
+ url: `http://127.0.0.1:${port}`,
704
+ async stop() {
705
+ await new Promise((resolve5, reject) => {
706
+ nodeServer.close((err2) => err2 ? reject(err2) : resolve5());
707
+ });
708
+ }
709
+ };
710
+ }
711
+
712
+ // src/hooks/installer.ts
713
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
714
+ import { dirname as dirname2, join as join3 } from "path";
715
+
716
+ // src/hooks/scripts/pre-compact.ps1
717
+ var pre_compact_default = '# PreCompact hook \u2014 Windows PowerShell.\n# Re-injects the primer after Claude auto-compacts. Same logic as prime.ps1.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/prime" -Method GET -TimeoutSec 3\n if ($resp.primer) { Write-Output $resp.primer }\n} catch {\n # silent\n}\nexit 0\n';
718
+
719
+ // src/hooks/scripts/pre-compact.sh
720
+ var pre_compact_default2 = `#!/usr/bin/env bash
721
+ # PreCompact hook \u2014 bash. Re-injects the primer after Claude auto-compacts.
722
+
723
+ set +e
724
+
725
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
726
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
727
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
728
+ if [ -z "$PORT" ]; then exit 0; fi
729
+
730
+ PRIMER=$(curl -sS --max-time 3 "http://127.0.0.1:$PORT/prime" 2>/dev/null \\
731
+ | sed -n 's/.*"primer"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p' \\
732
+ | head -c 8000)
733
+
734
+ if [ -n "$PRIMER" ]; then
735
+ printf '%b\\n' "$PRIMER"
736
+ fi
737
+ exit 0
738
+ `;
739
+
740
+ // src/hooks/scripts/pre-tool-use.ps1
741
+ var pre_tool_use_default = '# PreToolUse hook \u2014 Windows PowerShell.\n# THE MOAT (improvement #1). Reads the tool call from stdin (JSON), POSTs it\n# to /gate, and if the server says "block" emits a JSON deny-decision to\n# stdout. Claude Code reads stdout JSON to enforce the decision.\n# Always exits 0; failure-to-reach-server leaves Claude untouched.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$raw = [Console]::In.ReadToEnd()\nif (-not $raw) { exit 0 }\n\ntry {\n $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop\n} catch {\n exit 0\n}\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\n$payload = @{\n tool_name = $hookInput.tool_name\n tool_input = $hookInput.tool_input\n} | ConvertTo-Json -Depth 10 -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/gate" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n} catch {\n exit 0\n}\n\nif ($resp.decision -eq "block") {\n $denyJson = @{\n hookSpecificOutput = @{\n hookEventName = "PreToolUse"\n permissionDecision = "deny"\n permissionDecisionReason = $resp.reason\n }\n } | ConvertTo-Json -Depth 5 -Compress\n Write-Output $denyJson\n}\nexit 0\n';
742
+
743
+ // src/hooks/scripts/pre-tool-use.sh
744
+ var pre_tool_use_default2 = `#!/usr/bin/env bash
745
+ # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
746
+ # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.
747
+ # Always exits 0; server failures leave Claude untouched.
748
+
749
+ set +e
750
+
751
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
752
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
753
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
754
+ if [ -z "$PORT" ]; then exit 0; fi
755
+
756
+ INPUT=$(cat 2>/dev/null)
757
+ if [ -z "$INPUT" ]; then exit 0; fi
758
+
759
+ RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
760
+ --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)
761
+
762
+ case "$RESP" in
763
+ *'"decision":"block"'*)
764
+ REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')
765
+ cat <<EOF
766
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}
767
+ EOF
768
+ ;;
769
+ esac
770
+ exit 0
771
+ `;
772
+
773
+ // src/hooks/scripts/prime.ps1
774
+ var prime_default = `# SessionStart + PreCompact hook \u2014 Windows PowerShell.
775
+ # Reads .synthra-graph/mcp_port, calls GET /prime, prints the primer to stdout
776
+ # (Claude Code appends stdout to the session's system prompt). Always exits 0;
777
+ # any failure leaves Claude with the prompt it would have gotten without Synthra.
778
+
779
+ $ErrorActionPreference = "SilentlyContinue"
780
+
781
+ $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
782
+ if (-not (Test-Path $portFile)) { exit 0 }
783
+ $port = (Get-Content -Path $portFile -Raw).Trim()
784
+ if (-not $port) { exit 0 }
785
+
786
+ try {
787
+ $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/prime" -Method GET -TimeoutSec 3
788
+ if ($resp.primer) { Write-Output $resp.primer }
789
+ } catch {
790
+ # silent on failure \u2014 Claude continues without the primer
791
+ }
792
+ exit 0
793
+ `;
794
+
795
+ // src/hooks/scripts/prime.sh
796
+ var prime_default2 = `#!/usr/bin/env bash
797
+ # SessionStart + PreCompact hook \u2014 bash.
798
+ # Reads .synthra-graph/mcp_port, calls GET /prime, prints the primer to stdout.
799
+ # Always exits 0; any failure leaves Claude with the prompt it would have had
800
+ # without Synthra.
801
+
802
+ set +e
803
+
804
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
805
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
806
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
807
+ if [ -z "$PORT" ]; then exit 0; fi
808
+
809
+ PRIMER=$(curl -sS --max-time 3 "http://127.0.0.1:$PORT/prime" 2>/dev/null \\
810
+ | sed -n 's/.*"primer"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p' \\
811
+ | head -c 8000)
812
+
813
+ if [ -n "$PRIMER" ]; then
814
+ printf '%b\\n' "$PRIMER"
815
+ fi
816
+ exit 0
817
+ `;
818
+
819
+ // src/hooks/scripts/stop.ps1
820
+ var stop_default = `# Stop hook \u2014 Windows PowerShell.
821
+ # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums
822
+ # usage.* token counts across all assistant turns since the last offset, and
823
+ # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid
824
+ # double-counting on session resume.
825
+
826
+ $ErrorActionPreference = "SilentlyContinue"
827
+
828
+ $raw = [Console]::In.ReadToEnd()
829
+ if (-not $raw) { exit 0 }
830
+ try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }
831
+
832
+ $transcript = $hookInput.transcript_path
833
+ if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }
834
+
835
+ $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
836
+ if (-not (Test-Path $portFile)) { exit 0 }
837
+ $port = (Get-Content -Path $portFile -Raw).Trim()
838
+ if (-not $port) { exit 0 }
839
+
840
+ $offsetFile = "$transcript.stopoffset"
841
+ $startOffset = 0
842
+ if (Test-Path $offsetFile) {
843
+ $val = (Get-Content -Path $offsetFile -Raw).Trim()
844
+ if ($val -match '^\\d+$') { $startOffset = [int]$val }
845
+ }
846
+
847
+ $lines = Get-Content -Path $transcript
848
+ $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""
849
+ $lineNum = 0
850
+ foreach ($line in $lines) {
851
+ $lineNum++
852
+ if ($lineNum -le $startOffset) { continue }
853
+ if (-not $line) { continue }
854
+ try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }
855
+ $usage = $e.message.usage
856
+ if (-not $usage) { continue }
857
+ $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
858
+ $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
859
+ $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
860
+ $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
861
+ if ($e.message.model) { $model = $e.message.model }
862
+ }
863
+
864
+ Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII
865
+
866
+ if ($inT -eq 0 -and $outT -eq 0) { exit 0 }
867
+
868
+ $payload = @{
869
+ input_tokens = $inT
870
+ output_tokens = $outT
871
+ cache_creation_input_tokens = $cc
872
+ cache_read_input_tokens = $cr
873
+ model = $model
874
+ description = "synthra-stop-hook"
875
+ project = $PWD.Path
876
+ } | ConvertTo-Json -Compress
877
+
878
+ try {
879
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`
880
+ -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null
881
+ } catch {
882
+ # silent
883
+ }
884
+
885
+ # Refresh CONTEXT.md from the branch-scoped store.
886
+ $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress
887
+ try {
888
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`
889
+ -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null
890
+ } catch {
891
+ # silent
892
+ }
893
+ exit 0
894
+ `;
895
+
896
+ // src/hooks/scripts/stop.sh
897
+ var stop_default2 = `#!/usr/bin/env bash
898
+ # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,
899
+ # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.
900
+ # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.
901
+
902
+ set +e
903
+
904
+ INPUT=$(cat 2>/dev/null)
905
+ if [ -z "$INPUT" ]; then exit 0; fi
906
+
907
+ TRANSCRIPT=$(printf '%s' "$INPUT" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')
908
+ if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi
909
+
910
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
911
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
912
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
913
+ if [ -z "$PORT" ]; then exit 0; fi
914
+
915
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi
916
+
917
+ OFFSET_FILE="\${TRANSCRIPT}.stopoffset"
918
+ START_OFFSET=0
919
+ if [ -f "$OFFSET_FILE" ]; then
920
+ START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')
921
+ case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac
922
+ fi
923
+
924
+ TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
925
+ TOTAL_LINES=\${TOTAL_LINES:-0}
926
+
927
+ if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi
928
+
929
+ USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\
930
+ | jq -s '
931
+ map(select(.message.usage != null) | .message)
932
+ | reduce .[] as $m (
933
+ {in:0, out:0, cc:0, cr:0, model:""};
934
+ .in += ($m.usage.input_tokens // 0)
935
+ | .out += ($m.usage.output_tokens // 0)
936
+ | .cc += ($m.usage.cache_creation_input_tokens // 0)
937
+ | .cr += ($m.usage.cache_read_input_tokens // 0)
938
+ | .model = ($m.model // .model)
939
+ )
940
+ ' 2>/dev/null)
941
+
942
+ printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"
943
+
944
+ IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')
945
+ OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')
946
+ CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')
947
+ CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')
948
+ MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')
949
+
950
+ if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi
951
+
952
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
953
+ --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\
954
+ '{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\
955
+ "http://127.0.0.1:$PORT/log" >/dev/null 2>&1
956
+
957
+ # Refresh CONTEXT.md from the branch-scoped store.
958
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
959
+ --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\
960
+ "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1
961
+
962
+ exit 0
963
+ `;
964
+
965
+ // src/hooks/installer.ts
966
+ var SCRIPTS = [
967
+ { event: "SessionStart", baseName: "synthra-prime", ps1: prime_default, sh: prime_default2 },
968
+ { event: "PreToolUse", matcher: "Grep|Glob", baseName: "synthra-pre-tool-use", ps1: pre_tool_use_default, sh: pre_tool_use_default2 },
969
+ { event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
970
+ { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
971
+ ];
972
+ var SYNTHRA_HOOK_MARKER = "synthra-hook=true";
973
+ function commandFor(scriptPath) {
974
+ if (process.platform === "win32") {
975
+ return `powershell.exe -ExecutionPolicy Bypass -NoProfile -File "${scriptPath}"`;
976
+ }
977
+ return `bash "${scriptPath}"`;
978
+ }
979
+ function chosenScriptBody(s) {
980
+ return process.platform === "win32" ? s.ps1 : s.sh;
981
+ }
982
+ function chosenScriptExt() {
983
+ return process.platform === "win32" ? ".ps1" : ".sh";
984
+ }
985
+ async function readSettings(path) {
986
+ try {
987
+ const raw = await readFile3(path, "utf8");
988
+ return JSON.parse(raw);
989
+ } catch {
990
+ return {};
991
+ }
992
+ }
993
+ function stripOurHooks(config) {
994
+ if (!config.hooks) return config;
995
+ const next = {};
996
+ for (const [event, entries] of Object.entries(config.hooks)) {
997
+ const filtered = entries.map((entry) => ({
998
+ ...entry,
999
+ hooks: (entry.hooks ?? []).filter((h) => h.meta !== SYNTHRA_HOOK_MARKER)
1000
+ })).filter((entry) => (entry.hooks?.length ?? 0) > 0);
1001
+ if (filtered.length) next[event] = filtered;
1002
+ }
1003
+ config.hooks = next;
1004
+ return config;
1005
+ }
1006
+ function mergeOurHooks(config, paths) {
1007
+ const hooks = config.hooks = config.hooks ?? {};
1008
+ for (const s of SCRIPTS) {
1009
+ const scriptPath = join3(paths.claudeHooksDir, `${s.baseName}${chosenScriptExt()}`);
1010
+ const entry = {
1011
+ ...s.matcher ? { matcher: s.matcher } : {},
1012
+ hooks: [
1013
+ {
1014
+ type: "command",
1015
+ command: commandFor(scriptPath),
1016
+ meta: SYNTHRA_HOOK_MARKER
1017
+ }
1018
+ ]
1019
+ };
1020
+ const list = hooks[s.event] = hooks[s.event] ?? [];
1021
+ list.push(entry);
1022
+ }
1023
+ return config;
1024
+ }
1025
+ async function installHooks(paths) {
1026
+ await mkdir2(paths.claudeHooksDir, { recursive: true });
1027
+ const scriptsWritten = [];
1028
+ for (const s of SCRIPTS) {
1029
+ const target = join3(paths.claudeHooksDir, `${s.baseName}${chosenScriptExt()}`);
1030
+ await writeFile2(target, chosenScriptBody(s), "utf8");
1031
+ scriptsWritten.push(target);
1032
+ }
1033
+ await mkdir2(dirname2(paths.claudeSettings), { recursive: true });
1034
+ const existing = await readSettings(paths.claudeSettings);
1035
+ const stripped = stripOurHooks(existing);
1036
+ const merged = mergeOurHooks(stripped, paths);
1037
+ await writeFile2(paths.claudeSettings, JSON.stringify(merged, null, 2) + "\n", "utf8");
1038
+ log.debug(`installed ${scriptsWritten.length} hook script(s) into ${paths.claudeHooksDir}`);
1039
+ return { scriptsWritten, settingsUpdated: true };
1040
+ }
1041
+
1042
+ // src/server/http.ts
1043
+ import { serve as serve2 } from "@hono/node-server";
1044
+ import { Hono as Hono2 } from "hono";
1045
+ import { writeFile as writeFile8 } from "fs/promises";
1046
+
1047
+ // src/activity/activity-log.ts
1048
+ import { appendFile, mkdir as mkdir3 } from "fs/promises";
1049
+ import { dirname as dirname3 } from "path";
1050
+ var DEFAULT_RING_SIZE = 100;
1051
+ var ActivityStore = class {
1052
+ ring = [];
1053
+ maxRingSize;
1054
+ persistPath;
1055
+ constructor(persistPath, maxRingSize = DEFAULT_RING_SIZE) {
1056
+ this.persistPath = persistPath;
1057
+ this.maxRingSize = maxRingSize;
1058
+ }
1059
+ async add(event) {
1060
+ this.ring.push(event);
1061
+ while (this.ring.length > this.maxRingSize) this.ring.shift();
1062
+ await this.persist(event);
1063
+ }
1064
+ /** Get events newer than `sinceMs` (epoch ms). If omitted, returns the full ring. */
1065
+ getEvents(sinceMs) {
1066
+ if (!sinceMs || !Number.isFinite(sinceMs)) return this.ring.slice();
1067
+ const cutoff = new Date(sinceMs).toISOString();
1068
+ return this.ring.filter((e) => e.ts >= cutoff);
1069
+ }
1070
+ /** Project-relative file paths that have a save/create event newer than `maxAgeMs` ms ago. */
1071
+ recentFilePaths(maxAgeMs) {
1072
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
1073
+ const out = /* @__PURE__ */ new Set();
1074
+ for (const e of this.ring) {
1075
+ if ("path" in e && (e.kind === "save" || e.kind === "create") && e.ts >= cutoff) {
1076
+ out.add(e.path);
1077
+ }
1078
+ }
1079
+ return Array.from(out);
1080
+ }
1081
+ size() {
1082
+ return this.ring.length;
1083
+ }
1084
+ async persist(event) {
1085
+ try {
1086
+ await mkdir3(dirname3(this.persistPath), { recursive: true });
1087
+ await appendFile(this.persistPath, JSON.stringify(event) + "\n", "utf8");
1088
+ } catch {
1089
+ }
1090
+ }
1091
+ };
1092
+
1093
+ // src/activity/file-watcher.ts
1094
+ import chokidar from "chokidar";
1095
+ import { readFile as readFile4 } from "fs/promises";
1096
+ import { join as join4, relative, sep } from "path";
1097
+ import ignore from "ignore";
1098
+ var ALWAYS_IGNORE = [
1099
+ ".git",
1100
+ ".synthra",
1101
+ ".synthra-graph",
1102
+ ".claude",
1103
+ "node_modules",
1104
+ "dist",
1105
+ "build",
1106
+ "out",
1107
+ "coverage",
1108
+ ".next",
1109
+ ".nuxt",
1110
+ ".svelte-kit",
1111
+ ".turbo",
1112
+ ".cache",
1113
+ ".vscode",
1114
+ ".idea"
1115
+ ];
1116
+ async function readIgnoreFile(path) {
1117
+ try {
1118
+ const text = await readFile4(path, "utf8");
1119
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
1120
+ } catch {
1121
+ return [];
1122
+ }
1123
+ }
1124
+ async function buildMatcher(root) {
1125
+ const ig = ignore();
1126
+ ig.add(ALWAYS_IGNORE.map((d) => `${d}/`));
1127
+ ig.add(await readIgnoreFile(join4(root, ".gitignore")));
1128
+ ig.add(await readIgnoreFile(join4(root, ".synthraignore")));
1129
+ return ig;
1130
+ }
1131
+ function toPosixRel(root, abs) {
1132
+ const rel = relative(root, abs);
1133
+ return sep === "/" ? rel : rel.split(sep).join("/");
1134
+ }
1135
+ function createFileWatcher(root, onEvent) {
1136
+ let watcher = null;
1137
+ let ig = null;
1138
+ const emit2 = async (kind, abs) => {
1139
+ if (!ig) return;
1140
+ const rel = toPosixRel(root, abs);
1141
+ if (!rel || rel.startsWith("..")) return;
1142
+ if (ig.ignores(rel)) return;
1143
+ try {
1144
+ await onEvent({ kind, path: rel, ts: (/* @__PURE__ */ new Date()).toISOString() });
1145
+ } catch {
1146
+ }
1147
+ };
1148
+ return {
1149
+ async start() {
1150
+ ig = await buildMatcher(root);
1151
+ watcher = chokidar.watch(root, {
1152
+ // Cross-platform glob ignore. We match both the directory itself and
1153
+ // anything inside it. picomatch (chokidar's matcher) normalizes path
1154
+ // separators so a single set of forward-slash globs handles
1155
+ // Windows + POSIX. Function-based ignore was unreliable on Windows
1156
+ // and let chokidar descend into .git/, which crashed on transient
1157
+ // index.lock files held exclusively by git.
1158
+ ignored: ALWAYS_IGNORE.flatMap((d) => [`**/${d}`, `**/${d}/**`]),
1159
+ ignoreInitial: true,
1160
+ persistent: true,
1161
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
1162
+ });
1163
+ watcher.on("error", (err2) => {
1164
+ const e = err2;
1165
+ log.debug(`file watcher error (swallowed): ${e?.code ?? ""} ${e?.message ?? String(err2)}`);
1166
+ });
1167
+ watcher.on("add", (path) => emit2("create", path));
1168
+ watcher.on("change", (path) => emit2("save", path));
1169
+ watcher.on("unlink", (path) => emit2("delete", path));
1170
+ },
1171
+ async stop() {
1172
+ if (watcher) {
1173
+ await watcher.close();
1174
+ watcher = null;
1175
+ }
1176
+ }
1177
+ };
1178
+ }
1179
+
1180
+ // src/activity/git-watcher.ts
1181
+ import { execFile } from "child_process";
1182
+ import { watch } from "fs";
1183
+ import { readFile as readFile5 } from "fs/promises";
1184
+ import { join as join5 } from "path";
1185
+ import { promisify } from "util";
1186
+ var execFileAsync = promisify(execFile);
1187
+ var POLL_MS = 2e3;
1188
+ async function readHeadBranch(projectRoot) {
1189
+ try {
1190
+ const head = await readFile5(join5(projectRoot, ".git", "HEAD"), "utf8");
1191
+ const m = head.trim().match(/^ref:\s+refs\/heads\/(.+)$/);
1192
+ return m?.[1] ?? null;
1193
+ } catch {
1194
+ return null;
1195
+ }
1196
+ }
1197
+ async function readStatusPorcelain(projectRoot) {
1198
+ try {
1199
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
1200
+ cwd: projectRoot
1201
+ });
1202
+ return stdout;
1203
+ } catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+ function createGitWatcher(root, onEvent) {
1208
+ let headWatcher = null;
1209
+ let pollTimer = null;
1210
+ let lastBranch = null;
1211
+ let lastStatus = null;
1212
+ const emitSafe = async (event) => {
1213
+ try {
1214
+ await onEvent(event);
1215
+ } catch {
1216
+ }
1217
+ };
1218
+ const checkHead = async () => {
1219
+ const branch = await readHeadBranch(root);
1220
+ if (branch && branch !== lastBranch) {
1221
+ const prev = lastBranch;
1222
+ lastBranch = branch;
1223
+ if (prev !== null) {
1224
+ await emitSafe({
1225
+ kind: "branch-switch",
1226
+ details: { from: prev, to: branch },
1227
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1228
+ });
1229
+ }
1230
+ }
1231
+ };
1232
+ const pollStatus = async () => {
1233
+ const status = await readStatusPorcelain(root);
1234
+ if (status === null) return;
1235
+ if (lastStatus !== null && status !== lastStatus) {
1236
+ const prevFiles = parseStatusFiles(lastStatus);
1237
+ const nowFiles = parseStatusFiles(status);
1238
+ const added = nowFiles.filter((f) => !prevFiles.includes(f));
1239
+ const removed = prevFiles.filter((f) => !nowFiles.includes(f));
1240
+ await emitSafe({
1241
+ kind: "diff-change",
1242
+ details: {
1243
+ changed_count: nowFiles.length,
1244
+ newly_dirty: added,
1245
+ newly_clean: removed
1246
+ },
1247
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1248
+ });
1249
+ }
1250
+ lastStatus = status;
1251
+ };
1252
+ return {
1253
+ async start() {
1254
+ lastBranch = await readHeadBranch(root);
1255
+ lastStatus = await readStatusPorcelain(root);
1256
+ try {
1257
+ headWatcher = watch(join5(root, ".git", "HEAD"), () => {
1258
+ void checkHead();
1259
+ });
1260
+ headWatcher.on("error", () => {
1261
+ });
1262
+ } catch {
1263
+ }
1264
+ pollTimer = setInterval(() => {
1265
+ void pollStatus();
1266
+ }, POLL_MS);
1267
+ pollTimer.unref?.();
1268
+ },
1269
+ async stop() {
1270
+ if (headWatcher) {
1271
+ headWatcher.close();
1272
+ headWatcher = null;
1273
+ }
1274
+ if (pollTimer) {
1275
+ clearInterval(pollTimer);
1276
+ pollTimer = null;
1277
+ }
1278
+ }
1279
+ };
1280
+ }
1281
+ function parseStatusFiles(porcelain) {
1282
+ return porcelain.split(/\r?\n/).map((l) => l.slice(3).trim()).filter((l) => l.length > 0);
1283
+ }
1284
+
1285
+ // src/cli/scan-command.ts
1286
+ import { resolve } from "path";
1287
+
1288
+ // src/scanner/extract.ts
1289
+ import { dirname as dirname4, join as join6, posix } from "path";
1290
+
1291
+ // src/scanner/hash.ts
1292
+ import { createHash } from "crypto";
1293
+ function fileHash(content) {
1294
+ return createHash("sha1").update(content).digest("hex").slice(0, 8);
1295
+ }
1296
+
1297
+ // src/scanner/keywords.ts
1298
+ var STOPWORDS = /* @__PURE__ */ new Set([
1299
+ "a",
1300
+ "an",
1301
+ "and",
1302
+ "are",
1303
+ "as",
1304
+ "at",
1305
+ "be",
1306
+ "but",
1307
+ "by",
1308
+ "do",
1309
+ "for",
1310
+ "from",
1311
+ "has",
1312
+ "have",
1313
+ "he",
1314
+ "if",
1315
+ "in",
1316
+ "is",
1317
+ "it",
1318
+ "its",
1319
+ "not",
1320
+ "of",
1321
+ "on",
1322
+ "or",
1323
+ "she",
1324
+ "that",
1325
+ "the",
1326
+ "they",
1327
+ "this",
1328
+ "to",
1329
+ "was",
1330
+ "we",
1331
+ "were",
1332
+ "will",
1333
+ "with",
1334
+ "you",
1335
+ "your",
1336
+ "i",
1337
+ "me",
1338
+ "my",
1339
+ "our",
1340
+ "us",
1341
+ "their",
1342
+ "them",
1343
+ "his",
1344
+ "her",
1345
+ // common code words that add no signal
1346
+ "function",
1347
+ "const",
1348
+ "let",
1349
+ "var",
1350
+ "class",
1351
+ "interface",
1352
+ "type",
1353
+ "enum",
1354
+ "import",
1355
+ "export",
1356
+ "from",
1357
+ "default",
1358
+ "return",
1359
+ "if",
1360
+ "else",
1361
+ "for",
1362
+ "while",
1363
+ "do",
1364
+ "switch",
1365
+ "case",
1366
+ "break",
1367
+ "continue",
1368
+ "new",
1369
+ "this",
1370
+ "super",
1371
+ "throw",
1372
+ "try",
1373
+ "catch",
1374
+ "finally",
1375
+ "async",
1376
+ "await",
1377
+ "yield",
1378
+ "true",
1379
+ "false",
1380
+ "null",
1381
+ "undefined",
1382
+ "void",
1383
+ "any",
1384
+ "string",
1385
+ "number",
1386
+ "boolean",
1387
+ "object",
1388
+ "array",
1389
+ "self",
1390
+ "cls",
1391
+ "def",
1392
+ "lambda",
1393
+ "pass",
1394
+ "raise",
1395
+ "with",
1396
+ "as",
1397
+ "in",
1398
+ "todo",
1399
+ "fixme",
1400
+ "note"
1401
+ ]);
1402
+ var COMMON_CODE = /* @__PURE__ */ new Set([
1403
+ "value",
1404
+ "data",
1405
+ "result",
1406
+ "args",
1407
+ "kwargs",
1408
+ "options",
1409
+ "config",
1410
+ "params",
1411
+ "name",
1412
+ "id",
1413
+ "key",
1414
+ "index",
1415
+ "item",
1416
+ "items",
1417
+ "list",
1418
+ "map",
1419
+ "set",
1420
+ "get",
1421
+ "set",
1422
+ "add",
1423
+ "remove",
1424
+ "delete",
1425
+ "create",
1426
+ "update",
1427
+ "find",
1428
+ "fetch",
1429
+ "load",
1430
+ "save",
1431
+ "init",
1432
+ "main",
1433
+ "run",
1434
+ "start",
1435
+ "stop",
1436
+ "test",
1437
+ "check",
1438
+ "validate",
1439
+ "error",
1440
+ "err",
1441
+ "warn",
1442
+ "info",
1443
+ "debug",
1444
+ "log",
1445
+ "trace",
1446
+ "msg",
1447
+ "message",
1448
+ "path",
1449
+ "file",
1450
+ "dir",
1451
+ "url",
1452
+ "host",
1453
+ "port",
1454
+ "size",
1455
+ "length",
1456
+ "count",
1457
+ "input",
1458
+ "output",
1459
+ "source",
1460
+ "target",
1461
+ "callback",
1462
+ "handler",
1463
+ "listener",
1464
+ "props",
1465
+ "state",
1466
+ "context",
1467
+ "render",
1468
+ "component",
1469
+ "node",
1470
+ "tree",
1471
+ "root"
1472
+ ]);
1473
+ function score(token) {
1474
+ if (STOPWORDS.has(token)) return 0;
1475
+ if (COMMON_CODE.has(token)) return 0.2;
1476
+ if (token.length <= 2) return 0.1;
1477
+ return 1;
1478
+ }
1479
+ function splitIdentifier(id) {
1480
+ const partsRaw = id.split(/[_\-./]+/).filter(Boolean);
1481
+ const out = [];
1482
+ for (const part of partsRaw) {
1483
+ const camelParts = part.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g);
1484
+ if (camelParts) out.push(...camelParts);
1485
+ else out.push(part);
1486
+ }
1487
+ return out.map((w) => w.toLowerCase()).filter((w) => /[a-z]/.test(w));
1488
+ }
1489
+ function extractKeywords(content, _ext) {
1490
+ const tokens = content.match(/[A-Za-z_][A-Za-z0-9_]{1,40}/g) ?? [];
1491
+ const counts = /* @__PURE__ */ new Map();
1492
+ for (const tok of tokens) {
1493
+ for (const word of splitIdentifier(tok)) {
1494
+ const w = score(word);
1495
+ if (w === 0) continue;
1496
+ counts.set(word, (counts.get(word) ?? 0) + w);
1497
+ }
1498
+ }
1499
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 32).map(([w]) => w);
1500
+ }
1501
+
1502
+ // src/scanner/extract.ts
1503
+ var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue"];
1504
+ var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py"];
1505
+ function fileId(relPath) {
1506
+ return `file:${relPath}`;
1507
+ }
1508
+ function symbolId(relPath, sym) {
1509
+ return `symbol:${relPath}::${sym.name}:${sym.startLine}`;
1510
+ }
1511
+ function toFileNode(parsed) {
1512
+ const content = parsed.source;
1513
+ return {
1514
+ id: fileId(parsed.file.relPath),
1515
+ kind: "file",
1516
+ path: parsed.file.relPath,
1517
+ ext: parsed.file.ext,
1518
+ size: parsed.file.size,
1519
+ keywords: extractKeywords(content, parsed.file.ext),
1520
+ content,
1521
+ summary: extractSummary(content),
1522
+ file_hash: fileHash(content)
1523
+ };
1524
+ }
1525
+ function extractSummary(content) {
1526
+ const trimmed = content.replace(/^\s+/, "");
1527
+ const slashMatch = trimmed.match(/^\/\/\s?(.*(?:\r?\n\/\/\s?.*)*)/);
1528
+ if (slashMatch?.[1]) return slashMatch[1].split(/\r?\n/).join(" ").trim().slice(0, 200);
1529
+ const blockMatch = trimmed.match(/^\/\*\*?([\s\S]*?)\*\//);
1530
+ if (blockMatch?.[1]) {
1531
+ return blockMatch[1].split(/\r?\n/).map((l) => l.replace(/^\s*\*\s?/, "")).join(" ").trim().slice(0, 200);
1532
+ }
1533
+ const hashMatch = trimmed.match(/^#\s?(.*(?:\r?\n#\s?.*)*)/);
1534
+ if (hashMatch?.[1]) return hashMatch[1].split(/\r?\n/).join(" ").trim().slice(0, 200);
1535
+ return "";
1536
+ }
1537
+ function toSymbolNode(parsed, sym) {
1538
+ return {
1539
+ id: symbolId(parsed.file.relPath, sym),
1540
+ kind: "symbol",
1541
+ symbol_kind: sym.kind,
1542
+ name: sym.name,
1543
+ file: parsed.file.relPath,
1544
+ start_line: sym.startLine,
1545
+ end_line: sym.endLine,
1546
+ signature: sym.signature
1547
+ };
1548
+ }
1549
+ var REWRITE_EXT_RE = /\.(js|jsx|mjs|cjs)$/;
1550
+ function resolveImport(fromRelPath, spec, filesByPath) {
1551
+ if (!spec.startsWith(".")) return null;
1552
+ const fromDir = posix.dirname(toPosix(fromRelPath));
1553
+ const base = posix.normalize(posix.join(fromDir, toPosix(spec)));
1554
+ const candidates = [base];
1555
+ const rewritten = base.replace(REWRITE_EXT_RE, "");
1556
+ if (rewritten !== base) candidates.push(rewritten);
1557
+ for (const c of candidates) {
1558
+ if (filesByPath.has(c)) return c;
1559
+ for (const ext of RESOLVE_EXTS) {
1560
+ if (filesByPath.has(c + ext)) return c + ext;
1561
+ }
1562
+ for (const idx of INDEX_FILES) {
1563
+ const candidate = posix.join(c, idx);
1564
+ if (filesByPath.has(candidate)) return candidate;
1565
+ }
1566
+ }
1567
+ return null;
1568
+ }
1569
+ function toPosix(p) {
1570
+ return p.split(/[\\/]/).join("/");
1571
+ }
1572
+ var TEST_RE = /^(?<base>.+?)\.(test|spec)\.(?<ext>[tj]sx?|py)$/;
1573
+ function testTarget(relPath, filesByPath) {
1574
+ const fileName = relPath.split("/").pop() ?? relPath;
1575
+ const match = TEST_RE.exec(fileName);
1576
+ if (!match) return null;
1577
+ const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
1578
+ const base = match.groups?.base ?? "";
1579
+ const ext = match.groups?.ext ?? "";
1580
+ if (!base || !ext) return null;
1581
+ const candidate = `${dir}${base}.${ext}`;
1582
+ if (filesByPath.has(candidate)) return candidate;
1583
+ for (const e of RESOLVE_EXTS) {
1584
+ const alt = `${dir}${base}${e}`;
1585
+ if (filesByPath.has(alt)) return alt;
1586
+ }
1587
+ return null;
1588
+ }
1589
+ async function buildGraph(root, parsed) {
1590
+ const filesByPath = /* @__PURE__ */ new Map();
1591
+ for (const p of parsed) filesByPath.set(p.file.relPath, true);
1592
+ const nodes = [];
1593
+ const edges = [];
1594
+ for (const p of parsed) {
1595
+ const fileNode = toFileNode(p);
1596
+ nodes.push(fileNode);
1597
+ for (const sym of p.symbols) {
1598
+ const symNode = toSymbolNode(p, sym);
1599
+ nodes.push(symNode);
1600
+ edges.push({ from: fileNode.id, to: symNode.id, kind: "defines" });
1601
+ }
1602
+ const importEdges = /* @__PURE__ */ new Set();
1603
+ for (const spec of p.imports) {
1604
+ const target = resolveImport(p.file.relPath, spec, filesByPath);
1605
+ if (!target) continue;
1606
+ const key = `${fileNode.id}->${fileId(target)}`;
1607
+ if (importEdges.has(key)) continue;
1608
+ importEdges.add(key);
1609
+ edges.push({ from: fileNode.id, to: fileId(target), kind: "imports" });
1610
+ }
1611
+ const testTargetPath = testTarget(p.file.relPath, filesByPath);
1612
+ if (testTargetPath && testTargetPath !== p.file.relPath) {
1613
+ edges.push({ from: fileNode.id, to: fileId(testTargetPath), kind: "tests" });
1614
+ }
1615
+ }
1616
+ const symbolCount = nodes.filter((n) => n.kind === "symbol").length;
1617
+ const fileCount = nodes.length - symbolCount;
1618
+ return {
1619
+ root,
1620
+ node_count: nodes.length,
1621
+ edge_count: edges.length,
1622
+ file_count: fileCount,
1623
+ symbol_count: symbolCount,
1624
+ nodes,
1625
+ edges,
1626
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1627
+ schema_version: 1
1628
+ };
1629
+ }
1630
+ function buildSymbolIndex(graph) {
1631
+ const out = {};
1632
+ for (const node of graph.nodes) {
1633
+ if (node.kind !== "symbol") continue;
1634
+ const list = out[node.name] ?? (out[node.name] = []);
1635
+ list.push({ file: node.file, line: node.start_line, kind: node.symbol_kind });
1636
+ }
1637
+ return out;
1638
+ }
1639
+
1640
+ // src/scanner/parser.ts
1641
+ import { readFile as readFile6 } from "fs/promises";
1642
+ import { createRequire } from "module";
1643
+ import Parser from "web-tree-sitter";
1644
+
1645
+ // src/scanner/parsers/_generic.ts
1646
+ function firstLine(text, max = 200) {
1647
+ const line = text.split(/\r?\n/, 1)[0] ?? "";
1648
+ return line.length > max ? line.slice(0, max) + "\u2026" : line;
1649
+ }
1650
+ function cleanImport(s) {
1651
+ return s.replace(/^["'`<]+|["'`>]+$/g, "").trim();
1652
+ }
1653
+ async function runGenericParser(config, f, source) {
1654
+ let symbols = [];
1655
+ let imports = [];
1656
+ try {
1657
+ const { parser, language } = await createParser(config.grammar);
1658
+ const tree = parser.parse(source);
1659
+ if (!tree) return { file: f, source, symbols, imports, calls: [] };
1660
+ const query = language.query(config.query);
1661
+ const matches = query.matches(tree.rootNode);
1662
+ for (const match of matches) {
1663
+ const byName = /* @__PURE__ */ new Map();
1664
+ for (const cap of match.captures) byName.set(cap.name, cap.node);
1665
+ let matched = null;
1666
+ for (const d of config.decls) {
1667
+ if (byName.has(d.declCapture) && byName.has(d.nameCapture)) {
1668
+ matched = d;
1669
+ break;
1670
+ }
1671
+ }
1672
+ if (matched) {
1673
+ const declNode = byName.get(matched.declCapture);
1674
+ const nameNode = byName.get(matched.nameCapture);
1675
+ symbols.push({
1676
+ name: nameNode.text,
1677
+ kind: matched.kind,
1678
+ startLine: declNode.startPosition.row + 1,
1679
+ endLine: declNode.endPosition.row + 1,
1680
+ signature: firstLine(declNode.text)
1681
+ });
1682
+ continue;
1683
+ }
1684
+ if (config.importCapture) {
1685
+ const imp = byName.get(config.importCapture);
1686
+ if (imp) imports.push(cleanImport(imp.text));
1687
+ }
1688
+ }
1689
+ const seen = /* @__PURE__ */ new Set();
1690
+ symbols = symbols.filter((s) => {
1691
+ const k = `${s.name}:${s.startLine}`;
1692
+ if (seen.has(k)) return false;
1693
+ seen.add(k);
1694
+ return true;
1695
+ });
1696
+ imports = Array.from(new Set(imports)).filter((s) => s.length > 0);
1697
+ } catch {
1698
+ }
1699
+ return { file: f, source, symbols, imports, calls: [] };
1700
+ }
1701
+
1702
+ // src/scanner/parsers/c.ts
1703
+ var QUERY = `
1704
+ (function_definition declarator: (function_declarator declarator: (identifier) @function.name)) @function
1705
+ (struct_specifier name: (type_identifier) @struct.name) @struct
1706
+ (enum_specifier name: (type_identifier) @enum.name) @enum
1707
+ (type_definition declarator: (type_identifier) @type.name) @type
1708
+ (preproc_include path: (string_literal) @import)
1709
+ (preproc_include path: (system_lib_string) @import)
1710
+ `;
1711
+ async function parseC(f, source) {
1712
+ return runGenericParser(
1713
+ {
1714
+ grammar: "c",
1715
+ query: QUERY,
1716
+ decls: [
1717
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1718
+ { declCapture: "struct", nameCapture: "struct.name", kind: "class" },
1719
+ { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
1720
+ { declCapture: "type", nameCapture: "type.name", kind: "type" }
1721
+ ],
1722
+ importCapture: "import"
1723
+ },
1724
+ f,
1725
+ source
1726
+ );
1727
+ }
1728
+
1729
+ // src/scanner/parsers/cpp.ts
1730
+ var QUERY2 = `
1731
+ (function_definition declarator: (function_declarator declarator: (identifier) @function.name)) @function
1732
+ (function_definition declarator: (function_declarator declarator: (qualified_identifier) @method.name)) @method
1733
+ (class_specifier name: (type_identifier) @class.name) @class
1734
+ (struct_specifier name: (type_identifier) @struct.name) @struct
1735
+ (enum_specifier name: (type_identifier) @enum.name) @enum
1736
+ (namespace_definition name: (namespace_identifier) @namespace.name) @namespace
1737
+ (preproc_include path: (string_literal) @import)
1738
+ (preproc_include path: (system_lib_string) @import)
1739
+ `;
1740
+ async function parseCpp(f, source) {
1741
+ return runGenericParser(
1742
+ {
1743
+ grammar: "cpp",
1744
+ query: QUERY2,
1745
+ decls: [
1746
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1747
+ { declCapture: "method", nameCapture: "method.name", kind: "method" },
1748
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1749
+ { declCapture: "struct", nameCapture: "struct.name", kind: "class" },
1750
+ { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
1751
+ { declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
1752
+ ],
1753
+ importCapture: "import"
1754
+ },
1755
+ f,
1756
+ source
1757
+ );
1758
+ }
1759
+
1760
+ // src/scanner/parsers/csharp.ts
1761
+ var QUERY3 = `
1762
+ (class_declaration name: (identifier) @class.name) @class
1763
+ (interface_declaration name: (identifier) @interface.name) @interface
1764
+ (struct_declaration name: (identifier) @struct.name) @struct
1765
+ (enum_declaration name: (identifier) @enum.name) @enum
1766
+ (method_declaration name: (identifier) @method.name) @method
1767
+ (namespace_declaration name: (_) @namespace.name) @namespace
1768
+ (using_directive (_) @import)
1769
+ `;
1770
+ async function parseCSharp(f, source) {
1771
+ return runGenericParser(
1772
+ {
1773
+ grammar: "csharp",
1774
+ query: QUERY3,
1775
+ decls: [
1776
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1777
+ { declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
1778
+ { declCapture: "struct", nameCapture: "struct.name", kind: "class" },
1779
+ { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
1780
+ { declCapture: "method", nameCapture: "method.name", kind: "method" },
1781
+ { declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
1782
+ ],
1783
+ importCapture: "import"
1784
+ },
1785
+ f,
1786
+ source
1787
+ );
1788
+ }
1789
+
1790
+ // src/scanner/parsers/dart.ts
1791
+ var QUERY4 = `
1792
+ (class_definition (identifier) @class.name) @class
1793
+ (mixin_declaration (identifier) @class.name) @mixin
1794
+ (extension_declaration (identifier) @class.name) @ext
1795
+ (function_signature (identifier) @function.name) @function
1796
+ `;
1797
+ async function parseDart(f, source) {
1798
+ return runGenericParser(
1799
+ {
1800
+ grammar: "dart",
1801
+ query: QUERY4,
1802
+ decls: [
1803
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1804
+ { declCapture: "mixin", nameCapture: "class.name", kind: "class" },
1805
+ { declCapture: "ext", nameCapture: "class.name", kind: "class" },
1806
+ { declCapture: "function", nameCapture: "function.name", kind: "function" }
1807
+ ]
1808
+ },
1809
+ f,
1810
+ source
1811
+ );
1812
+ }
1813
+
1814
+ // src/scanner/parsers/go.ts
1815
+ var QUERY5 = `
1816
+ (function_declaration name: (identifier) @function.name) @function
1817
+ (method_declaration name: (field_identifier) @method.name) @method
1818
+ (type_spec name: (type_identifier) @type.name) @type
1819
+ (import_spec path: (interpreted_string_literal) @import)
1820
+ `;
1821
+ async function parseGo(f, source) {
1822
+ return runGenericParser(
1823
+ {
1824
+ grammar: "go",
1825
+ query: QUERY5,
1826
+ decls: [
1827
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1828
+ { declCapture: "method", nameCapture: "method.name", kind: "method" },
1829
+ { declCapture: "type", nameCapture: "type.name", kind: "type" }
1830
+ ],
1831
+ importCapture: "import"
1832
+ },
1833
+ f,
1834
+ source
1835
+ );
1836
+ }
1837
+
1838
+ // src/scanner/parsers/java.ts
1839
+ var QUERY6 = `
1840
+ (class_declaration name: (identifier) @class.name) @class
1841
+ (interface_declaration name: (identifier) @interface.name) @interface
1842
+ (method_declaration name: (identifier) @method.name) @method
1843
+ (enum_declaration name: (identifier) @enum.name) @enum
1844
+ (import_declaration (scoped_identifier) @import)
1845
+ `;
1846
+ async function parseJava(f, source) {
1847
+ return runGenericParser(
1848
+ {
1849
+ grammar: "java",
1850
+ query: QUERY6,
1851
+ decls: [
1852
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1853
+ { declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
1854
+ { declCapture: "method", nameCapture: "method.name", kind: "method" },
1855
+ { declCapture: "enum", nameCapture: "enum.name", kind: "enum" }
1856
+ ],
1857
+ importCapture: "import"
1858
+ },
1859
+ f,
1860
+ source
1861
+ );
1862
+ }
1863
+
1864
+ // src/scanner/parsers/kotlin.ts
1865
+ var QUERY7 = `
1866
+ (function_declaration (simple_identifier) @function.name) @function
1867
+ (class_declaration (type_identifier) @class.name) @class
1868
+ (object_declaration (type_identifier) @object.name) @object
1869
+ (import_header (identifier) @import)
1870
+ `;
1871
+ async function parseKotlin(f, source) {
1872
+ return runGenericParser(
1873
+ {
1874
+ grammar: "kotlin",
1875
+ query: QUERY7,
1876
+ decls: [
1877
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1878
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1879
+ { declCapture: "object", nameCapture: "object.name", kind: "class" }
1880
+ ],
1881
+ importCapture: "import"
1882
+ },
1883
+ f,
1884
+ source
1885
+ );
1886
+ }
1887
+
1888
+ // src/scanner/parsers/php.ts
1889
+ var QUERY8 = `
1890
+ (function_definition name: (name) @function.name) @function
1891
+ (class_declaration name: (name) @class.name) @class
1892
+ (interface_declaration name: (name) @interface.name) @interface
1893
+ (trait_declaration name: (name) @trait.name) @trait
1894
+ (method_declaration name: (name) @method.name) @method
1895
+ `;
1896
+ async function parsePhp(f, source) {
1897
+ return runGenericParser(
1898
+ {
1899
+ grammar: "php",
1900
+ query: QUERY8,
1901
+ decls: [
1902
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1903
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1904
+ { declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
1905
+ { declCapture: "trait", nameCapture: "trait.name", kind: "class" },
1906
+ { declCapture: "method", nameCapture: "method.name", kind: "method" }
1907
+ ]
1908
+ },
1909
+ f,
1910
+ source
1911
+ );
1912
+ }
1913
+
1914
+ // src/scanner/parsers/python.ts
1915
+ var QUERY9 = `
1916
+ (function_definition name: (identifier) @function.name) @function
1917
+ (class_definition name: (identifier) @class.name) @class
1918
+ (import_statement name: (dotted_name) @import.module)
1919
+ (import_from_statement module_name: (dotted_name) @import.from)
1920
+ (import_from_statement module_name: (relative_import) @import.from)
1921
+ `;
1922
+ function firstLine2(text, max = 200) {
1923
+ const line = text.split(/\r?\n/, 1)[0] ?? "";
1924
+ return line.length > max ? line.slice(0, max) + "\u2026" : line;
1925
+ }
1926
+ async function parsePython(f, source) {
1927
+ let symbols = [];
1928
+ let imports = [];
1929
+ try {
1930
+ const { parser, language } = await createParser("python");
1931
+ const tree = parser.parse(source);
1932
+ if (!tree) return { file: f, source, symbols, imports, calls: [] };
1933
+ const query = language.query(QUERY9);
1934
+ const matches = query.matches(tree.rootNode);
1935
+ for (const match of matches) {
1936
+ const byName = /* @__PURE__ */ new Map();
1937
+ for (const cap of match.captures) byName.set(cap.name, cap.node);
1938
+ const funcDecl = byName.get("function");
1939
+ const funcName = byName.get("function.name");
1940
+ if (funcDecl && funcName) {
1941
+ const parentType = funcDecl.parent?.parent?.type;
1942
+ const isMethod = parentType === "class_definition";
1943
+ symbols.push({
1944
+ name: funcName.text,
1945
+ kind: isMethod ? "method" : "function",
1946
+ startLine: funcDecl.startPosition.row + 1,
1947
+ endLine: funcDecl.endPosition.row + 1,
1948
+ signature: firstLine2(funcDecl.text)
1949
+ });
1950
+ continue;
1951
+ }
1952
+ const classDecl = byName.get("class");
1953
+ const className = byName.get("class.name");
1954
+ if (classDecl && className) {
1955
+ symbols.push({
1956
+ name: className.text,
1957
+ kind: "class",
1958
+ startLine: classDecl.startPosition.row + 1,
1959
+ endLine: classDecl.endPosition.row + 1,
1960
+ signature: firstLine2(classDecl.text)
1961
+ });
1962
+ continue;
1963
+ }
1964
+ const importNode = byName.get("import.module") ?? byName.get("import.from");
1965
+ if (importNode) imports.push(importNode.text);
1966
+ }
1967
+ const seen = /* @__PURE__ */ new Set();
1968
+ symbols = symbols.filter((s) => {
1969
+ const key = `${s.name}:${s.startLine}`;
1970
+ if (seen.has(key)) return false;
1971
+ seen.add(key);
1972
+ return true;
1973
+ });
1974
+ imports = Array.from(new Set(imports));
1975
+ } catch {
1976
+ }
1977
+ return { file: f, source, symbols, imports, calls: [] };
1978
+ }
1979
+
1980
+ // src/scanner/parsers/ruby.ts
1981
+ var QUERY10 = `
1982
+ (method name: (identifier) @function.name) @function
1983
+ (singleton_method name: (identifier) @method.name) @method
1984
+ (class name: (constant) @class.name) @class
1985
+ (module name: (constant) @module.name) @module
1986
+ `;
1987
+ async function parseRuby(f, source) {
1988
+ return runGenericParser(
1989
+ {
1990
+ grammar: "ruby",
1991
+ query: QUERY10,
1992
+ decls: [
1993
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
1994
+ { declCapture: "method", nameCapture: "method.name", kind: "method" },
1995
+ { declCapture: "class", nameCapture: "class.name", kind: "class" },
1996
+ { declCapture: "module", nameCapture: "module.name", kind: "class" }
1997
+ ]
1998
+ },
1999
+ f,
2000
+ source
2001
+ );
2002
+ }
2003
+
2004
+ // src/scanner/parsers/rust.ts
2005
+ var QUERY11 = `
2006
+ (function_item name: (identifier) @function.name) @function
2007
+ (struct_item name: (type_identifier) @struct.name) @struct
2008
+ (enum_item name: (type_identifier) @enum.name) @enum
2009
+ (trait_item name: (type_identifier) @trait.name) @trait
2010
+ (impl_item type: (type_identifier) @impl.name) @impl
2011
+ `;
2012
+ async function parseRust(f, source) {
2013
+ return runGenericParser(
2014
+ {
2015
+ grammar: "rust",
2016
+ query: QUERY11,
2017
+ decls: [
2018
+ { declCapture: "function", nameCapture: "function.name", kind: "function" },
2019
+ { declCapture: "struct", nameCapture: "struct.name", kind: "class" },
2020
+ { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
2021
+ { declCapture: "trait", nameCapture: "trait.name", kind: "interface" },
2022
+ { declCapture: "impl", nameCapture: "impl.name", kind: "class" }
2023
+ ]
2024
+ },
2025
+ f,
2026
+ source
2027
+ );
2028
+ }
2029
+
2030
+ // src/scanner/parsers/typescript.ts
2031
+ var QUERY12 = `
2032
+ (function_declaration name: (identifier) @function.name) @function
2033
+ (class_declaration name: (type_identifier) @class.name) @class
2034
+ (interface_declaration name: (type_identifier) @interface.name) @interface
2035
+ (type_alias_declaration name: (type_identifier) @type.name) @type
2036
+ (enum_declaration name: (identifier) @enum.name) @enum
2037
+ (method_definition name: (property_identifier) @method.name) @method
2038
+ (lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
2039
+ (import_statement source: (string) @import)
2040
+ `;
2041
+ function grammarFor(ext) {
2042
+ if (ext === ".tsx" || ext === ".jsx") return "tsx";
2043
+ if (ext === ".js" || ext === ".cjs" || ext === ".mjs") return "javascript";
2044
+ return "typescript";
2045
+ }
2046
+ function unquote(s) {
2047
+ return s.replace(/^["'`]|["'`]$/g, "");
2048
+ }
2049
+ function firstLine3(text, max = 200) {
2050
+ const line = text.split(/\r?\n/, 1)[0] ?? "";
2051
+ return line.length > max ? line.slice(0, max) + "\u2026" : line;
2052
+ }
2053
+ function shapeFromCaptures(captures) {
2054
+ const findDecl = (k, sk) => {
2055
+ const decl = captures.get(k);
2056
+ const name = captures.get(`${k}.name`);
2057
+ return decl && name ? { decl, name, kind: sk } : null;
2058
+ };
2059
+ return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function");
2060
+ }
2061
+ async function parseTypeScript(f, source) {
2062
+ const grammar = grammarFor(f.ext);
2063
+ let symbols = [];
2064
+ let imports = [];
2065
+ try {
2066
+ const { parser, language } = await createParser(grammar);
2067
+ const tree = parser.parse(source);
2068
+ if (!tree) return { file: f, source, symbols, imports, calls: [] };
2069
+ const query = language.query(QUERY12);
2070
+ const matches = query.matches(tree.rootNode);
2071
+ for (const match of matches) {
2072
+ const byName = /* @__PURE__ */ new Map();
2073
+ for (const cap of match.captures) byName.set(cap.name, cap.node);
2074
+ const shape = shapeFromCaptures(byName);
2075
+ if (shape) {
2076
+ symbols.push({
2077
+ name: shape.name.text,
2078
+ kind: shape.kind,
2079
+ startLine: shape.decl.startPosition.row + 1,
2080
+ endLine: shape.decl.endPosition.row + 1,
2081
+ signature: firstLine3(shape.decl.text)
2082
+ });
2083
+ continue;
2084
+ }
2085
+ const importNode = byName.get("import");
2086
+ if (importNode) imports.push(unquote(importNode.text));
2087
+ }
2088
+ const seen = /* @__PURE__ */ new Set();
2089
+ symbols = symbols.filter((s) => {
2090
+ const key = `${s.name}:${s.startLine}`;
2091
+ if (seen.has(key)) return false;
2092
+ seen.add(key);
2093
+ return true;
2094
+ });
2095
+ imports = Array.from(new Set(imports));
2096
+ } catch {
2097
+ }
2098
+ return { file: f, source, symbols, imports, calls: [] };
2099
+ }
2100
+
2101
+ // src/scanner/parsers/svelte.ts
2102
+ var SCRIPT_RE = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
2103
+ function extractScripts(source) {
2104
+ const out = [];
2105
+ for (const match of source.matchAll(SCRIPT_RE)) {
2106
+ const full = match[0];
2107
+ const inner = match[1] ?? "";
2108
+ const openTag = full.slice(0, full.indexOf(">") + 1);
2109
+ const tagStart = match.index ?? 0;
2110
+ const contentStart = tagStart + openTag.length;
2111
+ const startLine = source.slice(0, contentStart).split(/\r?\n/).length;
2112
+ const isTsx = /\blang\s*=\s*["']?(ts|tsx|typescript)["']?/i.test(openTag);
2113
+ out.push({ source: inner, startLine, isTsx });
2114
+ }
2115
+ return out;
2116
+ }
2117
+ async function parseSvelte(f, source) {
2118
+ const blocks = extractScripts(source);
2119
+ const out = { file: f, source, symbols: [], imports: [], calls: [] };
2120
+ for (const block of blocks) {
2121
+ const virtual = { ...f, ext: block.isTsx ? ".ts" : ".js" };
2122
+ const parsed = await parseTypeScript(virtual, block.source);
2123
+ const offset = block.startLine - 1;
2124
+ for (const sym of parsed.symbols) {
2125
+ out.symbols.push({
2126
+ ...sym,
2127
+ startLine: sym.startLine + offset,
2128
+ endLine: sym.endLine + offset
2129
+ });
2130
+ }
2131
+ for (const imp of parsed.imports) out.imports.push(imp);
2132
+ }
2133
+ out.symbols.push({
2134
+ name: f.relPath.split("/").pop()?.replace(/\.svelte$/i, "") ?? f.relPath,
2135
+ kind: "component",
2136
+ startLine: 1,
2137
+ endLine: source.split(/\r?\n/).length,
2138
+ signature: f.relPath
2139
+ });
2140
+ out.imports = Array.from(new Set(out.imports));
2141
+ return out;
2142
+ }
2143
+
2144
+ // src/scanner/parsers/vue.ts
2145
+ var SCRIPT_RE2 = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
2146
+ function extractScripts2(source) {
2147
+ const out = [];
2148
+ for (const match of source.matchAll(SCRIPT_RE2)) {
2149
+ const full = match[0];
2150
+ const inner = match[1] ?? "";
2151
+ const openTag = full.slice(0, full.indexOf(">") + 1);
2152
+ const tagStart = match.index ?? 0;
2153
+ const contentStart = tagStart + openTag.length;
2154
+ const startLine = source.slice(0, contentStart).split(/\r?\n/).length;
2155
+ const isTs = /\blang\s*=\s*["']?(ts|tsx|typescript)["']?/i.test(openTag);
2156
+ out.push({ source: inner, startLine, isTs });
2157
+ }
2158
+ return out;
2159
+ }
2160
+ async function parseVue(f, source) {
2161
+ const blocks = extractScripts2(source);
2162
+ const out = { file: f, source, symbols: [], imports: [], calls: [] };
2163
+ for (const block of blocks) {
2164
+ const virtual = { ...f, ext: block.isTs ? ".ts" : ".js" };
2165
+ const parsed = await parseTypeScript(virtual, block.source);
2166
+ const offset = block.startLine - 1;
2167
+ for (const sym of parsed.symbols) {
2168
+ out.symbols.push({
2169
+ ...sym,
2170
+ startLine: sym.startLine + offset,
2171
+ endLine: sym.endLine + offset
2172
+ });
2173
+ }
2174
+ for (const imp of parsed.imports) out.imports.push(imp);
2175
+ }
2176
+ out.symbols.push({
2177
+ name: f.relPath.split("/").pop()?.replace(/\.vue$/i, "") ?? f.relPath,
2178
+ kind: "component",
2179
+ startLine: 1,
2180
+ endLine: source.split(/\r?\n/).length,
2181
+ signature: f.relPath
2182
+ });
2183
+ out.imports = Array.from(new Set(out.imports));
2184
+ return out;
2185
+ }
2186
+
2187
+ // src/scanner/parser.ts
2188
+ var require2 = createRequire(import.meta.url);
2189
+ var GRAMMAR_FILES = {
2190
+ typescript: "tree-sitter-wasms/out/tree-sitter-typescript.wasm",
2191
+ tsx: "tree-sitter-wasms/out/tree-sitter-tsx.wasm",
2192
+ javascript: "tree-sitter-wasms/out/tree-sitter-javascript.wasm",
2193
+ python: "tree-sitter-wasms/out/tree-sitter-python.wasm",
2194
+ go: "tree-sitter-wasms/out/tree-sitter-go.wasm",
2195
+ rust: "tree-sitter-wasms/out/tree-sitter-rust.wasm",
2196
+ java: "tree-sitter-wasms/out/tree-sitter-java.wasm",
2197
+ kotlin: "tree-sitter-wasms/out/tree-sitter-kotlin.wasm",
2198
+ php: "tree-sitter-wasms/out/tree-sitter-php.wasm",
2199
+ ruby: "tree-sitter-wasms/out/tree-sitter-ruby.wasm",
2200
+ c: "tree-sitter-wasms/out/tree-sitter-c.wasm",
2201
+ cpp: "tree-sitter-wasms/out/tree-sitter-cpp.wasm",
2202
+ dart: "tree-sitter-wasms/out/tree-sitter-dart.wasm",
2203
+ csharp: "tree-sitter-wasms/out/tree-sitter-c_sharp.wasm"
2204
+ };
2205
+ var parserInit = null;
2206
+ var languageCache = /* @__PURE__ */ new Map();
2207
+ async function ensureParserInit() {
2208
+ if (!parserInit) {
2209
+ parserInit = Parser.init();
2210
+ }
2211
+ return parserInit;
2212
+ }
2213
+ async function loadGrammar(name) {
2214
+ await ensureParserInit();
2215
+ const cached = languageCache.get(name);
2216
+ if (cached) return cached;
2217
+ const wasmPath = require2.resolve(GRAMMAR_FILES[name]);
2218
+ const lang = await Parser.Language.load(wasmPath);
2219
+ languageCache.set(name, lang);
2220
+ return lang;
2221
+ }
2222
+ async function createParser(name) {
2223
+ const language = await loadGrammar(name);
2224
+ const parser = new Parser();
2225
+ parser.setLanguage(language);
2226
+ return { parser, language };
2227
+ }
2228
+ function emptyParsed(file, source) {
2229
+ return { file, source, symbols: [], imports: [], calls: [] };
2230
+ }
2231
+ async function parseFile(f) {
2232
+ let source;
2233
+ try {
2234
+ source = await readFile6(f.absPath, "utf8");
2235
+ } catch {
2236
+ return emptyParsed(f, "");
2237
+ }
2238
+ switch (f.ext) {
2239
+ case ".ts":
2240
+ case ".tsx":
2241
+ case ".cts":
2242
+ case ".mts":
2243
+ case ".js":
2244
+ case ".jsx":
2245
+ case ".cjs":
2246
+ case ".mjs":
2247
+ return parseTypeScript(f, source);
2248
+ case ".py":
2249
+ case ".pyi":
2250
+ return parsePython(f, source);
2251
+ case ".svelte":
2252
+ return parseSvelte(f, source);
2253
+ case ".vue":
2254
+ return parseVue(f, source);
2255
+ case ".go":
2256
+ return parseGo(f, source);
2257
+ case ".rs":
2258
+ return parseRust(f, source);
2259
+ case ".java":
2260
+ return parseJava(f, source);
2261
+ case ".kt":
2262
+ case ".kts":
2263
+ return parseKotlin(f, source);
2264
+ case ".php":
2265
+ return parsePhp(f, source);
2266
+ case ".rb":
2267
+ return parseRuby(f, source);
2268
+ case ".c":
2269
+ case ".h":
2270
+ return parseC(f, source);
2271
+ case ".cpp":
2272
+ case ".cc":
2273
+ case ".cxx":
2274
+ case ".hpp":
2275
+ case ".hh":
2276
+ case ".hxx":
2277
+ return parseCpp(f, source);
2278
+ case ".dart":
2279
+ return parseDart(f, source);
2280
+ case ".cs":
2281
+ return parseCSharp(f, source);
2282
+ default:
2283
+ return emptyParsed(f, source);
2284
+ }
2285
+ }
2286
+
2287
+ // src/scanner/walker.ts
2288
+ import { readFile as readFile7, readdir, stat } from "fs/promises";
2289
+ import { extname, join as join7, relative as relative2, sep as sep2 } from "path";
2290
+ import ignore2 from "ignore";
2291
+ var DEFAULT_IGNORE = [
2292
+ ".git/",
2293
+ ".synthra/",
2294
+ ".synthra-graph/",
2295
+ ".claude/",
2296
+ "node_modules/",
2297
+ "dist/",
2298
+ "build/",
2299
+ "out/",
2300
+ "coverage/",
2301
+ ".next/",
2302
+ ".nuxt/",
2303
+ ".svelte-kit/",
2304
+ ".turbo/",
2305
+ ".cache/",
2306
+ ".vscode/",
2307
+ ".idea/"
2308
+ ];
2309
+ var BINARY_EXTS = /* @__PURE__ */ new Set([
2310
+ ".png",
2311
+ ".jpg",
2312
+ ".jpeg",
2313
+ ".gif",
2314
+ ".webp",
2315
+ ".svg",
2316
+ ".ico",
2317
+ ".bmp",
2318
+ ".pdf",
2319
+ ".zip",
2320
+ ".tar",
2321
+ ".gz",
2322
+ ".7z",
2323
+ ".rar",
2324
+ ".mp3",
2325
+ ".mp4",
2326
+ ".mov",
2327
+ ".avi",
2328
+ ".webm",
2329
+ ".wav",
2330
+ ".ogg",
2331
+ ".ttf",
2332
+ ".otf",
2333
+ ".woff",
2334
+ ".woff2",
2335
+ ".eot",
2336
+ ".exe",
2337
+ ".dll",
2338
+ ".so",
2339
+ ".dylib",
2340
+ ".bin",
2341
+ ".wasm",
2342
+ ".lock",
2343
+ ".lockb"
2344
+ ]);
2345
+ async function readIgnoreFile2(path) {
2346
+ try {
2347
+ const text = await readFile7(path, "utf8");
2348
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
2349
+ } catch {
2350
+ return [];
2351
+ }
2352
+ }
2353
+ async function buildMatcher2(root, extra) {
2354
+ const ig = ignore2();
2355
+ ig.add(DEFAULT_IGNORE);
2356
+ ig.add(await readIgnoreFile2(join7(root, ".gitignore")));
2357
+ ig.add(await readIgnoreFile2(join7(root, ".synthraignore")));
2358
+ if (extra.length) ig.add(extra);
2359
+ return ig;
2360
+ }
2361
+ function toPosix2(p) {
2362
+ return sep2 === "/" ? p : p.split(sep2).join("/");
2363
+ }
2364
+ async function* walk(root, options = {}) {
2365
+ const maxFileSize = options.maxFileSize ?? 2e6;
2366
+ const ig = await buildMatcher2(root, options.extraIgnore ?? []);
2367
+ async function* recurse(dir) {
2368
+ let entries;
2369
+ try {
2370
+ entries = await readdir(dir, { withFileTypes: true });
2371
+ } catch {
2372
+ return;
2373
+ }
2374
+ for (const entry of entries) {
2375
+ const abs = join7(dir, entry.name);
2376
+ const rel = relative2(root, abs);
2377
+ if (!rel) continue;
2378
+ const relPosix = toPosix2(rel);
2379
+ const matchPath = entry.isDirectory() ? `${relPosix}/` : relPosix;
2380
+ if (ig.ignores(matchPath)) continue;
2381
+ if (entry.isDirectory()) {
2382
+ yield* recurse(abs);
2383
+ } else if (entry.isFile()) {
2384
+ const ext = extname(entry.name).toLowerCase();
2385
+ if (BINARY_EXTS.has(ext)) continue;
2386
+ let size;
2387
+ try {
2388
+ const s = await stat(abs);
2389
+ size = s.size;
2390
+ } catch {
2391
+ continue;
2392
+ }
2393
+ if (size > maxFileSize) continue;
2394
+ yield { absPath: abs, relPath: relPosix, ext, size };
2395
+ }
2396
+ }
2397
+ }
2398
+ yield* recurse(root);
2399
+ }
2400
+
2401
+ // src/graph/store.ts
2402
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile3 } from "fs/promises";
2403
+ import { dirname as dirname5 } from "path";
2404
+ async function writeJson(path, data, pretty) {
2405
+ await mkdir4(dirname5(path), { recursive: true });
2406
+ const text = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
2407
+ await writeFile3(path, text + "\n", "utf8");
2408
+ }
2409
+ async function readJson(path) {
2410
+ const text = await readFile8(path, "utf8");
2411
+ return JSON.parse(text);
2412
+ }
2413
+ async function writeGraph(path, graph) {
2414
+ await writeJson(path, graph, false);
2415
+ }
2416
+ async function readGraph(path) {
2417
+ return readJson(path);
2418
+ }
2419
+ async function writeSymbolIndex(path, index) {
2420
+ await writeJson(path, index, true);
2421
+ }
2422
+ async function readSymbolIndex(path) {
2423
+ return readJson(path);
2424
+ }
2425
+
2426
+ // src/cli/bootstrap.ts
2427
+ import { mkdir as mkdir5, readFile as readFile10, stat as stat2, writeFile as writeFile5 } from "fs/promises";
2428
+
2429
+ // src/hooks/claude-md.ts
2430
+ import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
2431
+ var POLICY_VERSION = 1;
2432
+ var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
2433
+ var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
2434
+ var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
2435
+ function policyBlock() {
2436
+ return [
2437
+ POLICY_BEGIN,
2438
+ "## Synthra context policy",
2439
+ "",
2440
+ "Synthra has pre-loaded a structured context pack into this session and",
2441
+ "exposes the project's code graph through three MCP tools. **Prefer these",
2442
+ "tools over Grep / Glob** \u2014 they are faster, cheaper, and already filtered",
2443
+ "to relevant files.",
2444
+ "",
2445
+ "### Tools",
2446
+ "",
2447
+ "- **`graph_continue(query)`** \u2014 your default first move when you need",
2448
+ " project context. Returns signatures + top function bodies + linked test",
2449
+ ' files, with a `confidence` label. If `confidence === "high"`, **stop**:',
2450
+ " do not call Grep/Glob for the same query.",
2451
+ '- **`graph_read(target)`** \u2014 fetch source for a specific `"file/path.ts"`',
2452
+ ' or `"file/path.ts::SymbolName"`. Use this once you know what you want.',
2453
+ "- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
2454
+ " Synthra ranks them higher and avoids surfacing stale snapshots.",
2455
+ "",
2456
+ "### Rules",
2457
+ "",
2458
+ "1. Call `graph_continue` **before** Grep / Glob for any question about",
2459
+ " project code. Grep / Glob calls for the same query may be blocked at",
2460
+ " the hook layer when the graph already has a confident answer.",
2461
+ '2. When `graph_continue` returns `confidence: "high"`, treat the pack as',
2462
+ " authoritative \u2014 don't second-guess it with a Grep.",
2463
+ "3. Use `graph_read` instead of `Read` when you only need a specific symbol",
2464
+ " from a file (you get less noise + line numbers).",
2465
+ "4. After editing files, call `graph_register_edit(files)` so subsequent",
2466
+ " turns weight your changes correctly.",
2467
+ "",
2468
+ "_This block is managed by Synthra. Edits inside the BEGIN/END markers",
2469
+ "are overwritten on every `syn .` run._",
2470
+ "",
2471
+ POLICY_END
2472
+ ].join("\n");
2473
+ }
2474
+ async function patchClaudeMd(path) {
2475
+ let existing;
2476
+ try {
2477
+ existing = await readFile9(path, "utf8");
2478
+ } catch {
2479
+ existing = null;
2480
+ }
2481
+ const block = policyBlock();
2482
+ if (existing === null) {
2483
+ await writeFile4(path, block + "\n", "utf8");
2484
+ return { created: true, updated: false, skipped: false };
2485
+ }
2486
+ const stripped = existing.replace(ANY_BLOCK_RE, "");
2487
+ const hadBlock = stripped !== existing;
2488
+ const desired = stripped.endsWith("\n") ? stripped + "\n" + block + "\n" : (stripped.length ? stripped + "\n\n" : "") + block + "\n";
2489
+ if (hadBlock && desired === existing) {
2490
+ return { created: false, updated: false, skipped: true };
2491
+ }
2492
+ await writeFile4(path, desired, "utf8");
2493
+ return { created: false, updated: true, skipped: false };
2494
+ }
2495
+
2496
+ // src/cli/bootstrap.ts
2497
+ var GITIGNORE_MARKER = "# added by synthra";
2498
+ var GITIGNORE_ENTRY = ".synthra-graph/";
2499
+ async function exists(path) {
2500
+ try {
2501
+ await stat2(path);
2502
+ return true;
2503
+ } catch {
2504
+ return false;
2505
+ }
2506
+ }
2507
+ async function ensureDir(path) {
2508
+ const had = await exists(path);
2509
+ await mkdir5(path, { recursive: true });
2510
+ return !had;
2511
+ }
2512
+ async function patchGitignore(path) {
2513
+ let existing = "";
2514
+ try {
2515
+ existing = await readFile10(path, "utf8");
2516
+ } catch {
2517
+ }
2518
+ const lines = existing.split(/\r?\n/);
2519
+ if (lines.some((l) => l.trim() === GITIGNORE_ENTRY)) return false;
2520
+ const appendix = (existing.length === 0 || existing.endsWith("\n") ? "" : "\n") + (existing.length ? "\n" : "") + `${GITIGNORE_MARKER}
2521
+ ${GITIGNORE_ENTRY}
2522
+ `;
2523
+ await writeFile5(path, existing + appendix, "utf8");
2524
+ return true;
2525
+ }
2526
+ async function bootstrap(paths) {
2527
+ const graphCreated = await ensureDir(paths.graphDir);
2528
+ const contextCreated = await ensureDir(paths.contextDir);
2529
+ const gitignoreUpdated = await patchGitignore(paths.gitignore);
2530
+ const claudeMdExistedBefore = await exists(paths.claudeMd);
2531
+ const patch = await patchClaudeMd(paths.claudeMd);
2532
+ return {
2533
+ graphCreated,
2534
+ contextCreated,
2535
+ gitignoreUpdated,
2536
+ claudeMdUpdated: patch.updated,
2537
+ claudeMdCreated: patch.created && !claudeMdExistedBefore
2538
+ };
2539
+ }
2540
+
2541
+ // src/cli/scan-command.ts
2542
+ var PARSABLE_EXTS = /* @__PURE__ */ new Set([
2543
+ ".ts",
2544
+ ".tsx",
2545
+ ".cts",
2546
+ ".mts",
2547
+ ".js",
2548
+ ".jsx",
2549
+ ".cjs",
2550
+ ".mjs",
2551
+ ".py",
2552
+ ".pyi",
2553
+ ".svelte",
2554
+ ".vue",
2555
+ ".go",
2556
+ ".rs",
2557
+ ".java",
2558
+ ".kt",
2559
+ ".kts",
2560
+ ".php",
2561
+ ".rb",
2562
+ ".c",
2563
+ ".h",
2564
+ ".cpp",
2565
+ ".cc",
2566
+ ".cxx",
2567
+ ".hpp",
2568
+ ".hh",
2569
+ ".hxx",
2570
+ ".dart",
2571
+ ".cs"
2572
+ ]);
2573
+ async function scanProject(projectRootRaw, opts = {}) {
2574
+ const projectRoot = resolve(projectRootRaw);
2575
+ const paths = resolvePaths(projectRoot);
2576
+ const start = Date.now();
2577
+ const verbose = !opts.silent;
2578
+ if (verbose) log.info(`scanning ${projectRoot}`);
2579
+ const boot = await bootstrap(paths);
2580
+ if (verbose) {
2581
+ if (boot.graphCreated) log.info(" created .synthra-graph/");
2582
+ if (boot.contextCreated) log.info(" created .synthra/");
2583
+ if (boot.gitignoreUpdated) log.info(" updated .gitignore");
2584
+ if (boot.claudeMdCreated) log.info(" created CLAUDE.md");
2585
+ else if (boot.claudeMdUpdated) log.info(" updated CLAUDE.md");
2586
+ }
2587
+ const walked = [];
2588
+ for await (const file of walk(projectRoot)) walked.push(file);
2589
+ if (verbose) log.info(` walked ${walked.length} files`);
2590
+ const parsable = walked.filter((f) => PARSABLE_EXTS.has(f.ext));
2591
+ const parsed = [];
2592
+ let parseErrors = 0;
2593
+ for (const file of parsable) {
2594
+ try {
2595
+ parsed.push(await parseFile(file));
2596
+ } catch (err2) {
2597
+ parseErrors += 1;
2598
+ if (verbose) log.debug(` parse failed: ${file.relPath} \u2014 ${err2.message}`);
2599
+ }
2600
+ }
2601
+ if (verbose) {
2602
+ log.info(
2603
+ ` parsed ${parsed.length} files (${walked.length - parsable.length} skipped` + (parseErrors ? `, ${parseErrors} errored` : "") + ")"
2604
+ );
2605
+ }
2606
+ const graph = await buildGraph(projectRoot, parsed);
2607
+ const symbolIndex = buildSymbolIndex(graph);
2608
+ await writeGraph(paths.infoGraph, graph);
2609
+ await writeSymbolIndex(paths.symbolIndex, symbolIndex);
2610
+ if (verbose) {
2611
+ log.info(
2612
+ ` wrote ${paths.infoGraph} \u2014 ${graph.symbol_count} symbols, ${graph.edge_count} edges`
2613
+ );
2614
+ log.info(` wrote ${paths.symbolIndex} \u2014 ${Object.keys(symbolIndex).length} names`);
2615
+ }
2616
+ const durationMs = Date.now() - start;
2617
+ if (verbose) log.info(`done in ${(durationMs / 1e3).toFixed(2)}s`);
2618
+ return {
2619
+ walked: walked.length,
2620
+ parsed: parsed.length,
2621
+ symbolCount: graph.symbol_count,
2622
+ edgeCount: graph.edge_count,
2623
+ durationMs
2624
+ };
2625
+ }
2626
+ async function scanCommand(rawPath) {
2627
+ return scanProject(rawPath);
2628
+ }
2629
+
2630
+ // src/graph/rank.ts
2631
+ var STOPWORDS2 = /* @__PURE__ */ new Set([
2632
+ "a",
2633
+ "an",
2634
+ "and",
2635
+ "are",
2636
+ "as",
2637
+ "at",
2638
+ "be",
2639
+ "by",
2640
+ "for",
2641
+ "from",
2642
+ "has",
2643
+ "have",
2644
+ "in",
2645
+ "is",
2646
+ "it",
2647
+ "of",
2648
+ "on",
2649
+ "or",
2650
+ "that",
2651
+ "the",
2652
+ "this",
2653
+ "to",
2654
+ "was",
2655
+ "we",
2656
+ "with",
2657
+ "what",
2658
+ "where",
2659
+ "when",
2660
+ "why",
2661
+ "how",
2662
+ "do",
2663
+ "does",
2664
+ "i",
2665
+ "me",
2666
+ "my",
2667
+ "you",
2668
+ "your",
2669
+ "code",
2670
+ "file"
2671
+ ]);
2672
+ function tokenizeQuery(query) {
2673
+ const tokens = query.toLowerCase().split(/[^a-z0-9_]+/g).filter((t) => t.length > 1 && !STOPWORDS2.has(t));
2674
+ const expanded = /* @__PURE__ */ new Set();
2675
+ for (const t of tokens) {
2676
+ expanded.add(t);
2677
+ const parts = t.match(/[a-z]+|[0-9]+/g) ?? [];
2678
+ for (const p of parts) if (p.length > 1) expanded.add(p);
2679
+ }
2680
+ return Array.from(expanded);
2681
+ }
2682
+ function indexSymbolsByFile(graph) {
2683
+ const out = /* @__PURE__ */ new Map();
2684
+ if (!graph) return out;
2685
+ for (const n of graph.nodes) {
2686
+ if (n.kind !== "symbol") continue;
2687
+ const list = out.get(n.file) ?? [];
2688
+ list.push(n);
2689
+ out.set(n.file, list);
2690
+ }
2691
+ return out;
2692
+ }
2693
+ function indexImportEdges(graph) {
2694
+ const out = /* @__PURE__ */ new Map();
2695
+ if (!graph) return out;
2696
+ const idToPath = /* @__PURE__ */ new Map();
2697
+ for (const n of graph.nodes) if (n.kind === "file") idToPath.set(n.id, n.path);
2698
+ for (const e of graph.edges) {
2699
+ if (e.kind !== "imports") continue;
2700
+ const from = idToPath.get(e.from);
2701
+ const to = idToPath.get(e.to);
2702
+ if (!from || !to) continue;
2703
+ const s = out.get(from) ?? /* @__PURE__ */ new Set();
2704
+ s.add(to);
2705
+ out.set(from, s);
2706
+ }
2707
+ return out;
2708
+ }
2709
+ function scoreFiles(inputs) {
2710
+ const qTokens = new Set(tokenizeQuery(inputs.query));
2711
+ const symbolsByFile = indexSymbolsByFile(inputs.graph);
2712
+ const importsFrom = indexImportEdges(inputs.graph);
2713
+ const seeds = new Set(inputs.sessionKnownPaths ?? []);
2714
+ for (const p of inputs.recentlyEditedPaths ?? []) seeds.add(p);
2715
+ const scored = [];
2716
+ for (const file of inputs.candidates) {
2717
+ const reasons = [];
2718
+ let score2 = 0;
2719
+ let kwHits = 0;
2720
+ for (const kw of file.keywords) if (qTokens.has(kw)) kwHits += 1;
2721
+ if (kwHits) {
2722
+ score2 += kwHits * 2;
2723
+ reasons.push(`kw=${kwHits}`);
2724
+ }
2725
+ const symbols = symbolsByFile.get(file.path) ?? [];
2726
+ let symHits = 0;
2727
+ for (const sym of symbols) {
2728
+ const name = sym.name.toLowerCase();
2729
+ if (qTokens.has(name)) {
2730
+ symHits += 3;
2731
+ } else {
2732
+ for (const t of qTokens) {
2733
+ if (name.includes(t) || t.includes(name)) {
2734
+ symHits += 1;
2735
+ break;
2736
+ }
2737
+ }
2738
+ }
2739
+ }
2740
+ if (symHits) {
2741
+ score2 += symHits;
2742
+ reasons.push(`sym=${symHits}`);
2743
+ }
2744
+ const pathLower = file.path.toLowerCase();
2745
+ let pathHits = 0;
2746
+ for (const t of qTokens) if (pathLower.includes(t)) pathHits += 1;
2747
+ if (pathHits) {
2748
+ score2 += pathHits;
2749
+ reasons.push(`path=${pathHits}`);
2750
+ }
2751
+ if (seeds.has(file.path)) {
2752
+ score2 += 5;
2753
+ reasons.push("seed");
2754
+ }
2755
+ scored.push({ file, score: score2, reasons });
2756
+ }
2757
+ const positivePaths = new Set(scored.filter((s) => s.score > 0).map((s) => s.file.path));
2758
+ if (positivePaths.size > 0) {
2759
+ for (const s of scored) {
2760
+ if (s.score > 0) continue;
2761
+ let importBoost = 0;
2762
+ for (const [from, tos] of importsFrom) {
2763
+ if (!positivePaths.has(from)) continue;
2764
+ if (tos.has(s.file.path)) {
2765
+ importBoost += 1;
2766
+ break;
2767
+ }
2768
+ }
2769
+ if (importBoost) {
2770
+ s.score += importBoost * 0.5;
2771
+ s.reasons.push("imp-adj");
2772
+ }
2773
+ }
2774
+ }
2775
+ scored.sort((a, b) => b.score - a.score);
2776
+ return scored;
2777
+ }
2778
+
2779
+ // src/graph/retrieve.ts
2780
+ async function retrieve(graph, query, options = {}) {
2781
+ const topK = options.topK ?? 12;
2782
+ const qTokens = tokenizeQuery(query);
2783
+ const allFiles = graph.nodes.filter(
2784
+ (n) => n.kind === "file"
2785
+ );
2786
+ if (allFiles.length === 0 || qTokens.length === 0) {
2787
+ return {
2788
+ files: [],
2789
+ confidence: "low",
2790
+ reason: qTokens.length === 0 ? "empty query" : "empty graph"
2791
+ };
2792
+ }
2793
+ const rankInputs = {
2794
+ candidates: allFiles,
2795
+ query,
2796
+ graph,
2797
+ recentlyEditedPaths: options.recentlyEditedPaths,
2798
+ sessionKnownPaths: options.sessionKnownPaths
2799
+ };
2800
+ const scored = scoreFiles(rankInputs);
2801
+ const positive = scored.filter((s) => s.score > 0);
2802
+ if (positive.length === 0) {
2803
+ return {
2804
+ files: [],
2805
+ confidence: "low",
2806
+ reason: `no matches for ${JSON.stringify(qTokens)}`
2807
+ };
2808
+ }
2809
+ const top = positive.slice(0, topK).map((s) => s.file);
2810
+ const topScore = positive[0]?.score ?? 0;
2811
+ const secondScore = positive[1]?.score ?? 0;
2812
+ let confidence;
2813
+ if (positive.length === 1) confidence = "high";
2814
+ else if (topScore >= 6 && topScore >= secondScore * 2) confidence = "high";
2815
+ else if (topScore >= 3) confidence = "medium";
2816
+ else confidence = "low";
2817
+ const reasons = positive.slice(0, Math.min(3, top.length)).map((s) => `${s.file.path} (${s.reasons.join(",")})`).join("; ");
2818
+ return {
2819
+ files: top,
2820
+ confidence,
2821
+ reason: `top: ${reasons}`
2822
+ };
2823
+ }
2824
+
2825
+ // src/memory/branches.ts
2826
+ import { execFile as execFile2 } from "child_process";
2827
+ import { readFile as readFile11 } from "fs/promises";
2828
+ import { join as join8 } from "path";
2829
+ import { promisify as promisify2 } from "util";
2830
+ var execFileAsync2 = promisify2(execFile2);
2831
+ async function currentBranch(projectRoot) {
2832
+ try {
2833
+ const headPath = join8(projectRoot, ".git", "HEAD");
2834
+ const head = await readFile11(headPath, "utf8");
2835
+ const trimmed = head.trim();
2836
+ const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
2837
+ if (match?.[1]) return match[1];
2838
+ } catch {
2839
+ }
2840
+ try {
2841
+ const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], {
2842
+ cwd: projectRoot
2843
+ });
2844
+ const name = stdout.trim();
2845
+ if (name) return name;
2846
+ } catch {
2847
+ }
2848
+ return "main";
2849
+ }
2850
+ async function defaultBranch(projectRoot) {
2851
+ try {
2852
+ const { stdout } = await execFileAsync2(
2853
+ "git",
2854
+ ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
2855
+ { cwd: projectRoot }
2856
+ );
2857
+ const trimmed = stdout.trim();
2858
+ const match = trimmed.match(/^origin\/(.+)$/);
2859
+ if (match?.[1]) return match[1];
2860
+ } catch {
2861
+ }
2862
+ return "main";
2863
+ }
2864
+ function sanitizeBranchName(name) {
2865
+ return name.replaceAll("/", "-").replaceAll("\\", "-");
2866
+ }
2867
+ function resolveBranchPaths(contextDir, branch, isDefault) {
2868
+ if (isDefault) {
2869
+ return {
2870
+ contextStore: join8(contextDir, "context-store.json"),
2871
+ contextMd: join8(contextDir, "CONTEXT.md"),
2872
+ branchDir: null
2873
+ };
2874
+ }
2875
+ const branchDir = join8(contextDir, "branches", sanitizeBranchName(branch));
2876
+ return {
2877
+ contextStore: join8(branchDir, "context-store.json"),
2878
+ contextMd: join8(branchDir, "CONTEXT.md"),
2879
+ branchDir
2880
+ };
2881
+ }
2882
+
2883
+ // src/memory/context-md.ts
2884
+ import { mkdir as mkdir6, readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
2885
+ import { dirname as dirname6 } from "path";
2886
+ var MAX_BULLETS = 3;
2887
+ function deriveContextMd(entries, branch) {
2888
+ const tasks = entries.filter((e) => e.type === "task").reverse();
2889
+ const currentTask = tasks[0]?.content ?? "";
2890
+ const keyDecisions = entries.filter((e) => e.type === "decision").slice(-MAX_BULLETS).map((e) => e.content);
2891
+ const nextSteps = entries.filter((e) => e.type === "next").slice(-MAX_BULLETS).map((e) => e.content);
2892
+ return {
2893
+ branch,
2894
+ currentTask,
2895
+ keyDecisions,
2896
+ nextSteps,
2897
+ date: (/* @__PURE__ */ new Date()).toISOString()
2898
+ };
2899
+ }
2900
+ function formatContextMd(ctx) {
2901
+ const lines = [];
2902
+ lines.push(`# Context \u2014 ${ctx.branch}`);
2903
+ lines.push("");
2904
+ lines.push(`_Updated: ${ctx.date}_`);
2905
+ lines.push("");
2906
+ if (ctx.currentTask) {
2907
+ lines.push(`## Current task`);
2908
+ lines.push(ctx.currentTask);
2909
+ lines.push("");
2910
+ }
2911
+ if (ctx.keyDecisions.length) {
2912
+ lines.push(`## Key decisions`);
2913
+ for (const d of ctx.keyDecisions) lines.push(`- ${d}`);
2914
+ lines.push("");
2915
+ }
2916
+ if (ctx.nextSteps.length) {
2917
+ lines.push(`## Next steps`);
2918
+ for (const n of ctx.nextSteps) lines.push(`- ${n}`);
2919
+ lines.push("");
2920
+ }
2921
+ if (!ctx.currentTask && !ctx.keyDecisions.length && !ctx.nextSteps.length) {
2922
+ lines.push("_(no context entries yet \u2014 use `context_remember` to add one)_");
2923
+ lines.push("");
2924
+ }
2925
+ return lines.join("\n");
2926
+ }
2927
+ async function writeContextMd(path, ctx) {
2928
+ await mkdir6(dirname6(path), { recursive: true });
2929
+ await writeFile6(path, formatContextMd(ctx), "utf8");
2930
+ }
2931
+
2932
+ // src/memory/context-store.ts
2933
+ import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
2934
+ import { dirname as dirname7 } from "path";
2935
+ var SCHEMA_VERSION2 = 1;
2936
+ async function readEntries(path) {
2937
+ try {
2938
+ const raw = await readFile13(path, "utf8");
2939
+ const parsed = JSON.parse(raw);
2940
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
2941
+ } catch {
2942
+ return [];
2943
+ }
2944
+ }
2945
+ async function writeEntries(path, entries) {
2946
+ await mkdir7(dirname7(path), { recursive: true });
2947
+ const store = { schema_version: SCHEMA_VERSION2, entries };
2948
+ await writeFile7(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2949
+ }
2950
+ async function appendEntry(path, entry) {
2951
+ const entries = await readEntries(path);
2952
+ entries.push(entry);
2953
+ await writeEntries(path, entries);
2954
+ }
2955
+
2956
+ // src/memory/index.ts
2957
+ async function resolveActiveBranch(paths, override) {
2958
+ const branch = override ?? await currentBranch(paths.projectRoot);
2959
+ const def = await defaultBranch(paths.projectRoot);
2960
+ const isDefault = branch === def;
2961
+ return {
2962
+ branch,
2963
+ isDefault,
2964
+ paths: resolveBranchPaths(paths.contextDir, branch, isDefault)
2965
+ };
2966
+ }
2967
+ async function rememberEntry(paths, input) {
2968
+ const active = await resolveActiveBranch(paths);
2969
+ const entry = {
2970
+ type: input.kind,
2971
+ content: input.text,
2972
+ tags: input.tags ?? [],
2973
+ files: input.files ?? [],
2974
+ date: (/* @__PURE__ */ new Date()).toISOString()
2975
+ };
2976
+ await appendEntry(active.paths.contextStore, entry);
2977
+ const entries = await readEntries(active.paths.contextStore);
2978
+ const md = deriveContextMd(entries, active.branch);
2979
+ await writeContextMd(active.paths.contextMd, md);
2980
+ return {
2981
+ entry,
2982
+ branch: active.branch,
2983
+ storePath: active.paths.contextStore,
2984
+ contextMdPath: active.paths.contextMd
2985
+ };
2986
+ }
2987
+ async function recallEntries(paths, input = {}) {
2988
+ const active = await resolveActiveBranch(paths, input.branch);
2989
+ let entries = await readEntries(active.paths.contextStore);
2990
+ if (input.kind) entries = entries.filter((e) => e.type === input.kind);
2991
+ if (input.limit && input.limit > 0) entries = entries.slice(-input.limit);
2992
+ return {
2993
+ branch: active.branch,
2994
+ entries,
2995
+ storePath: active.paths.contextStore
2996
+ };
2997
+ }
2998
+ async function refreshContextMd(paths, branchOverride) {
2999
+ const active = await resolveActiveBranch(paths, branchOverride);
3000
+ const entries = await readEntries(active.paths.contextStore);
3001
+ const md = deriveContextMd(entries, active.branch);
3002
+ await writeContextMd(active.paths.contextMd, md);
3003
+ return {
3004
+ branch: active.branch,
3005
+ path: active.paths.contextMd,
3006
+ entriesSeen: entries.length
3007
+ };
3008
+ }
3009
+
3010
+ // src/packer/format.ts
3011
+ function formatPack(inputs) {
3012
+ const parts = [];
3013
+ parts.push(`# Synthra context \u2014 query: ${JSON.stringify(inputs.query)}
3014
+ `);
3015
+ if (inputs.files.length === 0) {
3016
+ parts.push("> No matching files found in the graph.\n");
3017
+ }
3018
+ for (const f of inputs.files) {
3019
+ const heading = f.reason ? `## ${f.path} _(${f.reason})_` : `## ${f.path}`;
3020
+ parts.push(heading);
3021
+ if (f.signatures.length === 0) {
3022
+ parts.push("_(no symbols extracted)_");
3023
+ } else {
3024
+ parts.push("**Signatures:**");
3025
+ for (const s of f.signatures) parts.push(`- ${s}`);
3026
+ }
3027
+ if (f.inlineBodies.trim().length > 0) {
3028
+ parts.push("");
3029
+ parts.push("**Bodies:**");
3030
+ parts.push("```");
3031
+ parts.push(f.inlineBodies.trimEnd());
3032
+ parts.push("```");
3033
+ }
3034
+ if (f.associatedTests?.length) {
3035
+ parts.push("");
3036
+ parts.push(`**Tests:** ${f.associatedTests.join(", ")}`);
3037
+ }
3038
+ parts.push("");
3039
+ }
3040
+ if (inputs.recentActivity?.trim()) {
3041
+ parts.push("---");
3042
+ parts.push("## Recent human activity");
3043
+ parts.push(inputs.recentActivity.trim());
3044
+ parts.push("");
3045
+ }
3046
+ if (inputs.truncated) {
3047
+ parts.push("> _(pack truncated to fit budget)_");
3048
+ }
3049
+ return parts.join("\n");
3050
+ }
3051
+
3052
+ // src/packer/inline.ts
3053
+ var INLINABLE_KINDS = /* @__PURE__ */ new Set(["function", "method", "class"]);
3054
+ var MAX_BODY_CHARS = 1500;
3055
+ function sliceLines(content, startLine, endLine) {
3056
+ const lines = content.split(/\r?\n/);
3057
+ return lines.slice(Math.max(0, startLine - 1), endLine).join("\n");
3058
+ }
3059
+ function scoreSymbol(name, qTokens) {
3060
+ const lower = name.toLowerCase();
3061
+ if (qTokens.has(lower)) return 3;
3062
+ for (const t of qTokens) {
3063
+ if (lower.includes(t) || t.includes(lower)) return 1;
3064
+ }
3065
+ return 0;
3066
+ }
3067
+ function truncate(body) {
3068
+ if (body.length <= MAX_BODY_CHARS) return body;
3069
+ return body.slice(0, MAX_BODY_CHARS).trimEnd() + "\n // \u2026 truncated";
3070
+ }
3071
+ function selectInlineBodies(file, symbols, query, budgetChars) {
3072
+ if (budgetChars <= 0) {
3073
+ return { text: "", charsUsed: 0, functionsInlined: [] };
3074
+ }
3075
+ const qTokens = new Set(tokenizeQuery(query));
3076
+ const mine = symbols.filter((s) => s.file === file.path && INLINABLE_KINDS.has(s.symbol_kind));
3077
+ const scored = mine.map((s) => ({ sym: s, score: scoreSymbol(s.name, qTokens) })).sort((a, b) => {
3078
+ if (b.score !== a.score) return b.score - a.score;
3079
+ const aSpan = a.sym.end_line - a.sym.start_line || 1;
3080
+ const bSpan = b.sym.end_line - b.sym.start_line || 1;
3081
+ return aSpan - bSpan;
3082
+ });
3083
+ const parts = [];
3084
+ const inlined = [];
3085
+ let used = 0;
3086
+ for (const { sym, score: score2 } of scored) {
3087
+ if (score2 === 0 && inlined.length > 0) break;
3088
+ const body = truncate(sliceLines(file.content, sym.start_line, sym.end_line));
3089
+ const header = `${file.path}::${sym.name} (L${sym.start_line}-${sym.end_line})`;
3090
+ const block = `${header}
3091
+ ${body}
3092
+ `;
3093
+ if (used + block.length > budgetChars) {
3094
+ if (inlined.length > 0) break;
3095
+ const remaining = Math.max(0, budgetChars - used - header.length - 16);
3096
+ if (remaining <= 0) break;
3097
+ const partial = body.slice(0, remaining).trimEnd() + "\n // \u2026 truncated";
3098
+ const finalBlock = `${header}
3099
+ ${partial}
3100
+ `;
3101
+ parts.push(finalBlock);
3102
+ inlined.push(sym.name);
3103
+ used += finalBlock.length;
3104
+ break;
3105
+ }
3106
+ parts.push(block);
3107
+ inlined.push(sym.name);
3108
+ used += block.length;
3109
+ }
3110
+ return { text: parts.join("\n"), charsUsed: used, functionsInlined: inlined };
3111
+ }
3112
+
3113
+ // src/packer/signatures.ts
3114
+ function extractSignatures(file, symbols) {
3115
+ const mine = symbols.filter((s) => s.file === file.path).slice().sort((a, b) => a.start_line - b.start_line);
3116
+ return mine.map((s) => `L${s.start_line}: ${s.signature.trim()}`);
3117
+ }
3118
+
3119
+ // src/packer/tests.ts
3120
+ function findTestsForFile(graph, file) {
3121
+ const fileNodesById = /* @__PURE__ */ new Map();
3122
+ for (const n of graph.nodes) {
3123
+ if (n.kind === "file") fileNodesById.set(n.id, n);
3124
+ }
3125
+ const out = [];
3126
+ for (const e of graph.edges) {
3127
+ if (e.kind !== "tests" || e.to !== file.id) continue;
3128
+ const testFile = fileNodesById.get(e.from);
3129
+ if (testFile && !out.includes(testFile)) out.push(testFile);
3130
+ }
3131
+ return out;
3132
+ }
3133
+
3134
+ // src/packer/index.ts
3135
+ var STATIC_OVERHEAD_PER_FILE = 200;
3136
+ var MAX_INLINE_CHARS_PER_FILE = 2500;
3137
+ function indexSymbolsByFile2(graph) {
3138
+ return graph.nodes.filter((n) => n.kind === "symbol");
3139
+ }
3140
+ async function pack(files, opts) {
3141
+ const budgetTokens = opts.budgetTokens ?? 4e3;
3142
+ const budgetChars = budgetTokens * 4;
3143
+ const inlineRatio = opts.inlineBodyRatio ?? 0.5;
3144
+ const includeTests = opts.includeTests ?? true;
3145
+ const reasons = opts.reasons ?? /* @__PURE__ */ new Map();
3146
+ const symbols = indexSymbolsByFile2(opts.graph);
3147
+ const sections = [];
3148
+ const testsCoRetrieved = [];
3149
+ let used = 0;
3150
+ let truncated = false;
3151
+ for (const file of files) {
3152
+ const sig = extractSignatures(file, symbols);
3153
+ const testFiles = includeTests ? findTestsForFile(opts.graph, file) : [];
3154
+ const testPaths = testFiles.map((t) => t.path);
3155
+ const staticCost = file.path.length + sig.join("\n").length + testPaths.join(",").length + STATIC_OVERHEAD_PER_FILE;
3156
+ if (used + staticCost > budgetChars) {
3157
+ truncated = true;
3158
+ break;
3159
+ }
3160
+ const remaining = budgetChars - used - staticCost;
3161
+ const inlineBudget = Math.min(Math.floor(remaining * inlineRatio), MAX_INLINE_CHARS_PER_FILE);
3162
+ const inline = selectInlineBodies(file, symbols, opts.query, inlineBudget);
3163
+ sections.push({
3164
+ path: file.path,
3165
+ reason: reasons.get(file.path),
3166
+ signatures: sig,
3167
+ inlineBodies: inline.text,
3168
+ associatedTests: testPaths
3169
+ });
3170
+ used += staticCost + inline.charsUsed;
3171
+ for (const t of testPaths) if (!testsCoRetrieved.includes(t)) testsCoRetrieved.push(t);
3172
+ if (used >= budgetChars) {
3173
+ truncated = true;
3174
+ break;
3175
+ }
3176
+ }
3177
+ if (sections.length < files.length) truncated = true;
3178
+ const text = formatPack({
3179
+ query: opts.query,
3180
+ files: sections,
3181
+ truncated
3182
+ });
3183
+ const tokenEstimate = Math.ceil(text.length / 4);
3184
+ return {
3185
+ text,
3186
+ tokenEstimate,
3187
+ filesUsed: sections.map((s) => s.path),
3188
+ testsCoRetrieved,
3189
+ truncated
3190
+ };
3191
+ }
3192
+
3193
+ // src/server/mcp.ts
3194
+ var PROTOCOL_VERSION = "2024-11-05";
3195
+ var SERVER_INFO = { name: "synthra", version: "0.0.1" };
3196
+ var ERR = {
3197
+ parse: -32700,
3198
+ invalidRequest: -32600,
3199
+ methodNotFound: -32601,
3200
+ invalidParams: -32602,
3201
+ internal: -32603
3202
+ };
3203
+ function ok(id, result) {
3204
+ return { jsonrpc: "2.0", id, result };
3205
+ }
3206
+ function err(id, code, message, data) {
3207
+ return { jsonrpc: "2.0", id, error: { code, message, data } };
3208
+ }
3209
+ function textContent(text) {
3210
+ return { content: [{ type: "text", text }], isError: false };
3211
+ }
3212
+ function errorContent(message) {
3213
+ return { content: [{ type: "text", text: message }], isError: true };
3214
+ }
3215
+ var TOOLS = [
3216
+ {
3217
+ name: "graph_continue",
3218
+ description: "Returns the project context most relevant to a query \u2014 function signatures, top function bodies, and linked test files. Use this BEFORE Grep/Glob. If `confidence` is 'high', do not call Grep/Glob for the same query.",
3219
+ inputSchema: {
3220
+ type: "object",
3221
+ properties: {
3222
+ query: { type: "string", description: "Natural-language description of what you're looking for." }
3223
+ },
3224
+ required: ["query"]
3225
+ }
3226
+ },
3227
+ {
3228
+ name: "graph_read",
3229
+ description: "Return the source code for a specific file or symbol. Target is either a project-relative file path (e.g. 'src/auth.ts') or 'file::symbol' (e.g. 'src/auth.ts::AuthService').",
3230
+ inputSchema: {
3231
+ type: "object",
3232
+ properties: {
3233
+ target: { type: "string", description: "File path or file::symbol notation." }
3234
+ },
3235
+ required: ["target"]
3236
+ }
3237
+ },
3238
+ {
3239
+ name: "graph_register_edit",
3240
+ description: "Tell Synthra that you (the AI) have edited these files. Lets Synthra rank them higher in subsequent retrieval and avoid surfacing stale context.",
3241
+ inputSchema: {
3242
+ type: "object",
3243
+ properties: {
3244
+ files: {
3245
+ type: "array",
3246
+ items: { type: "string" },
3247
+ description: "Project-relative file paths that were edited."
3248
+ }
3249
+ },
3250
+ required: ["files"]
3251
+ }
3252
+ },
3253
+ {
3254
+ name: "context_remember",
3255
+ description: "Persist a decision/task/next-step/fact/blocker into the project's branch-aware context store. Use when the user makes a decision worth keeping, identifies a TODO, or surfaces a key fact. Entries land in `.synthra/context-store.json` on the default branch, or `.synthra/branches/<sanitized>/context-store.json` on a feature branch \u2014 git-tracked, so teammates inherit them and they merge naturally.",
3256
+ inputSchema: {
3257
+ type: "object",
3258
+ properties: {
3259
+ text: { type: "string", description: "The thing to remember (1\u20133 sentences)." },
3260
+ kind: {
3261
+ type: "string",
3262
+ enum: ["decision", "task", "next", "fact", "blocker"],
3263
+ description: "What kind of entry. Required."
3264
+ },
3265
+ tags: {
3266
+ type: "array",
3267
+ items: { type: "string" },
3268
+ description: "Optional tags for grouping (e.g. 'auth', 'perf')."
3269
+ },
3270
+ files: {
3271
+ type: "array",
3272
+ items: { type: "string" },
3273
+ description: "Optional project-relative file paths this entry relates to."
3274
+ }
3275
+ },
3276
+ required: ["text", "kind"]
3277
+ }
3278
+ },
3279
+ {
3280
+ name: "context_recall",
3281
+ description: "Read previously-stored decisions/tasks/facts from the project's branch-aware context store. Defaults to the current branch.",
3282
+ inputSchema: {
3283
+ type: "object",
3284
+ properties: {
3285
+ kind: {
3286
+ type: "string",
3287
+ enum: ["decision", "task", "next", "fact", "blocker"],
3288
+ description: "Filter to a single kind."
3289
+ },
3290
+ branch: { type: "string", description: "Override which branch to read from." },
3291
+ limit: { type: "number", description: "Return only the most recent N entries." }
3292
+ }
3293
+ }
3294
+ },
3295
+ {
3296
+ name: "recent_activity",
3297
+ description: "What has the human been doing in the editor recently \u2014 file saves, branch switches, and uncommitted-diff changes. Use this to check whether the static context pack may be stale (e.g. before answering a question about a file that was just edited).",
3298
+ inputSchema: {
3299
+ type: "object",
3300
+ properties: {
3301
+ since_ms: {
3302
+ type: "number",
3303
+ description: "Epoch milliseconds. Only return events newer than this. Defaults to the last 60 minutes."
3304
+ },
3305
+ limit: { type: "number", description: "Cap on returned events." }
3306
+ }
3307
+ }
3308
+ },
3309
+ {
3310
+ name: "count_tokens",
3311
+ description: "Estimate token count for a piece of text using a char/4 approximation. Accurate within ~10% for English + code. Useful for budgeting prompt content before sending.",
3312
+ inputSchema: {
3313
+ type: "object",
3314
+ properties: {
3315
+ text: { type: "string", description: "The text to estimate tokens for." }
3316
+ },
3317
+ required: ["text"]
3318
+ }
3319
+ },
3320
+ {
3321
+ name: "blast_radius",
3322
+ description: "Given a file (or 'file::symbol' target), return all files that depend on it transitively via imports + tests edges. Use BEFORE editing a widely-used file to see what could break. Symbol-level granularity is approximated at the file level (we don't track call edges in v0.1).",
3323
+ inputSchema: {
3324
+ type: "object",
3325
+ properties: {
3326
+ target: { type: "string", description: "File path or 'file::symbol' notation." },
3327
+ depth: { type: "number", description: "Max hops to traverse. Default 3." }
3328
+ },
3329
+ required: ["target"]
3330
+ }
3331
+ },
3332
+ {
3333
+ name: "dead_code",
3334
+ description: "Return files in the project that no other file imports and no test file references \u2014 strong candidates for unused/orphaned code. File-level granularity (v0.1 limitation \u2014 symbol-level needs call-graph edges). Common entry-point patterns (main, index, app, CLI, bin/) are excluded heuristically.",
3335
+ inputSchema: {
3336
+ type: "object",
3337
+ properties: {
3338
+ limit: { type: "number", description: "Cap on returned files. Default 50." }
3339
+ }
3340
+ }
3341
+ }
3342
+ ];
3343
+ async function callTool(name, args, ctx) {
3344
+ switch (name) {
3345
+ case "graph_continue":
3346
+ return graphContinue(args, ctx);
3347
+ case "graph_read":
3348
+ return graphRead(args, ctx);
3349
+ case "graph_register_edit":
3350
+ return graphRegisterEdit(args, ctx);
3351
+ case "context_remember":
3352
+ return contextRemember(args, ctx);
3353
+ case "context_recall":
3354
+ return contextRecall(args, ctx);
3355
+ case "recent_activity":
3356
+ return recentActivity(args, ctx);
3357
+ case "count_tokens":
3358
+ return countTokens(args);
3359
+ case "blast_radius":
3360
+ return blastRadius(args, ctx);
3361
+ case "dead_code":
3362
+ return deadCode(args, ctx);
3363
+ default:
3364
+ return errorContent(`Unknown tool: ${name}`);
3365
+ }
3366
+ }
3367
+ function countTokens(args) {
3368
+ const text = typeof args?.text === "string" ? args.text : "";
3369
+ if (!text) return errorContent("count_tokens: 'text' (string) is required");
3370
+ const tokens = Math.ceil(text.length / 4);
3371
+ return textContent(JSON.stringify({ tokens, method: "chars/4 estimate", chars: text.length }));
3372
+ }
3373
+ function blastRadius(args, ctx) {
3374
+ const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
3375
+ const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
3376
+ if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
3377
+ const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
3378
+ const root = ctx.graph.nodes.find(
3379
+ (n) => n.kind === "file" && n.path === filePath
3380
+ );
3381
+ if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
3382
+ const incoming = /* @__PURE__ */ new Map();
3383
+ for (const e of ctx.graph.edges) {
3384
+ if (e.kind !== "imports" && e.kind !== "tests") continue;
3385
+ const list = incoming.get(e.to) ?? [];
3386
+ list.push({ from: e.from, kind: e.kind });
3387
+ incoming.set(e.to, list);
3388
+ }
3389
+ const visited = /* @__PURE__ */ new Set([root.id]);
3390
+ const hits = [];
3391
+ const pathById = /* @__PURE__ */ new Map();
3392
+ for (const n of ctx.graph.nodes) if (n.kind === "file") pathById.set(n.id, n.path);
3393
+ let frontier = [root.id];
3394
+ for (let d = 1; d <= maxDepth; d++) {
3395
+ const next = [];
3396
+ for (const cur of frontier) {
3397
+ const callers = incoming.get(cur) ?? [];
3398
+ for (const c of callers) {
3399
+ if (visited.has(c.from)) continue;
3400
+ visited.add(c.from);
3401
+ next.push(c.from);
3402
+ const path = pathById.get(c.from) ?? c.from;
3403
+ hits.push({ path, depth: d, via: c.kind });
3404
+ }
3405
+ }
3406
+ frontier = next;
3407
+ if (next.length === 0) break;
3408
+ }
3409
+ if (hits.length === 0) {
3410
+ return textContent(`# Blast radius for ${filePath}
3411
+
3412
+ _(no dependents \u2014 file is isolated)_`);
3413
+ }
3414
+ hits.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
3415
+ const lines = [`# Blast radius for ${filePath} (depth \u2264 ${maxDepth})`, ""];
3416
+ lines.push(`${hits.length} dependent file(s):`);
3417
+ for (const h of hits) {
3418
+ lines.push(`- **depth ${h.depth}** \`${h.path}\` _(via ${h.via})_`);
3419
+ }
3420
+ return textContent(lines.join("\n"));
3421
+ }
3422
+ var LIKELY_ENTRY_PATTERNS = [
3423
+ /(?:^|\/)main\.[a-z0-9_]+$/i,
3424
+ /(?:^|\/)index\.[a-z0-9_]+$/i,
3425
+ /(?:^|\/)app\.[a-z0-9_]+$/i,
3426
+ /(?:^|\/)entry\.[a-z0-9_]+$/i,
3427
+ /(?:^|\/)cli[\/.]/i,
3428
+ /(?:^|\/)bin[\/.]/i,
3429
+ /(?:^|\/)server\.[a-z0-9_]+$/i,
3430
+ /\.test\.[a-z0-9_]+$/i,
3431
+ /\.spec\.[a-z0-9_]+$/i,
3432
+ /(?:^|\/)tests?\//i,
3433
+ /(?:^|\/)__tests__\//i,
3434
+ /(?:^|\/)__init__\.py$/i
3435
+ ];
3436
+ function isLikelyEntry(path) {
3437
+ return LIKELY_ENTRY_PATTERNS.some((re) => re.test(path));
3438
+ }
3439
+ function deadCode(args, ctx) {
3440
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 50;
3441
+ const hasIncoming = /* @__PURE__ */ new Set();
3442
+ for (const e of ctx.graph.edges) {
3443
+ if (e.kind === "imports" || e.kind === "tests") hasIncoming.add(e.to);
3444
+ }
3445
+ const candidates = ctx.graph.nodes.filter((n) => n.kind === "file").filter((f) => !hasIncoming.has(f.id)).filter((f) => !isLikelyEntry(f.path));
3446
+ if (candidates.length === 0) {
3447
+ return textContent(
3448
+ `# Dead code
3449
+
3450
+ _(no file is unreferenced \u2014 every file is either imported by another, has a linked test, or matches an entry-point pattern)_`
3451
+ );
3452
+ }
3453
+ candidates.sort((a, b) => a.path.localeCompare(b.path));
3454
+ const shown = candidates.slice(0, limit);
3455
+ const lines = [`# Dead code candidates (file-level, v0.1)`, ""];
3456
+ lines.push(
3457
+ `${shown.length} of ${candidates.length} unreferenced file(s) \u2014 no other file imports them and no test links them:`
3458
+ );
3459
+ lines.push("");
3460
+ for (const f of shown) {
3461
+ lines.push(`- \`${f.path}\``);
3462
+ }
3463
+ lines.push("");
3464
+ lines.push(
3465
+ `_v0.1 caveat:_ this is file-level only. Symbol-level dead code (unused exports) needs call-graph edges, which land in v0.2.`
3466
+ );
3467
+ return textContent(lines.join("\n"));
3468
+ }
3469
+ async function graphContinue(args, ctx) {
3470
+ const query = typeof args?.query === "string" ? args.query : "";
3471
+ if (!query) return errorContent("graph_continue: 'query' (string) is required");
3472
+ const retrieval = await retrieve(ctx.graph, query);
3473
+ const packed = await pack(retrieval.files, { query, graph: ctx.graph });
3474
+ const header = `Confidence: ${retrieval.confidence}
3475
+ Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
3476
+ Reason: ${retrieval.reason}
3477
+ `;
3478
+ return textContent(`${header}
3479
+ ${packed.text}`);
3480
+ }
3481
+ function graphRead(args, ctx) {
3482
+ const target = typeof args?.target === "string" ? args.target : "";
3483
+ if (!target) return errorContent("graph_read: 'target' (string) is required");
3484
+ const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
3485
+ const filePath = (rawFile ?? "").trim();
3486
+ const fileNode = ctx.graph.nodes.find(
3487
+ (n) => n.kind === "file" && n.path === filePath
3488
+ );
3489
+ if (!fileNode) return errorContent(`graph_read: file not found in graph: ${filePath}`);
3490
+ if (!symbolName) {
3491
+ return textContent(`# ${fileNode.path}
3492
+
3493
+ ${fileNode.content}`);
3494
+ }
3495
+ const cleanSym = symbolName.trim();
3496
+ const symbol = ctx.graph.nodes.find(
3497
+ (n) => n.kind === "symbol" && n.file === filePath && n.name === cleanSym
3498
+ );
3499
+ if (!symbol) {
3500
+ return errorContent(`graph_read: symbol '${cleanSym}' not found in ${filePath}`);
3501
+ }
3502
+ const lines = fileNode.content.split(/\r?\n/);
3503
+ const body = lines.slice(symbol.start_line - 1, symbol.end_line).join("\n");
3504
+ return textContent(
3505
+ `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
3506
+
3507
+ ${body}`
3508
+ );
3509
+ }
3510
+ var editedFiles = /* @__PURE__ */ new Set();
3511
+ function graphRegisterEdit(args, _ctx) {
3512
+ const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
3513
+ for (const f of files) editedFiles.add(f);
3514
+ return textContent(`Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`);
3515
+ }
3516
+ var VALID_KINDS = /* @__PURE__ */ new Set(["decision", "task", "next", "fact", "blocker"]);
3517
+ async function contextRemember(args, ctx) {
3518
+ const text = typeof args?.text === "string" ? args.text.trim() : "";
3519
+ const kindRaw = typeof args?.kind === "string" ? args.kind : "";
3520
+ if (!text) return errorContent("context_remember: 'text' (string) is required");
3521
+ if (!VALID_KINDS.has(kindRaw)) {
3522
+ return errorContent(
3523
+ `context_remember: 'kind' must be one of ${Array.from(VALID_KINDS).join(", ")}`
3524
+ );
3525
+ }
3526
+ const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
3527
+ const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
3528
+ const result = await rememberEntry(ctx.paths, {
3529
+ text,
3530
+ kind: kindRaw,
3531
+ tags,
3532
+ files
3533
+ });
3534
+ return textContent(
3535
+ `Remembered ${result.entry.type} on branch '${result.branch}'.
3536
+ Stored: ${result.storePath}
3537
+ CONTEXT.md refreshed: ${result.contextMdPath}`
3538
+ );
3539
+ }
3540
+ var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
3541
+ function recentActivity(args, ctx) {
3542
+ const sinceMs = typeof args?.since_ms === "number" && Number.isFinite(args.since_ms) ? args.since_ms : Date.now() - DEFAULT_RECENT_WINDOW_MS;
3543
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : void 0;
3544
+ let events = ctx.activity.getEvents(sinceMs);
3545
+ if (limit) events = events.slice(-limit);
3546
+ if (events.length === 0) {
3547
+ return textContent(
3548
+ `No human-activity events since ${new Date(sinceMs).toISOString()}.`
3549
+ );
3550
+ }
3551
+ const lines = [`# Recent human activity (${events.length} events)`, ""];
3552
+ for (const e of events) {
3553
+ if ("path" in e) {
3554
+ lines.push(`- **${e.kind}** ${e.path} _(${e.ts})_`);
3555
+ } else {
3556
+ const summary = JSON.stringify(e.details);
3557
+ lines.push(`- **${e.kind}** ${summary} _(${e.ts})_`);
3558
+ }
3559
+ }
3560
+ return textContent(lines.join("\n"));
3561
+ }
3562
+ async function contextRecall(args, ctx) {
3563
+ const kind = typeof args?.kind === "string" && VALID_KINDS.has(args.kind) ? args.kind : void 0;
3564
+ const branch = typeof args?.branch === "string" ? args.branch : void 0;
3565
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : void 0;
3566
+ const result = await recallEntries(ctx.paths, { kind, branch, limit });
3567
+ if (result.entries.length === 0) {
3568
+ const filter = kind ? ` of kind '${kind}'` : "";
3569
+ return textContent(`No context entries${filter} on branch '${result.branch}'.`);
3570
+ }
3571
+ const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
3572
+ for (const e of result.entries) {
3573
+ const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
3574
+ lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}`);
3575
+ if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
3576
+ }
3577
+ return textContent(lines.join("\n"));
3578
+ }
3579
+ async function handleMcpRequest(body, ctx) {
3580
+ if (!body || typeof body !== "object") {
3581
+ return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
3582
+ }
3583
+ const req = body;
3584
+ if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
3585
+ return err(req.id ?? null, ERR.invalidRequest, "Invalid JSON-RPC envelope.");
3586
+ }
3587
+ const id = req.id ?? null;
3588
+ try {
3589
+ switch (req.method) {
3590
+ case "initialize":
3591
+ return ok(id, {
3592
+ protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : PROTOCOL_VERSION,
3593
+ capabilities: { tools: {} },
3594
+ serverInfo: SERVER_INFO
3595
+ });
3596
+ case "notifications/initialized":
3597
+ return ok(id, {});
3598
+ case "tools/list":
3599
+ return ok(id, { tools: TOOLS });
3600
+ case "tools/call": {
3601
+ const params = req.params ?? {};
3602
+ const toolName = typeof params.name === "string" ? params.name : "";
3603
+ if (!toolName) return err(id, ERR.invalidParams, "'name' is required for tools/call.");
3604
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
3605
+ const result = await callTool(toolName, args, ctx);
3606
+ return ok(id, result);
3607
+ }
3608
+ case "ping":
3609
+ return ok(id, {});
3610
+ default:
3611
+ return err(id, ERR.methodNotFound, `Method not found: ${req.method}`);
3612
+ }
3613
+ } catch (e) {
3614
+ return err(id, ERR.internal, e.message);
3615
+ }
3616
+ }
3617
+
3618
+ // src/server/routes/activity.ts
3619
+ async function handleActivity(sinceMs, ctx) {
3620
+ const events = ctx.activity.getEvents(sinceMs);
3621
+ return {
3622
+ events,
3623
+ since: new Date(sinceMs ?? Date.now()).toISOString(),
3624
+ ring_size: ctx.activity.size()
3625
+ };
3626
+ }
3627
+
3628
+ // src/server/routes/context-update.ts
3629
+ async function handleContextUpdate(req, ctx) {
3630
+ const r = await refreshContextMd(ctx.paths, req?.branch);
3631
+ return {
3632
+ updated: true,
3633
+ branch: r.branch,
3634
+ path: r.path,
3635
+ entries: r.entriesSeen
3636
+ };
3637
+ }
3638
+
3639
+ // src/server/routes/gate.ts
3640
+ import { appendFile as appendFile2, mkdir as mkdir8 } from "fs/promises";
3641
+ import { dirname as dirname8 } from "path";
3642
+ var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
3643
+ var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
3644
+ function extractQuery(toolName, input) {
3645
+ if (toolName === "Grep") {
3646
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
3647
+ const query = typeof input.query === "string" ? input.query : "";
3648
+ return (pattern || query).trim() || null;
3649
+ }
3650
+ if (toolName === "Glob") {
3651
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
3652
+ return pattern.replace(/[*?/\\.]+/g, " ").trim() || null;
3653
+ }
3654
+ return null;
3655
+ }
3656
+ function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
3657
+ const matches = [];
3658
+ for (const path of recentPaths) {
3659
+ const lower = path.toLowerCase();
3660
+ for (const t of queryTokens) {
3661
+ if (lower.includes(t)) {
3662
+ matches.push(path);
3663
+ break;
3664
+ }
3665
+ }
3666
+ }
3667
+ return matches;
3668
+ }
3669
+ async function logDecision(ctx, toolName, query, decision, reason) {
3670
+ try {
3671
+ await mkdir8(dirname8(ctx.paths.gateLog), { recursive: true });
3672
+ const entry = {
3673
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3674
+ tool: toolName,
3675
+ decision,
3676
+ query,
3677
+ reason
3678
+ };
3679
+ await appendFile2(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
3680
+ } catch {
3681
+ }
3682
+ }
3683
+ async function handleGate(req, ctx) {
3684
+ if (!req?.tool_name || typeof req.tool_name !== "string") {
3685
+ return { decision: "allow", reason: "no tool_name" };
3686
+ }
3687
+ if (!BLOCKABLE_TOOLS.has(req.tool_name)) {
3688
+ return { decision: "allow" };
3689
+ }
3690
+ const input = req.tool_input && typeof req.tool_input === "object" ? req.tool_input : {};
3691
+ const query = extractQuery(req.tool_name, input);
3692
+ if (!query) {
3693
+ const res2 = { decision: "allow", reason: "no extractable query" };
3694
+ await logDecision(ctx, req.tool_name, null, res2.decision, res2.reason);
3695
+ return res2;
3696
+ }
3697
+ const retrieval = await retrieve(ctx.graph, query);
3698
+ if (retrieval.confidence === "low") {
3699
+ const res2 = {
3700
+ decision: "allow",
3701
+ reason: `confidence=low \u2014 no graph context for "${query}", letting ${req.tool_name} through`
3702
+ };
3703
+ await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
3704
+ return res2;
3705
+ }
3706
+ const qTokens = new Set(tokenizeQuery(query));
3707
+ const recentPaths = ctx.activity.recentFilePaths(RECENT_ACTIVITY_WINDOW_MS);
3708
+ const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens);
3709
+ if (overlap.length > 0) {
3710
+ const res2 = {
3711
+ decision: "allow",
3712
+ reason: `confidence=${retrieval.confidence} but human just touched ${overlap.slice(0, 3).join(", ")} \u2014 static context may be stale, letting ${req.tool_name} through.`
3713
+ };
3714
+ await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
3715
+ return res2;
3716
+ }
3717
+ const top = retrieval.files.slice(0, 3).map((f) => f.path).join(", ");
3718
+ const res = {
3719
+ decision: "block",
3720
+ reason: `Synthra has ${retrieval.confidence}-confidence context for "${query}" (top files: ${top}). Use the \`graph_continue\` MCP tool with this query instead of ${req.tool_name}, or read a specific file/symbol with \`graph_read\`.`
3721
+ };
3722
+ await logDecision(ctx, req.tool_name, query, res.decision, res.reason);
3723
+ return res;
3724
+ }
3725
+
3726
+ // src/server/routes/log.ts
3727
+ import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
3728
+ import { dirname as dirname9 } from "path";
3729
+ async function handleLog(entry, ctx) {
3730
+ if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
3731
+ throw new Error("log: input_tokens and output_tokens (number) are required");
3732
+ }
3733
+ const written_at = (/* @__PURE__ */ new Date()).toISOString();
3734
+ const record = { ...entry, written_at };
3735
+ await mkdir9(dirname9(ctx.paths.tokenLog), { recursive: true });
3736
+ await appendFile3(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
3737
+ return { ok: true, written_at };
3738
+ }
3739
+
3740
+ // src/server/routes/pack.ts
3741
+ async function handlePack(req, ctx) {
3742
+ if (!req?.query || typeof req.query !== "string") {
3743
+ throw new Error("pack: 'query' (string) is required");
3744
+ }
3745
+ const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
3746
+ const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
3747
+ const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
3748
+ const scored = scoreFiles({
3749
+ candidates: allFiles,
3750
+ query: req.query,
3751
+ graph: ctx.graph,
3752
+ recentlyEditedPaths
3753
+ });
3754
+ const reasons = /* @__PURE__ */ new Map();
3755
+ for (const s of scored) {
3756
+ if (s.reasons.length) reasons.set(s.file.path, s.reasons.join(","));
3757
+ }
3758
+ const result = await pack(retrieval.files, {
3759
+ query: req.query,
3760
+ graph: ctx.graph,
3761
+ budgetTokens: req.maxTokens,
3762
+ includeTests: req.includeTests,
3763
+ reasons
3764
+ });
3765
+ return {
3766
+ ...result,
3767
+ query: req.query,
3768
+ confidence: retrieval.confidence,
3769
+ retrievalReason: retrieval.reason
3770
+ };
3771
+ }
3772
+
3773
+ // src/server/routes/prime.ts
3774
+ async function handlePrime(ctx, port) {
3775
+ const g = ctx.graph;
3776
+ const fileCount = g.file_count;
3777
+ const symbolCount = g.symbol_count;
3778
+ const primer = `Synthra context loaded for ${g.root}.
3779
+ ${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.
3780
+ (Full primer wired in M3.)`;
3781
+ return { primer, port };
3782
+ }
3783
+
3784
+ // src/server/http.ts
3785
+ async function loadContext(paths) {
3786
+ try {
3787
+ const [graph, symbolIndex] = await Promise.all([
3788
+ readGraph(paths.infoGraph),
3789
+ readSymbolIndex(paths.symbolIndex)
3790
+ ]);
3791
+ const activity = new ActivityStore(paths.activityLog);
3792
+ return { paths, graph, symbolIndex, activity };
3793
+ } catch (err2) {
3794
+ throw new Error(
3795
+ `failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
3796
+ );
3797
+ }
3798
+ }
3799
+ function buildApp(ctx, port) {
3800
+ const app = new Hono2();
3801
+ app.get(
3802
+ "/",
3803
+ (c) => c.json({
3804
+ service: "synthra",
3805
+ version: "0.0.1",
3806
+ port,
3807
+ file_count: ctx.graph.file_count,
3808
+ symbol_count: ctx.graph.symbol_count,
3809
+ generated_at: ctx.graph.generated_at
3810
+ })
3811
+ );
3812
+ app.get("/health", (c) => c.json({ ok: true }));
3813
+ app.get("/prime", async (c) => c.json(await handlePrime(ctx, port)));
3814
+ app.post("/pack", async (c) => {
3815
+ const body = await c.req.json().catch(() => ({}));
3816
+ return c.json(await handlePack(body, ctx));
3817
+ });
3818
+ app.post("/log", async (c) => {
3819
+ const body = await c.req.json().catch(() => ({}));
3820
+ return c.json(await handleLog(body, ctx));
3821
+ });
3822
+ app.post("/gate", async (c) => {
3823
+ const body = await c.req.json().catch(() => ({}));
3824
+ return c.json(await handleGate(body, ctx));
3825
+ });
3826
+ app.get("/activity", async (c) => {
3827
+ const sinceParam = c.req.query("since");
3828
+ const sinceMs = sinceParam ? Number(sinceParam) : void 0;
3829
+ return c.json(
3830
+ await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
3831
+ );
3832
+ });
3833
+ app.post("/context-update", async (c) => {
3834
+ const body = await c.req.json().catch(() => ({}));
3835
+ return c.json(await handleContextUpdate(body, ctx));
3836
+ });
3837
+ app.post("/mcp", async (c) => {
3838
+ const body = await c.req.json().catch(() => null);
3839
+ return c.json(await handleMcpRequest(body, ctx));
3840
+ });
3841
+ app.onError((err2, c) => {
3842
+ log.error("route error:", err2.message);
3843
+ return c.json({ error: err2.message }, 400);
3844
+ });
3845
+ return app;
3846
+ }
3847
+ async function startServer(paths, options = {}) {
3848
+ const ctx = await loadContext(paths);
3849
+ const port = options.port ?? await findFreePort();
3850
+ const app = buildApp(ctx, port);
3851
+ const nodeServer = serve2({ fetch: app.fetch, port, hostname: "127.0.0.1" });
3852
+ await writeFile8(paths.mcpPort, String(port), "utf8");
3853
+ const fileWatcher = createFileWatcher(
3854
+ paths.projectRoot,
3855
+ (e) => ctx.activity.add(e)
3856
+ );
3857
+ const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
3858
+ await ctx.activity.add(e);
3859
+ if (e.kind === "branch-switch") {
3860
+ try {
3861
+ const to = e.details?.to ?? "unknown";
3862
+ log.info(`branch switched to '${to}' \u2014 rebuilding graph\u2026`);
3863
+ await scanProject(paths.projectRoot, { silent: true });
3864
+ const [g, idx] = await Promise.all([
3865
+ readGraph(paths.infoGraph),
3866
+ readSymbolIndex(paths.symbolIndex)
3867
+ ]);
3868
+ ctx.graph = g;
3869
+ ctx.symbolIndex = idx;
3870
+ log.info(`graph rebuilt for '${to}' (${g.symbol_count} symbols).`);
3871
+ } catch (err2) {
3872
+ log.warn(`branch rescan failed: ${err2.message}`);
3873
+ }
3874
+ }
3875
+ });
3876
+ try {
3877
+ await fileWatcher.start();
3878
+ } catch (err2) {
3879
+ log.warn(`file watcher failed to start: ${err2.message}`);
3880
+ }
3881
+ try {
3882
+ await gitWatcher.start();
3883
+ } catch (err2) {
3884
+ log.warn(`git watcher failed to start: ${err2.message}`);
3885
+ }
3886
+ const url = `http://127.0.0.1:${port}`;
3887
+ return {
3888
+ port,
3889
+ url,
3890
+ async stop() {
3891
+ await fileWatcher.stop().catch(() => void 0);
3892
+ await gitWatcher.stop().catch(() => void 0);
3893
+ await new Promise((resolve5, reject) => {
3894
+ nodeServer.close((err2) => err2 ? reject(err2) : resolve5());
3895
+ });
3896
+ }
3897
+ };
3898
+ }
3899
+
3900
+ // src/shared/config.ts
3901
+ function num(name, fallback) {
3902
+ const v = process.env[name];
3903
+ if (!v) return fallback;
3904
+ const n = Number(v);
3905
+ return Number.isFinite(n) ? n : fallback;
3906
+ }
3907
+ function str(name, fallback) {
3908
+ return process.env[name] ?? fallback;
3909
+ }
3910
+ function loadConfig() {
3911
+ return {
3912
+ hardMaxReadChars: num("SYN_HARD_MAX_READ_CHARS", 4e3),
3913
+ turnReadBudgetChars: num("SYN_TURN_READ_BUDGET_CHARS", 18e3),
3914
+ fallbackMaxCallsPerTurn: num("SYN_FALLBACK_MAX_CALLS_PER_TURN", 1),
3915
+ retrieveCacheTtlSec: num("SYN_RETRIEVE_CACHE_TTL_SEC", 900),
3916
+ mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
3917
+ dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
3918
+ logLevel: str("SYN_LOG_LEVEL", "info"),
3919
+ claudeBin: str("SYN_CLAUDE_BIN", "claude")
3920
+ };
3921
+ }
3922
+
3923
+ // src/cli/session-discovery.ts
3924
+ import { readdir as readdir2, stat as stat3 } from "fs/promises";
3925
+ import { homedir as homedir2 } from "os";
3926
+ import { join as join9 } from "path";
3927
+ function encodeProjectPath(projectRoot) {
3928
+ return projectRoot.replace(/[\\/:]/g, "-");
3929
+ }
3930
+ async function findLatestSession(projectRoot) {
3931
+ const encoded = encodeProjectPath(projectRoot);
3932
+ const dir = join9(homedir2(), ".claude", "projects", encoded);
3933
+ let entries;
3934
+ try {
3935
+ entries = await readdir2(dir);
3936
+ } catch {
3937
+ return null;
3938
+ }
3939
+ const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
3940
+ if (jsonlFiles.length === 0) return null;
3941
+ let latest = null;
3942
+ for (const file of jsonlFiles) {
3943
+ const path = join9(dir, file);
3944
+ try {
3945
+ const s = await stat3(path);
3946
+ if (!latest || s.mtime > latest.modifiedAt) {
3947
+ latest = {
3948
+ sessionId: file.replace(/\.jsonl$/, ""),
3949
+ transcriptPath: path,
3950
+ modifiedAt: s.mtime
3951
+ };
3952
+ }
3953
+ } catch {
3954
+ }
3955
+ }
3956
+ return latest;
3957
+ }
3958
+
3959
+ // src/cli/cleanup.ts
3960
+ async function cleanup(paths) {
3961
+ const session = await findLatestSession(paths.projectRoot);
3962
+ if (!session) {
3963
+ log.info("(no Claude session transcript found \u2014 nothing to resume)");
3964
+ return;
3965
+ }
3966
+ log.info("");
3967
+ log.info(`To resume this session: syn --resume ${session.sessionId}`);
3968
+ }
3969
+
3970
+ // src/cli/dashboard-command.ts
3971
+ import { resolve as resolve2 } from "path";
3972
+ async function dashboardCommand(rawPath) {
3973
+ const projectRoot = resolve2(rawPath);
3974
+ const paths = resolvePaths(projectRoot);
3975
+ const cfg = loadConfig();
3976
+ const handle = await startDashboard(paths, cfg.dashboardPort);
3977
+ log.info(`Synthra dashboard listening on ${handle.url}`);
3978
+ log.info(`project: ${projectRoot}`);
3979
+ log.info(`reading: ${paths.tokenLog}`);
3980
+ log.info(` ${paths.gateLog}`);
3981
+ log.info("press Ctrl+C to stop.");
3982
+ await new Promise((res) => {
3983
+ const shutdown = async (signal) => {
3984
+ log.info(`received ${signal} \u2014 shutting down\u2026`);
3985
+ try {
3986
+ await handle.stop();
3987
+ } catch (err2) {
3988
+ log.warn(`dashboard stop error: ${err2.message}`);
3989
+ }
3990
+ res();
3991
+ };
3992
+ process.on("SIGINT", shutdown);
3993
+ process.on("SIGTERM", shutdown);
3994
+ });
3995
+ }
3996
+
3997
+ // src/cli/self-update.ts
3998
+ import { mkdir as mkdir10, readFile as readFile14, writeFile as writeFile9 } from "fs/promises";
3999
+ import { homedir as homedir3 } from "os";
4000
+ import { join as join10 } from "path";
4001
+ var PKG_NAME = "@jefuriiij/synthra";
4002
+ var CACHE_DIR = join10(homedir3(), ".synthra");
4003
+ var CACHE_PATH = join10(CACHE_DIR, "version-check.json");
4004
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
4005
+ var REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PKG_NAME)}/latest`;
4006
+ var FETCH_TIMEOUT_MS = 2e3;
4007
+ var currentVersionCache = null;
4008
+ async function getCurrentVersion() {
4009
+ if (currentVersionCache) return currentVersionCache;
4010
+ try {
4011
+ const pkg = await Promise.resolve().then(() => (init_package(), package_exports));
4012
+ const version = "default" in pkg ? pkg.default.version : pkg.version;
4013
+ currentVersionCache = version;
4014
+ return version;
4015
+ } catch {
4016
+ return "0.0.0";
4017
+ }
4018
+ }
4019
+ async function readCache() {
4020
+ try {
4021
+ const raw = await readFile14(CACHE_PATH, "utf8");
4022
+ const parsed = JSON.parse(raw);
4023
+ if (!parsed.checked_at || !parsed.latest_version) return null;
4024
+ return parsed;
4025
+ } catch {
4026
+ return null;
4027
+ }
4028
+ }
4029
+ async function writeCache(cache) {
4030
+ try {
4031
+ await mkdir10(CACHE_DIR, { recursive: true });
4032
+ await writeFile9(CACHE_PATH, JSON.stringify(cache, null, 2), "utf8");
4033
+ } catch {
4034
+ }
4035
+ }
4036
+ function isNewer(candidate, baseline) {
4037
+ const a = candidate.split(/[.-]/).map((p) => Number(p));
4038
+ const b = baseline.split(/[.-]/).map((p) => Number(p));
4039
+ const len = Math.max(a.length, b.length);
4040
+ for (let i = 0; i < len; i++) {
4041
+ const ai = Number.isFinite(a[i]) ? a[i] : 0;
4042
+ const bi = Number.isFinite(b[i]) ? b[i] : 0;
4043
+ if (ai > bi) return true;
4044
+ if (ai < bi) return false;
4045
+ }
4046
+ return false;
4047
+ }
4048
+ async function fetchLatestFromRegistry() {
4049
+ try {
4050
+ const res = await fetch(REGISTRY_URL, {
4051
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
4052
+ headers: { Accept: "application/json" }
4053
+ });
4054
+ if (!res.ok) return null;
4055
+ const data = await res.json();
4056
+ return typeof data.version === "string" ? data.version : null;
4057
+ } catch {
4058
+ return null;
4059
+ }
4060
+ }
4061
+ async function checkForUpdate() {
4062
+ const current = await getCurrentVersion();
4063
+ if (process.env.SYN_NO_UPDATE_CHECK === "1") {
4064
+ return { current, latest: null, hasUpdate: false };
4065
+ }
4066
+ const cache = await readCache();
4067
+ const now = Date.now();
4068
+ const cacheAge = cache ? now - Date.parse(cache.checked_at) : Infinity;
4069
+ let latest = null;
4070
+ if (cache && cacheAge < CACHE_TTL_MS) {
4071
+ latest = cache.latest_version;
4072
+ } else {
4073
+ latest = await fetchLatestFromRegistry();
4074
+ if (latest) {
4075
+ await writeCache({ checked_at: (/* @__PURE__ */ new Date()).toISOString(), latest_version: latest });
4076
+ } else if (cache) {
4077
+ latest = cache.latest_version;
4078
+ }
4079
+ }
4080
+ const hasUpdate = latest ? isNewer(latest, current) : false;
4081
+ return { current, latest, hasUpdate };
4082
+ }
4083
+ async function logUpdateHintIfNeeded() {
4084
+ try {
4085
+ const r = await checkForUpdate();
4086
+ if (r.hasUpdate && r.latest) {
4087
+ log.info(
4088
+ `Synthra ${r.latest} is available (you have ${r.current}) \u2014 run: npm install -g @jefuriiij/synthra@latest`
4089
+ );
4090
+ }
4091
+ } catch {
4092
+ }
4093
+ }
4094
+
4095
+ // src/cli/serve-command.ts
4096
+ import { resolve as resolve3 } from "path";
4097
+ import { stat as stat4 } from "fs/promises";
4098
+ async function serveCommand(rawPath) {
4099
+ const projectRoot = resolve3(rawPath);
4100
+ const paths = resolvePaths(projectRoot);
4101
+ try {
4102
+ await stat4(paths.infoGraph);
4103
+ } catch {
4104
+ log.error(`no graph found at ${paths.infoGraph}`);
4105
+ log.error("run `syn scan` in this project first.");
4106
+ process.exit(2);
4107
+ }
4108
+ const handle = await startServer(paths);
4109
+ log.info(`MCP server listening on ${handle.url}`);
4110
+ log.info(`port written to ${paths.mcpPort}`);
4111
+ log.info("press Ctrl+C to stop.");
4112
+ const shutdown = async (signal) => {
4113
+ log.info(`received ${signal} \u2014 shutting down\u2026`);
4114
+ try {
4115
+ await handle.stop();
4116
+ } catch (err2) {
4117
+ log.error("shutdown error:", err2.message);
4118
+ }
4119
+ process.exit(0);
4120
+ };
4121
+ process.on("SIGINT", shutdown);
4122
+ process.on("SIGTERM", shutdown);
4123
+ }
4124
+
4125
+ // src/cli/start-claude.ts
4126
+ import { spawn } from "child_process";
4127
+ var MCP_NAME = "synthra";
4128
+ function runClaude(bin, args, cwd, stdio = "pipe") {
4129
+ return new Promise((resolve5) => {
4130
+ const proc = spawn(bin, args, {
4131
+ cwd,
4132
+ stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
4133
+ shell: process.platform === "win32"
4134
+ });
4135
+ let stdout = "";
4136
+ let stderr = "";
4137
+ proc.stdout?.on("data", (c) => stdout += String(c));
4138
+ proc.stderr?.on("data", (c) => stderr += String(c));
4139
+ proc.on("error", () => resolve5({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
4140
+ proc.on("exit", (code) => resolve5({ code: code ?? 0, stdout, stderr }));
4141
+ });
4142
+ }
4143
+ async function registerMcp(bin, mcpPort, cwd) {
4144
+ const url = `http://127.0.0.1:${mcpPort}/mcp`;
4145
+ await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "local"], cwd).catch(() => void 0);
4146
+ const reg = await runClaude(
4147
+ bin,
4148
+ ["mcp", "add", MCP_NAME, "--transport", "http", "--scope", "local", url],
4149
+ cwd
4150
+ );
4151
+ if (reg.code !== 0) {
4152
+ log.warn(`claude mcp add failed (code ${reg.code}). stderr: ${reg.stderr.trim()}`);
4153
+ log.warn(`Synthra's MCP tools won't be visible to Claude this session.`);
4154
+ return false;
4155
+ }
4156
+ log.info(`registered MCP with Claude: ${MCP_NAME} \u2192 ${url}`);
4157
+ return true;
4158
+ }
4159
+ async function unregisterMcp(bin, cwd) {
4160
+ const r = await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "local"], cwd);
4161
+ if (r.code === 0) log.debug("unregistered MCP server");
4162
+ }
4163
+ async function spawnClaude(bin, opts) {
4164
+ const args = [];
4165
+ if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
4166
+ if (opts.initialPrompt) args.push(opts.initialPrompt);
4167
+ log.info(`launching ${bin} ${args.join(" ")}`);
4168
+ const result = await runClaude(bin, args, opts.cwd, "inherit");
4169
+ return result.code;
4170
+ }
4171
+
4172
+ // src/cli/index.ts
4173
+ var VERSION = "0.0.1";
4174
+ function printReadyBanner(info) {
4175
+ log.info("");
4176
+ log.info(` \u2705 scanned ${info.scan.parsed} files \xB7 ${info.scan.symbolCount} symbols \xB7 ${info.scan.edgeCount} edges`);
4177
+ if (info.mcpRegistered) {
4178
+ log.info(` \u{1F9E0} MCP ${info.mcpUrl} \u2192 registered as 'synthra'`);
4179
+ } else {
4180
+ log.info(` \u{1F9E0} MCP ${info.mcpUrl} \u26A0 registration with claude failed`);
4181
+ }
4182
+ if (info.dashboardUrl) {
4183
+ log.info(` \u{1F4CA} Dashboard ${info.dashboardUrl}`);
4184
+ } else {
4185
+ log.info(` \u{1F4CA} Dashboard (failed to start; data is still logged to .synthra-graph/)`);
4186
+ }
4187
+ log.info(` \u{1FA9D} Hooks installed in .claude/settings.local.json`);
4188
+ log.info("");
4189
+ log.info(` \u{1F916} Ready \u2014 open the Claude Code IDE extension (or run \`claude\` in another terminal).`);
4190
+ log.info(` Synthra's tools and gate will be active for that session.`);
4191
+ log.info("");
4192
+ log.info(` Press Ctrl+C here when you're done.`);
4193
+ log.info("");
4194
+ }
4195
+ function waitForSignal() {
4196
+ return new Promise((resolve5) => {
4197
+ const handler = (sig) => {
4198
+ process.off("SIGINT", handler);
4199
+ process.off("SIGTERM", handler);
4200
+ resolve5(sig);
4201
+ };
4202
+ process.on("SIGINT", handler);
4203
+ process.on("SIGTERM", handler);
4204
+ });
4205
+ }
4206
+ async function defaultFlow(rawPath, opts) {
4207
+ const launchCli = opts["launch-cli"] === true;
4208
+ const projectRoot = resolve4(rawPath);
4209
+ const paths = resolvePaths(projectRoot);
4210
+ const cfg = loadConfig();
4211
+ void logUpdateHintIfNeeded();
4212
+ await recordProject(projectRoot);
4213
+ const scan = await scanCommand(rawPath);
4214
+ const mcpHandle = await startServer(paths);
4215
+ let dashboardHandle = null;
4216
+ try {
4217
+ dashboardHandle = await startDashboard(paths, cfg.dashboardPort);
4218
+ } catch (err2) {
4219
+ log.warn(`dashboard failed to start on port ${cfg.dashboardPort}: ${err2.message}`);
4220
+ }
4221
+ await installHooks(paths);
4222
+ const mcpRegistered = await registerMcp(cfg.claudeBin, mcpHandle.port, projectRoot);
4223
+ let claudeExitCode = 0;
4224
+ try {
4225
+ if (launchCli) {
4226
+ claudeExitCode = await spawnClaude(cfg.claudeBin, {
4227
+ cwd: projectRoot,
4228
+ resumeSessionId: opts.resume
4229
+ });
4230
+ log.info(`claude exited with code ${claudeExitCode}`);
4231
+ } else {
4232
+ printReadyBanner({
4233
+ projectRoot,
4234
+ scan,
4235
+ mcpUrl: mcpHandle.url,
4236
+ dashboardUrl: dashboardHandle?.url ?? null,
4237
+ mcpRegistered
4238
+ });
4239
+ const sig = await waitForSignal();
4240
+ log.info(`received ${sig} \u2014 shutting down\u2026`);
4241
+ }
4242
+ } finally {
4243
+ await unregisterMcp(cfg.claudeBin, projectRoot).catch(() => void 0);
4244
+ if (dashboardHandle) {
4245
+ await dashboardHandle.stop().catch(
4246
+ (err2) => log.warn(`dashboard stop error: ${err2.message}`)
4247
+ );
4248
+ }
4249
+ await mcpHandle.stop().catch(
4250
+ (err2) => log.warn(`MCP server stop error: ${err2.message}`)
4251
+ );
4252
+ await cleanup(paths).catch(
4253
+ (err2) => log.warn(`cleanup error: ${err2.message}`)
4254
+ );
4255
+ }
4256
+ }
4257
+ function buildProgram() {
4258
+ const prog = sade("syn");
4259
+ prog.version(VERSION).describe("Local context engine for AI coding assistants.");
4260
+ prog.command(". [path]", "Scan + MCP + dashboard + hooks. Default flow \u2014 use with the Claude Code IDE extension.", {
4261
+ default: true
4262
+ }).option("--resume <id>", "Resume an existing Claude session (only with --launch-cli)").option("--launch-cli", "Also spawn `claude` CLI in this terminal (legacy M3 behavior)", false).action(async (path, opts) => {
4263
+ await defaultFlow(path ?? ".", opts);
4264
+ });
4265
+ prog.command("scan [path]", "Scan only \u2014 walk + parse + write graph.").action(async (path) => {
4266
+ await scanCommand(path ?? ".");
4267
+ });
4268
+ prog.command("serve [path]", "Start the HTTP MCP server against a scanned project.").action(async (path) => {
4269
+ await serveCommand(path ?? ".");
4270
+ });
4271
+ prog.command("dashboard [path]", "Run the token dashboard server (localhost:8901).").action(async (path) => {
4272
+ await dashboardCommand(path ?? ".");
4273
+ });
4274
+ return prog;
4275
+ }
4276
+ async function main(argv) {
4277
+ const prog = buildProgram();
4278
+ prog.parse(argv);
4279
+ }
4280
+ export {
4281
+ buildProgram,
4282
+ main
4283
+ };
4284
+ //# sourceMappingURL=index.js.map