@jefuriiij/synthra 0.1.0 → 0.1.2
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 +765 -36
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +762 -34
- package/dist/dashboard/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/dashboard/server.ts","../../src/shared/logger.ts","../../src/server/port.ts","../../src/dashboard/delta.ts","../../src/shared/paths.ts","../../src/shared/pricing.ts","../../src/shared/project-registry.ts","../../src/dashboard/public/index.html","../../src/dashboard/public/style.css"],"sourcesContent":["// Standalone dashboard server. Default port 8901 (override via\n// SYN_DASHBOARD_PORT); falls back through a small range 8901–8910 if the\n// preferred port is busy (so we can coexist with other dashboards like\n// GrapeRoot's on the same machine).\n// Reads .synthra-graph/token_log.jsonl + .synthra-graph/gate_log.jsonl for the\n// given project and renders a live SPA backed by GET /data polled every 2s.\n\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\n\nimport { log } from \"../shared/logger.js\";\nimport type { SynthraPaths } from \"../shared/paths.js\";\nimport { findFreePort } from \"../server/port.js\";\nimport { computeDashboardData } from \"./delta.js\";\n\nimport indexHtml from \"./public/index.html\";\nimport styleCss from \"./public/style.css\";\n\nconst FALLBACK_RANGE = 9; // try preferredPort + [0..9]\n\nexport interface DashboardServerHandle {\n port: number;\n url: string;\n stop(): Promise<void>;\n}\n\nexport async function startDashboard(\n paths: SynthraPaths,\n preferredPort = 8901,\n): Promise<DashboardServerHandle> {\n const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);\n if (port !== preferredPort) {\n log.info(\n `dashboard port ${preferredPort} was busy — bound to ${port} instead (likely another dashboard from a coexisting tool).`,\n );\n }\n const app = new Hono();\n\n app.get(\"/\", (c) => c.html(indexHtml));\n\n app.get(\"/style.css\", (c) => {\n c.header(\"Content-Type\", \"text/css; charset=utf-8\");\n c.header(\"Cache-Control\", \"no-cache\");\n return c.body(styleCss);\n });\n\n app.get(\"/health\", (c) => c.json({ ok: true }));\n\n app.get(\"/data\", async (c) => {\n const data = await computeDashboardData(paths);\n return c.json(data);\n });\n\n const nodeServer = serve({ fetch: app.fetch, port, hostname: \"127.0.0.1\" });\n\n return {\n port,\n url: `http://127.0.0.1:${port}`,\n async stop() {\n await new Promise<void>((resolve, reject) => {\n nodeServer.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n","// Minimal logger. Prefixes Synthra output with [syn].\n\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LEVEL_PRIORITY: Record<Level, number> = {\n debug: 10,\n info: 20,\n warn: 30,\n error: 40,\n};\n\nlet activeLevel: Level = (process.env.SYN_LOG_LEVEL as Level) ?? \"info\";\n\nexport function setLevel(level: Level): void {\n activeLevel = level;\n}\n\nfunction shouldLog(level: Level): boolean {\n return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];\n}\n\nfunction emit(level: Level, msg: string, ...args: unknown[]): void {\n if (!shouldLog(level)) return;\n const stream = level === \"error\" || level === \"warn\" ? process.stderr : process.stdout;\n stream.write(`[syn] ${msg}${args.length ? \" \" + args.map(String).join(\" \") : \"\"}\\n`);\n}\n\nexport const log = {\n debug: (m: string, ...a: unknown[]) => emit(\"debug\", m, ...a),\n info: (m: string, ...a: unknown[]) => emit(\"info\", m, ...a),\n warn: (m: string, ...a: unknown[]) => emit(\"warn\", m, ...a),\n error: (m: string, ...a: unknown[]) => emit(\"error\", m, ...a),\n};\n","// Finds a free port in the 8080–8099 range. Writes the chosen port to\n// .synthra-graph/mcp_port so PowerShell/Bash hook scripts can read it.\n// TODO: M2\n\nimport { createServer } from \"node:net\";\n\nexport const PORT_RANGE_START = 8080;\nexport const PORT_RANGE_END = 8099;\n\nexport async function findFreePort(\n start = PORT_RANGE_START,\n end = PORT_RANGE_END,\n): Promise<number> {\n for (let port = start; port <= end; port++) {\n if (await isFree(port)) return port;\n }\n throw new Error(`Synthra: no free port in ${start}-${end}`);\n}\n\nfunction isFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once(\"error\", () => resolve(false));\n s.once(\"listening\", () => s.close(() => resolve(true)));\n s.listen(port, \"127.0.0.1\");\n });\n}\n","// Reads token_log.jsonl + gate_log.jsonl for the active project AND every\n// project registered in ~/.synthra/projects.json, then computes the\n// dashboard's rendered shape: per-project + global aggregate + recent calls\n// across all projects.\n\nimport { readFile } from \"node:fs/promises\";\n\nimport { resolvePaths, type SynthraPaths } from \"../shared/paths.js\";\nimport { estimateCostUsd } from \"../shared/pricing.js\";\nimport { listProjects } from \"../shared/project-registry.js\";\n\nconst AVG_TOKENS_PER_BLOCKED_GREP = 500;\n\nexport interface TokenLogEntry {\n /** Stop-hook-supplied timestamp (preferred). */\n ts?: string;\n /** Server-side fallback added by handleLog when ts isn't provided. */\n written_at?: string;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model: string;\n description?: string;\n project: string;\n}\n\nexport interface GateLogEntry {\n ts: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n reason?: string;\n}\n\nexport interface ProjectStats {\n path: string;\n name: string;\n last_seen: string | null;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n estimated_cost_usd: number;\n models: Record<string, number>;\n}\n\nexport interface RecentTurn {\n ts: string;\n project_name: string;\n project_path: string;\n input: number;\n output: number;\n cache_read: number;\n cache_create: number;\n model: string;\n cost_usd: number;\n}\n\nexport interface RecentGate {\n ts: string;\n project_name: string;\n project_path: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n}\n\nexport interface DashboardData {\n active: {\n project_root: string;\n project_name: string;\n stats: ProjectStats;\n };\n global: {\n project_count: number;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n saved_percent: number;\n estimated_cost_usd: number;\n };\n projects: ProjectStats[];\n recent_turns: RecentTurn[];\n recent_gates: RecentGate[];\n}\n\nasync function readJsonl<T>(path: string): Promise<T[]> {\n try {\n const text = await readFile(path, \"utf8\");\n return text\n .split(/\\r?\\n/)\n .filter((l) => l.length > 0)\n .map((l) => {\n try {\n return JSON.parse(l) as T;\n } catch {\n return null;\n }\n })\n .filter((v): v is T => v !== null);\n } catch {\n return [];\n }\n}\n\nfunction basename(p: string): string {\n const parts = p.split(/[\\\\/]/);\n return parts[parts.length - 1] || p;\n}\n\ninterface ProjectFiles {\n path: string;\n name: string;\n last_seen: string | null;\n tokens: TokenLogEntry[];\n gates: GateLogEntry[];\n}\n\nfunction summarize(p: ProjectFiles): ProjectStats {\n let totalIn = 0;\n let totalOut = 0;\n let totalCacheRead = 0;\n let totalCacheCreate = 0;\n let costUsd = 0;\n const models: Record<string, number> = {};\n\n for (const t of p.tokens) {\n totalIn += t.input_tokens ?? 0;\n totalOut += t.output_tokens ?? 0;\n totalCacheRead += t.cache_read_input_tokens ?? 0;\n totalCacheCreate += t.cache_creation_input_tokens ?? 0;\n costUsd += estimateCostUsd(t);\n if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;\n }\n\n const blocked = p.gates.filter((g) => g.decision === \"block\").length;\n const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;\n\n return {\n path: p.path,\n name: p.name,\n last_seen: p.last_seen,\n total_turns: p.tokens.length,\n total_input_tokens: totalIn,\n total_output_tokens: totalOut,\n total_cache_read: totalCacheRead,\n total_cache_create: totalCacheCreate,\n total_gate_calls: p.gates.length,\n blocked_count: blocked,\n estimated_tokens_saved: saved,\n estimated_cost_usd: Math.round(costUsd * 100) / 100,\n models,\n };\n}\n\nasync function loadProjectFiles(\n path: string,\n name: string,\n lastSeen: string | null,\n): Promise<ProjectFiles> {\n const paths = resolvePaths(path);\n const [rawTokens, gates] = await Promise.all([\n readJsonl<TokenLogEntry>(paths.tokenLog),\n readJsonl<GateLogEntry>(paths.gateLog),\n ]);\n return { path, name, last_seen: lastSeen, tokens: dedupeTokens(rawTokens), gates };\n}\n\n/**\n * Collapse duplicate token-log entries from co-installed AI tools.\n *\n * Synthra is friendly with other tools that share the .synthra-graph/\n * token_log.jsonl shape (GrapeRoot writes to it too). Both Stop hooks\n * fire on the same turn and emit nearly-identical entries within ~10ms,\n * which double-counts every metric in the dashboard.\n *\n * Strategy: group by (project, usage counts, second-rounded timestamp);\n * inside a group, keep the entry with the most credible model field —\n * a real Claude model > \"<synthetic>\" > empty.\n */\nfunction dedupeTokens(entries: TokenLogEntry[]): TokenLogEntry[] {\n const score = (model: string | undefined): number => {\n if (!model) return 0;\n if (model === \"<synthetic>\") return 1;\n return 2; // real model name\n };\n\n const groups = new Map<string, TokenLogEntry[]>();\n for (const e of entries) {\n const ts = e.ts ?? e.written_at ?? \"\";\n const second = ts.slice(0, 19); // YYYY-MM-DDTHH:mm:ss\n const key = [\n e.project ?? \"\",\n e.input_tokens ?? 0,\n e.output_tokens ?? 0,\n e.cache_creation_input_tokens ?? 0,\n e.cache_read_input_tokens ?? 0,\n second,\n ].join(\"|\");\n const arr = groups.get(key) ?? [];\n arr.push(e);\n groups.set(key, arr);\n }\n\n const out: TokenLogEntry[] = [];\n for (const arr of groups.values()) {\n if (arr.length === 1) {\n out.push(arr[0]!);\n continue;\n }\n arr.sort((a, b) => score(b.model) - score(a.model));\n out.push(arr[0]!);\n }\n\n // Preserve chronological order in the per-project list.\n out.sort((a, b) => {\n const at = a.ts ?? a.written_at ?? \"\";\n const bt = b.ts ?? b.written_at ?? \"\";\n return at.localeCompare(bt);\n });\n return out;\n}\n\nexport async function computeDashboardData(\n activePaths: SynthraPaths,\n recentN = 25,\n): Promise<DashboardData> {\n const registered = await listProjects();\n\n // Always include the active project, even if not yet in the registry.\n const activePath = activePaths.projectRoot;\n const activeName = basename(activePath);\n const knownPaths = new Set(registered.map((p) => p.path));\n const allEntries: Array<{ path: string; name: string; last_seen: string | null }> = [\n ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen })),\n ];\n if (!knownPaths.has(activePath)) {\n allEntries.unshift({ path: activePath, name: activeName, last_seen: null });\n }\n\n const loaded = await Promise.all(\n allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen)),\n );\n\n const projects = loaded\n .map(summarize)\n .sort((a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens));\n\n const activeFiles =\n loaded.find((p) => p.path === activePath) ?? {\n path: activePath,\n name: activeName,\n last_seen: null,\n tokens: [],\n gates: [],\n };\n const activeStats = summarize(activeFiles);\n\n // Global aggregates\n let g_in = 0,\n g_out = 0,\n g_cr = 0,\n g_cc = 0,\n g_gate = 0,\n g_block = 0,\n g_cost = 0,\n g_turns = 0;\n for (const s of projects) {\n g_turns += s.total_turns;\n g_in += s.total_input_tokens;\n g_out += s.total_output_tokens;\n g_cr += s.total_cache_read;\n g_cc += s.total_cache_create;\n g_gate += s.total_gate_calls;\n g_block += s.blocked_count;\n g_cost += s.estimated_cost_usd;\n }\n const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;\n const g_used = g_in + g_out + g_cc;\n const g_saved_pct = g_used + g_saved > 0 ? (g_saved / (g_used + g_saved)) * 100 : 0;\n\n // Recent turns + gates across all projects, sorted by ts descending\n const allTurns: RecentTurn[] = [];\n const allGates: RecentGate[] = [];\n for (const p of loaded) {\n for (const t of p.tokens) {\n allTurns.push({\n // Fall back to written_at — the Stop hook today posts entries without\n // a `ts` field, and the server tags them with written_at on receive.\n ts: t.ts ?? t.written_at ?? \"\",\n project_name: p.name,\n project_path: p.path,\n input: t.input_tokens ?? 0,\n output: t.output_tokens ?? 0,\n cache_read: t.cache_read_input_tokens ?? 0,\n cache_create: t.cache_creation_input_tokens ?? 0,\n model: t.model ?? \"\",\n cost_usd: Math.round(estimateCostUsd(t) * 1000) / 1000,\n });\n }\n for (const gate of p.gates) {\n allGates.push({\n ts: gate.ts,\n project_name: p.name,\n project_path: p.path,\n tool: gate.tool,\n decision: gate.decision,\n query: gate.query,\n });\n }\n }\n allTurns.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n allGates.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n\n return {\n active: {\n project_root: activePath,\n project_name: activeName,\n stats: activeStats,\n },\n global: {\n project_count: projects.length,\n total_turns: g_turns,\n total_input_tokens: g_in,\n total_output_tokens: g_out,\n total_cache_read: g_cr,\n total_cache_create: g_cc,\n total_gate_calls: g_gate,\n blocked_count: g_block,\n estimated_tokens_saved: g_saved,\n saved_percent: Math.round(g_saved_pct * 10) / 10,\n estimated_cost_usd: Math.round(g_cost * 100) / 100,\n },\n projects,\n recent_turns: allTurns.slice(0, recentN),\n recent_gates: allGates.slice(0, recentN),\n };\n}\n\n// Legacy shapes from the M2 stub — kept for compat.\nexport interface TurnBreakdown {\n systemPromptTokens: number;\n conversationHistoryTokens: number;\n synthraPackTokens: number;\n userMessageTokens: number;\n responseTokens: number;\n totalTokens: number;\n costUsd: number;\n}\n\nexport interface SavingsDelta {\n withSynthra: TurnBreakdown;\n estimatedWithoutSynthra: TurnBreakdown;\n savedUsd: number;\n savedPercent: number;\n}\n\nexport function computeDelta(breakdown: TurnBreakdown, blockedGreps: number): SavingsDelta {\n const savedTokens = blockedGreps * AVG_TOKENS_PER_BLOCKED_GREP;\n const without: TurnBreakdown = {\n ...breakdown,\n conversationHistoryTokens: breakdown.conversationHistoryTokens + savedTokens,\n totalTokens: breakdown.totalTokens + savedTokens,\n costUsd: breakdown.costUsd + (savedTokens / 1_000_000) * 3,\n };\n const savedUsd = without.costUsd - breakdown.costUsd;\n const savedPercent = without.totalTokens > 0 ? (savedTokens / without.totalTokens) * 100 : 0;\n return {\n withSynthra: breakdown,\n estimatedWithoutSynthra: without,\n savedUsd,\n savedPercent: Math.round(savedPercent * 10) / 10,\n };\n}\n","// Resolves Synthra's storage locations inside a project root.\n\nimport { join } from \"node:path\";\n\nexport interface SynthraPaths {\n projectRoot: string;\n graphDir: string;\n contextDir: string;\n infoGraph: string;\n symbolIndex: string;\n sessionState: string;\n activityLog: string;\n tokenLog: string;\n gateLog: string;\n mcpPort: string;\n mcpServerLog: string;\n mcpServerErrLog: string;\n contextStore: string;\n contextMd: string;\n branchesDir: string;\n claudeDir: string;\n claudeSettings: string;\n claudeHooksDir: string;\n claudeMd: string;\n gitignore: string;\n}\n\nexport function resolvePaths(projectRoot: string): SynthraPaths {\n const graphDir = join(projectRoot, \".synthra-graph\");\n const contextDir = join(projectRoot, \".synthra\");\n const claudeDir = join(projectRoot, \".claude\");\n\n return {\n projectRoot,\n graphDir,\n contextDir,\n infoGraph: join(graphDir, \"info_graph.json\"),\n symbolIndex: join(graphDir, \"symbol_index.json\"),\n sessionState: join(graphDir, \"session.json\"),\n activityLog: join(graphDir, \"activity.jsonl\"),\n tokenLog: join(graphDir, \"token_log.jsonl\"),\n gateLog: join(graphDir, \"gate_log.jsonl\"),\n mcpPort: join(graphDir, \"mcp_port\"),\n mcpServerLog: join(graphDir, \"mcp_server.log\"),\n mcpServerErrLog: join(graphDir, \"mcp_server.err.log\"),\n contextStore: join(contextDir, \"context-store.json\"),\n contextMd: join(contextDir, \"CONTEXT.md\"),\n branchesDir: join(contextDir, \"branches\"),\n claudeDir,\n claudeSettings: join(claudeDir, \"settings.local.json\"),\n claudeHooksDir: join(claudeDir, \"hooks\"),\n claudeMd: join(projectRoot, \"CLAUDE.md\"),\n gitignore: join(projectRoot, \".gitignore\"),\n };\n}\n","// Approximate per-million-token pricing for Claude models, in USD.\n// Sourced from Anthropic's published rates. Tilde everywhere — these can shift.\n//\n// Used only for the dashboard's \"~$X\" estimate; not for billing.\n\nexport interface ModelPricing {\n /** Cost per 1M raw-input tokens. */\n input: number;\n /** Cost per 1M output tokens. */\n output: number;\n /** Cost per 1M cache-read tokens (typically ~10% of input). */\n cacheRead: number;\n /** Cost per 1M cache-creation tokens (typically input × 1.25). */\n cacheCreate: number;\n}\n\nconst PRICING: Record<string, ModelPricing> = {\n // Opus-class models — premium tier\n \"claude-opus-4-7\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-6\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-5\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n // Sonnet-class — workhorse\n \"claude-sonnet-4-6\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n \"claude-sonnet-4-5\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n // Haiku-class — fast and cheap\n \"claude-haiku-4-5\": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },\n};\n\nconst FALLBACK: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };\n\nexport function pricingFor(model: string | undefined | null): ModelPricing {\n if (!model) return FALLBACK;\n const direct = PRICING[model];\n if (direct) return direct;\n // Loose prefix match: \"claude-opus-…\" / \"claude-sonnet-…\" / \"claude-haiku-…\"\n if (model.includes(\"opus\")) return PRICING[\"claude-opus-4-7\"] ?? FALLBACK;\n if (model.includes(\"sonnet\")) return PRICING[\"claude-sonnet-4-6\"] ?? FALLBACK;\n if (model.includes(\"haiku\")) return PRICING[\"claude-haiku-4-5\"] ?? FALLBACK;\n return FALLBACK;\n}\n\nexport interface UsageRecord {\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model?: string;\n}\n\n/** Approximate USD cost of a single usage record. */\nexport function estimateCostUsd(usage: UsageRecord): number {\n const p = pricingFor(usage.model);\n return (\n (usage.input_tokens / 1_000_000) * p.input +\n (usage.output_tokens / 1_000_000) * p.output +\n ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * p.cacheRead +\n ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * p.cacheCreate\n );\n}\n","// Global registry of projects that have run `syn .` on this machine.\n// Stored at ~/.synthra/projects.json so the dashboard can enumerate them\n// without walking the filesystem.\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\n\nconst REGISTRY_DIR = join(homedir(), \".synthra\");\nconst REGISTRY_PATH = join(REGISTRY_DIR, \"projects.json\");\nconst SCHEMA_VERSION = 1;\n\nexport interface ProjectRegistryEntry {\n path: string; // absolute project root\n name: string; // basename for display\n first_seen: string; // ISO timestamp\n last_seen: string; // ISO timestamp\n}\n\ninterface Registry {\n schema_version: number;\n projects: ProjectRegistryEntry[];\n}\n\nasync function readRegistry(): Promise<Registry> {\n try {\n const raw = await readFile(REGISTRY_PATH, \"utf8\");\n const parsed = JSON.parse(raw) as Partial<Registry>;\n if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };\n return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };\n } catch {\n return { schema_version: SCHEMA_VERSION, projects: [] };\n }\n}\n\nasync function writeRegistry(registry: Registry): Promise<void> {\n await mkdir(dirname(REGISTRY_PATH), { recursive: true });\n await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + \"\\n\", \"utf8\");\n}\n\n/** Upsert this project's entry. Updates `last_seen`; preserves `first_seen`. */\nexport async function recordProject(projectRoot: string): Promise<void> {\n const now = new Date().toISOString();\n const registry = await readRegistry();\n const existing = registry.projects.find((p) => p.path === projectRoot);\n if (existing) {\n existing.last_seen = now;\n existing.name = basename(projectRoot);\n } else {\n registry.projects.push({\n path: projectRoot,\n name: basename(projectRoot),\n first_seen: now,\n last_seen: now,\n });\n }\n try {\n await writeRegistry(registry);\n } catch {\n // Registry is best-effort — a write failure shouldn't block the session.\n }\n}\n\nexport async function listProjects(): Promise<ProjectRegistryEntry[]> {\n const registry = await readRegistry();\n // Sort by last_seen descending so the most active project surfaces first.\n return registry.projects\n .slice()\n .sort((a, b) => (a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0));\n}\n\nexport { REGISTRY_PATH, REGISTRY_DIR };\n","<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Synthra — Token Dashboard</title>\n <link rel=\"stylesheet\" href=\"./style.css\" />\n</head>\n<body>\n <header>\n <div class=\"brand\">\n <h1>Synthra</h1>\n <span class=\"tag\">Token Dashboard</span>\n </div>\n <div class=\"meta\">\n <span class=\"active-project\" id=\"active-project\">…</span>\n <span class=\"dot\" id=\"dot\"></span>\n <span id=\"status\">connecting…</span>\n </div>\n </header>\n\n <main>\n <section>\n <h2>Global totals <span class=\"muted\">(all projects)</span></h2>\n <div class=\"cards\" id=\"cards\"></div>\n </section>\n\n <section>\n <h2>Projects</h2>\n <div class=\"projects\" id=\"projects\"></div>\n <p class=\"empty hidden\" id=\"projects-empty\">No projects registered yet. Run <code>syn .</code> in any project to add it.</p>\n </section>\n\n <section>\n <h2>Recent calls <span class=\"muted\">(across all projects)</span></h2>\n <table id=\"turns\">\n <thead>\n <tr>\n <th>Time</th>\n <th>Project</th>\n <th>Model</th>\n <th class=\"num\">Input</th>\n <th class=\"num\">Output</th>\n <th class=\"num\">Cache R / W</th>\n <th class=\"num\">Cost</th>\n </tr>\n </thead>\n <tbody></tbody>\n </table>\n <p class=\"empty hidden\" id=\"turns-empty\">No turns logged yet. Use Claude via the IDE extension or <code>claude</code> CLI while <code>syn .</code> is running.</p>\n </section>\n\n <section>\n <h2>Recent gate decisions</h2>\n <table id=\"gates\">\n <thead>\n <tr>\n <th>Time</th>\n <th>Project</th>\n <th>Tool</th>\n <th>Decision</th>\n <th>Query</th>\n </tr>\n </thead>\n <tbody></tbody>\n </table>\n <p class=\"empty hidden\" id=\"gates-empty\">No gate decisions yet.</p>\n </section>\n </main>\n\n <footer>\n <span>Token Counter MCP · live polling every 2s</span>\n <span class=\"muted\">Cost figures are approximate — see /docs/PROTOCOL.md</span>\n </footer>\n\n <script>\n const $ = (sel) => document.querySelector(sel);\n const cardsEl = $(\"#cards\");\n const projectsEl = $(\"#projects\");\n const turnsBody = $(\"#turns tbody\");\n const gatesBody = $(\"#gates tbody\");\n const turnsEmpty = $(\"#turns-empty\");\n const gatesEmpty = $(\"#gates-empty\");\n const projectsEmpty = $(\"#projects-empty\");\n const statusEl = $(\"#status\");\n const dotEl = $(\"#dot\");\n const activeProjectEl = $(\"#active-project\");\n\n function fmt(n) {\n if (typeof n !== \"number\" || !Number.isFinite(n)) return \"0\";\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + \"M\";\n if (n >= 1000) return (n / 1000).toFixed(1) + \"K\";\n return n.toLocaleString();\n }\n\n function fmtFull(n) {\n return (typeof n === \"number\" ? n : 0).toLocaleString();\n }\n\n function fmtCost(usd) {\n if (typeof usd !== \"number\") return \"~$0.00\";\n if (usd >= 1) return \"~$\" + usd.toFixed(2);\n if (usd >= 0.01) return \"~$\" + usd.toFixed(3);\n return \"~$\" + usd.toFixed(4);\n }\n\n function fmtTs(iso) {\n try {\n const d = new Date(iso);\n const today = new Date();\n const isToday = d.toDateString() === today.toDateString();\n if (isToday) return d.toLocaleTimeString();\n return d.toLocaleString();\n } catch {\n return iso;\n }\n }\n\n function renderCards(g) {\n cardsEl.innerHTML = \"\";\n const cards = [\n { label: \"Total cost\", value: fmtCost(g.estimated_cost_usd), accent: true },\n { label: \"Turns\", value: fmt(g.total_turns) },\n { label: \"Input\", value: fmt(g.total_input_tokens) },\n { label: \"Output\", value: fmt(g.total_output_tokens) },\n { label: \"Cache read\", value: fmt(g.total_cache_read) },\n { label: \"Cache write\", value: fmt(g.total_cache_create) },\n { label: \"Projects\", value: fmt(g.project_count) },\n { label: \"Blocked Grep / Glob\", value: fmt(g.blocked_count), accent: true },\n { label: \"Tokens saved\", value: fmt(g.estimated_tokens_saved), accent: true },\n ];\n for (const c of cards) {\n const el = document.createElement(\"div\");\n el.className = \"card\" + (c.accent ? \" accent\" : \"\");\n el.innerHTML = '<div class=\"card-label\">' + c.label + '</div><div class=\"card-value\">' + c.value + '</div>';\n cardsEl.appendChild(el);\n }\n }\n\n function renderProjects(projects, globalCost) {\n projectsEl.innerHTML = \"\";\n if (!projects.length) {\n projectsEmpty.classList.remove(\"hidden\");\n return;\n }\n projectsEmpty.classList.add(\"hidden\");\n const maxTokens = Math.max(1, ...projects.map((p) => p.total_input_tokens + p.total_output_tokens));\n for (const p of projects) {\n const total = p.total_input_tokens + p.total_output_tokens;\n const pct = Math.round((total / maxTokens) * 100);\n const sharePct = globalCost > 0 ? ((p.estimated_cost_usd / globalCost) * 100).toFixed(1) : \"0.0\";\n const row = document.createElement(\"div\");\n row.className = \"project-row\";\n row.innerHTML =\n '<div class=\"project-name\">' +\n '<strong>' + p.name + '</strong>' +\n '<code class=\"project-path\">' + p.path + '</code>' +\n '</div>' +\n '<div class=\"project-stats\">' +\n '<div class=\"stat\"><span class=\"stat-value cost\">' + fmtCost(p.estimated_cost_usd) + '</span><span class=\"stat-label\">cost (' + sharePct + '%)</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(total) + '</span><span class=\"stat-label\">tokens</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(p.total_turns) + '</span><span class=\"stat-label\">turns</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(p.blocked_count) + '</span><span class=\"stat-label\">blocks</span></div>' +\n '</div>' +\n '<div class=\"bar\"><div class=\"bar-fill\" style=\"width:' + pct + '%\"></div></div>';\n projectsEl.appendChild(row);\n }\n }\n\n function renderTurns(turns) {\n turnsBody.innerHTML = \"\";\n if (!turns.length) {\n turnsEmpty.classList.remove(\"hidden\");\n return;\n }\n turnsEmpty.classList.add(\"hidden\");\n for (const t of turns) {\n const tr = document.createElement(\"tr\");\n const modelCell =\n t.model && t.model !== \"<synthetic>\"\n ? \"<code>\" + t.model + \"</code>\"\n : '<span class=\"muted\">' + (t.model === \"<synthetic>\" ? \"synthetic\" : \"unknown\") + \"</span>\";\n tr.innerHTML =\n \"<td>\" + fmtTs(t.ts) + \"</td>\" +\n \"<td><code>\" + t.project_name + \"</code></td>\" +\n \"<td>\" + modelCell + \"</td>\" +\n '<td class=\"num\">' + fmtFull(t.input) + \"</td>\" +\n '<td class=\"num\">' + fmtFull(t.output) + \"</td>\" +\n '<td class=\"num\">' + fmt(t.cache_read) + \" / \" + fmt(t.cache_create) + \"</td>\" +\n '<td class=\"num cost\">' + fmtCost(t.cost_usd) + \"</td>\";\n turnsBody.appendChild(tr);\n }\n }\n\n function renderGates(gates) {\n gatesBody.innerHTML = \"\";\n if (!gates.length) {\n gatesEmpty.classList.remove(\"hidden\");\n return;\n }\n gatesEmpty.classList.add(\"hidden\");\n for (const g of gates) {\n const tr = document.createElement(\"tr\");\n const cls = g.decision === \"block\" ? \"decision-block\" : \"decision-allow\";\n tr.innerHTML =\n \"<td>\" + fmtTs(g.ts) + \"</td>\" +\n \"<td><code>\" + g.project_name + \"</code></td>\" +\n \"<td><code>\" + g.tool + \"</code></td>\" +\n '<td class=\"' + cls + '\">' + g.decision + \"</td>\" +\n \"<td><code>\" + (g.query || \"\") + \"</code></td>\";\n gatesBody.appendChild(tr);\n }\n }\n\n async function tick() {\n try {\n const res = await fetch(\"/data\");\n if (!res.ok) throw new Error(\"HTTP \" + res.status);\n const data = await res.json();\n activeProjectEl.textContent = data.active.project_root;\n renderCards(data.global);\n renderProjects(data.projects, data.global.estimated_cost_usd);\n renderTurns(data.recent_turns);\n renderGates(data.recent_gates);\n statusEl.textContent = \"live · \" + new Date().toLocaleTimeString();\n dotEl.classList.add(\"live\");\n dotEl.classList.remove(\"dead\");\n } catch (e) {\n statusEl.textContent = \"disconnected · \" + e.message;\n dotEl.classList.add(\"dead\");\n dotEl.classList.remove(\"live\");\n }\n }\n\n tick();\n setInterval(tick, 2000);\n </script>\n</body>\n</html>\n","/* Synthra token dashboard — palette per project brief. */\n\n:root {\n --color-heading: #ECEBD8;\n --color-body: #EDECD9;\n --color-bg: #000000;\n --color-surface: #140009;\n --color-surface-raised: #1E000D;\n --color-border: #4D0020;\n --color-accent: #FF0073;\n --color-accent-darker: #EB006A;\n --color-form-bg: #2E0014;\n --color-muted: rgba(237, 236, 217, 0.55);\n --color-very-muted: rgba(237, 236, 217, 0.35);\n --color-block: #FF0073;\n --color-allow: #ECEBD8;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n background: var(--color-bg);\n color: var(--color-body);\n font-family:\n ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n system-ui, sans-serif;\n font-size: 14px;\n line-height: 1.5;\n min-height: 100vh;\n}\n\ncode, .num, table, .project-path {\n font-family: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n}\n\nheader {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n padding: 1.1rem 2rem;\n background: var(--color-surface);\n border-bottom: 1px solid var(--color-border);\n}\n\nheader .brand {\n display: flex;\n align-items: baseline;\n gap: 0.75rem;\n}\n\nheader h1 {\n color: var(--color-heading);\n font-size: 1.35rem;\n font-weight: 700;\n letter-spacing: 0.02em;\n}\n\nheader .tag {\n color: var(--color-muted);\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n}\n\nheader .meta {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n font-size: 0.78rem;\n font-family: ui-monospace, monospace;\n color: var(--color-muted);\n}\n\nheader .active-project {\n max-width: 480px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nmain {\n padding: 2rem;\n display: flex;\n flex-direction: column;\n gap: 2rem;\n max-width: 1400px;\n margin: 0 auto;\n}\n\nsection {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\nh2 {\n color: var(--color-heading);\n font-size: 0.85rem;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\nh2 .muted {\n color: var(--color-very-muted);\n font-size: 0.78rem;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: none;\n margin-left: 0.5rem;\n}\n\n/* Cards row */\n.cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));\n gap: 0.7rem;\n}\n\n.card {\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.85rem 1rem;\n}\n\n.card.accent {\n background: var(--color-surface-raised);\n border-color: var(--color-accent);\n}\n\n.card-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n margin-bottom: 0.35rem;\n}\n\n.card-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n.card.accent .card-value {\n color: var(--color-accent);\n}\n\n/* Projects list */\n.projects {\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.project-row {\n display: grid;\n grid-template-columns: minmax(220px, 1fr) auto;\n grid-template-rows: auto auto;\n gap: 0.6rem 1.25rem;\n align-items: center;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.9rem 1.2rem;\n}\n\n.project-row:hover {\n background: var(--color-surface-raised);\n}\n\n.project-name {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n overflow: hidden;\n}\n\n.project-name strong {\n color: var(--color-heading);\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.project-name .project-path {\n color: var(--color-very-muted);\n font-size: 0.72rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.project-stats {\n display: flex;\n gap: 1.6rem;\n justify-self: end;\n text-align: right;\n}\n\n.stat {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n min-width: 70px;\n}\n\n.stat-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.stat-value.cost {\n color: var(--color-accent);\n}\n\n.stat-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.bar {\n grid-column: 1 / -1;\n height: 3px;\n background: var(--color-form-bg);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.bar-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--color-accent-darker), var(--color-accent));\n border-radius: 2px;\n transition: width 400ms ease;\n}\n\n/* Tables */\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.83rem;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n overflow: hidden;\n}\n\ntable thead th {\n text-align: left;\n color: var(--color-muted);\n text-transform: uppercase;\n font-size: 0.66rem;\n letter-spacing: 0.08em;\n font-weight: 600;\n padding: 0.65rem 0.85rem;\n border-bottom: 1px solid var(--color-border);\n background: var(--color-surface);\n}\n\ntable thead th.num {\n text-align: right;\n}\n\ntable tbody td {\n padding: 0.55rem 0.85rem;\n border-bottom: 1px solid rgba(77, 0, 32, 0.4);\n color: var(--color-body);\n}\n\ntable tbody td.num {\n text-align: right;\n}\n\ntable tbody td.cost {\n color: var(--color-accent);\n font-weight: 600;\n}\n\ntable tbody tr:last-child td {\n border-bottom: none;\n}\n\ntable tbody tr:hover {\n background: var(--color-surface-raised);\n}\n\ncode {\n color: var(--color-heading);\n background: var(--color-form-bg);\n padding: 0.1rem 0.4rem;\n border-radius: 3px;\n font-size: 0.85em;\n}\n\n.decision-block {\n color: var(--color-block);\n font-weight: 700;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.decision-allow {\n color: var(--color-allow);\n opacity: 0.7;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.empty {\n color: var(--color-muted);\n font-style: italic;\n padding: 1rem;\n background: var(--color-surface);\n border: 1px dashed var(--color-border);\n border-radius: 6px;\n}\n\n.hidden {\n display: none;\n}\n\nfooter {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 0.85rem 2rem;\n background: var(--color-surface);\n border-top: 1px solid var(--color-border);\n color: var(--color-muted);\n font-size: 0.72rem;\n font-family: ui-monospace, monospace;\n}\n\nfooter .muted {\n color: var(--color-very-muted);\n}\n\n.dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--color-muted);\n transition: background 200ms ease, box-shadow 200ms ease;\n}\n\n.dot.live {\n background: var(--color-accent);\n box-shadow: 0 0 8px var(--color-accent-darker);\n}\n\n.dot.dead {\n background: var(--color-border);\n box-shadow: none;\n}\n"],"mappings":"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","names":["readFile","join","readFile","basename"]}
|
|
1
|
+
{"version":3,"sources":["../../src/dashboard/server.ts","../../src/shared/logger.ts","../../src/server/port.ts","../../src/dashboard/delta.ts","../../src/shared/paths.ts","../../src/shared/pricing.ts","../../src/shared/project-registry.ts","../../src/dashboard/public/index.html","../../src/dashboard/public/style.css"],"sourcesContent":["// Standalone dashboard server. Default port 8901 (override via\n// SYN_DASHBOARD_PORT); falls back through a small range 8901–8910 if the\n// preferred port is busy (so we can coexist with other dashboards like\n// GrapeRoot's on the same machine).\n// Reads .synthra-graph/token_log.jsonl + .synthra-graph/gate_log.jsonl for the\n// given project and renders a live SPA backed by GET /data polled every 2s.\n\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\n\nimport { log } from \"../shared/logger.js\";\nimport type { SynthraPaths } from \"../shared/paths.js\";\nimport { findFreePort } from \"../server/port.js\";\nimport { computeDashboardData } from \"./delta.js\";\n\nimport indexHtml from \"./public/index.html\";\nimport styleCss from \"./public/style.css\";\n\nconst FALLBACK_RANGE = 9; // try preferredPort + [0..9]\n\nexport interface DashboardServerHandle {\n port: number;\n url: string;\n stop(): Promise<void>;\n}\n\nexport async function startDashboard(\n paths: SynthraPaths,\n preferredPort = 8901,\n): Promise<DashboardServerHandle> {\n const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);\n if (port !== preferredPort) {\n log.info(\n `dashboard port ${preferredPort} was busy — bound to ${port} instead (likely another dashboard from a coexisting tool).`,\n );\n }\n const app = new Hono();\n\n app.get(\"/\", (c) => c.html(indexHtml));\n\n app.get(\"/style.css\", (c) => {\n c.header(\"Content-Type\", \"text/css; charset=utf-8\");\n c.header(\"Cache-Control\", \"no-cache\");\n return c.body(styleCss);\n });\n\n app.get(\"/health\", (c) => c.json({ ok: true }));\n\n app.get(\"/data\", async (c) => {\n const data = await computeDashboardData(paths);\n return c.json(data);\n });\n\n const nodeServer = serve({ fetch: app.fetch, port, hostname: \"127.0.0.1\" });\n\n return {\n port,\n url: `http://127.0.0.1:${port}`,\n async stop() {\n await new Promise<void>((resolve, reject) => {\n nodeServer.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n","// Minimal logger. Prefixes Synthra output with [syn].\n\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LEVEL_PRIORITY: Record<Level, number> = {\n debug: 10,\n info: 20,\n warn: 30,\n error: 40,\n};\n\nlet activeLevel: Level = (process.env.SYN_LOG_LEVEL as Level) ?? \"info\";\n\nexport function setLevel(level: Level): void {\n activeLevel = level;\n}\n\nfunction shouldLog(level: Level): boolean {\n return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];\n}\n\nfunction emit(level: Level, msg: string, ...args: unknown[]): void {\n if (!shouldLog(level)) return;\n const stream = level === \"error\" || level === \"warn\" ? process.stderr : process.stdout;\n stream.write(`[syn] ${msg}${args.length ? \" \" + args.map(String).join(\" \") : \"\"}\\n`);\n}\n\nexport const log = {\n debug: (m: string, ...a: unknown[]) => emit(\"debug\", m, ...a),\n info: (m: string, ...a: unknown[]) => emit(\"info\", m, ...a),\n warn: (m: string, ...a: unknown[]) => emit(\"warn\", m, ...a),\n error: (m: string, ...a: unknown[]) => emit(\"error\", m, ...a),\n};\n","// Finds a free port in the 8080–8099 range. Writes the chosen port to\n// .synthra-graph/mcp_port so PowerShell/Bash hook scripts can read it.\n// TODO: M2\n\nimport { createServer } from \"node:net\";\n\nexport const PORT_RANGE_START = 8080;\nexport const PORT_RANGE_END = 8099;\n\nexport async function findFreePort(\n start = PORT_RANGE_START,\n end = PORT_RANGE_END,\n): Promise<number> {\n for (let port = start; port <= end; port++) {\n if (await isFree(port)) return port;\n }\n throw new Error(`Synthra: no free port in ${start}-${end}`);\n}\n\nfunction isFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once(\"error\", () => resolve(false));\n s.once(\"listening\", () => s.close(() => resolve(true)));\n s.listen(port, \"127.0.0.1\");\n });\n}\n","// Reads token_log.jsonl + gate_log.jsonl for the active project AND every\n// project registered in ~/.synthra/projects.json, then computes the\n// dashboard's rendered shape: per-project + global aggregate + recent calls\n// across all projects.\n\nimport { readFile } from \"node:fs/promises\";\n\nimport { resolvePaths, type SynthraPaths } from \"../shared/paths.js\";\nimport { estimateCostUsd } from \"../shared/pricing.js\";\nimport { listProjects } from \"../shared/project-registry.js\";\n\nconst AVG_TOKENS_PER_BLOCKED_GREP = 500;\n\nexport interface TokenLogEntry {\n /** Stop-hook-supplied timestamp (preferred). */\n ts?: string;\n /** Server-side fallback added by handleLog when ts isn't provided. */\n written_at?: string;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model: string;\n description?: string;\n project: string;\n}\n\nexport interface GateLogEntry {\n ts: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n reason?: string;\n}\n\nexport interface ProjectStats {\n path: string;\n name: string;\n last_seen: string | null;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n estimated_cost_usd: number;\n models: Record<string, number>;\n}\n\nexport interface RecentTurn {\n ts: string;\n project_name: string;\n project_path: string;\n input: number;\n output: number;\n cache_read: number;\n cache_create: number;\n model: string;\n cost_usd: number;\n}\n\nexport interface RecentGate {\n ts: string;\n project_name: string;\n project_path: string;\n tool: string;\n decision: \"allow\" | \"block\";\n query: string | null;\n}\n\nexport interface DashboardData {\n active: {\n project_root: string;\n project_name: string;\n stats: ProjectStats;\n };\n global: {\n project_count: number;\n total_turns: number;\n total_input_tokens: number;\n total_output_tokens: number;\n total_cache_read: number;\n total_cache_create: number;\n total_gate_calls: number;\n blocked_count: number;\n estimated_tokens_saved: number;\n saved_percent: number;\n estimated_cost_usd: number;\n };\n projects: ProjectStats[];\n recent_turns: RecentTurn[];\n recent_gates: RecentGate[];\n}\n\nasync function readJsonl<T>(path: string): Promise<T[]> {\n try {\n const text = await readFile(path, \"utf8\");\n return text\n .split(/\\r?\\n/)\n .filter((l) => l.length > 0)\n .map((l) => {\n try {\n return JSON.parse(l) as T;\n } catch {\n return null;\n }\n })\n .filter((v): v is T => v !== null);\n } catch {\n return [];\n }\n}\n\nfunction basename(p: string): string {\n const parts = p.split(/[\\\\/]/);\n return parts[parts.length - 1] || p;\n}\n\ninterface ProjectFiles {\n path: string;\n name: string;\n last_seen: string | null;\n tokens: TokenLogEntry[];\n gates: GateLogEntry[];\n}\n\nfunction summarize(p: ProjectFiles): ProjectStats {\n let totalIn = 0;\n let totalOut = 0;\n let totalCacheRead = 0;\n let totalCacheCreate = 0;\n let costUsd = 0;\n const models: Record<string, number> = {};\n\n for (const t of p.tokens) {\n totalIn += t.input_tokens ?? 0;\n totalOut += t.output_tokens ?? 0;\n totalCacheRead += t.cache_read_input_tokens ?? 0;\n totalCacheCreate += t.cache_creation_input_tokens ?? 0;\n costUsd += estimateCostUsd(t);\n if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;\n }\n\n const blocked = p.gates.filter((g) => g.decision === \"block\").length;\n const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;\n\n return {\n path: p.path,\n name: p.name,\n last_seen: p.last_seen,\n total_turns: p.tokens.length,\n total_input_tokens: totalIn,\n total_output_tokens: totalOut,\n total_cache_read: totalCacheRead,\n total_cache_create: totalCacheCreate,\n total_gate_calls: p.gates.length,\n blocked_count: blocked,\n estimated_tokens_saved: saved,\n estimated_cost_usd: Math.round(costUsd * 100) / 100,\n models,\n };\n}\n\nasync function loadProjectFiles(\n path: string,\n name: string,\n lastSeen: string | null,\n): Promise<ProjectFiles> {\n const paths = resolvePaths(path);\n const [rawTokens, gates] = await Promise.all([\n readJsonl<TokenLogEntry>(paths.tokenLog),\n readJsonl<GateLogEntry>(paths.gateLog),\n ]);\n return { path, name, last_seen: lastSeen, tokens: dedupeTokens(rawTokens), gates };\n}\n\n/**\n * Collapse duplicate token-log entries from co-installed AI tools.\n *\n * Synthra is friendly with other tools that share the .synthra-graph/\n * token_log.jsonl shape (GrapeRoot writes to it too). Both Stop hooks\n * fire on the same turn and emit nearly-identical entries within ~10ms,\n * which double-counts every metric in the dashboard.\n *\n * Strategy: group by (project, usage counts, second-rounded timestamp);\n * inside a group, keep the entry with the most credible model field —\n * a real Claude model > \"<synthetic>\" > empty.\n */\nfunction dedupeTokens(entries: TokenLogEntry[]): TokenLogEntry[] {\n const score = (model: string | undefined): number => {\n if (!model) return 0;\n if (model === \"<synthetic>\") return 1;\n return 2; // real model name\n };\n\n const groups = new Map<string, TokenLogEntry[]>();\n for (const e of entries) {\n const ts = e.ts ?? e.written_at ?? \"\";\n const second = ts.slice(0, 19); // YYYY-MM-DDTHH:mm:ss\n const key = [\n e.project ?? \"\",\n e.input_tokens ?? 0,\n e.output_tokens ?? 0,\n e.cache_creation_input_tokens ?? 0,\n e.cache_read_input_tokens ?? 0,\n second,\n ].join(\"|\");\n const arr = groups.get(key) ?? [];\n arr.push(e);\n groups.set(key, arr);\n }\n\n const out: TokenLogEntry[] = [];\n for (const arr of groups.values()) {\n if (arr.length === 1) {\n out.push(arr[0]!);\n continue;\n }\n arr.sort((a, b) => score(b.model) - score(a.model));\n out.push(arr[0]!);\n }\n\n // Preserve chronological order in the per-project list.\n out.sort((a, b) => {\n const at = a.ts ?? a.written_at ?? \"\";\n const bt = b.ts ?? b.written_at ?? \"\";\n return at.localeCompare(bt);\n });\n return out;\n}\n\nexport async function computeDashboardData(\n activePaths: SynthraPaths,\n recentN = 25,\n): Promise<DashboardData> {\n const registered = await listProjects();\n\n // Always include the active project, even if not yet in the registry.\n const activePath = activePaths.projectRoot;\n const activeName = basename(activePath);\n const knownPaths = new Set(registered.map((p) => p.path));\n const allEntries: Array<{ path: string; name: string; last_seen: string | null }> = [\n ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen })),\n ];\n if (!knownPaths.has(activePath)) {\n allEntries.unshift({ path: activePath, name: activeName, last_seen: null });\n }\n\n const loaded = await Promise.all(\n allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen)),\n );\n\n const projects = loaded\n .map(summarize)\n .sort((a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens));\n\n const activeFiles =\n loaded.find((p) => p.path === activePath) ?? {\n path: activePath,\n name: activeName,\n last_seen: null,\n tokens: [],\n gates: [],\n };\n const activeStats = summarize(activeFiles);\n\n // Global aggregates\n let g_in = 0,\n g_out = 0,\n g_cr = 0,\n g_cc = 0,\n g_gate = 0,\n g_block = 0,\n g_cost = 0,\n g_turns = 0;\n for (const s of projects) {\n g_turns += s.total_turns;\n g_in += s.total_input_tokens;\n g_out += s.total_output_tokens;\n g_cr += s.total_cache_read;\n g_cc += s.total_cache_create;\n g_gate += s.total_gate_calls;\n g_block += s.blocked_count;\n g_cost += s.estimated_cost_usd;\n }\n const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;\n const g_used = g_in + g_out + g_cc;\n const g_saved_pct = g_used + g_saved > 0 ? (g_saved / (g_used + g_saved)) * 100 : 0;\n\n // Recent turns + gates across all projects, sorted by ts descending\n const allTurns: RecentTurn[] = [];\n const allGates: RecentGate[] = [];\n for (const p of loaded) {\n for (const t of p.tokens) {\n allTurns.push({\n // Fall back to written_at — the Stop hook today posts entries without\n // a `ts` field, and the server tags them with written_at on receive.\n ts: t.ts ?? t.written_at ?? \"\",\n project_name: p.name,\n project_path: p.path,\n input: t.input_tokens ?? 0,\n output: t.output_tokens ?? 0,\n cache_read: t.cache_read_input_tokens ?? 0,\n cache_create: t.cache_creation_input_tokens ?? 0,\n model: t.model ?? \"\",\n cost_usd: Math.round(estimateCostUsd(t) * 1000) / 1000,\n });\n }\n for (const gate of p.gates) {\n allGates.push({\n ts: gate.ts,\n project_name: p.name,\n project_path: p.path,\n tool: gate.tool,\n decision: gate.decision,\n query: gate.query,\n });\n }\n }\n allTurns.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n allGates.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));\n\n return {\n active: {\n project_root: activePath,\n project_name: activeName,\n stats: activeStats,\n },\n global: {\n project_count: projects.length,\n total_turns: g_turns,\n total_input_tokens: g_in,\n total_output_tokens: g_out,\n total_cache_read: g_cr,\n total_cache_create: g_cc,\n total_gate_calls: g_gate,\n blocked_count: g_block,\n estimated_tokens_saved: g_saved,\n saved_percent: Math.round(g_saved_pct * 10) / 10,\n estimated_cost_usd: Math.round(g_cost * 100) / 100,\n },\n projects,\n recent_turns: allTurns.slice(0, recentN),\n recent_gates: allGates.slice(0, recentN),\n };\n}\n\n// Legacy shapes from the M2 stub — kept for compat.\nexport interface TurnBreakdown {\n systemPromptTokens: number;\n conversationHistoryTokens: number;\n synthraPackTokens: number;\n userMessageTokens: number;\n responseTokens: number;\n totalTokens: number;\n costUsd: number;\n}\n\nexport interface SavingsDelta {\n withSynthra: TurnBreakdown;\n estimatedWithoutSynthra: TurnBreakdown;\n savedUsd: number;\n savedPercent: number;\n}\n\nexport function computeDelta(breakdown: TurnBreakdown, blockedGreps: number): SavingsDelta {\n const savedTokens = blockedGreps * AVG_TOKENS_PER_BLOCKED_GREP;\n const without: TurnBreakdown = {\n ...breakdown,\n conversationHistoryTokens: breakdown.conversationHistoryTokens + savedTokens,\n totalTokens: breakdown.totalTokens + savedTokens,\n costUsd: breakdown.costUsd + (savedTokens / 1_000_000) * 3,\n };\n const savedUsd = without.costUsd - breakdown.costUsd;\n const savedPercent = without.totalTokens > 0 ? (savedTokens / without.totalTokens) * 100 : 0;\n return {\n withSynthra: breakdown,\n estimatedWithoutSynthra: without,\n savedUsd,\n savedPercent: Math.round(savedPercent * 10) / 10,\n };\n}\n","// Resolves Synthra's storage locations inside a project root.\n\nimport { join } from \"node:path\";\n\nexport interface SynthraPaths {\n projectRoot: string;\n graphDir: string;\n contextDir: string;\n infoGraph: string;\n symbolIndex: string;\n sessionState: string;\n activityLog: string;\n tokenLog: string;\n gateLog: string;\n mcpPort: string;\n mcpServerLog: string;\n mcpServerErrLog: string;\n contextStore: string;\n contextMd: string;\n branchesDir: string;\n claudeDir: string;\n claudeSettings: string;\n claudeHooksDir: string;\n claudeMd: string;\n gitignore: string;\n}\n\nexport function resolvePaths(projectRoot: string): SynthraPaths {\n const graphDir = join(projectRoot, \".synthra-graph\");\n const contextDir = join(projectRoot, \".synthra\");\n const claudeDir = join(projectRoot, \".claude\");\n\n return {\n projectRoot,\n graphDir,\n contextDir,\n infoGraph: join(graphDir, \"info_graph.json\"),\n symbolIndex: join(graphDir, \"symbol_index.json\"),\n sessionState: join(graphDir, \"session.json\"),\n activityLog: join(graphDir, \"activity.jsonl\"),\n tokenLog: join(graphDir, \"token_log.jsonl\"),\n gateLog: join(graphDir, \"gate_log.jsonl\"),\n mcpPort: join(graphDir, \"mcp_port\"),\n mcpServerLog: join(graphDir, \"mcp_server.log\"),\n mcpServerErrLog: join(graphDir, \"mcp_server.err.log\"),\n contextStore: join(contextDir, \"context-store.json\"),\n contextMd: join(contextDir, \"CONTEXT.md\"),\n branchesDir: join(contextDir, \"branches\"),\n claudeDir,\n claudeSettings: join(claudeDir, \"settings.local.json\"),\n claudeHooksDir: join(claudeDir, \"hooks\"),\n claudeMd: join(projectRoot, \"CLAUDE.md\"),\n gitignore: join(projectRoot, \".gitignore\"),\n };\n}\n","// Approximate per-million-token pricing for Claude models, in USD.\n// Sourced from Anthropic's published rates. Tilde everywhere — these can shift.\n//\n// Used only for the dashboard's \"~$X\" estimate; not for billing.\n\nexport interface ModelPricing {\n /** Cost per 1M raw-input tokens. */\n input: number;\n /** Cost per 1M output tokens. */\n output: number;\n /** Cost per 1M cache-read tokens (typically ~10% of input). */\n cacheRead: number;\n /** Cost per 1M cache-creation tokens (typically input × 1.25). */\n cacheCreate: number;\n}\n\nconst PRICING: Record<string, ModelPricing> = {\n // Opus-class models — premium tier\n \"claude-opus-4-7\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-6\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n \"claude-opus-4-5\": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },\n // Sonnet-class — workhorse\n \"claude-sonnet-4-6\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n \"claude-sonnet-4-5\": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },\n // Haiku-class — fast and cheap\n \"claude-haiku-4-5\": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },\n};\n\nconst FALLBACK: ModelPricing = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };\n\nexport function pricingFor(model: string | undefined | null): ModelPricing {\n if (!model) return FALLBACK;\n const direct = PRICING[model];\n if (direct) return direct;\n // Loose prefix match: \"claude-opus-…\" / \"claude-sonnet-…\" / \"claude-haiku-…\"\n if (model.includes(\"opus\")) return PRICING[\"claude-opus-4-7\"] ?? FALLBACK;\n if (model.includes(\"sonnet\")) return PRICING[\"claude-sonnet-4-6\"] ?? FALLBACK;\n if (model.includes(\"haiku\")) return PRICING[\"claude-haiku-4-5\"] ?? FALLBACK;\n return FALLBACK;\n}\n\nexport interface UsageRecord {\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n model?: string;\n}\n\n/** Approximate USD cost of a single usage record. */\nexport function estimateCostUsd(usage: UsageRecord): number {\n const p = pricingFor(usage.model);\n return (\n (usage.input_tokens / 1_000_000) * p.input +\n (usage.output_tokens / 1_000_000) * p.output +\n ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * p.cacheRead +\n ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * p.cacheCreate\n );\n}\n","// Global registry of projects that have run `syn .` on this machine.\n// Stored at ~/.synthra/projects.json so the dashboard can enumerate them\n// without walking the filesystem.\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\n\nconst REGISTRY_DIR = join(homedir(), \".synthra\");\nconst REGISTRY_PATH = join(REGISTRY_DIR, \"projects.json\");\nconst SCHEMA_VERSION = 1;\n\nexport interface ProjectRegistryEntry {\n path: string; // absolute project root\n name: string; // basename for display\n first_seen: string; // ISO timestamp\n last_seen: string; // ISO timestamp\n}\n\ninterface Registry {\n schema_version: number;\n projects: ProjectRegistryEntry[];\n}\n\nasync function readRegistry(): Promise<Registry> {\n try {\n const raw = await readFile(REGISTRY_PATH, \"utf8\");\n const parsed = JSON.parse(raw) as Partial<Registry>;\n if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };\n return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };\n } catch {\n return { schema_version: SCHEMA_VERSION, projects: [] };\n }\n}\n\nasync function writeRegistry(registry: Registry): Promise<void> {\n await mkdir(dirname(REGISTRY_PATH), { recursive: true });\n await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + \"\\n\", \"utf8\");\n}\n\n/** Upsert this project's entry. Updates `last_seen`; preserves `first_seen`. */\nexport async function recordProject(projectRoot: string): Promise<void> {\n const now = new Date().toISOString();\n const registry = await readRegistry();\n const existing = registry.projects.find((p) => p.path === projectRoot);\n if (existing) {\n existing.last_seen = now;\n existing.name = basename(projectRoot);\n } else {\n registry.projects.push({\n path: projectRoot,\n name: basename(projectRoot),\n first_seen: now,\n last_seen: now,\n });\n }\n try {\n await writeRegistry(registry);\n } catch {\n // Registry is best-effort — a write failure shouldn't block the session.\n }\n}\n\nexport async function listProjects(): Promise<ProjectRegistryEntry[]> {\n const registry = await readRegistry();\n // Sort by last_seen descending so the most active project surfaces first.\n return registry.projects\n .slice()\n .sort((a, b) => (a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0));\n}\n\nexport { REGISTRY_PATH, REGISTRY_DIR };\n","<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>Synthra — Token Dashboard</title>\n <link rel=\"stylesheet\" href=\"./style.css\" />\n</head>\n<body>\n <header>\n <div class=\"brand\">\n <h1>Synthra</h1>\n <span class=\"tag\">Token Dashboard</span>\n </div>\n <div class=\"meta\">\n <span class=\"active-project\" id=\"active-project\">…</span>\n <span class=\"dot\" id=\"dot\"></span>\n <span id=\"status\">connecting…</span>\n </div>\n </header>\n\n <main>\n <section>\n <h2>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 3v18h18\"/><path d=\"M7 14l4-4 4 4 5-5\"/></svg>\n Global totals\n <span class=\"muted\">(all projects)</span>\n </h2>\n <div class=\"cards\" id=\"cards\"></div>\n </section>\n\n <section>\n <h2>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"/></svg>\n Projects\n </h2>\n <div class=\"projects\" id=\"projects\"></div>\n <p class=\"empty hidden\" id=\"projects-empty\">No projects registered yet. Run <code>syn .</code> in any project to add it.</p>\n </section>\n\n <section>\n <h2>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>\n Recent calls\n <span class=\"muted\">(across all projects)</span>\n </h2>\n <table id=\"turns\">\n <thead>\n <tr>\n <th>Time</th>\n <th>Project</th>\n <th>Model</th>\n <th class=\"num\"><span class=\"has-tooltip\" data-tooltip=\"New (uncached) tokens you sent to Claude this turn. Usually small — most of the conversation comes from cache.\">Input <span class=\"help-icon\">i</span></span></th>\n <th class=\"num\"><span class=\"has-tooltip\" data-tooltip=\"Tokens Claude generated in its response. The most expensive line item — ~5× the input rate on Opus.\">Output <span class=\"help-icon\">i</span></span></th>\n <th class=\"num\"><span class=\"has-tooltip\" data-tooltip=\"Cache read / cache write. Reads (~10% of input rate) reuse prior context; writes (~125% of input rate) save new context for future turns.\">Cache R / W <span class=\"help-icon\">i</span></span></th>\n <th class=\"num\"><span class=\"has-tooltip\" data-tooltip=\"Approximate USD cost for this turn — input × rate + output × 5×rate + cache_read × 0.1×rate + cache_write × 1.25×rate, using the turn's model.\">Cost <span class=\"help-icon\">i</span></span></th>\n </tr>\n </thead>\n <tbody></tbody>\n </table>\n <p class=\"empty hidden\" id=\"turns-empty\">No turns logged yet. Use Claude via the IDE extension or <code>claude</code> CLI while <code>syn .</code> is running.</p>\n </section>\n\n <section>\n <h2>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/></svg>\n Recent gate decisions\n </h2>\n <table id=\"gates\">\n <thead>\n <tr>\n <th>Time</th>\n <th>Project</th>\n <th>Tool</th>\n <th><span class=\"has-tooltip\" data-tooltip=\"ALLOW = Synthra let the tool call through. BLOCK = Synthra intercepted it because the graph already had high-confidence context — Claude should use graph_continue instead.\">Decision <span class=\"help-icon\">i</span></span></th>\n <th>Query</th>\n </tr>\n </thead>\n <tbody></tbody>\n </table>\n <p class=\"empty hidden\" id=\"gates-empty\">No gate decisions yet.</p>\n </section>\n </main>\n\n <footer>\n <span>Synthra Token Dashboard · live polling every 2s</span>\n <span class=\"muted\">Cost figures are approximate — based on published Anthropic rates.</span>\n </footer>\n\n <script>\n // Inline SVG icons. Each is a stroke-based icon, currentColor for the\n // stroke, designed to inherit color from the parent's CSS.\n const ICONS = {\n dollar: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"23\"/><path d=\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\"/></svg>',\n chat: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/></svg>',\n arrowDown: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><polyline points=\"19 12 12 19 5 12\"/></svg>',\n arrowUp: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>',\n refresh: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><polyline points=\"1 20 1 14 7 14\"/><path d=\"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15\"/></svg>',\n save: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>',\n folder: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"/></svg>',\n shield: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/></svg>',\n trending: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 6 13.5 15.5 8.5 10.5 1 18\"/><polyline points=\"17 6 23 6 23 12\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"4.93\" y1=\"4.93\" x2=\"19.07\" y2=\"19.07\"/></svg>',\n };\n\n // Classify a model name into a family for color-coding.\n function modelFamily(model) {\n if (!model) return 'unknown';\n const m = model.toLowerCase();\n if (m === '<synthetic>') return 'unknown';\n if (m.includes('opus')) return 'opus';\n if (m.includes('sonnet')) return 'sonnet';\n if (m.includes('haiku')) return 'haiku';\n return 'unknown';\n }\n\n function modelLabel(model) {\n if (!model || model === '<synthetic>') return model === '<synthetic>' ? 'synthetic' : 'unknown';\n return model;\n }\n\n const $ = (sel) => document.querySelector(sel);\n const cardsEl = $(\"#cards\");\n const projectsEl = $(\"#projects\");\n const turnsBody = $(\"#turns tbody\");\n const gatesBody = $(\"#gates tbody\");\n const turnsEmpty = $(\"#turns-empty\");\n const gatesEmpty = $(\"#gates-empty\");\n const projectsEmpty = $(\"#projects-empty\");\n const statusEl = $(\"#status\");\n const dotEl = $(\"#dot\");\n const activeProjectEl = $(\"#active-project\");\n\n function fmt(n) {\n if (typeof n !== \"number\" || !Number.isFinite(n)) return \"0\";\n if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + \"M\";\n if (n >= 1000) return (n / 1000).toFixed(1) + \"K\";\n return n.toLocaleString();\n }\n\n function fmtFull(n) {\n return (typeof n === \"number\" ? n : 0).toLocaleString();\n }\n\n function fmtCost(usd) {\n if (typeof usd !== \"number\") return \"~$0.00\";\n if (usd >= 1) return \"~$\" + usd.toFixed(2);\n if (usd >= 0.01) return \"~$\" + usd.toFixed(3);\n return \"~$\" + usd.toFixed(4);\n }\n\n function fmtTs(iso) {\n try {\n const d = new Date(iso);\n const today = new Date();\n const isToday = d.toDateString() === today.toDateString();\n if (isToday) return d.toLocaleTimeString();\n return d.toLocaleString();\n } catch {\n return iso;\n }\n }\n\n // Definitions for the global-totals cards. Each: label, value-source key,\n // icon, optional class (accent | money), tooltip text.\n function cardConfigs(g) {\n return [\n {\n label: \"Total cost\",\n value: fmtCost(g.estimated_cost_usd),\n icon: ICONS.dollar,\n cls: \"money\",\n tooltip: \"Approximate USD cost across all projects. Computed from per-model pricing (Opus, Sonnet, Haiku) applied to each turn's input/output/cache. Tilde everywhere — real cost depends on Anthropic's current rates.\",\n },\n {\n label: \"Turns\",\n value: fmt(g.total_turns),\n icon: ICONS.chat,\n tooltip: \"Total number of back-and-forth exchanges with Claude across all projects. One turn = you send a message, Claude responds.\",\n },\n {\n label: \"Input\",\n value: fmt(g.total_input_tokens),\n icon: ICONS.arrowDown,\n tooltip: \"New (uncached) tokens sent to Claude across all turns. Usually small — most of the conversation comes from cache.\",\n },\n {\n label: \"Output\",\n value: fmt(g.total_output_tokens),\n icon: ICONS.arrowUp,\n tooltip: \"Tokens Claude generated in responses. The most expensive line item per turn (~5× input rate on Opus).\",\n },\n {\n label: \"Cache read\",\n value: fmt(g.total_cache_read),\n icon: ICONS.refresh,\n tooltip: \"Tokens read from Claude's prompt cache — conversation history, system prompt, Synthra's pack. Cheap: ~10% of the input rate. The bulk of every long session.\",\n },\n {\n label: \"Cache write\",\n value: fmt(g.total_cache_create),\n icon: ICONS.save,\n tooltip: \"Tokens newly added to the prompt cache so future turns can read them cheaply. Premium-priced (~125% of input) but pays back over the rest of the session.\",\n },\n {\n label: \"Projects\",\n value: fmt(g.project_count),\n icon: ICONS.folder,\n tooltip: \"Projects that have ever run `syn .` on this machine. Tracked in ~/.synthra/projects.json.\",\n },\n {\n label: \"Blocked Grep / Glob\",\n value: fmt(g.blocked_count),\n icon: ICONS.shield,\n cls: \"accent\",\n tooltip: \"PreToolUse hook intercepts: Synthra blocked these Grep/Glob calls because the graph already had high-confidence context for the query. Claude pivots to graph_continue or graph_read instead.\",\n },\n {\n label: \"Tokens saved\",\n value: fmt(g.estimated_tokens_saved),\n icon: ICONS.trending,\n cls: \"accent\",\n tooltip: \"Estimated tokens avoided by blocking exploratory Grep/Glob calls. Calculated as blocks × 500 — conservative; under-counts the cache thrash you also avoid.\",\n },\n ];\n }\n\n function renderCards(g) {\n cardsEl.innerHTML = \"\";\n for (const c of cardConfigs(g)) {\n const el = document.createElement(\"div\");\n el.className = \"card\" + (c.cls ? \" \" + c.cls : \"\");\n el.innerHTML =\n '<div class=\"card-head\">' +\n '<div class=\"card-label has-tooltip\" data-tooltip=\"' + c.tooltip.replace(/\"/g, '"') + '\">' +\n c.label +\n ' <span class=\"help-icon\">i</span>' +\n '</div>' +\n '<span class=\"card-icon\">' + c.icon + '</span>' +\n '</div>' +\n '<div class=\"card-value\">' + c.value + '</div>';\n cardsEl.appendChild(el);\n }\n }\n\n function renderProjects(projects, globalCost) {\n projectsEl.innerHTML = \"\";\n if (!projects.length) {\n projectsEmpty.classList.remove(\"hidden\");\n return;\n }\n projectsEmpty.classList.add(\"hidden\");\n const maxTokens = Math.max(1, ...projects.map((p) => p.total_input_tokens + p.total_output_tokens));\n for (const p of projects) {\n const total = p.total_input_tokens + p.total_output_tokens;\n const pct = Math.round((total / maxTokens) * 100);\n const sharePct = globalCost > 0 ? ((p.estimated_cost_usd / globalCost) * 100).toFixed(1) : \"0.0\";\n const row = document.createElement(\"div\");\n row.className = \"project-row\";\n row.innerHTML =\n '<div class=\"project-name\">' +\n '<strong>' + ICONS.folder + p.name + '</strong>' +\n '<code class=\"project-path\">' + p.path + '</code>' +\n '</div>' +\n '<div class=\"project-stats\">' +\n '<div class=\"stat\"><span class=\"stat-value cost\">' + fmtCost(p.estimated_cost_usd) + '</span><span class=\"stat-label\">cost (' + sharePct + '%)</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(total) + '</span><span class=\"stat-label\">tokens</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(p.total_turns) + '</span><span class=\"stat-label\">turns</span></div>' +\n '<div class=\"stat\"><span class=\"stat-value\">' + fmt(p.blocked_count) + '</span><span class=\"stat-label\">blocks</span></div>' +\n '</div>' +\n '<div class=\"bar\"><div class=\"bar-fill\" style=\"width:' + pct + '%\"></div></div>';\n projectsEl.appendChild(row);\n }\n }\n\n function renderTurns(turns) {\n turnsBody.innerHTML = \"\";\n if (!turns.length) {\n turnsEmpty.classList.remove(\"hidden\");\n return;\n }\n turnsEmpty.classList.add(\"hidden\");\n for (const t of turns) {\n const family = modelFamily(t.model);\n const tr = document.createElement(\"tr\");\n tr.innerHTML =\n \"<td>\" + fmtTs(t.ts) + \"</td>\" +\n \"<td><code>\" + t.project_name + \"</code></td>\" +\n '<td><span class=\"model-pill ' + family + '\">' + modelLabel(t.model) + \"</span></td>\" +\n '<td class=\"num\">' + fmtFull(t.input) + \"</td>\" +\n '<td class=\"num\">' + fmtFull(t.output) + \"</td>\" +\n '<td class=\"num\">' + fmt(t.cache_read) + \" / \" + fmt(t.cache_create) + \"</td>\" +\n '<td class=\"num cost\">' + fmtCost(t.cost_usd) + \"</td>\";\n turnsBody.appendChild(tr);\n }\n }\n\n function renderGates(gates) {\n gatesBody.innerHTML = \"\";\n if (!gates.length) {\n gatesEmpty.classList.remove(\"hidden\");\n return;\n }\n gatesEmpty.classList.add(\"hidden\");\n for (const g of gates) {\n const tr = document.createElement(\"tr\");\n const isBlock = g.decision === \"block\";\n const cls = isBlock ? \"decision-block\" : \"decision-allow\";\n const label = isBlock\n ? '<span class=\"' + cls + '\">' + ICONS.ban + ' BLOCK</span>'\n : '<span class=\"' + cls + '\">ALLOW</span>';\n tr.innerHTML =\n \"<td>\" + fmtTs(g.ts) + \"</td>\" +\n \"<td><code>\" + g.project_name + \"</code></td>\" +\n \"<td><code>\" + g.tool + \"</code></td>\" +\n \"<td>\" + label + \"</td>\" +\n \"<td><code>\" + (g.query || \"\") + \"</code></td>\";\n gatesBody.appendChild(tr);\n }\n }\n\n async function tick() {\n try {\n const res = await fetch(\"/data\");\n if (!res.ok) throw new Error(\"HTTP \" + res.status);\n const data = await res.json();\n activeProjectEl.textContent = data.active.project_root;\n renderCards(data.global);\n renderProjects(data.projects, data.global.estimated_cost_usd);\n renderTurns(data.recent_turns);\n renderGates(data.recent_gates);\n statusEl.textContent = \"live · \" + new Date().toLocaleTimeString();\n dotEl.classList.add(\"live\");\n dotEl.classList.remove(\"dead\");\n } catch (e) {\n statusEl.textContent = \"disconnected · \" + e.message;\n dotEl.classList.add(\"dead\");\n dotEl.classList.remove(\"live\");\n }\n }\n\n tick();\n setInterval(tick, 2000);\n </script>\n</body>\n</html>\n","/* Synthra token dashboard — palette per project brief.\n Base: cream-on-near-black, dark-red borders, hot-pink highlights.\n Plus: money green for dollar amounts, per-family model colors,\n subtle dot grid + top glow background, tooltip system, icons. */\n\n:root {\n /* Brand */\n --color-heading: #ECEBD8;\n --color-body: #EDECD9;\n --color-bg: #000000;\n --color-surface: #140009;\n --color-surface-raised: #1E000D;\n --color-border: #4D0020;\n --color-accent: #FF0073;\n --color-accent-darker: #EB006A;\n --color-form-bg: #2E0014;\n --color-muted: rgba(237, 236, 217, 0.55);\n --color-very-muted: rgba(237, 236, 217, 0.35);\n --color-block: #FF0073;\n --color-allow: #ECEBD8;\n\n /* Money green — used everywhere a $ amount appears */\n --color-money: #36E596;\n --color-money-darker: #00B85F;\n --color-money-bg: rgba(54, 229, 150, 0.08);\n\n /* Per-family model colors */\n --color-model-opus: #C9A2FF;\n --color-model-sonnet: #6BD0FF;\n --color-model-haiku: #7BFFC7;\n\n /* Background layering */\n --bg-dot-color: rgba(237, 236, 217, 0.045);\n --bg-glow-color: rgba(255, 0, 115, 0.07);\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n background-color: var(--color-bg);\n color: var(--color-body);\n font-family:\n ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n system-ui, sans-serif;\n font-size: 14px;\n line-height: 1.5;\n min-height: 100vh;\n}\n\n/* Body becomes a flex column so main can grow + header/footer stay sticky\n at the natural top/bottom of the viewport. */\nbody {\n display: flex;\n flex-direction: column;\n min-height: 100vh;\n}\n\nmain {\n flex: 1;\n}\n\n/* Layered backdrop: dot grid + soft pink glow at the top.\n Both are fixed so they don't repeat as you scroll. */\nbody {\n background-image:\n radial-gradient(ellipse 70% 40% at 50% 0%, var(--bg-glow-color), transparent 70%),\n radial-gradient(circle at 1px 1px, var(--bg-dot-color) 1px, transparent 0);\n background-size: 100% 100%, 22px 22px;\n background-attachment: fixed;\n}\n\ncode, .num, table, .project-path {\n font-family: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n}\n\nheader {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n padding: 1.1rem 2rem;\n background: linear-gradient(180deg, rgba(20, 0, 9, 0.7), rgba(20, 0, 9, 0.4));\n backdrop-filter: blur(8px);\n border-bottom: 1px solid var(--color-border);\n position: sticky;\n top: 0;\n z-index: 5;\n}\n\nheader .brand {\n display: flex;\n align-items: baseline;\n gap: 0.75rem;\n}\n\nheader h1 {\n color: var(--color-heading);\n font-size: 1.35rem;\n font-weight: 700;\n letter-spacing: 0.02em;\n}\n\nheader .tag {\n color: var(--color-muted);\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n}\n\nheader .meta {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n font-size: 0.78rem;\n font-family: ui-monospace, monospace;\n color: var(--color-muted);\n}\n\nheader .active-project {\n max-width: 480px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nmain {\n padding: 2rem;\n display: flex;\n flex-direction: column;\n gap: 2rem;\n max-width: 1400px;\n margin: 0 auto;\n width: 100%;\n}\n\nsection {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\nh2 {\n color: var(--color-heading);\n font-size: 0.85rem;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\nh2 svg {\n width: 14px;\n height: 14px;\n opacity: 0.5;\n}\n\nh2 .muted {\n color: var(--color-very-muted);\n font-size: 0.78rem;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: none;\n margin-left: 0.5rem;\n}\n\n/* ============================================================\n Cards row (Global totals)\n ============================================================ */\n\n.cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));\n gap: 0.7rem;\n}\n\n.card {\n position: relative;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 8px;\n padding: 0.95rem 1rem 0.85rem;\n transition: transform 120ms ease, border-color 120ms ease;\n}\n\n.card:hover {\n transform: translateY(-1px);\n border-color: rgba(77, 0, 32, 0.85);\n}\n\n.card.accent {\n background: var(--color-surface-raised);\n border-color: var(--color-accent);\n}\n\n.card.money {\n background: linear-gradient(180deg, var(--color-money-bg), transparent 80%), var(--color-surface);\n border-color: var(--color-money-darker);\n}\n\n.card.money:hover {\n border-color: var(--color-money);\n}\n\n.card-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n margin-bottom: 0.4rem;\n}\n\n.card-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.09em;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.card-icon {\n width: 14px;\n height: 14px;\n color: var(--color-muted);\n opacity: 0.65;\n flex-shrink: 0;\n}\n\n.card.accent .card-icon { color: var(--color-accent); opacity: 0.85; }\n.card.money .card-icon { color: var(--color-money); opacity: 0.85; }\n\n.card-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 1.5rem;\n font-weight: 600;\n letter-spacing: -0.01em;\n}\n\n.card.accent .card-value { color: var(--color-accent); }\n.card.money .card-value { color: var(--color-money); }\n\n/* ============================================================\n Tooltip (data-tooltip on .has-tooltip)\n ============================================================ */\n\n.has-tooltip {\n position: relative;\n cursor: help;\n}\n\n.has-tooltip::before,\n.has-tooltip::after {\n position: absolute;\n pointer-events: none;\n opacity: 0;\n transition: opacity 150ms ease, transform 150ms ease;\n z-index: 100;\n}\n\n.has-tooltip::after {\n content: attr(data-tooltip);\n bottom: calc(100% + 10px);\n left: 50%;\n transform: translate(-50%, 4px);\n background: var(--color-surface-raised);\n color: var(--color-body);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.6rem 0.75rem;\n font-size: 0.74rem;\n font-weight: 400;\n text-transform: none;\n letter-spacing: 0;\n white-space: normal;\n width: 260px;\n text-align: left;\n line-height: 1.45;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);\n}\n\n.has-tooltip::before {\n content: \"\";\n bottom: calc(100% + 4px);\n left: 50%;\n transform: translate(-50%, 4px);\n border: 6px solid transparent;\n border-top-color: var(--color-border);\n}\n\n.has-tooltip:hover::after,\n.has-tooltip:hover::before {\n opacity: 1;\n transform: translate(-50%, 0);\n}\n\n/* Small ⓘ icon used to indicate tooltips */\n.help-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 13px;\n height: 13px;\n border-radius: 50%;\n border: 1px solid var(--color-muted);\n color: var(--color-muted);\n font-size: 9px;\n font-weight: 600;\n font-family: ui-sans-serif, sans-serif;\n line-height: 1;\n cursor: help;\n user-select: none;\n transition: color 120ms, border-color 120ms;\n}\n\n.has-tooltip:hover .help-icon {\n border-color: var(--color-accent);\n color: var(--color-accent);\n}\n\n.card.money .has-tooltip:hover .help-icon { border-color: var(--color-money); color: var(--color-money); }\n\n/* ============================================================\n Projects list\n ============================================================ */\n\n.projects {\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.project-row {\n display: grid;\n grid-template-columns: minmax(220px, 1fr) auto;\n grid-template-rows: auto auto;\n gap: 0.6rem 1.25rem;\n align-items: center;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 8px;\n padding: 0.9rem 1.2rem;\n transition: background 120ms ease;\n}\n\n.project-row:hover {\n background: var(--color-surface-raised);\n}\n\n.project-name {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n overflow: hidden;\n}\n\n.project-name strong {\n color: var(--color-heading);\n font-size: 0.95rem;\n font-weight: 600;\n display: flex;\n align-items: center;\n gap: 0.4rem;\n}\n\n.project-name strong svg {\n width: 13px;\n height: 13px;\n opacity: 0.65;\n color: var(--color-muted);\n flex-shrink: 0;\n}\n\n.project-name .project-path {\n color: var(--color-very-muted);\n font-size: 0.72rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.project-stats {\n display: flex;\n gap: 1.6rem;\n justify-self: end;\n text-align: right;\n}\n\n.stat {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n min-width: 70px;\n}\n\n.stat-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.stat-value.cost {\n color: var(--color-money);\n}\n\n.stat-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.bar {\n grid-column: 1 / -1;\n height: 3px;\n background: var(--color-form-bg);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.bar-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--color-accent-darker), var(--color-accent));\n border-radius: 2px;\n transition: width 400ms ease;\n}\n\n/* ============================================================\n Tables\n ============================================================ */\n\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.83rem;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 8px;\n overflow: hidden;\n}\n\ntable thead th {\n text-align: left;\n color: var(--color-muted);\n text-transform: uppercase;\n font-size: 0.66rem;\n letter-spacing: 0.08em;\n font-weight: 600;\n padding: 0.65rem 0.85rem;\n border-bottom: 1px solid var(--color-border);\n background: var(--color-surface);\n}\n\ntable thead th.num {\n text-align: right;\n}\n\ntable thead th .has-tooltip {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n}\n\ntable tbody td {\n padding: 0.55rem 0.85rem;\n border-bottom: 1px solid rgba(77, 0, 32, 0.4);\n color: var(--color-body);\n}\n\ntable tbody td.num {\n text-align: right;\n}\n\ntable tbody td.cost {\n color: var(--color-money);\n font-weight: 600;\n}\n\ntable tbody tr:last-child td {\n border-bottom: none;\n}\n\ntable tbody tr:hover {\n background: var(--color-surface-raised);\n}\n\n/* ============================================================\n Model pills — color-coded by family\n ============================================================ */\n\n.model-pill {\n display: inline-block;\n padding: 0.1rem 0.5rem;\n border-radius: 3px;\n background: var(--color-form-bg);\n font-family: ui-monospace, monospace;\n font-size: 0.82em;\n border: 1px solid transparent;\n color: var(--color-body);\n white-space: nowrap;\n}\n\n.model-pill.opus {\n color: var(--color-model-opus);\n border-color: rgba(201, 162, 255, 0.3);\n background: rgba(201, 162, 255, 0.08);\n}\n\n.model-pill.sonnet {\n color: var(--color-model-sonnet);\n border-color: rgba(107, 208, 255, 0.3);\n background: rgba(107, 208, 255, 0.08);\n}\n\n.model-pill.haiku {\n color: var(--color-model-haiku);\n border-color: rgba(123, 255, 199, 0.3);\n background: rgba(123, 255, 199, 0.08);\n}\n\n.model-pill.unknown {\n color: var(--color-muted);\n font-style: italic;\n border-color: rgba(237, 236, 217, 0.15);\n}\n\n/* Existing inline code in tables (project name, etc.) */\ncode {\n color: var(--color-heading);\n background: var(--color-form-bg);\n padding: 0.1rem 0.4rem;\n border-radius: 3px;\n font-size: 0.85em;\n}\n\n/* ============================================================\n Decisions (allow / block)\n ============================================================ */\n\n.decision-block {\n color: var(--color-block);\n font-weight: 700;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n}\n\n.decision-block svg {\n width: 11px;\n height: 11px;\n}\n\n.decision-allow {\n color: var(--color-allow);\n opacity: 0.7;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.empty {\n color: var(--color-muted);\n font-style: italic;\n padding: 1rem;\n background: var(--color-surface);\n border: 1px dashed var(--color-border);\n border-radius: 8px;\n}\n\n.hidden {\n display: none;\n}\n\nfooter {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 0.85rem 2rem;\n background: linear-gradient(0deg, rgba(20, 0, 9, 0.7), rgba(20, 0, 9, 0.4));\n backdrop-filter: blur(8px);\n border-top: 1px solid var(--color-border);\n color: var(--color-muted);\n font-size: 0.72rem;\n font-family: ui-monospace, monospace;\n position: sticky;\n bottom: 0;\n z-index: 5;\n}\n\nfooter .muted {\n color: var(--color-very-muted);\n}\n\n.dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--color-muted);\n transition: background 200ms ease, box-shadow 200ms ease;\n}\n\n.dot.live {\n background: var(--color-money);\n box-shadow: 0 0 8px var(--color-money-darker);\n}\n\n.dot.dead {\n background: var(--color-border);\n box-shadow: none;\n}\n"],"mappings":"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AA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ARkBA,IAAM,iBAAiB;AAQvB,eAAsB,eACpB,OACA,gBAAgB,MACgB;AAChC,QAAM,OAAO,MAAM,aAAa,eAAe,gBAAgB,cAAc;AAC7E,MAAI,SAAS,eAAe;AAC1B,QAAI;AAAA,MACF,kBAAkB,aAAa,6BAAwB,IAAI;AAAA,IAC7D;AAAA,EACF;AACA,QAAM,MAAM,IAAI,KAAK;AAErB,MAAI,IAAI,KAAK,CAAC,MAAM,EAAE,KAAK,cAAS,CAAC;AAErC,MAAI,IAAI,cAAc,CAAC,MAAM;AAC3B,MAAE,OAAO,gBAAgB,yBAAyB;AAClD,MAAE,OAAO,iBAAiB,UAAU;AACpC,WAAO,EAAE,KAAK,aAAQ;AAAA,EACxB,CAAC;AAED,MAAI,IAAI,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,KAAK,CAAC,CAAC;AAE9C,MAAI,IAAI,SAAS,OAAO,MAAM;AAC5B,UAAM,OAAO,MAAM,qBAAqB,KAAK;AAC7C,WAAO,EAAE,KAAK,IAAI;AAAA,EACpB,CAAC;AAED,QAAM,aAAa,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,YAAY,CAAC;AAE1E,SAAO;AAAA,IACL;AAAA,IACA,KAAK,oBAAoB,IAAI;AAAA,IAC7B,MAAM,OAAO;AACX,YAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,mBAAW,MAAM,CAAC,QAAS,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAE;AAAA,MAC3D,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":["readFile","join","readFile","basename"]}
|