@jefuriiij/synthra 0.2.1 → 0.3.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.
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.2.1",
21
+ version: "0.3.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -146,7 +146,125 @@ function isFree(port) {
146
146
  }
147
147
 
148
148
  // src/dashboard/delta.ts
149
- import { readFile as readFile2 } from "fs/promises";
149
+ import { readFile as readFile3 } from "fs/promises";
150
+
151
+ // src/learn/store.ts
152
+ import { appendFile, mkdir, readFile, writeFile } from "fs/promises";
153
+ import { dirname } from "path";
154
+
155
+ // src/learn/usage.ts
156
+ var LEARN_SCHEMA_VERSION = 1;
157
+ var DAY_MS = 24 * 60 * 60 * 1e3;
158
+ function halfLifeMs() {
159
+ const env = Number(process.env.SYN_LEARN_HALFLIFE_DAYS);
160
+ const days = Number.isFinite(env) && env > 0 ? env : 7;
161
+ return days * DAY_MS;
162
+ }
163
+ function weightFor(source) {
164
+ switch (source) {
165
+ case "register_edit":
166
+ return 2;
167
+ case "read":
168
+ return 1;
169
+ default:
170
+ return 0;
171
+ }
172
+ }
173
+ function emptyStore() {
174
+ return {
175
+ schema_version: LEARN_SCHEMA_VERSION,
176
+ asOf: (/* @__PURE__ */ new Date(0)).toISOString(),
177
+ files: {}
178
+ };
179
+ }
180
+ function decayFactor(fromTs, toMs, hl) {
181
+ const fromMs = Date.parse(fromTs);
182
+ if (!Number.isFinite(fromMs)) return 1;
183
+ const dt = toMs - fromMs;
184
+ if (dt <= 0) return 1;
185
+ return Math.exp(-(Math.LN2 / hl) * dt);
186
+ }
187
+ function foldEvent(store, ev) {
188
+ const w = weightFor(ev.source);
189
+ if (w <= 0 || !ev.path) return store;
190
+ const tMs = Date.parse(ev.ts);
191
+ if (!Number.isFinite(tMs)) return store;
192
+ const hl = halfLifeMs();
193
+ const prev = store.files[ev.path];
194
+ if (prev) {
195
+ const decayed = prev.decayed * decayFactor(prev.lastTs, tMs, hl) + w;
196
+ store.files[ev.path] = { count: prev.count + 1, decayed, lastTs: ev.ts };
197
+ } else {
198
+ store.files[ev.path] = { count: 1, decayed: w, lastTs: ev.ts };
199
+ }
200
+ return store;
201
+ }
202
+ function effectiveScores(store, nowMs) {
203
+ const hl = halfLifeMs();
204
+ const out = /* @__PURE__ */ new Map();
205
+ for (const [path, stat6] of Object.entries(store.files)) {
206
+ const eff = stat6.decayed * decayFactor(stat6.lastTs, nowMs, hl);
207
+ if (eff > 0.01) out.set(path, eff);
208
+ }
209
+ return out;
210
+ }
211
+ function recomputeFromLog(events) {
212
+ const store = emptyStore();
213
+ for (const ev of events) foldEvent(store, ev);
214
+ return store;
215
+ }
216
+
217
+ // src/learn/store.ts
218
+ async function readLearnStore(path) {
219
+ try {
220
+ const raw = await readFile(path, "utf8");
221
+ const parsed = JSON.parse(raw);
222
+ if (parsed.schema_version !== LEARN_SCHEMA_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
223
+ return emptyStore();
224
+ }
225
+ return {
226
+ schema_version: LEARN_SCHEMA_VERSION,
227
+ asOf: typeof parsed.asOf === "string" ? parsed.asOf : emptyStore().asOf,
228
+ files: parsed.files
229
+ };
230
+ } catch {
231
+ return emptyStore();
232
+ }
233
+ }
234
+ async function writeLearnStore(path, store) {
235
+ try {
236
+ await mkdir(dirname(path), { recursive: true });
237
+ await writeFile(path, JSON.stringify(store, null, 2) + "\n", "utf8");
238
+ } catch {
239
+ }
240
+ }
241
+ async function readAccessLog(path) {
242
+ try {
243
+ const raw = await readFile(path, "utf8");
244
+ const out = [];
245
+ for (const line of raw.split("\n")) {
246
+ const t = line.trim();
247
+ if (!t) continue;
248
+ try {
249
+ const ev = JSON.parse(t);
250
+ if (ev && typeof ev.ts === "string" && typeof ev.path === "string" && typeof ev.source === "string") {
251
+ out.push(ev);
252
+ }
253
+ } catch {
254
+ }
255
+ }
256
+ return out;
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+ async function appendAccess(path, ev) {
262
+ try {
263
+ await mkdir(dirname(path), { recursive: true });
264
+ await appendFile(path, JSON.stringify(ev) + "\n", "utf8");
265
+ } catch {
266
+ }
267
+ }
150
268
 
151
269
  // src/shared/paths.ts
152
270
  import { join } from "path";
@@ -167,6 +285,7 @@ function resolvePaths(projectRoot) {
167
285
  toolLog: join(graphDir, "tool_log.jsonl"),
168
286
  accessLog: join(graphDir, "access_log.jsonl"),
169
287
  learnStore: join(graphDir, "learn_store.json"),
288
+ parseCache: join(graphDir, "parse_cache.json"),
170
289
  mcpPort: join(graphDir, "mcp_port"),
171
290
  mcpServerLog: join(graphDir, "mcp_server.log"),
172
291
  mcpServerErrLog: join(graphDir, "mcp_server.err.log"),
@@ -209,15 +328,15 @@ function estimateCostUsd(usage) {
209
328
  }
210
329
 
211
330
  // src/shared/project-registry.ts
212
- import { mkdir, readFile, writeFile } from "fs/promises";
331
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
213
332
  import { homedir } from "os";
214
- import { basename, dirname, join as join2 } from "path";
333
+ import { basename, dirname as dirname2, join as join2 } from "path";
215
334
  var REGISTRY_DIR = join2(homedir(), ".synthra");
216
335
  var REGISTRY_PATH = join2(REGISTRY_DIR, "projects.json");
217
336
  var SCHEMA_VERSION = 1;
218
337
  async function readRegistry() {
219
338
  try {
220
- const raw = await readFile(REGISTRY_PATH, "utf8");
339
+ const raw = await readFile2(REGISTRY_PATH, "utf8");
221
340
  const parsed = JSON.parse(raw);
222
341
  if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };
223
342
  return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };
@@ -226,8 +345,8 @@ async function readRegistry() {
226
345
  }
227
346
  }
228
347
  async function writeRegistry(registry) {
229
- await mkdir(dirname(REGISTRY_PATH), { recursive: true });
230
- await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf8");
348
+ await mkdir2(dirname2(REGISTRY_PATH), { recursive: true });
349
+ await writeFile2(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf8");
231
350
  }
232
351
  async function recordProject(projectRoot) {
233
352
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -264,9 +383,12 @@ function countToolCalls(entries) {
264
383
  }
265
384
  return out;
266
385
  }
386
+ function topHotFiles(store, nowMs, limit = 8) {
387
+ return [...effectiveScores(store, nowMs).entries()].map(([path, score2]) => ({ path, score: Math.round(score2 * 10) / 10 })).sort((a, b) => b.score - a.score).slice(0, limit);
388
+ }
267
389
  async function readJsonl(path) {
268
390
  try {
269
- const text = await readFile2(path, "utf8");
391
+ const text = await readFile3(path, "utf8");
270
392
  return text.split(/\r?\n/).filter((l) => l.length > 0).map((l) => {
271
393
  try {
272
394
  return JSON.parse(l);
@@ -299,6 +421,7 @@ function summarize(p) {
299
421
  }
300
422
  const blocked = p.gates.filter((g) => g.decision === "block").length;
301
423
  const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;
424
+ const now = Date.now();
302
425
  return {
303
426
  path: p.path,
304
427
  name: p.name,
@@ -314,6 +437,8 @@ function summarize(p) {
314
437
  estimated_cost_usd: Math.round(costUsd * 100) / 100,
315
438
  total_tool_calls: p.tools.length,
316
439
  tool_calls: countToolCalls(p.tools),
440
+ hot_files: topHotFiles(p.learn, now),
441
+ hot_files_total: effectiveScores(p.learn, now).size,
317
442
  models
318
443
  };
319
444
  }
@@ -323,13 +448,14 @@ function dedupeEnabled() {
323
448
  }
324
449
  async function loadProjectFiles(path, name, lastSeen) {
325
450
  const paths = resolvePaths(path);
326
- const [rawTokens, gates, tools] = await Promise.all([
451
+ const [rawTokens, gates, tools, learn] = await Promise.all([
327
452
  readJsonl(paths.tokenLog),
328
453
  readJsonl(paths.gateLog),
329
- readJsonl(paths.toolLog)
454
+ readJsonl(paths.toolLog),
455
+ readLearnStore(paths.learnStore)
330
456
  ]);
331
457
  const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;
332
- return { path, name, last_seen: lastSeen, tokens, gates, tools };
458
+ return { path, name, last_seen: lastSeen, tokens, gates, tools, learn };
333
459
  }
334
460
  function dedupeTokens(entries) {
335
461
  const score2 = (model) => {
@@ -392,7 +518,8 @@ async function computeDashboardData(activePaths, recentN = 500) {
392
518
  last_seen: null,
393
519
  tokens: [],
394
520
  gates: [],
395
- tools: []
521
+ tools: [],
522
+ learn: emptyStore()
396
523
  };
397
524
  const activeStats = summarize(activeFiles);
398
525
  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, g_tools = 0;
@@ -477,6 +604,7 @@ var public_default = `<!doctype html>
477
604
  <meta charset="UTF-8" />
478
605
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
479
606
  <title>Synthra \xB7 Dashboard</title>
607
+ <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
480
608
  <link rel="preconnect" href="https://fonts.googleapis.com">
481
609
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
482
610
  <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
@@ -671,6 +799,16 @@ var public_default = `<!doctype html>
671
799
  <div class="gate-mini" id="gate-mini"></div>
672
800
  </div>
673
801
 
802
+ <!-- Hot files (usage-learning) -->
803
+ <div class="card tools-card has-tooltip" data-tooltip="Files Synthra has learned you work in most, weighted by recent use \u2014 it ranks these higher in retrieval. Scoped to the active project; decays over ~7 days, so the list reflects what's hot now.">
804
+ <div class="card-head">
805
+ <div class="card-eyebrow">Hot <em>files</em></div>
806
+ <div class="card-meta" id="hot-files-project">active project</div>
807
+ </div>
808
+ <div class="moat-value"><span id="hot-files-total">0</span> <em>tracked</em></div>
809
+ <div class="cost-sub" id="hot-files-list"></div>
810
+ </div>
811
+
674
812
  </aside>
675
813
  </main>
676
814
 
@@ -1042,6 +1180,40 @@ var public_default = `<!doctype html>
1042
1180
  el.appendChild(frag);
1043
1181
  }
1044
1182
 
1183
+ function shortenPath(p) {
1184
+ const parts = String(p).split('/');
1185
+ return parts.length <= 2 ? p : '\u2026/' + parts.slice(-2).join('/');
1186
+ }
1187
+
1188
+ function renderHotFiles(active) {
1189
+ const stats = (active && active.stats) || {};
1190
+ const files = stats.hot_files || [];
1191
+ $('#hot-files-total').innerHTML = fmt(stats.hot_files_total || 0);
1192
+ const proj = $('#hot-files-project');
1193
+ if (proj) proj.textContent = (active && active.project_name) || 'active project';
1194
+ const el = $('#hot-files-list');
1195
+ el.innerHTML = '';
1196
+ if (!files.length) {
1197
+ el.innerHTML = '<div class="empty">No usage learned yet \u2014 Synthra learns as you read/edit files.</div>';
1198
+ return;
1199
+ }
1200
+ const frag = document.createDocumentFragment();
1201
+ for (const f of files) {
1202
+ const row = document.createElement('div');
1203
+ row.className = 'cs-row';
1204
+ row.title = f.path;
1205
+ const k = document.createElement('span');
1206
+ k.className = 'cs-k path';
1207
+ k.textContent = shortenPath(f.path);
1208
+ const v = document.createElement('span');
1209
+ v.className = 'cs-v';
1210
+ v.textContent = String(f.score);
1211
+ row.append(k, v);
1212
+ frag.appendChild(row);
1213
+ }
1214
+ el.appendChild(frag);
1215
+ }
1216
+
1045
1217
  function renderTurns(turns) {
1046
1218
  const tbody = $('#turns-body');
1047
1219
  const empty = $('#turns-empty');
@@ -1308,6 +1480,7 @@ var public_default = `<!doctype html>
1308
1480
  renderCostHero(data.global);
1309
1481
  renderMoat(data.global);
1310
1482
  renderToolUsage(data.global);
1483
+ renderHotFiles(data.active);
1311
1484
  renderTurns(turns);
1312
1485
  renderGateMini(gates);
1313
1486
 
@@ -1396,7 +1569,10 @@ var public_default = `<!doctype html>
1396
1569
  `;
1397
1570
 
1398
1571
  // src/dashboard/public/style.css
1399
- var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;\n --font-serif: "Instrument Serif", "Times New Roman", serif;\n --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop \u2014 quieter */\nbody::before,\nbody::after {\n content: "";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* ============================================================\n Panels / cards \u2014 darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column \u2014 Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column \u2014 Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip \u2014 viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n .col-right > *:nth-child(3) { animation-delay: 320ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row \u2014 live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: "\u203A";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: "\u203A";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: "";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 \u2014 date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n';
1572
+ var style_default = '/* Synthra dashboard \xB7 v0.2 \xB7 Cool Marine\n Darkened surfaces; brand blue reserved for hero elements only.\n Layout: top nav + hero strip + 3-column main, fits 1280\xD7720. */\n\n:root {\n /* Core palette */\n --ink: #04081A;\n --navy: #0A1530;\n --navy-2: #122549;\n --deep-blue: #1B3A78;\n --blue: #2C5DB8;\n --blue-bright: #5C8FE6;\n --sky: #9BC2EF;\n --mist: #D7E6F7;\n --bone: #F4F7FC;\n\n /* Text */\n --text: #ECF2FB;\n --text-dim: #A9BBD6;\n --text-mute: #6D80A0;\n\n /* Rules / dividers */\n --rule: rgba(155, 194, 239, .14);\n --rule-2: rgba(155, 194, 239, .06);\n --rule-hover: rgba(155, 194, 239, .28);\n\n /* Surfaces (darker than v0.1.2) */\n --surface-1: rgba(18, 37, 73, .14);\n --surface-2: rgba(18, 37, 73, .22);\n --surface-3: rgba(4, 8, 26, .55);\n\n /* Signal accents (OKLCH shared chroma) */\n --signal-cyan: oklch(78% 0.14 220);\n --signal-amber: oklch(78% 0.14 75);\n --signal-rose: oklch(70% 0.14 20);\n --signal-green: oklch(75% 0.14 155);\n --signal-violet: oklch(72% 0.14 285);\n\n /* Model family colors */\n --c-opus: #FF6338;\n --c-sonnet: #FFB938;\n --c-haiku: #7438FF;\n --c-unknown: #12CBF5;\n\n /* Money */\n --money: var(--signal-green);\n\n /* Type */\n --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;\n --font-serif: "Instrument Serif", "Times New Roman", serif;\n --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\n/* ============================================================\n Reset + base\n ============================================================ */\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n}\n\nhtml,\nbody {\n height: 100vh;\n overflow: hidden;\n}\n\nbody {\n background: var(--ink);\n color: var(--text);\n font-family: var(--font-sans);\n font-size: 13px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n display: grid;\n grid-template-rows: auto 1fr auto;\n position: relative;\n}\n\n/* Layered backdrop \u2014 quieter */\nbody::before,\nbody::after {\n content: "";\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 0;\n}\n\nbody::before {\n background-image: radial-gradient(circle, rgba(155, 194, 239, .06) 1px, transparent 1.2px);\n background-size: 22px 22px;\n}\n\nbody::after {\n background:\n radial-gradient(60% 40% at 50% 105%, rgba(44, 93, 184, .16) 0%, rgba(10, 21, 48, 0) 65%),\n radial-gradient(30% 25% at 50% 0%, rgba(92, 143, 230, .06) 0%, transparent 70%);\n}\n\nbody>* {\n position: relative;\n z-index: 1;\n}\n\nbutton {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: transparent;\n color: inherit;\n}\n\na {\n color: inherit;\n text-decoration: none;\n}\n\n/* ============================================================\n Top nav\n ============================================================ */\n.topnav {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n height: 52px;\n padding: 0 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(180deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n}\n\n.brand {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.brand-mark {\n width: 22px;\n height: 22px;\n border-radius: 7px;\n background: radial-gradient(120% 120% at 30% 30%, #6FA6E8 0%, #2C5DB8 45%, #0A1530 100%);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), 0 4px 12px -6px #2C5DB8;\n}\n\n.brand-name {\n font-size: 15px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--mist);\n}\n\n.brand-name em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: 0;\n}\n\n.brand-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-left: 6px;\n padding-left: 10px;\n border-left: 1px solid var(--rule);\n}\n\n.top-right {\n display: flex;\n align-items: center;\n gap: 12px;\n grid-column: 2;\n justify-self: center;\n}\n\n.topnav-right {\n grid-column: 3;\n justify-self: end;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.port-badge {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding: 6px 10px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n}\n\n.port-badge .mono {\n color: var(--text-dim);\n letter-spacing: 0.04em;\n text-transform: none;\n}\n\n.faq-btn {\n width: 30px;\n height: 30px;\n border-radius: 50%;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .55);\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 13px;\n font-weight: 500;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, border-color 180ms, color 180ms, transform 180ms;\n}\n\n.faq-btn:hover {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n\n.status-pill {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 12px;\n border: 1px solid var(--rule);\n border-radius: 999px;\n background: rgba(4, 8, 26, .55);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-dim);\n transition: border-color 240ms ease;\n}\n\n.status-pill:has(.dot.live) {\n border-color: rgba(155, 194, 239, .45);\n color: var(--mist);\n animation: pill-glow 2.4s ease-in-out infinite;\n}\n\n.status-pill:has(.dot.dead) {\n border-color: rgba(220, 90, 90, .40);\n color: oklch(80% 0.10 20);\n}\n\n@keyframes pill-glow {\n\n 0%,\n 100% {\n box-shadow: 0 0 14px -4px rgba(155, 194, 239, .30), inset 0 0 12px -8px rgba(155, 194, 239, .30);\n }\n\n 50% {\n box-shadow: 0 0 26px -2px rgba(155, 194, 239, .55), inset 0 0 18px -6px rgba(155, 194, 239, .45);\n }\n}\n\n.dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: var(--text-mute);\n transition: background 200ms;\n}\n\n.dot.live {\n background: var(--signal-cyan);\n animation: dot-pulse 1.8s ease-in-out infinite;\n}\n\n.dot.dead {\n background: var(--signal-rose);\n box-shadow: 0 0 0 3px rgba(220, 90, 90, .10);\n}\n\n@keyframes dot-pulse {\n\n 0%,\n 100% {\n box-shadow:\n 0 0 0 3px rgba(155, 194, 239, .10),\n 0 0 6px rgba(155, 194, 239, .50);\n }\n\n 50% {\n box-shadow:\n 0 0 0 6px rgba(155, 194, 239, .05),\n 0 0 14px rgba(155, 194, 239, .90);\n }\n}\n\n/* ============================================================\n Hero strip\n ============================================================ */\n.hero-strip {\n display: flex;\n align-items: center;\n gap: 24px;\n padding: 14px 24px;\n border-bottom: 1px solid var(--rule);\n background: linear-gradient(90deg, rgba(27, 58, 120, .10) 0%, rgba(4, 8, 26, 0) 100%);\n position: relative;\n overflow: hidden;\n}\n\n.hero-spacer {\n flex: 1;\n}\n\n.date-block {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.d-day {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 38px;\n line-height: 1;\n letter-spacing: -0.04em;\n color: var(--mist);\n}\n\n.d-rest {\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-dim);\n}\n\n.d-rest .d-mute {\n color: var(--text-mute);\n}\n\n.active-block {\n display: flex;\n flex-direction: column;\n gap: 2px;\n text-align: right;\n max-width: 360px;\n overflow: hidden;\n}\n\n.ab-label {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.ab-value {\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 360px;\n}\n\n/* ============================================================\n Main grid\n ============================================================ */\n.grid-main {\n display: grid;\n grid-template-columns: 260px 1fr 340px;\n gap: 16px;\n padding: 16px 24px;\n min-height: 0;\n z-index: 10;\n}\n\n.col-left,\n.col-center,\n.col-right {\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n}\n\n/* ============================================================\n Panels / cards \u2014 darker\n ============================================================ */\n.panel,\n.card {\n position: relative;\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n transition: border-color 180ms ease, background 180ms ease;\n}\n\n.card.has-tooltip {\n cursor: help;\n}\n\n.card.has-tooltip:hover {\n border-color: var(--rule-hover);\n background: var(--surface-2);\n}\n\n.card-head {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n}\n\n.card-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.card-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.card-meta {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* Legend panel */\n.panel {\n padding: 14px 14px 16px;\n gap: 14px;\n flex-shrink: 0;\n}\n\n.p-head {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.p-section {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.p-section+.p-section {\n padding-top: 12px;\n border-top: 1px solid var(--rule-2);\n}\n\n.ps-head {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 4px;\n}\n\n.check {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 3px 6px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n letter-spacing: 0.02em;\n}\n\nbutton.check {\n border: 0;\n background: transparent;\n width: 100%;\n text-align: left;\n}\n\n.check-clickable {\n cursor: pointer;\n border-radius: 6px;\n padding: 5px 6px;\n transition: background 140ms, color 140ms, transform 140ms;\n}\n\n.check-clickable .pf-arrow {\n margin-left: auto;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 12px;\n transition: color 140ms, transform 140ms;\n}\n\n.check-clickable:hover {\n background: rgba(155, 194, 239, .07);\n color: var(--mist);\n}\n\n.check-clickable:hover .pf-arrow {\n color: var(--sky);\n transform: translateX(2px);\n}\n\n.dot-sq {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n background: var(--text-mute);\n flex-shrink: 0;\n}\n\n.dot-sq.opus {\n background: var(--c-opus);\n}\n\n.dot-sq.sonnet {\n background: var(--c-sonnet);\n}\n\n.dot-sq.haiku {\n background: var(--c-haiku);\n}\n\n.dot-sq.unknown {\n background: var(--c-unknown);\n}\n\n.proj-filter {\n display: flex;\n flex-direction: column;\n gap: 1px;\n max-height: 90px;\n overflow-y: auto;\n}\n\n.pf-name {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 140px;\n}\n\n/* ============================================================\n Donut card (model usage)\n ============================================================ */\n.donut-card {\n flex: 1;\n gap: 10px;\n}\n\n.donut-wrap {\n position: relative;\n width: 140px;\n height: 140px;\n margin: 4px auto 0;\n}\n\n.donut {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.donut-track {\n fill: none;\n stroke: rgba(155, 194, 239, .07);\n stroke-width: 14;\n}\n\n.donut-seg {\n transition: stroke-dashoffset 400ms ease, stroke-dasharray 400ms ease;\n}\n\n.donut-center {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n pointer-events: none;\n}\n\n.donut-total {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.donut-total-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.donut-legend {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dl-row {\n display: grid;\n grid-template-columns: auto 1fr auto;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n}\n\n.dl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.dl-name {\n color: var(--text-dim);\n}\n\n.dl-pct {\n color: var(--mist);\n font-weight: 500;\n}\n\n/* ============================================================\n Center column \u2014 Metric strip (no card chrome, divider-separated)\n ============================================================ */\n.metric-strip {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--rule);\n border-radius: 14px;\n background: var(--surface-1);\n overflow: hidden;\n flex-shrink: 0;\n}\n\n.metric-item {\n padding: 14px 18px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n cursor: help;\n border-right: 1px solid var(--rule-2);\n transition: background 200ms ease;\n min-width: 0;\n}\n.metric-item:last-child { border-right: 0; }\n.metric-item:hover { background: var(--surface-2); }\n\n.m-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.m-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 26px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1;\n}\n\n.m-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n color: var(--sky);\n letter-spacing: -0.005em;\n}\n\n/* ============================================================\n Savings card\n ============================================================ */\n.card.savings {\n flex-shrink: 0;\n gap: 12px;\n background:\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 50%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .18);\n}\n\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .32);\n}\n\n.savings-body {\n display: grid;\n grid-template-columns: auto 1fr;\n align-items: center;\n gap: 18px;\n}\n\n.savings-figure {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.savings-money {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n}\n\n.savings-tokens {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-top: 4px;\n}\n\n.savings-bar {\n position: relative;\n height: 8px;\n border-radius: 999px;\n overflow: hidden;\n background: var(--surface-3);\n display: flex;\n}\n\n.savings-actual {\n height: 100%;\n background: rgba(215, 230, 247, .55);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.savings-saved {\n height: 100%;\n background: var(--signal-green);\n transition: width 500ms cubic-bezier(0.16, 1, 0.3, 1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12);\n}\n\n.savings-legend {\n grid-column: 2;\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 24px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.08em;\n color: var(--text-mute);\n margin-top: 8px;\n}\n\n.sl-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.sl-row b {\n color: var(--mist);\n font-weight: 500;\n letter-spacing: 0.04em;\n}\n\n.sl-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.sl-dot.actual {\n background: var(--mist);\n}\n\n.sl-dot.saved {\n background: var(--signal-green);\n}\n\n/* ============================================================\n Recent turns table\n ============================================================ */\n.turns-card {\n flex: 1;\n padding: 0;\n overflow: hidden;\n}\n\n.turns-card .card-head {\n padding: 14px 16px 10px;\n border-bottom: 1px solid var(--rule-2);\n}\n\n.turns-scroll {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n\n.turns-table {\n width: 100%;\n border-collapse: collapse;\n}\n\n.turns-table thead th {\n position: sticky;\n top: 0;\n background: var(--ink);\n padding: 9px 16px;\n text-align: left;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n font-weight: 500;\n border-bottom: 1px solid var(--rule);\n z-index: 1;\n}\n\n.turns-table thead th.num {\n text-align: right;\n}\n\n.turns-table tbody td {\n padding: 8px 16px;\n border-bottom: 1px solid var(--rule-2);\n color: var(--text-dim);\n font-size: 12px;\n}\n\n.turns-table tbody td.num {\n text-align: right;\n font-family: var(--font-mono);\n}\n\n.turns-table tbody td.cost {\n color: var(--money);\n font-family: var(--font-mono);\n font-weight: 500;\n}\n\n.turns-table tbody td.ts {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n}\n\n.turns-table tbody td.proj {\n color: var(--mist);\n}\n\n.turns-table tbody tr:hover {\n background: rgba(155, 194, 239, .03);\n}\n\n.turns-table tbody tr:last-child td {\n border-bottom: 0;\n}\n\n/* Model pills */\n.model-pill {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 2px 8px;\n border-radius: 999px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.04em;\n border: 1px solid var(--rule);\n background: rgba(4, 8, 26, .5);\n color: var(--mist);\n}\n\n.model-pill .sq {\n width: 6px;\n height: 6px;\n border-radius: 2px;\n background: var(--text-mute);\n}\n\n.model-pill.opus {\n color: #FF8A66;\n border-color: rgba(255, 99, 56, .32);\n background: rgba(255, 99, 56, .07);\n}\n\n.model-pill.opus .sq {\n background: var(--c-opus);\n}\n\n.model-pill.sonnet {\n color: #FFC766;\n border-color: rgba(255, 185, 56, .32);\n background: rgba(255, 185, 56, .07);\n}\n\n.model-pill.sonnet .sq {\n background: var(--c-sonnet);\n}\n\n.model-pill.haiku {\n color: #A878FF;\n border-color: rgba(116, 56, 255, .42);\n background: rgba(116, 56, 255, .10);\n}\n\n.model-pill.haiku .sq {\n background: var(--c-haiku);\n}\n\n.model-pill.unknown {\n color: #5BDDF7;\n border-color: rgba(18, 203, 245, .32);\n background: rgba(18, 203, 245, .07);\n font-style: italic;\n}\n\n.model-pill.unknown .sq {\n background: var(--c-unknown);\n}\n\n/* ============================================================\n Right column \u2014 Cost hero\n ============================================================ */\n.cost-hero {\n position: relative;\n overflow: hidden;\n background:\n radial-gradient(120% 80% at 50% 110%, rgba(44, 93, 184, .18) 0%, rgba(4, 8, 26, .20) 60%),\n var(--surface-1);\n padding: 16px 18px 18px;\n gap: 10px;\n flex-shrink: 0;\n}\n\n.big-money {\n position: relative;\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 42px;\n letter-spacing: -0.035em;\n line-height: 1;\n color: var(--money);\n margin-top: 2px;\n}\n\n.big-money em {\n font-family: inherit;\n font-style: normal;\n font-weight: inherit;\n color: inherit;\n letter-spacing: inherit;\n opacity: 1;\n}\n\n.cost-sub {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin-top: 4px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n}\n\n.cs-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n font-family: var(--font-mono);\n font-size: 11px;\n}\n\n.cs-k {\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n/* File paths stay as-is (no uppercase/wide tracking) and may overflow \u2014 clip. */\n.cs-k.path {\n text-transform: none;\n letter-spacing: 0.01em;\n font-size: 11px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 14rem;\n}\n\n.cs-v {\n color: var(--mist);\n}\n\n/* ============================================================\n Moat card\n ============================================================ */\n.moat {\n flex: 1;\n gap: 8px;\n}\n\n.moat-value {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 34px;\n letter-spacing: -0.03em;\n line-height: 1;\n color: var(--mist);\n margin-top: 2px;\n}\n\n.moat-value em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 18px;\n color: var(--sky);\n letter-spacing: 0;\n margin-left: 6px;\n}\n\n.gate-mini {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n padding-top: 10px;\n border-top: 1px solid var(--rule-2);\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.gate-row {\n display: grid;\n grid-template-columns: auto auto 1fr;\n align-items: center;\n gap: 8px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 3px 0;\n}\n\n.gate-row .g-ts {\n color: var(--text-mute);\n font-size: 9px;\n min-width: 38px;\n}\n\n.gate-row .g-decision {\n font-size: 9px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 999px;\n}\n\n.gate-row .g-decision.block {\n color: var(--signal-rose);\n background: rgba(220, 90, 90, .06);\n}\n\n.gate-row .g-decision.allow {\n color: var(--text-mute);\n background: rgba(155, 194, 239, .03);\n}\n\n.gate-row .g-q {\n color: var(--mist);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ============================================================\n Tooltips\n ============================================================ */\n.has-tooltip {\n position: relative;\n}\n\n/* Global JS-positioned tooltip \u2014 viewport-clamped */\n.global-tooltip {\n position: fixed;\n top: 0;\n left: 0;\n background: linear-gradient(180deg, rgba(18, 37, 73, .98), rgba(10, 21, 48, .98));\n color: var(--mist);\n border: 1px solid var(--rule-hover);\n border-radius: 12px;\n padding: 14px 16px;\n font-family: var(--font-sans);\n font-size: 15px;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 320px;\n max-width: calc(100vw - 24px);\n text-align: left;\n line-height: 1.55;\n box-shadow: 0 16px 36px rgba(0, 0, 0, .7);\n backdrop-filter: blur(10px);\n z-index: 99999;\n opacity: 0;\n pointer-events: none;\n transform: translateY(6px);\n transition: opacity 180ms ease, transform 180ms ease;\n}\n\n.global-tooltip.on {\n opacity: 1;\n transform: translateY(0);\n}\n\n/* ============================================================\n Footer\n ============================================================ */\n.foot {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 24px;\n border-top: 1px solid var(--rule);\n background: linear-gradient(0deg, rgba(4, 8, 26, .7), rgba(4, 8, 26, .4));\n backdrop-filter: blur(10px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.foot em {\n font-family: var(--font-serif);\n font-style: italic;\n text-transform: none;\n letter-spacing: 0;\n color: var(--sky);\n font-size: 12px;\n}\n\n.foot .mono {\n color: var(--text-dim);\n text-transform: none;\n letter-spacing: 0.04em;\n}\n\n/* ============================================================\n Empty state\n ============================================================ */\n.empty {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--text-mute);\n text-align: center;\n padding: 16px 8px;\n font-style: italic;\n text-transform: none;\n}\n\n/* Scrollbar styling */\n.turns-scroll::-webkit-scrollbar,\n.proj-chart::-webkit-scrollbar,\n.gate-mini::-webkit-scrollbar {\n width: 6px;\n}\n\n.turns-scroll::-webkit-scrollbar-thumb,\n.proj-chart::-webkit-scrollbar-thumb,\n.gate-mini::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.turns-scroll::-webkit-scrollbar-track,\n.proj-chart::-webkit-scrollbar-track,\n.gate-mini::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ============================================================\n Staggered cascade on first paint (one-time, MOTION 6)\n ============================================================ */\n@keyframes cascade-in {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .col-left > *,\n .col-center > *,\n .col-right > * {\n opacity: 0;\n animation: cascade-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;\n will-change: transform, opacity;\n }\n .col-left > *:nth-child(1) { animation-delay: 0ms; }\n .col-left > *:nth-child(2) { animation-delay: 120ms; }\n .col-center > *:nth-child(1) { animation-delay: 40ms; }\n .col-center > *:nth-child(2) { animation-delay: 140ms; }\n .col-center > *:nth-child(3) { animation-delay: 240ms; }\n .col-right > *:nth-child(1) { animation-delay: 80ms; }\n .col-right > *:nth-child(2) { animation-delay: 200ms; }\n .col-right > *:nth-child(3) { animation-delay: 320ms; }\n .col-right > *:nth-child(4) { animation-delay: 440ms; }\n\n /* Clear will-change after animation completes */\n .col-left > *,\n .col-center > *,\n .col-right > * {\n animation-fill-mode: forwards;\n }\n}\n\n/* ============================================================\n Source / basis annotations\n ============================================================ */\n.card-source {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: lowercase;\n color: var(--text-mute);\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-top: auto;\n padding-top: 8px;\n border-top: 1px solid var(--rule-2);\n width: 100%;\n}\n\n.src-badge {\n font-family: var(--font-mono);\n font-size: 8px;\n font-weight: 500;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 2px 6px;\n border-radius: 4px;\n flex-shrink: 0;\n}\n\n.src-badge.verified {\n color: var(--signal-green);\n background: rgba(123, 255, 199, .08);\n border: 1px solid rgba(123, 255, 199, .25);\n}\n\n.src-badge.estimated,\n.src-badge.estimated.floor {\n color: var(--signal-amber);\n background: rgba(255, 185, 56, .10);\n border: 1px solid rgba(255, 185, 56, .30);\n}\n\n.src-badge.priced {\n color: var(--signal-cyan);\n background: rgba(155, 194, 239, .08);\n border: 1px solid rgba(155, 194, 239, .25);\n}\n\n/* Eyebrow that contains a badge */\n.card-eyebrow .src-badge {\n margin-left: 4px;\n}\n\n/* Savings audit row \u2014 live formula reveal */\n.savings-audit {\n margin-top: 10px;\n padding: 10px 12px;\n border: 1px dashed rgba(255, 185, 56, .25);\n border-radius: 8px;\n background: rgba(255, 185, 56, .04);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: 0.04em;\n color: var(--text-mute);\n text-align: center;\n}\n\n.savings-audit b {\n color: var(--text-dim);\n font-weight: 500;\n}\n\n.savings-audit .audit-result {\n color: var(--money);\n}\n\n/* ============================================================\n FAQ dialog\n ============================================================ */\n.dialog.dialog-faq {\n max-width: min(80vw, 1100px);\n width: 100%;\n max-height: 86vh;\n display: flex;\n flex-direction: column;\n padding: 28px 32px 24px;\n gap: 6px;\n}\n\n.dialog.dialog-faq .dialog-path {\n margin-bottom: 4px;\n word-break: normal;\n overflow-wrap: anywhere;\n}\n\n.faq-content {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n margin-top: 18px;\n padding-right: 8px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.faq-content::-webkit-scrollbar-thumb {\n background: var(--rule);\n border-radius: 999px;\n}\n\n.faq-content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.faq-content details {\n border: 1px solid var(--rule);\n border-radius: 12px;\n background: var(--surface-1);\n overflow: hidden;\n transition: background 180ms, border-color 180ms;\n flex-shrink: 0;\n}\n\n.faq-content details:hover {\n border-color: rgba(155, 194, 239, .22);\n}\n\n.faq-content details[open] {\n background: var(--surface-2);\n border-color: var(--rule-hover);\n}\n\n.faq-content summary {\n cursor: pointer;\n padding: 14px 20px;\n font-family: var(--font-sans);\n font-size: 14px;\n font-weight: 500;\n color: var(--mist);\n list-style: none;\n display: flex;\n align-items: center;\n gap: 12px;\n user-select: none;\n}\n\n.faq-content summary::-webkit-details-marker {\n display: none;\n}\n\n.faq-content summary::before {\n content: "\u203A";\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n color: var(--text-mute);\n font-family: var(--font-mono);\n font-size: 14px;\n transition: transform 220ms ease, color 220ms ease;\n}\n\n.faq-content details[open] summary::before {\n transform: rotate(90deg);\n color: var(--sky);\n}\n\n.faq-content .faq-body {\n padding: 0 22px 20px 46px;\n color: var(--text-dim);\n font-size: 13.5px;\n line-height: 1.7;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.faq-content .faq-body p {\n margin: 0;\n}\n\n.faq-content .faq-body ul {\n margin: 0;\n padding-left: 20px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.faq-content .faq-body li {\n margin: 0;\n}\n\n.faq-content .faq-body b,\n.faq-content .faq-body strong {\n color: var(--mist);\n font-weight: 500;\n}\n\n.faq-content .faq-body code {\n font-family: var(--font-mono);\n font-size: 12px;\n background: rgba(155, 194, 239, .08);\n padding: 2px 6px;\n border-radius: 4px;\n color: var(--mist);\n border: 1px solid rgba(155, 194, 239, .12);\n word-break: break-word;\n}\n\n.faq-content .faq-body a {\n color: var(--blue-bright);\n text-decoration: underline;\n text-decoration-color: rgba(92, 143, 230, .40);\n text-underline-offset: 3px;\n transition: color 140ms, text-decoration-color 140ms;\n}\n\n.faq-content .faq-body a:hover {\n color: var(--mist);\n text-decoration-color: var(--sky);\n}\n\n.faq-content .faq-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 4px 0;\n font-size: 13px;\n table-layout: fixed;\n}\n\n.faq-content .faq-body thead td {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--text-mute);\n padding-bottom: 6px;\n border-bottom: 1px solid var(--rule);\n font-weight: 500;\n}\n\n.faq-content .faq-body td {\n padding: 9px 10px;\n border-bottom: 1px solid var(--rule-2);\n vertical-align: top;\n word-break: break-word;\n}\n\n.faq-content .faq-body tr:last-child td {\n border-bottom: 0;\n}\n\n.faq-content .faq-body td:first-child {\n color: var(--text-dim);\n width: 38%;\n}\n\n.faq-content .faq-body td:first-child code {\n font-size: 11.5px;\n}\n\n.faq-content .faq-body .formula-box {\n font-family: var(--font-mono);\n font-size: 12.5px;\n background: rgba(255, 185, 56, .06);\n padding: 12px 14px;\n border-radius: 8px;\n border: 1px dashed rgba(255, 185, 56, .30);\n color: var(--mist);\n letter-spacing: 0.02em;\n}\n\n.faq-content .faq-body .link-list {\n list-style: none;\n padding-left: 0;\n}\n\n.faq-content .faq-body .link-list li {\n padding-left: 18px;\n position: relative;\n}\n\n.faq-content .faq-body .link-list li::before {\n content: "\u203A";\n position: absolute;\n left: 0;\n color: var(--sky);\n font-family: var(--font-mono);\n}\n\n.faq-content .faq-body .warning {\n margin-top: 14px;\n padding: 12px 14px;\n background: rgba(255, 185, 56, .06);\n border: 1px solid rgba(255, 185, 56, .25);\n border-left: 3px solid var(--signal-amber);\n border-radius: 8px;\n font-size: 12.5px;\n color: var(--text-dim);\n}\n\n.faq-content .faq-body .warning .icon {\n color: var(--signal-amber);\n margin-right: 8px;\n font-weight: 500;\n}\n\n/* ============================================================\n Project dialog\n ============================================================ */\n.dialog-backdrop {\n position: fixed;\n inset: 0;\n background: rgba(4, 8, 26, .78);\n backdrop-filter: blur(10px);\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n animation: dlg-fade 180ms ease;\n}\n\n@keyframes dlg-fade {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n.dialog {\n position: relative;\n width: 100%;\n max-width: 520px;\n background:\n radial-gradient(120% 80% at 50% 0%, rgba(44, 93, 184, .22) 0%, rgba(4, 8, 26, .20) 60%),\n linear-gradient(180deg, rgba(18, 37, 73, .88) 0%, rgba(10, 21, 48, .96) 100%);\n border: 1px solid var(--rule-hover);\n border-radius: 18px;\n padding: 28px 32px 32px;\n box-shadow:\n 0 30px 80px -20px rgba(0, 0, 0, .7),\n inset 0 1px 0 rgba(255, 255, 255, .04);\n animation: dlg-rise 220ms cubic-bezier(.2, .7, .2, 1);\n}\n\n@keyframes dlg-rise {\n from {\n opacity: 0;\n transform: translateY(8px) scale(.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.dialog-close {\n position: absolute;\n top: 14px;\n right: 14px;\n width: 30px;\n height: 30px;\n border-radius: 50%;\n color: var(--text-mute);\n font-size: 22px;\n line-height: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 180ms, color 180ms;\n}\n\n.dialog-close:hover {\n background: rgba(155, 194, 239, .10);\n color: var(--mist);\n}\n\n.dialog-eyebrow {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-mute);\n margin-bottom: 10px;\n}\n\n.dialog-eyebrow em {\n font-family: var(--font-serif);\n font-style: italic;\n font-weight: 400;\n font-size: 12px;\n color: var(--sky);\n letter-spacing: 0;\n text-transform: none;\n}\n\n.dialog-name {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 28px;\n letter-spacing: -0.025em;\n color: var(--mist);\n line-height: 1.1;\n}\n\n.dialog-path {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n margin-top: 6px;\n word-break: break-all;\n}\n\n.dialog-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 18px 24px;\n margin-top: 22px;\n padding-top: 20px;\n border-top: 1px solid var(--rule-2);\n}\n\n.dg-cell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.dg-k {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n}\n\n.dg-v {\n font-family: var(--font-sans);\n font-weight: 500;\n font-size: 22px;\n letter-spacing: -0.02em;\n color: var(--mist);\n line-height: 1;\n}\n\n.dg-v.money {\n color: var(--money);\n}\n\n.dg-v-sm {\n font-size: 13px;\n font-family: var(--font-mono);\n font-weight: 400;\n color: var(--text-dim);\n letter-spacing: 0;\n}\n\n/* ============================================================\n v0.3 visual refresh\n - merged model-family legend into donut (count column)\n - Projects -> colored bar chart\n - elevated Savings card\n ============================================================ */\n\n/* Left column sizing: donut natural height, projects fills + scrolls */\n.donut-card { flex: 0 0 auto; }\n\n/* Donut legend now carries a count column */\n.dl-row {\n grid-template-columns: auto 1fr auto auto;\n gap: 8px;\n padding: 1px 0;\n}\n.dl-count {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n}\n.dl-pct {\n min-width: 30px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---- Projects bar chart ---- */\n.projects-card {\n flex: 1 1 auto;\n min-height: 0;\n gap: 10px;\n}\n.proj-chart {\n display: flex;\n flex-direction: column;\n gap: 9px;\n overflow-y: auto;\n min-height: 0;\n flex: 1;\n padding-right: 2px;\n}\n.proj-row {\n display: flex;\n flex-direction: column;\n gap: 7px;\n width: 100%;\n text-align: left;\n padding: 8px;\n border-radius: 9px;\n background: transparent;\n border: 0;\n cursor: pointer;\n transition: background 150ms ease;\n}\n.proj-row:hover { background: rgba(155, 194, 239, .055); }\n.pr-top {\n display: grid;\n grid-template-columns: auto 1fr auto auto;\n align-items: center;\n gap: 9px;\n}\n.pr-dot {\n width: 9px;\n height: 9px;\n border-radius: 3px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 9px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n.pr-name {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--text-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n letter-spacing: 0.01em;\n transition: color 150ms ease;\n}\n.proj-row:hover .pr-name { color: var(--mist); }\n.pr-turns {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--mist);\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n.pr-arrow {\n font-family: var(--font-mono);\n font-size: 13px;\n color: var(--text-mute);\n transition: color 150ms ease, transform 150ms ease;\n}\n.proj-row:hover .pr-arrow {\n color: var(--pc, var(--sky));\n transform: translateX(2px);\n}\n.pr-bar {\n position: relative;\n height: 5px;\n border-radius: 999px;\n background: var(--surface-3);\n overflow: hidden;\n}\n.pr-fill {\n display: block;\n height: 100%;\n border-radius: 999px;\n background: linear-gradient(90deg,\n color-mix(in oklch, var(--pc, var(--sky)) 45%, transparent) 0%,\n var(--pc, var(--sky)) 100%);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);\n transition: width 640ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n/* ---- Elevated Savings card (priority focus) ---- */\n.card.savings {\n background:\n radial-gradient(120% 140% at 10% -10%, rgba(123, 255, 199, .14) 0%, rgba(4, 8, 26, .08) 44%),\n linear-gradient(180deg, rgba(123, 255, 199, .05) 0%, rgba(4, 8, 26, .20) 52%),\n var(--surface-1);\n border-color: rgba(123, 255, 199, .24);\n box-shadow:\n inset 0 1px 0 rgba(123, 255, 199, .08),\n 0 20px 46px -30px rgba(123, 255, 199, .55);\n}\n.card.savings:hover {\n border-color: rgba(123, 255, 199, .38);\n}\n.savings-money {\n font-size: 40px;\n text-shadow: 0 0 26px rgba(123, 255, 199, .22);\n}\n.savings-bar { height: 9px; }\n.savings-saved {\n box-shadow:\n inset 0 1px 0 rgba(255, 255, 255, .18),\n 0 0 12px -2px var(--signal-green);\n}\n\n/* Project dialog: name gets a project-colored accent dot */\n.dialog-name.has-accent {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n.dialog-name.has-accent::before {\n content: "";\n width: 12px;\n height: 12px;\n border-radius: 4px;\n background: var(--pc, var(--sky));\n box-shadow: 0 0 12px -1px var(--pc, var(--sky));\n flex-shrink: 0;\n}\n\n\n/* ============================================================\n v0.3.1 \u2014 date + active project folded into the top nav\n ============================================================ */\n\n/* Let the right cluster shrink so the active path can ellipsize */\n.topnav-right { min-width: 0; }\n\n/* Compact date beside the brand */\n.nav-date {\n display: inline-flex;\n align-items: baseline;\n gap: 6px;\n margin-left: 8px;\n padding-left: 12px;\n border-left: 1px solid var(--rule);\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n white-space: nowrap;\n}\n.nav-date .nd-day { color: var(--mist); font-weight: 500; }\n.nav-date .nd-weekday { color: var(--text-dim); }\n.nav-date .nd-month { color: var(--text-mute); }\n\n/* Active project, compact, tail-truncated */\n.nav-active {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n max-width: 300px;\n padding-right: 12px;\n margin-right: 2px;\n border-right: 1px solid var(--rule);\n cursor: help;\n}\n.na-label {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--text-mute);\n flex-shrink: 0;\n}\n.na-value {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--mist);\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* keep the project folder (tail) visible, ellipsize the drive prefix */\n direction: rtl;\n text-align: left;\n}\n\n/* Tighten nav on narrow widths */\n@media (max-width: 1100px) {\n .nav-active { max-width: 200px; }\n .nav-date .nd-month { display: none; }\n}\n\n\n/* Column headers signal they are hover-explainable */\n.turns-table thead th.has-tooltip { cursor: help; }\n.turns-table thead th.has-tooltip:hover { color: var(--text-dim); }\n\n/* ---- Recent-turns pager ---- */\n.turns-pager {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 10px;\n padding: 8px 2px 0;\n margin-top: 6px;\n border-top: 1px solid var(--rule-2);\n}\n.turns-pager.hidden { display: none; }\n.turns-pager button {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--text-dim);\n background: rgba(4, 8, 26, .55);\n border: 1px solid var(--rule);\n border-radius: 7px;\n padding: 4px 10px;\n cursor: pointer;\n transition: background 150ms, border-color 150ms, color 150ms, transform 150ms;\n}\n.turns-pager button:hover:not(:disabled) {\n background: rgba(155, 194, 239, .10);\n border-color: var(--rule-hover);\n color: var(--mist);\n transform: translateY(-1px);\n}\n.turns-pager button:disabled {\n opacity: .35;\n cursor: default;\n}\n#turns-page-label {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-mute);\n letter-spacing: 0.04em;\n font-variant-numeric: tabular-nums;\n min-width: 84px;\n text-align: center;\n}\n';
1573
+
1574
+ // src/dashboard/public/favicon.svg
1575
+ var favicon_default = '<svg width="107" height="107" viewBox="0 0 107 107" fill="none" xmlns="http://www.w3.org/2000/svg">\n<rect x="0.5" y="0.5" width="106" height="106" rx="7.5" fill="url(#paint0_radial_21_11)"/>\n<rect x="0.5" y="0.5" width="106" height="106" rx="7.5" stroke="url(#paint1_linear_21_11)"/>\n<path d="M26.408 72.558C25.5813 72.558 24.6513 72.4753 23.618 72.31C22.626 72.1447 21.6753 71.938 20.766 71.69C19.898 71.442 19.216 71.1733 18.72 70.884C18.4307 70.7187 18.2033 70.5533 18.038 70.388C17.914 70.1813 17.852 69.8507 17.852 69.396L17.542 59.662C17.542 58.8353 17.8107 58.422 18.348 58.422C18.8027 58.422 19.1127 58.794 19.278 59.538L19.836 61.894C21.2413 67.8047 23.866 70.76 27.71 70.76C29.7767 70.76 31.4093 70.078 32.608 68.714C33.848 67.3087 34.468 65.3453 34.468 62.824C34.468 60.3853 33.8273 58.112 32.546 56.004C31.306 53.896 29.3013 51.8087 26.532 49.742C23.4733 47.5513 21.2413 45.4433 19.836 43.418C18.4307 41.3513 17.728 39.1607 17.728 36.846C17.728 33.7873 18.782 31.3487 20.89 29.53C23.0393 27.67 25.7673 26.74 29.074 26.74C30.314 26.74 31.5127 26.8847 32.67 27.174C33.8273 27.4633 34.778 27.8767 35.522 28.414C35.77 28.5793 35.956 28.7653 36.08 28.972C36.2453 29.1787 36.328 29.4473 36.328 29.778L36.514 39.14C36.514 39.8427 36.2453 40.194 35.708 40.194C35.336 40.194 35.0673 39.9047 34.902 39.326L34.468 37.776C33.5587 34.5933 32.608 32.2787 31.616 30.832C30.6653 29.344 29.2807 28.6 27.462 28.6C25.6847 28.6 24.2587 29.1787 23.184 30.336C22.1507 31.4933 21.634 33.126 21.634 35.234C21.634 37.0113 22.2127 38.706 23.37 40.318C24.5273 41.93 26.5527 43.8933 29.446 46.208C32.546 48.7293 34.7987 51.168 36.204 53.524C37.6507 55.8387 38.374 58.3187 38.374 60.964C38.374 63.2787 37.8573 65.304 36.824 67.04C35.7907 68.776 34.3647 70.14 32.546 71.132C30.7687 72.0827 28.7227 72.558 26.408 72.558ZM44.1831 84.71C43.0671 84.71 42.1784 84.3173 41.5171 83.532C40.8558 82.788 40.5251 81.92 40.5251 80.928C40.5251 80.1427 40.7524 79.4813 41.2071 78.944C41.6618 78.4067 42.2818 78.138 43.0671 78.138C43.7284 78.138 44.2038 78.2827 44.4931 78.572C44.7824 78.8613 44.9891 79.192 45.1131 79.564C45.2371 79.936 45.3611 80.2667 45.4851 80.556C45.6091 80.8453 45.8571 80.99 46.2291 80.99C46.6838 80.99 47.0971 80.6593 47.4691 79.998C47.8411 79.378 48.3578 78.0347 49.0191 75.968C49.4324 74.728 49.6804 73.6533 49.7631 72.744C49.8871 71.8347 49.8664 70.8633 49.7011 69.83C49.5771 68.7967 49.2671 67.536 48.7711 66.048L42.0131 44.534C41.7238 43.542 41.4344 42.9013 41.1451 42.612C40.8971 42.3227 40.4838 42.116 39.9051 41.992L38.9751 41.806C38.4378 41.682 38.1691 41.4133 38.1691 41C38.1691 40.5867 38.4584 40.38 39.0371 40.38H49.2671C49.8458 40.38 50.1351 40.5867 50.1351 41C50.1351 41.4547 49.8664 41.7233 49.3291 41.806L48.3371 41.93C47.3451 42.054 46.7044 42.302 46.4151 42.674C46.1671 43.0047 46.1878 43.6247 46.4771 44.534L51.9331 62.7C52.0571 63.1133 52.2431 63.32 52.4911 63.32C52.7804 63.32 52.9871 63.1133 53.1111 62.7L58.3811 45.65C58.7118 44.534 58.7944 43.7073 58.6291 43.17C58.5051 42.5913 57.9264 42.2193 56.8931 42.054L55.5911 41.806C55.0538 41.682 54.7851 41.4133 54.7851 41C54.7851 40.5867 55.0744 40.38 55.6531 40.38H63.5271C64.1058 40.38 64.3951 40.6073 64.3951 41.062C64.3951 41.4753 64.1471 41.7647 63.6511 41.93L63.0931 42.116C62.4731 42.3227 61.9564 42.6947 61.5431 43.232C61.1298 43.7693 60.6958 44.6993 60.2411 46.022L51.0031 74.852C50.0938 77.7453 49.2671 79.8947 48.5231 81.3C47.8204 82.7053 47.1384 83.6147 46.4771 84.028C45.8158 84.4827 45.0511 84.71 44.1831 84.71ZM64.9342 72C64.3142 72 64.0042 71.7727 64.0042 71.318C64.0042 70.946 64.2729 70.698 64.8102 70.574L65.5542 70.45C66.4222 70.2847 66.9802 70.0367 67.2282 69.706C67.5176 69.334 67.6622 68.714 67.6622 67.846V47.014C67.6622 46.27 67.5382 45.774 67.2902 45.526C67.0836 45.2367 66.6909 45.0507 66.1122 44.968L64.8102 44.782C64.2729 44.7407 64.0042 44.5133 64.0042 44.1C64.0042 43.7693 64.3349 43.542 64.9962 43.418C66.2776 43.2113 67.2489 42.8807 67.9102 42.426C68.6129 41.9713 69.3362 41.4133 70.0802 40.752C70.4522 40.38 70.7622 40.194 71.0102 40.194C71.3822 40.194 71.5682 40.442 71.5682 40.938V43.728C71.5682 44.1 71.6922 44.348 71.9402 44.472C72.2296 44.5547 72.5396 44.4307 72.8702 44.1C74.5236 42.5293 75.9702 41.4547 77.2102 40.876C78.4916 40.2973 79.8349 40.008 81.2402 40.008C83.1002 40.008 84.5882 40.6693 85.7042 41.992C86.8202 43.2733 87.3782 45.1747 87.3782 47.696V67.846C87.3782 68.714 87.5022 69.334 87.7502 69.706C88.0396 70.0367 88.6182 70.264 89.4862 70.388L90.8502 70.574C91.3049 70.6567 91.5322 70.9047 91.5322 71.318C91.5322 71.7727 91.2842 72 90.7882 72H80.3722C79.7936 72 79.5042 71.7727 79.5042 71.318C79.5042 70.9047 79.7316 70.6567 80.1862 70.574L81.0542 70.45C81.9222 70.326 82.4802 70.078 82.7282 69.706C83.0176 69.334 83.1622 68.714 83.1622 67.846V48.378C83.1622 46.3527 82.7902 44.9267 82.0462 44.1C81.3022 43.2733 80.2482 42.86 78.8842 42.86C77.5616 42.86 76.3629 43.2527 75.2882 44.038C74.2549 44.782 73.4282 45.7947 72.8082 47.076C72.1882 48.316 71.8782 49.7007 71.8782 51.23V67.846C71.8782 68.714 72.0022 69.334 72.2502 69.706C72.5396 70.078 73.1182 70.3053 73.9862 70.388L75.6602 70.574C76.1149 70.6567 76.3422 70.884 76.3422 71.256C76.3422 71.752 76.0322 72 75.4122 72H64.9342Z" fill="white"/>\n<defs>\n<radialGradient id="paint0_radial_21_11" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(14 16) rotate(43.5498) scale(167.638 156.2)">\n<stop stop-color="#2C5DB8"/>\n<stop offset="1" stop-color="#04081A"/>\n</radialGradient>\n<linearGradient id="paint1_linear_21_11" x1="1.77511" y1="1.50277e-06" x2="105.225" y2="107" gradientUnits="userSpaceOnUse">\n<stop stop-color="#5C8FE6"/>\n<stop offset="1" stop-color="#335080"/>\n</linearGradient>\n</defs>\n</svg>\n';
1400
1576
 
1401
1577
  // src/dashboard/server.ts
1402
1578
  var FALLBACK_RANGE = 9;
@@ -1416,6 +1592,11 @@ async function startDashboard(paths, preferredPort = 8901) {
1416
1592
  c.header("Cache-Control", "no-cache");
1417
1593
  return c.body(style_default);
1418
1594
  });
1595
+ app.get("/favicon.svg", (c) => {
1596
+ c.header("Content-Type", "image/svg+xml; charset=utf-8");
1597
+ c.header("Cache-Control", "public, max-age=86400");
1598
+ return c.body(favicon_default);
1599
+ });
1419
1600
  app.get("/health", (c) => c.json({ ok: true }));
1420
1601
  app.get("/data", async (c) => {
1421
1602
  const data = await computeDashboardData(paths, RECENT_N);
@@ -1434,8 +1615,8 @@ async function startDashboard(paths, preferredPort = 8901) {
1434
1615
  }
1435
1616
 
1436
1617
  // src/hooks/installer.ts
1437
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1438
- import { dirname as dirname2, join as join3 } from "path";
1618
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1619
+ import { dirname as dirname3, join as join3 } from "path";
1439
1620
 
1440
1621
  // src/hooks/scripts/pre-compact.ps1
1441
1622
  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';
@@ -1739,7 +1920,7 @@ function chosenScriptExt() {
1739
1920
  }
1740
1921
  async function readSettings(path) {
1741
1922
  try {
1742
- const raw = await readFile3(path, "utf8");
1923
+ const raw = await readFile4(path, "utf8");
1743
1924
  return JSON.parse(raw);
1744
1925
  } catch {
1745
1926
  return {};
@@ -1778,18 +1959,18 @@ function mergeOurHooks(config, paths) {
1778
1959
  return config;
1779
1960
  }
1780
1961
  async function installHooks(paths) {
1781
- await mkdir2(paths.claudeHooksDir, { recursive: true });
1962
+ await mkdir3(paths.claudeHooksDir, { recursive: true });
1782
1963
  const scriptsWritten = [];
1783
1964
  for (const s of SCRIPTS) {
1784
1965
  const target = join3(paths.claudeHooksDir, `${s.baseName}${chosenScriptExt()}`);
1785
- await writeFile2(target, chosenScriptBody(s), "utf8");
1966
+ await writeFile3(target, chosenScriptBody(s), "utf8");
1786
1967
  scriptsWritten.push(target);
1787
1968
  }
1788
- await mkdir2(dirname2(paths.claudeSettings), { recursive: true });
1969
+ await mkdir3(dirname3(paths.claudeSettings), { recursive: true });
1789
1970
  const existing = await readSettings(paths.claudeSettings);
1790
1971
  const stripped = stripOurHooks(existing);
1791
1972
  const merged = mergeOurHooks(stripped, paths);
1792
- await writeFile2(paths.claudeSettings, JSON.stringify(merged, null, 2) + "\n", "utf8");
1973
+ await writeFile3(paths.claudeSettings, JSON.stringify(merged, null, 2) + "\n", "utf8");
1793
1974
  log.debug(`installed ${scriptsWritten.length} hook script(s) into ${paths.claudeHooksDir}`);
1794
1975
  return { scriptsWritten, settingsUpdated: true };
1795
1976
  }
@@ -1797,11 +1978,11 @@ async function installHooks(paths) {
1797
1978
  // src/server/http.ts
1798
1979
  import { serve as serve2 } from "@hono/node-server";
1799
1980
  import { Hono as Hono2 } from "hono";
1800
- import { writeFile as writeFile10 } from "fs/promises";
1981
+ import { writeFile as writeFile11 } from "fs/promises";
1801
1982
 
1802
1983
  // src/activity/activity-log.ts
1803
- import { appendFile, mkdir as mkdir3 } from "fs/promises";
1804
- import { dirname as dirname3 } from "path";
1984
+ import { appendFile as appendFile2, mkdir as mkdir4 } from "fs/promises";
1985
+ import { dirname as dirname4 } from "path";
1805
1986
  var DEFAULT_RING_SIZE = 100;
1806
1987
  var ActivityStore = class {
1807
1988
  ring = [];
@@ -1838,8 +2019,8 @@ var ActivityStore = class {
1838
2019
  }
1839
2020
  async persist(event) {
1840
2021
  try {
1841
- await mkdir3(dirname3(this.persistPath), { recursive: true });
1842
- await appendFile(this.persistPath, JSON.stringify(event) + "\n", "utf8");
2022
+ await mkdir4(dirname4(this.persistPath), { recursive: true });
2023
+ await appendFile2(this.persistPath, JSON.stringify(event) + "\n", "utf8");
1843
2024
  } catch {
1844
2025
  }
1845
2026
  }
@@ -1847,7 +2028,7 @@ var ActivityStore = class {
1847
2028
 
1848
2029
  // src/activity/file-watcher.ts
1849
2030
  import chokidar from "chokidar";
1850
- import { readFile as readFile4 } from "fs/promises";
2031
+ import { readFile as readFile5 } from "fs/promises";
1851
2032
  import { join as join4, relative, sep } from "path";
1852
2033
  import ignore from "ignore";
1853
2034
  var ALWAYS_IGNORE = [
@@ -1870,7 +2051,7 @@ var ALWAYS_IGNORE = [
1870
2051
  ];
1871
2052
  async function readIgnoreFile(path) {
1872
2053
  try {
1873
- const text = await readFile4(path, "utf8");
2054
+ const text = await readFile5(path, "utf8");
1874
2055
  return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
1875
2056
  } catch {
1876
2057
  return [];
@@ -1935,14 +2116,14 @@ function createFileWatcher(root, onEvent) {
1935
2116
  // src/activity/git-watcher.ts
1936
2117
  import { execFile } from "child_process";
1937
2118
  import { watch } from "fs";
1938
- import { readFile as readFile5 } from "fs/promises";
2119
+ import { readFile as readFile6 } from "fs/promises";
1939
2120
  import { join as join5 } from "path";
1940
2121
  import { promisify } from "util";
1941
2122
  var execFileAsync = promisify(execFile);
1942
2123
  var POLL_MS = 2e3;
1943
2124
  async function readHeadBranch(projectRoot) {
1944
2125
  try {
1945
- const head = await readFile5(join5(projectRoot, ".git", "HEAD"), "utf8");
2126
+ const head = await readFile6(join5(projectRoot, ".git", "HEAD"), "utf8");
1946
2127
  const m = head.trim().match(/^ref:\s+refs\/heads\/(.+)$/);
1947
2128
  return m?.[1] ?? null;
1948
2129
  } catch {
@@ -2041,10 +2222,10 @@ function parseStatusFiles(porcelain) {
2041
2222
  import { resolve } from "path";
2042
2223
 
2043
2224
  // src/scanner/extract.ts
2044
- import { dirname as dirname4, join as join6, posix } from "path";
2225
+ import { dirname as dirname5, join as join6, posix } from "path";
2045
2226
 
2046
2227
  // src/graph/types.ts
2047
- var SCHEMA_VERSION2 = 1;
2228
+ var SCHEMA_VERSION2 = 2;
2048
2229
 
2049
2230
  // src/scanner/hash.ts
2050
2231
  import { createHash } from "crypto";
@@ -2362,14 +2543,20 @@ async function buildGraph(root, parsed) {
2362
2543
  for (const p of parsed) filesByPath.set(p.file.relPath, true);
2363
2544
  const nodes = [];
2364
2545
  const edges = [];
2546
+ const symbolsByFile = /* @__PURE__ */ new Map();
2547
+ const callsByFile = /* @__PURE__ */ new Map();
2365
2548
  for (const p of parsed) {
2366
2549
  const fileNode = toFileNode(p);
2367
2550
  nodes.push(fileNode);
2551
+ const fileSymNodes = [];
2368
2552
  for (const sym of p.symbols) {
2369
2553
  const symNode = toSymbolNode(p, sym);
2370
2554
  nodes.push(symNode);
2555
+ fileSymNodes.push(symNode);
2371
2556
  edges.push({ from: fileNode.id, to: symNode.id, kind: "defines" });
2372
2557
  }
2558
+ symbolsByFile.set(p.file.relPath, fileSymNodes);
2559
+ callsByFile.set(p.file.relPath, p.calls);
2373
2560
  const importEdges = /* @__PURE__ */ new Set();
2374
2561
  for (const spec of p.imports) {
2375
2562
  const target = resolveImport(p.file.relPath, spec, filesByPath);
@@ -2384,6 +2571,7 @@ async function buildGraph(root, parsed) {
2384
2571
  edges.push({ from: fileNode.id, to: fileId(testTargetPath), kind: "tests" });
2385
2572
  }
2386
2573
  }
2574
+ edges.push(...buildCallEdges(symbolsByFile, callsByFile));
2387
2575
  const symbolCount = nodes.filter((n) => n.kind === "symbol").length;
2388
2576
  const fileCount = nodes.length - symbolCount;
2389
2577
  return {
@@ -2407,9 +2595,52 @@ function buildSymbolIndex(graph) {
2407
2595
  }
2408
2596
  return out;
2409
2597
  }
2598
+ function tightestContainer(syms, line) {
2599
+ let best = null;
2600
+ for (const s of syms) {
2601
+ if (line < s.start_line || line > s.end_line) continue;
2602
+ if (!best || s.end_line - s.start_line < best.end_line - best.start_line) best = s;
2603
+ }
2604
+ return best;
2605
+ }
2606
+ function buildCallEdges(symbolsByFile, callsByFile) {
2607
+ const byName = /* @__PURE__ */ new Map();
2608
+ for (const syms of symbolsByFile.values()) {
2609
+ for (const s of syms) {
2610
+ const list = byName.get(s.name);
2611
+ if (list) list.push(s);
2612
+ else byName.set(s.name, [s]);
2613
+ }
2614
+ }
2615
+ const edges = [];
2616
+ const seen = /* @__PURE__ */ new Set();
2617
+ for (const [relPath, sites] of callsByFile) {
2618
+ const fileSyms = symbolsByFile.get(relPath) ?? [];
2619
+ for (const site of sites) {
2620
+ const caller = tightestContainer(fileSyms, site.line);
2621
+ if (!caller) continue;
2622
+ let callee = fileSyms.find((s) => s.name === site.callee);
2623
+ if (!callee) {
2624
+ const cands = byName.get(site.callee) ?? [];
2625
+ if (cands.length !== 1) continue;
2626
+ callee = cands[0];
2627
+ }
2628
+ if (!callee || callee.id === caller.id) continue;
2629
+ const key = `${caller.id}->${callee.id}`;
2630
+ if (seen.has(key)) continue;
2631
+ seen.add(key);
2632
+ edges.push({ from: caller.id, to: callee.id, kind: "calls" });
2633
+ }
2634
+ }
2635
+ return edges;
2636
+ }
2637
+
2638
+ // src/scanner/parse-cache.ts
2639
+ import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
2640
+ import { dirname as dirname6 } from "path";
2410
2641
 
2411
2642
  // src/scanner/parser.ts
2412
- import { readFile as readFile6 } from "fs/promises";
2643
+ import { readFile as readFile7 } from "fs/promises";
2413
2644
  import { createRequire } from "module";
2414
2645
  import { Language, Parser } from "web-tree-sitter";
2415
2646
 
@@ -2425,10 +2656,11 @@ function cleanImport(s) {
2425
2656
  async function runGenericParser(config, f, source) {
2426
2657
  let symbols = [];
2427
2658
  let imports = [];
2659
+ const calls = [];
2428
2660
  try {
2429
2661
  const { parser, language } = await createParser(config.grammar);
2430
2662
  const tree = parser.parse(source);
2431
- if (!tree) return { file: f, source, symbols, imports, calls: [] };
2663
+ if (!tree) return { file: f, source, symbols, imports, calls };
2432
2664
  const query = new Query(language, config.query);
2433
2665
  const matches = query.matches(tree.rootNode);
2434
2666
  for (const match of matches) {
@@ -2453,6 +2685,14 @@ async function runGenericParser(config, f, source) {
2453
2685
  });
2454
2686
  continue;
2455
2687
  }
2688
+ if (config.callCapture && config.callCalleeCapture) {
2689
+ const callNode = byName.get(config.callCapture);
2690
+ const calleeNode = byName.get(config.callCalleeCapture);
2691
+ if (callNode && calleeNode) {
2692
+ calls.push({ callee: calleeNode.text, line: callNode.startPosition.row + 1 });
2693
+ continue;
2694
+ }
2695
+ }
2456
2696
  if (config.importCapture) {
2457
2697
  const imp = byName.get(config.importCapture);
2458
2698
  if (imp) imports.push(cleanImport(imp.text));
@@ -2468,7 +2708,7 @@ async function runGenericParser(config, f, source) {
2468
2708
  imports = Array.from(new Set(imports)).filter((s) => s.length > 0);
2469
2709
  } catch {
2470
2710
  }
2471
- return { file: f, source, symbols, imports, calls: [] };
2711
+ return { file: f, source, symbols, imports, calls };
2472
2712
  }
2473
2713
 
2474
2714
  // src/scanner/parsers/c.ts
@@ -2479,6 +2719,7 @@ var QUERY = `
2479
2719
  (type_definition declarator: (type_identifier) @type.name) @type
2480
2720
  (preproc_include path: (string_literal) @import)
2481
2721
  (preproc_include path: (system_lib_string) @import)
2722
+ (call_expression function: (identifier) @call.name) @call
2482
2723
  `;
2483
2724
  async function parseC(f, source) {
2484
2725
  return runGenericParser(
@@ -2491,7 +2732,9 @@ async function parseC(f, source) {
2491
2732
  { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
2492
2733
  { declCapture: "type", nameCapture: "type.name", kind: "type" }
2493
2734
  ],
2494
- importCapture: "import"
2735
+ importCapture: "import",
2736
+ callCapture: "call",
2737
+ callCalleeCapture: "call.name"
2495
2738
  },
2496
2739
  f,
2497
2740
  source
@@ -2508,6 +2751,9 @@ var QUERY2 = `
2508
2751
  (namespace_definition name: (namespace_identifier) @namespace.name) @namespace
2509
2752
  (preproc_include path: (string_literal) @import)
2510
2753
  (preproc_include path: (system_lib_string) @import)
2754
+ (call_expression function: (identifier) @call.name) @call
2755
+ (call_expression function: (field_expression field: (field_identifier) @call.name)) @call
2756
+ (call_expression function: (qualified_identifier name: (identifier) @call.name)) @call
2511
2757
  `;
2512
2758
  async function parseCpp(f, source) {
2513
2759
  return runGenericParser(
@@ -2522,7 +2768,9 @@ async function parseCpp(f, source) {
2522
2768
  { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
2523
2769
  { declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
2524
2770
  ],
2525
- importCapture: "import"
2771
+ importCapture: "import",
2772
+ callCapture: "call",
2773
+ callCalleeCapture: "call.name"
2526
2774
  },
2527
2775
  f,
2528
2776
  source
@@ -2538,6 +2786,8 @@ var QUERY3 = `
2538
2786
  (method_declaration name: (identifier) @method.name) @method
2539
2787
  (namespace_declaration name: (_) @namespace.name) @namespace
2540
2788
  (using_directive (_) @import)
2789
+ (invocation_expression function: (identifier) @call.name) @call
2790
+ (invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call
2541
2791
  `;
2542
2792
  async function parseCSharp(f, source) {
2543
2793
  return runGenericParser(
@@ -2552,7 +2802,9 @@ async function parseCSharp(f, source) {
2552
2802
  { declCapture: "method", nameCapture: "method.name", kind: "method" },
2553
2803
  { declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
2554
2804
  ],
2555
- importCapture: "import"
2805
+ importCapture: "import",
2806
+ callCapture: "call",
2807
+ callCalleeCapture: "call.name"
2556
2808
  },
2557
2809
  f,
2558
2810
  source
@@ -2657,6 +2909,8 @@ var QUERY5 = `
2657
2909
  (method_declaration name: (field_identifier) @method.name) @method
2658
2910
  (type_spec name: (type_identifier) @type.name) @type
2659
2911
  (import_spec path: (interpreted_string_literal) @import)
2912
+ (call_expression function: (identifier) @call.name) @call
2913
+ (call_expression function: (selector_expression field: (field_identifier) @call.name)) @call
2660
2914
  `;
2661
2915
  async function parseGo(f, source) {
2662
2916
  return runGenericParser(
@@ -2668,7 +2922,9 @@ async function parseGo(f, source) {
2668
2922
  { declCapture: "method", nameCapture: "method.name", kind: "method" },
2669
2923
  { declCapture: "type", nameCapture: "type.name", kind: "type" }
2670
2924
  ],
2671
- importCapture: "import"
2925
+ importCapture: "import",
2926
+ callCapture: "call",
2927
+ callCalleeCapture: "call.name"
2672
2928
  },
2673
2929
  f,
2674
2930
  source
@@ -2733,6 +2989,7 @@ var QUERY6 = `
2733
2989
  (method_declaration name: (identifier) @method.name) @method
2734
2990
  (enum_declaration name: (identifier) @enum.name) @enum
2735
2991
  (import_declaration (scoped_identifier) @import)
2992
+ (method_invocation name: (identifier) @call.name) @call
2736
2993
  `;
2737
2994
  async function parseJava(f, source) {
2738
2995
  return runGenericParser(
@@ -2745,7 +3002,9 @@ async function parseJava(f, source) {
2745
3002
  { declCapture: "method", nameCapture: "method.name", kind: "method" },
2746
3003
  { declCapture: "enum", nameCapture: "enum.name", kind: "enum" }
2747
3004
  ],
2748
- importCapture: "import"
3005
+ importCapture: "import",
3006
+ callCapture: "call",
3007
+ callCalleeCapture: "call.name"
2749
3008
  },
2750
3009
  f,
2751
3010
  source
@@ -2758,6 +3017,7 @@ var QUERY7 = `
2758
3017
  (class_declaration (type_identifier) @class.name) @class
2759
3018
  (object_declaration (type_identifier) @object.name) @object
2760
3019
  (import_header (identifier) @import)
3020
+ (call_expression (simple_identifier) @call.name) @call
2761
3021
  `;
2762
3022
  async function parseKotlin(f, source) {
2763
3023
  return runGenericParser(
@@ -2769,7 +3029,9 @@ async function parseKotlin(f, source) {
2769
3029
  { declCapture: "class", nameCapture: "class.name", kind: "class" },
2770
3030
  { declCapture: "object", nameCapture: "object.name", kind: "class" }
2771
3031
  ],
2772
- importCapture: "import"
3032
+ importCapture: "import",
3033
+ callCapture: "call",
3034
+ callCalleeCapture: "call.name"
2773
3035
  },
2774
3036
  f,
2775
3037
  source
@@ -2783,6 +3045,9 @@ var QUERY8 = `
2783
3045
  (interface_declaration name: (name) @interface.name) @interface
2784
3046
  (trait_declaration name: (name) @trait.name) @trait
2785
3047
  (method_declaration name: (name) @method.name) @method
3048
+ (function_call_expression function: (name) @call.name) @call
3049
+ (member_call_expression name: (name) @call.name) @call
3050
+ (scoped_call_expression name: (name) @call.name) @call
2786
3051
  `;
2787
3052
  async function parsePhp(f, source) {
2788
3053
  return runGenericParser(
@@ -2795,7 +3060,9 @@ async function parsePhp(f, source) {
2795
3060
  { declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
2796
3061
  { declCapture: "trait", nameCapture: "trait.name", kind: "class" },
2797
3062
  { declCapture: "method", nameCapture: "method.name", kind: "method" }
2798
- ]
3063
+ ],
3064
+ callCapture: "call",
3065
+ callCalleeCapture: "call.name"
2799
3066
  },
2800
3067
  f,
2801
3068
  source
@@ -2810,6 +3077,8 @@ var QUERY9 = `
2810
3077
  (import_statement name: (dotted_name) @import.module)
2811
3078
  (import_from_statement module_name: (dotted_name) @import.from)
2812
3079
  (import_from_statement module_name: (relative_import) @import.from)
3080
+ (call function: (identifier) @call.name) @call
3081
+ (call function: (attribute attribute: (identifier) @call.name)) @call
2813
3082
  `;
2814
3083
  function firstLine3(text, max = 200) {
2815
3084
  const line = text.split(/\r?\n/, 1)[0] ?? "";
@@ -2818,10 +3087,11 @@ function firstLine3(text, max = 200) {
2818
3087
  async function parsePython(f, source) {
2819
3088
  let symbols = [];
2820
3089
  let imports = [];
3090
+ const calls = [];
2821
3091
  try {
2822
3092
  const { parser, language } = await createParser("python");
2823
3093
  const tree = parser.parse(source);
2824
- if (!tree) return { file: f, source, symbols, imports, calls: [] };
3094
+ if (!tree) return { file: f, source, symbols, imports, calls };
2825
3095
  const query = new Query3(language, QUERY9);
2826
3096
  const matches = query.matches(tree.rootNode);
2827
3097
  for (const match of matches) {
@@ -2854,7 +3124,15 @@ async function parsePython(f, source) {
2854
3124
  continue;
2855
3125
  }
2856
3126
  const importNode = byName.get("import.module") ?? byName.get("import.from");
2857
- if (importNode) imports.push(importNode.text);
3127
+ if (importNode) {
3128
+ imports.push(importNode.text);
3129
+ continue;
3130
+ }
3131
+ const callName = byName.get("call.name");
3132
+ const callNode = byName.get("call");
3133
+ if (callName && callNode) {
3134
+ calls.push({ callee: callName.text, line: callNode.startPosition.row + 1 });
3135
+ }
2858
3136
  }
2859
3137
  const seen = /* @__PURE__ */ new Set();
2860
3138
  symbols = symbols.filter((s) => {
@@ -2866,7 +3144,7 @@ async function parsePython(f, source) {
2866
3144
  imports = Array.from(new Set(imports));
2867
3145
  } catch {
2868
3146
  }
2869
- return { file: f, source, symbols, imports, calls: [] };
3147
+ return { file: f, source, symbols, imports, calls };
2870
3148
  }
2871
3149
 
2872
3150
  // src/scanner/parsers/ruby.ts
@@ -2875,6 +3153,7 @@ var QUERY10 = `
2875
3153
  (singleton_method name: (identifier) @method.name) @method
2876
3154
  (class name: (constant) @class.name) @class
2877
3155
  (module name: (constant) @module.name) @module
3156
+ (call method: (identifier) @call.name) @call
2878
3157
  `;
2879
3158
  async function parseRuby(f, source) {
2880
3159
  return runGenericParser(
@@ -2886,7 +3165,9 @@ async function parseRuby(f, source) {
2886
3165
  { declCapture: "method", nameCapture: "method.name", kind: "method" },
2887
3166
  { declCapture: "class", nameCapture: "class.name", kind: "class" },
2888
3167
  { declCapture: "module", nameCapture: "module.name", kind: "class" }
2889
- ]
3168
+ ],
3169
+ callCapture: "call",
3170
+ callCalleeCapture: "call.name"
2890
3171
  },
2891
3172
  f,
2892
3173
  source
@@ -2900,6 +3181,9 @@ var QUERY11 = `
2900
3181
  (enum_item name: (type_identifier) @enum.name) @enum
2901
3182
  (trait_item name: (type_identifier) @trait.name) @trait
2902
3183
  (impl_item type: (type_identifier) @impl.name) @impl
3184
+ (call_expression function: (identifier) @call.name) @call
3185
+ (call_expression function: (scoped_identifier name: (identifier) @call.name)) @call
3186
+ (call_expression function: (field_expression field: (field_identifier) @call.name)) @call
2903
3187
  `;
2904
3188
  async function parseRust(f, source) {
2905
3189
  return runGenericParser(
@@ -2912,7 +3196,9 @@ async function parseRust(f, source) {
2912
3196
  { declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
2913
3197
  { declCapture: "trait", nameCapture: "trait.name", kind: "interface" },
2914
3198
  { declCapture: "impl", nameCapture: "impl.name", kind: "class" }
2915
- ]
3199
+ ],
3200
+ callCapture: "call",
3201
+ callCalleeCapture: "call.name"
2916
3202
  },
2917
3203
  f,
2918
3204
  source
@@ -2931,6 +3217,8 @@ var TS_QUERY = `
2931
3217
  (lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
2932
3218
  (assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
2933
3219
  (import_statement source: (string) @import)
3220
+ (call_expression function: (identifier) @call.name) @call
3221
+ (call_expression function: (member_expression property: (property_identifier) @call.name)) @call
2934
3222
  `;
2935
3223
  var JS_QUERY = `
2936
3224
  (function_declaration name: (identifier) @function.name) @function
@@ -2940,6 +3228,8 @@ var JS_QUERY = `
2940
3228
  (assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
2941
3229
  (import_statement source: (string) @import)
2942
3230
  (call_expression function: (identifier) @_require_fn arguments: (arguments . (string) @require_source))
3231
+ (call_expression function: (identifier) @call.name) @call
3232
+ (call_expression function: (member_expression property: (property_identifier) @call.name)) @call
2943
3233
  `;
2944
3234
  function grammarFor(ext) {
2945
3235
  if (ext === ".tsx" || ext === ".jsx") return "tsx";
@@ -2968,10 +3258,11 @@ async function parseTypeScript(f, source) {
2968
3258
  const grammar = grammarFor(f.ext);
2969
3259
  let symbols = [];
2970
3260
  let imports = [];
3261
+ const calls = [];
2971
3262
  try {
2972
3263
  const { parser, language } = await createParser(grammar);
2973
3264
  const tree = parser.parse(source);
2974
- if (!tree) return { file: f, source, symbols, imports, calls: [] };
3265
+ if (!tree) return { file: f, source, symbols, imports, calls };
2975
3266
  const query = new Query4(language, queryFor(grammar));
2976
3267
  const matches = query.matches(tree.rootNode);
2977
3268
  for (const match of matches) {
@@ -2997,6 +3288,12 @@ async function parseTypeScript(f, source) {
2997
3288
  const requireSource = byName.get("require_source");
2998
3289
  if (requireFn && requireSource && requireFn.text === "require") {
2999
3290
  imports.push(unquote(requireSource.text));
3291
+ continue;
3292
+ }
3293
+ const callName = byName.get("call.name");
3294
+ const callNode = byName.get("call");
3295
+ if (callName && callNode) {
3296
+ calls.push({ callee: callName.text, line: callNode.startPosition.row + 1 });
3000
3297
  }
3001
3298
  }
3002
3299
  const seen = /* @__PURE__ */ new Set();
@@ -3009,7 +3306,7 @@ async function parseTypeScript(f, source) {
3009
3306
  imports = Array.from(new Set(imports));
3010
3307
  } catch {
3011
3308
  }
3012
- return { file: f, source, symbols, imports, calls: [] };
3309
+ return { file: f, source, symbols, imports, calls };
3013
3310
  }
3014
3311
 
3015
3312
  // src/scanner/parsers/svelte.ts
@@ -3043,6 +3340,7 @@ async function parseSvelte(f, source) {
3043
3340
  });
3044
3341
  }
3045
3342
  for (const imp of parsed.imports) out.imports.push(imp);
3343
+ for (const call of parsed.calls) out.calls.push({ ...call, line: call.line + offset });
3046
3344
  }
3047
3345
  out.symbols.push({
3048
3346
  name: f.relPath.split("/").pop()?.replace(/\.svelte$/i, "") ?? f.relPath,
@@ -3086,6 +3384,7 @@ async function parseVue(f, source) {
3086
3384
  });
3087
3385
  }
3088
3386
  for (const imp of parsed.imports) out.imports.push(imp);
3387
+ for (const call of parsed.calls) out.calls.push({ ...call, line: call.line + offset });
3089
3388
  }
3090
3389
  out.symbols.push({
3091
3390
  name: f.relPath.split("/").pop()?.replace(/\.vue$/i, "") ?? f.relPath,
@@ -3142,13 +3441,7 @@ async function createParser(name) {
3142
3441
  function emptyParsed(file, source) {
3143
3442
  return { file, source, symbols: [], imports: [], calls: [] };
3144
3443
  }
3145
- async function parseFile(f) {
3146
- let source;
3147
- try {
3148
- source = await readFile6(f.absPath, "utf8");
3149
- } catch {
3150
- return emptyParsed(f, "");
3151
- }
3444
+ async function parseSource(f, source) {
3152
3445
  switch (f.ext) {
3153
3446
  case ".ts":
3154
3447
  case ".tsx":
@@ -3201,8 +3494,72 @@ async function parseFile(f) {
3201
3494
  }
3202
3495
  }
3203
3496
 
3497
+ // src/scanner/parse-cache.ts
3498
+ var PARSE_CACHE_VERSION = 2;
3499
+ function emptyParseCache() {
3500
+ return { schema_version: PARSE_CACHE_VERSION, files: {} };
3501
+ }
3502
+ async function readParseCache(path) {
3503
+ try {
3504
+ const raw = await readFile8(path, "utf8");
3505
+ const parsed = JSON.parse(raw);
3506
+ if (parsed.schema_version !== PARSE_CACHE_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
3507
+ return emptyParseCache();
3508
+ }
3509
+ return { schema_version: PARSE_CACHE_VERSION, files: parsed.files };
3510
+ } catch {
3511
+ return emptyParseCache();
3512
+ }
3513
+ }
3514
+ async function writeParseCache(path, cache) {
3515
+ try {
3516
+ await mkdir5(dirname6(path), { recursive: true });
3517
+ await writeFile4(path, `${JSON.stringify(cache)}
3518
+ `, "utf8");
3519
+ } catch {
3520
+ }
3521
+ }
3522
+ async function incrementalParse(parsable, prev, opts = {}) {
3523
+ const cache = emptyParseCache();
3524
+ const parsed = [];
3525
+ let reused = 0;
3526
+ let reparsed = 0;
3527
+ let parseErrors = 0;
3528
+ for (const f of parsable) {
3529
+ let source;
3530
+ try {
3531
+ source = await readFile8(f.absPath, "utf8");
3532
+ } catch {
3533
+ continue;
3534
+ }
3535
+ const hash = fileHash(source);
3536
+ const cached = opts.full ? void 0 : prev.files[f.relPath];
3537
+ if (cached && cached.hash === hash) {
3538
+ parsed.push({
3539
+ file: f,
3540
+ source,
3541
+ symbols: cached.symbols,
3542
+ imports: cached.imports,
3543
+ calls: cached.calls
3544
+ });
3545
+ cache.files[f.relPath] = cached;
3546
+ reused += 1;
3547
+ continue;
3548
+ }
3549
+ try {
3550
+ const p = await parseSource(f, source);
3551
+ parsed.push(p);
3552
+ cache.files[f.relPath] = { hash, symbols: p.symbols, imports: p.imports, calls: p.calls };
3553
+ reparsed += 1;
3554
+ } catch {
3555
+ parseErrors += 1;
3556
+ }
3557
+ }
3558
+ return { parsed, cache, reused, reparsed, parseErrors };
3559
+ }
3560
+
3204
3561
  // src/scanner/walker.ts
3205
- import { readFile as readFile7, readdir, stat } from "fs/promises";
3562
+ import { readFile as readFile9, readdir, stat } from "fs/promises";
3206
3563
  import { extname, join as join7, relative as relative2, sep as sep2 } from "path";
3207
3564
  import ignore2 from "ignore";
3208
3565
  var DEFAULT_IGNORE = [
@@ -3283,7 +3640,7 @@ var BINARY_EXTS = /* @__PURE__ */ new Set([
3283
3640
  ]);
3284
3641
  async function readIgnoreFile2(path) {
3285
3642
  try {
3286
- const text = await readFile7(path, "utf8");
3643
+ const text = await readFile9(path, "utf8");
3287
3644
  return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
3288
3645
  } catch {
3289
3646
  return [];
@@ -3338,15 +3695,15 @@ async function* walk(root, options = {}) {
3338
3695
  }
3339
3696
 
3340
3697
  // src/graph/store.ts
3341
- import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile3 } from "fs/promises";
3342
- import { dirname as dirname5 } from "path";
3698
+ import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
3699
+ import { dirname as dirname7 } from "path";
3343
3700
  async function writeJson(path, data, pretty) {
3344
- await mkdir4(dirname5(path), { recursive: true });
3701
+ await mkdir6(dirname7(path), { recursive: true });
3345
3702
  const text = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
3346
- await writeFile3(path, text + "\n", "utf8");
3703
+ await writeFile5(path, text + "\n", "utf8");
3347
3704
  }
3348
3705
  async function readJson(path) {
3349
- const text = await readFile8(path, "utf8");
3706
+ const text = await readFile10(path, "utf8");
3350
3707
  return JSON.parse(text);
3351
3708
  }
3352
3709
  async function writeGraph(path, graph) {
@@ -3364,12 +3721,12 @@ async function readSymbolIndex(path) {
3364
3721
  }
3365
3722
 
3366
3723
  // src/cli/bootstrap.ts
3367
- import { mkdir as mkdir5, readFile as readFile10, stat as stat2, writeFile as writeFile5 } from "fs/promises";
3724
+ import { mkdir as mkdir7, readFile as readFile12, stat as stat2, writeFile as writeFile7 } from "fs/promises";
3368
3725
  import { basename as basename4 } from "path";
3369
3726
 
3370
3727
  // src/hooks/claude-md.ts
3371
- import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3372
- import { basename as basename3, dirname as dirname6 } from "path";
3728
+ import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3729
+ import { basename as basename3, dirname as dirname8 } from "path";
3373
3730
  var POLICY_VERSION = 6;
3374
3731
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3375
3732
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
@@ -3531,14 +3888,14 @@ function onboardingSkeleton(projectName) {
3531
3888
  async function patchClaudeMd(path, projectName) {
3532
3889
  let existing;
3533
3890
  try {
3534
- existing = await readFile9(path, "utf8");
3891
+ existing = await readFile11(path, "utf8");
3535
3892
  } catch {
3536
3893
  existing = null;
3537
3894
  }
3538
3895
  const block = policyBlock();
3539
3896
  if (existing === null) {
3540
- const name = projectName || basename3(dirname6(path)) || "this project";
3541
- await writeFile4(path, onboardingSkeleton(name) + "\n" + block + "\n", "utf8");
3897
+ const name = projectName || basename3(dirname8(path)) || "this project";
3898
+ await writeFile6(path, onboardingSkeleton(name) + "\n" + block + "\n", "utf8");
3542
3899
  return { created: true, updated: false, skipped: false };
3543
3900
  }
3544
3901
  const stripped = existing.replace(ANY_BLOCK_RE, "");
@@ -3547,7 +3904,7 @@ async function patchClaudeMd(path, projectName) {
3547
3904
  if (hadBlock && desired === existing) {
3548
3905
  return { created: false, updated: false, skipped: true };
3549
3906
  }
3550
- await writeFile4(path, desired, "utf8");
3907
+ await writeFile6(path, desired, "utf8");
3551
3908
  return { created: false, updated: true, skipped: false };
3552
3909
  }
3553
3910
 
@@ -3572,13 +3929,13 @@ async function exists(path) {
3572
3929
  }
3573
3930
  async function ensureDir(path) {
3574
3931
  const had = await exists(path);
3575
- await mkdir5(path, { recursive: true });
3932
+ await mkdir7(path, { recursive: true });
3576
3933
  return !had;
3577
3934
  }
3578
3935
  async function patchGitignore(path) {
3579
3936
  let existing = "";
3580
3937
  try {
3581
- existing = await readFile10(path, "utf8");
3938
+ existing = await readFile12(path, "utf8");
3582
3939
  } catch {
3583
3940
  }
3584
3941
  const trimmed = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
@@ -3587,7 +3944,7 @@ async function patchGitignore(path) {
3587
3944
  const block = missing.map((m) => `# ${m.comment}
3588
3945
  ${m.entry}`).join("\n") + "\n";
3589
3946
  const appendix = (existing.length === 0 || existing.endsWith("\n") ? "" : "\n") + (existing.length ? "\n" : "") + block;
3590
- await writeFile5(path, existing + appendix, "utf8");
3947
+ await writeFile7(path, existing + appendix, "utf8");
3591
3948
  return true;
3592
3949
  }
3593
3950
  async function bootstrap(paths) {
@@ -3661,25 +4018,22 @@ async function scanProject(projectRootRaw, opts = {}) {
3661
4018
  for await (const file of walk(projectRoot)) walked.push(file);
3662
4019
  if (verbose) log.info(` walked ${walked.length} files`);
3663
4020
  const parsable = walked.filter((f) => PARSABLE_EXTS.has(f.ext));
3664
- const parsed = [];
3665
- let parseErrors = 0;
3666
- for (const file of parsable) {
3667
- try {
3668
- parsed.push(await parseFile(file));
3669
- } catch (err2) {
3670
- parseErrors += 1;
3671
- if (verbose) log.debug(` parse failed: ${file.relPath} \u2014 ${err2.message}`);
3672
- }
3673
- }
4021
+ const prevCache = await readParseCache(paths.parseCache);
4022
+ const { parsed, cache, reused, reparsed, parseErrors } = await incrementalParse(
4023
+ parsable,
4024
+ prevCache,
4025
+ { full: opts.full }
4026
+ );
3674
4027
  if (verbose) {
3675
4028
  log.info(
3676
- ` parsed ${parsed.length} files (${walked.length - parsable.length} skipped` + (parseErrors ? `, ${parseErrors} errored` : "") + ")"
4029
+ ` parsed ${parsed.length} files (${reused} reused \xB7 ${reparsed} reparsed` + (parseErrors ? `, ${parseErrors} errored` : "") + `; ${walked.length - parsable.length} non-code skipped)`
3677
4030
  );
3678
4031
  }
3679
4032
  const graph = await buildGraph(projectRoot, parsed);
3680
4033
  const symbolIndex = buildSymbolIndex(graph);
3681
4034
  await writeGraph(paths.infoGraph, graph);
3682
4035
  await writeSymbolIndex(paths.symbolIndex, symbolIndex);
4036
+ await writeParseCache(paths.parseCache, cache);
3683
4037
  if (verbose) {
3684
4038
  log.info(
3685
4039
  ` wrote ${paths.infoGraph} \u2014 ${graph.symbol_count} symbols, ${graph.edge_count} edges`
@@ -3696,126 +4050,8 @@ async function scanProject(projectRootRaw, opts = {}) {
3696
4050
  durationMs
3697
4051
  };
3698
4052
  }
3699
- async function scanCommand(rawPath) {
3700
- return scanProject(rawPath);
3701
- }
3702
-
3703
- // src/learn/store.ts
3704
- import { appendFile as appendFile2, mkdir as mkdir6, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3705
- import { dirname as dirname7 } from "path";
3706
-
3707
- // src/learn/usage.ts
3708
- var LEARN_SCHEMA_VERSION = 1;
3709
- var DAY_MS = 24 * 60 * 60 * 1e3;
3710
- function halfLifeMs() {
3711
- const env = Number(process.env.SYN_LEARN_HALFLIFE_DAYS);
3712
- const days = Number.isFinite(env) && env > 0 ? env : 7;
3713
- return days * DAY_MS;
3714
- }
3715
- function weightFor(source) {
3716
- switch (source) {
3717
- case "register_edit":
3718
- return 2;
3719
- case "read":
3720
- return 1;
3721
- default:
3722
- return 0;
3723
- }
3724
- }
3725
- function emptyStore() {
3726
- return {
3727
- schema_version: LEARN_SCHEMA_VERSION,
3728
- asOf: (/* @__PURE__ */ new Date(0)).toISOString(),
3729
- files: {}
3730
- };
3731
- }
3732
- function decayFactor(fromTs, toMs, hl) {
3733
- const fromMs = Date.parse(fromTs);
3734
- if (!Number.isFinite(fromMs)) return 1;
3735
- const dt = toMs - fromMs;
3736
- if (dt <= 0) return 1;
3737
- return Math.exp(-(Math.LN2 / hl) * dt);
3738
- }
3739
- function foldEvent(store, ev) {
3740
- const w = weightFor(ev.source);
3741
- if (w <= 0 || !ev.path) return store;
3742
- const tMs = Date.parse(ev.ts);
3743
- if (!Number.isFinite(tMs)) return store;
3744
- const hl = halfLifeMs();
3745
- const prev = store.files[ev.path];
3746
- if (prev) {
3747
- const decayed = prev.decayed * decayFactor(prev.lastTs, tMs, hl) + w;
3748
- store.files[ev.path] = { count: prev.count + 1, decayed, lastTs: ev.ts };
3749
- } else {
3750
- store.files[ev.path] = { count: 1, decayed: w, lastTs: ev.ts };
3751
- }
3752
- return store;
3753
- }
3754
- function effectiveScores(store, nowMs) {
3755
- const hl = halfLifeMs();
3756
- const out = /* @__PURE__ */ new Map();
3757
- for (const [path, stat6] of Object.entries(store.files)) {
3758
- const eff = stat6.decayed * decayFactor(stat6.lastTs, nowMs, hl);
3759
- if (eff > 0.01) out.set(path, eff);
3760
- }
3761
- return out;
3762
- }
3763
- function recomputeFromLog(events) {
3764
- const store = emptyStore();
3765
- for (const ev of events) foldEvent(store, ev);
3766
- return store;
3767
- }
3768
-
3769
- // src/learn/store.ts
3770
- async function readLearnStore(path) {
3771
- try {
3772
- const raw = await readFile11(path, "utf8");
3773
- const parsed = JSON.parse(raw);
3774
- if (parsed.schema_version !== LEARN_SCHEMA_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
3775
- return emptyStore();
3776
- }
3777
- return {
3778
- schema_version: LEARN_SCHEMA_VERSION,
3779
- asOf: typeof parsed.asOf === "string" ? parsed.asOf : emptyStore().asOf,
3780
- files: parsed.files
3781
- };
3782
- } catch {
3783
- return emptyStore();
3784
- }
3785
- }
3786
- async function writeLearnStore(path, store) {
3787
- try {
3788
- await mkdir6(dirname7(path), { recursive: true });
3789
- await writeFile6(path, JSON.stringify(store, null, 2) + "\n", "utf8");
3790
- } catch {
3791
- }
3792
- }
3793
- async function readAccessLog(path) {
3794
- try {
3795
- const raw = await readFile11(path, "utf8");
3796
- const out = [];
3797
- for (const line of raw.split("\n")) {
3798
- const t = line.trim();
3799
- if (!t) continue;
3800
- try {
3801
- const ev = JSON.parse(t);
3802
- if (ev && typeof ev.ts === "string" && typeof ev.path === "string" && typeof ev.source === "string") {
3803
- out.push(ev);
3804
- }
3805
- } catch {
3806
- }
3807
- }
3808
- return out;
3809
- } catch {
3810
- return [];
3811
- }
3812
- }
3813
- async function appendAccess(path, ev) {
3814
- try {
3815
- await mkdir6(dirname7(path), { recursive: true });
3816
- await appendFile2(path, JSON.stringify(ev) + "\n", "utf8");
3817
- } catch {
3818
- }
4053
+ async function scanCommand(rawPath, opts = {}) {
4054
+ return scanProject(rawPath, opts);
3819
4055
  }
3820
4056
 
3821
4057
  // src/learn/runtime.ts
@@ -3876,8 +4112,8 @@ var LearnRuntime = class _LearnRuntime {
3876
4112
  };
3877
4113
 
3878
4114
  // src/server/mcp.ts
3879
- import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
3880
- import { dirname as dirname10 } from "path";
4115
+ import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
4116
+ import { dirname as dirname11 } from "path";
3881
4117
 
3882
4118
  // src/graph/rank.ts
3883
4119
  var KW_BASE_WEIGHT = 2;
@@ -4130,14 +4366,14 @@ async function retrieve(graph, query, options = {}) {
4130
4366
 
4131
4367
  // src/memory/branches.ts
4132
4368
  import { execFile as execFile2 } from "child_process";
4133
- import { readFile as readFile12 } from "fs/promises";
4369
+ import { readFile as readFile13 } from "fs/promises";
4134
4370
  import { join as join8 } from "path";
4135
4371
  import { promisify as promisify2 } from "util";
4136
4372
  var execFileAsync2 = promisify2(execFile2);
4137
4373
  async function currentBranch(projectRoot) {
4138
4374
  try {
4139
4375
  const headPath = join8(projectRoot, ".git", "HEAD");
4140
- const head = await readFile12(headPath, "utf8");
4376
+ const head = await readFile13(headPath, "utf8");
4141
4377
  const trimmed = head.trim();
4142
4378
  const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
4143
4379
  if (match?.[1]) return match[1];
@@ -4187,8 +4423,8 @@ function resolveBranchPaths(contextDir, branch, isDefault) {
4187
4423
  }
4188
4424
 
4189
4425
  // src/memory/context-md.ts
4190
- import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
4191
- import { dirname as dirname8 } from "path";
4426
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
4427
+ import { dirname as dirname9 } from "path";
4192
4428
  var MAX_BULLETS = 3;
4193
4429
  function deriveContextMd(entries, branch) {
4194
4430
  const tasks = entries.filter((e) => e.type === "task").reverse();
@@ -4231,17 +4467,17 @@ function formatContextMd(ctx) {
4231
4467
  return lines.join("\n");
4232
4468
  }
4233
4469
  async function writeContextMd(path, ctx) {
4234
- await mkdir7(dirname8(path), { recursive: true });
4235
- await writeFile7(path, formatContextMd(ctx), "utf8");
4470
+ await mkdir8(dirname9(path), { recursive: true });
4471
+ await writeFile8(path, formatContextMd(ctx), "utf8");
4236
4472
  }
4237
4473
 
4238
4474
  // src/memory/context-store.ts
4239
- import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
4240
- import { dirname as dirname9 } from "path";
4475
+ import { mkdir as mkdir9, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
4476
+ import { dirname as dirname10 } from "path";
4241
4477
  var SCHEMA_VERSION3 = 1;
4242
4478
  async function readEntries(path) {
4243
4479
  try {
4244
- const raw = await readFile14(path, "utf8");
4480
+ const raw = await readFile15(path, "utf8");
4245
4481
  const parsed = JSON.parse(raw);
4246
4482
  return Array.isArray(parsed.entries) ? parsed.entries : [];
4247
4483
  } catch {
@@ -4249,9 +4485,9 @@ async function readEntries(path) {
4249
4485
  }
4250
4486
  }
4251
4487
  async function writeEntries(path, entries) {
4252
- await mkdir8(dirname9(path), { recursive: true });
4488
+ await mkdir9(dirname10(path), { recursive: true });
4253
4489
  const store = { schema_version: SCHEMA_VERSION3, entries };
4254
- await writeFile8(path, JSON.stringify(store, null, 2) + "\n", "utf8");
4490
+ await writeFile9(path, JSON.stringify(store, null, 2) + "\n", "utf8");
4255
4491
  }
4256
4492
  async function appendEntry(path, entry) {
4257
4493
  const entries = await readEntries(path);
@@ -4628,7 +4864,7 @@ var TOOLS = [
4628
4864
  },
4629
4865
  {
4630
4866
  name: "blast_radius",
4631
- 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).",
4867
+ description: "Given a file (or 'file::symbol' target), return all files that depend on it transitively via imports, tests, and call edges (callers). Use BEFORE editing a widely-used file to see what could break. Call edges are name-resolved (precise within a file, unique-name across files) and projected to file granularity.",
4632
4868
  inputSchema: {
4633
4869
  type: "object",
4634
4870
  properties: {
@@ -4640,7 +4876,7 @@ var TOOLS = [
4640
4876
  },
4641
4877
  {
4642
4878
  name: "dead_code",
4643
- 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.",
4879
+ 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; symbol-level dead code (unused exports, on top of the call graph) is a planned follow-up. Common entry-point patterns (main, index, app, CLI, bin/) are excluded heuristically.",
4644
4880
  inputSchema: {
4645
4881
  type: "object",
4646
4882
  properties: {
@@ -4686,12 +4922,24 @@ function blastRadius(args, ctx) {
4686
4922
  const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
4687
4923
  const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
4688
4924
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4925
+ const fileIdBySymbol = /* @__PURE__ */ new Map();
4926
+ for (const n of ctx.graph.nodes) {
4927
+ if (n.kind === "symbol") fileIdBySymbol.set(n.id, `file:${n.file}`);
4928
+ }
4689
4929
  const incoming = /* @__PURE__ */ new Map();
4930
+ const addIncoming = (to, from, kind) => {
4931
+ const list = incoming.get(to) ?? [];
4932
+ list.push({ from, kind });
4933
+ incoming.set(to, list);
4934
+ };
4690
4935
  for (const e of ctx.graph.edges) {
4691
- if (e.kind !== "imports" && e.kind !== "tests") continue;
4692
- const list = incoming.get(e.to) ?? [];
4693
- list.push({ from: e.from, kind: e.kind });
4694
- incoming.set(e.to, list);
4936
+ if (e.kind === "imports" || e.kind === "tests") {
4937
+ addIncoming(e.to, e.from, e.kind);
4938
+ } else if (e.kind === "calls") {
4939
+ const fromFile = fileIdBySymbol.get(e.from);
4940
+ const toFile = fileIdBySymbol.get(e.to);
4941
+ if (fromFile && toFile && fromFile !== toFile) addIncoming(toFile, fromFile, "calls");
4942
+ }
4695
4943
  }
4696
4944
  const visited = /* @__PURE__ */ new Set([root.id]);
4697
4945
  const hits = [];
@@ -4769,7 +5017,7 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
4769
5017
  }
4770
5018
  lines.push("");
4771
5019
  lines.push(
4772
- `_v0.1 caveat:_ this is file-level only. Symbol-level dead code (unused exports) needs call-graph edges, which land in v0.2.`
5020
+ `_caveat:_ this is file-level only. Symbol-level dead code (unused exports), built on the now-populated call graph, is a planned follow-up.`
4773
5021
  );
4774
5022
  return textContent(lines.join("\n"));
4775
5023
  }
@@ -4921,7 +5169,7 @@ async function contextRecall(args, ctx) {
4921
5169
  }
4922
5170
  async function logToolCall(ctx, tool) {
4923
5171
  try {
4924
- await mkdir9(dirname10(ctx.paths.toolLog), { recursive: true });
5172
+ await mkdir10(dirname11(ctx.paths.toolLog), { recursive: true });
4925
5173
  await appendFile3(
4926
5174
  ctx.paths.toolLog,
4927
5175
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
@@ -5020,12 +5268,12 @@ async function getCommitsSince(projectRoot, sinceIso) {
5020
5268
  }
5021
5269
 
5022
5270
  // src/memory/session.ts
5023
- import { mkdir as mkdir10, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5024
- import { dirname as dirname11 } from "path";
5271
+ import { mkdir as mkdir11, readFile as readFile16, writeFile as writeFile10 } from "fs/promises";
5272
+ import { dirname as dirname12 } from "path";
5025
5273
  var SESSION_SCHEMA_VERSION = 1;
5026
5274
  async function readSession(path) {
5027
5275
  try {
5028
- const raw = await readFile15(path, "utf8");
5276
+ const raw = await readFile16(path, "utf8");
5029
5277
  const parsed = JSON.parse(raw);
5030
5278
  if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
5031
5279
  return parsed;
@@ -5034,8 +5282,8 @@ async function readSession(path) {
5034
5282
  }
5035
5283
  }
5036
5284
  async function writeSession(path, state) {
5037
- await mkdir10(dirname11(path), { recursive: true });
5038
- await writeFile9(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5285
+ await mkdir11(dirname12(path), { recursive: true });
5286
+ await writeFile10(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5039
5287
  }
5040
5288
 
5041
5289
  // src/server/routes/context-update.ts
@@ -5080,8 +5328,8 @@ async function handleContextUpdate(req, ctx) {
5080
5328
  }
5081
5329
 
5082
5330
  // src/server/routes/gate.ts
5083
- import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
5084
- import { dirname as dirname12 } from "path";
5331
+ import { appendFile as appendFile4, mkdir as mkdir12 } from "fs/promises";
5332
+ import { dirname as dirname13 } from "path";
5085
5333
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
5086
5334
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
5087
5335
  function extractQuery(toolName, input) {
@@ -5137,7 +5385,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
5137
5385
  }
5138
5386
  async function logDecision(ctx, toolName, query, decision, reason) {
5139
5387
  try {
5140
- await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
5388
+ await mkdir12(dirname13(ctx.paths.gateLog), { recursive: true });
5141
5389
  const entry = {
5142
5390
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5143
5391
  tool: toolName,
@@ -5209,15 +5457,15 @@ async function handleGate(req, ctx) {
5209
5457
  }
5210
5458
 
5211
5459
  // src/server/routes/log.ts
5212
- import { appendFile as appendFile5, mkdir as mkdir12 } from "fs/promises";
5213
- import { dirname as dirname13 } from "path";
5460
+ import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
5461
+ import { dirname as dirname14 } from "path";
5214
5462
  async function handleLog(entry, ctx) {
5215
5463
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
5216
5464
  throw new Error("log: input_tokens and output_tokens (number) are required");
5217
5465
  }
5218
5466
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
5219
5467
  const record = { ...entry, written_at };
5220
- await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
5468
+ await mkdir13(dirname14(ctx.paths.tokenLog), { recursive: true });
5221
5469
  await appendFile5(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5222
5470
  return { ok: true, written_at };
5223
5471
  }
@@ -5403,7 +5651,7 @@ async function startServer(paths, options = {}) {
5403
5651
  const port = options.port ?? await findFreePort();
5404
5652
  const app = buildApp(ctx, port);
5405
5653
  const nodeServer = serve2({ fetch: app.fetch, port, hostname: "127.0.0.1" });
5406
- await writeFile10(paths.mcpPort, String(port), "utf8");
5654
+ await writeFile11(paths.mcpPort, String(port), "utf8");
5407
5655
  const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
5408
5656
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
5409
5657
  await ctx.activity.add(e);
@@ -5547,7 +5795,7 @@ async function dashboardCommand(rawPath) {
5547
5795
  }
5548
5796
 
5549
5797
  // src/cli/doctor-command.ts
5550
- import { readFile as readFile16, stat as stat4 } from "fs/promises";
5798
+ import { readFile as readFile17, stat as stat4 } from "fs/promises";
5551
5799
  import { join as join10, resolve as resolve3 } from "path";
5552
5800
  import spawn from "cross-spawn";
5553
5801
  var ICON = { ok: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
@@ -5616,7 +5864,7 @@ async function runDoctorChecks(projectRoot) {
5616
5864
  });
5617
5865
  } else {
5618
5866
  try {
5619
- const graph = JSON.parse(await readFile16(paths.infoGraph, "utf8"));
5867
+ const graph = JSON.parse(await readFile17(paths.infoGraph, "utf8"));
5620
5868
  const parts = [`${graph.symbol_count} symbols`, `${graph.file_count} files`];
5621
5869
  let status = "ok";
5622
5870
  const ageMs = Date.now() - Date.parse(graph.generated_at);
@@ -5657,7 +5905,7 @@ async function runDoctorChecks(projectRoot) {
5657
5905
  detail: "no CLAUDE.md \u2014 run `syn .` to scaffold + inject the policy block."
5658
5906
  });
5659
5907
  } else {
5660
- const md = await readFile16(paths.claudeMd, "utf8");
5908
+ const md = await readFile17(paths.claudeMd, "utf8");
5661
5909
  if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
5662
5910
  checks.push({
5663
5911
  status: "ok",
@@ -5680,7 +5928,7 @@ async function runDoctorChecks(projectRoot) {
5680
5928
  detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks."
5681
5929
  });
5682
5930
  } else {
5683
- const s = await readFile16(paths.claudeSettings, "utf8");
5931
+ const s = await readFile17(paths.claudeSettings, "utf8");
5684
5932
  checks.push(
5685
5933
  s.includes("synthra-hook=true") ? { status: "ok", label: "Hooks", detail: "registered in .claude/settings.local.json" } : {
5686
5934
  status: "warn",
@@ -5710,7 +5958,7 @@ async function doctorCommand(rawPath) {
5710
5958
  }
5711
5959
 
5712
5960
  // src/cli/self-update.ts
5713
- import { mkdir as mkdir13, readFile as readFile17, writeFile as writeFile11 } from "fs/promises";
5961
+ import { mkdir as mkdir14, readFile as readFile18, writeFile as writeFile12 } from "fs/promises";
5714
5962
  import { homedir as homedir3 } from "os";
5715
5963
  import { join as join11 } from "path";
5716
5964
  import { createInterface } from "readline/promises";
@@ -5768,7 +6016,7 @@ async function checkForUpdate() {
5768
6016
  }
5769
6017
  async function readLastSeen() {
5770
6018
  try {
5771
- const raw = await readFile17(LAST_SEEN_PATH, "utf8");
6019
+ const raw = await readFile18(LAST_SEEN_PATH, "utf8");
5772
6020
  const parsed = JSON.parse(raw);
5773
6021
  return parsed.version ?? null;
5774
6022
  } catch {
@@ -5777,9 +6025,9 @@ async function readLastSeen() {
5777
6025
  }
5778
6026
  async function writeLastSeen(version) {
5779
6027
  try {
5780
- await mkdir13(SYNTHRA_DIR, { recursive: true });
6028
+ await mkdir14(SYNTHRA_DIR, { recursive: true });
5781
6029
  const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
5782
- await writeFile11(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
6030
+ await writeFile12(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
5783
6031
  } catch {
5784
6032
  }
5785
6033
  }
@@ -5811,7 +6059,7 @@ async function readInstalledChangelog() {
5811
6059
  const root = await npmGlobalRoot();
5812
6060
  if (!root) return null;
5813
6061
  try {
5814
- return await readFile17(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
6062
+ return await readFile18(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
5815
6063
  } catch {
5816
6064
  return null;
5817
6065
  }
@@ -6018,7 +6266,7 @@ async function defaultFlow(rawPath, opts) {
6018
6266
  await runStartupChangelogCheck();
6019
6267
  await promptForUpdateOrLog();
6020
6268
  await recordProject(projectRoot);
6021
- const scan = await scanCommand(rawPath);
6269
+ const scan = await scanCommand(rawPath, { full: opts.full });
6022
6270
  const mcpHandle = await startServer(paths);
6023
6271
  let dashboardHandle = null;
6024
6272
  try {
@@ -6065,11 +6313,11 @@ function buildProgram() {
6065
6313
  {
6066
6314
  default: true
6067
6315
  }
6068
- ).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) => {
6316
+ ).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).option("--full", "Re-parse every file, ignoring the incremental parse cache", false).action(async (path, opts) => {
6069
6317
  await defaultFlow(path ?? ".", opts);
6070
6318
  });
6071
- prog.command("scan [path]", "Scan only \u2014 walk + parse + write graph.").action(async (path) => {
6072
- await scanCommand(path ?? ".");
6319
+ prog.command("scan [path]", "Scan only \u2014 walk + parse + write graph.").option("--full", "Re-parse every file, ignoring the incremental parse cache", false).action(async (path, opts) => {
6320
+ await scanCommand(path ?? ".", { full: opts.full });
6073
6321
  });
6074
6322
  prog.command("serve [path]", "Start the HTTP MCP server against a scanned project.").action(async (path) => {
6075
6323
  await serveCommand(path ?? ".");