milens 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.agents/skills/adapters/SKILL.md +59 -54
  2. package/.agents/skills/analyzer/SKILL.md +83 -78
  3. package/.agents/skills/apps/SKILL.md +65 -60
  4. package/.agents/skills/docs/SKILL.md +63 -58
  5. package/.agents/skills/milens/SKILL.md +322 -319
  6. package/.agents/skills/milens-security-review/SKILL.md +224 -224
  7. package/.agents/skills/orchestrator/SKILL.md +64 -59
  8. package/.agents/skills/parser/SKILL.md +86 -81
  9. package/.agents/skills/root/SKILL.md +89 -84
  10. package/.agents/skills/scripts/SKILL.md +56 -51
  11. package/.agents/skills/security/SKILL.md +67 -62
  12. package/.agents/skills/server/SKILL.md +84 -77
  13. package/.agents/skills/store/SKILL.md +77 -72
  14. package/.agents/skills/test/SKILL.md +155 -148
  15. package/.agents/skills/ui/SKILL.md +56 -51
  16. package/README.md +577 -577
  17. package/adapters/README.md +144 -144
  18. package/adapters/claude-code/.claude/mcp.json +9 -9
  19. package/adapters/claude-code/.claude/settings.json.hooks-snippet.json +26 -26
  20. package/adapters/claude-code/.claude-plugin/plugin.json +11 -11
  21. package/adapters/claude-code/.mcp.json +9 -9
  22. package/adapters/claude-code/CLAUDE.md +81 -79
  23. package/adapters/claude-code/hooks/hooks.json +27 -0
  24. package/adapters/codex/.codex/config.toml +3 -3
  25. package/adapters/copilot/.vscode/mcp.json +10 -10
  26. package/adapters/cursor/.cursor/mcp.json +9 -9
  27. package/adapters/cursor/.cursorrules +69 -69
  28. package/adapters/gemini/.gemini/context.md +81 -81
  29. package/adapters/gemini/.gemini/settings.json +9 -9
  30. package/adapters/opencode/.opencode/config.json +8 -8
  31. package/adapters/zed/.zed/settings.json +8 -8
  32. package/dist/agents-md.d.ts +1 -0
  33. package/dist/agents-md.js +45 -1
  34. package/dist/agents-md.js.map +1 -1
  35. package/dist/analyzer/config.js +32 -0
  36. package/dist/analyzer/config.js.map +1 -1
  37. package/dist/analyzer/engine.js +26 -2
  38. package/dist/analyzer/engine.js.map +1 -1
  39. package/dist/analyzer/enrich.js +68 -1
  40. package/dist/analyzer/enrich.js.map +1 -1
  41. package/dist/analyzer/resolver.d.ts +4 -1
  42. package/dist/analyzer/resolver.js +241 -38
  43. package/dist/analyzer/resolver.js.map +1 -1
  44. package/dist/analyzer/review.js +32 -2
  45. package/dist/analyzer/review.js.map +1 -1
  46. package/dist/analyzer/scanner.js +3 -0
  47. package/dist/analyzer/scanner.js.map +1 -1
  48. package/dist/analyzer/scope-resolver.js +1 -1
  49. package/dist/analyzer/scope-resolver.js.map +1 -1
  50. package/dist/build-info.d.ts +2 -2
  51. package/dist/build-info.js +2 -2
  52. package/dist/cli.js +566 -497
  53. package/dist/cli.js.map +1 -1
  54. package/dist/orchestrator/orchestrator.d.ts +1 -1
  55. package/dist/orchestrator/orchestrator.js +14 -6
  56. package/dist/orchestrator/orchestrator.js.map +1 -1
  57. package/dist/parser/extract.d.ts +7 -0
  58. package/dist/parser/extract.js +23 -1
  59. package/dist/parser/extract.js.map +1 -1
  60. package/dist/parser/lang-css.js +18 -6
  61. package/dist/parser/lang-css.js.map +1 -1
  62. package/dist/parser/lang-go.js +58 -58
  63. package/dist/parser/lang-html.js +3 -3
  64. package/dist/parser/lang-java.js +37 -37
  65. package/dist/parser/lang-js.js +120 -109
  66. package/dist/parser/lang-js.js.map +1 -1
  67. package/dist/parser/lang-md.js +1 -0
  68. package/dist/parser/lang-md.js.map +1 -1
  69. package/dist/parser/lang-php.js +45 -45
  70. package/dist/parser/lang-py.js +43 -43
  71. package/dist/parser/lang-ruby.js +28 -28
  72. package/dist/parser/lang-rust.js +48 -48
  73. package/dist/parser/lang-ts.js +230 -196
  74. package/dist/parser/lang-ts.js.map +1 -1
  75. package/dist/parser/lang-vue.js +0 -21
  76. package/dist/parser/lang-vue.js.map +1 -1
  77. package/dist/security/rules.js +22 -23
  78. package/dist/security/rules.js.map +1 -1
  79. package/dist/server/guard-hook.d.ts +10 -0
  80. package/dist/server/guard-hook.js +179 -0
  81. package/dist/server/guard-hook.js.map +1 -0
  82. package/dist/server/hooks.js +2 -1
  83. package/dist/server/hooks.js.map +1 -1
  84. package/dist/server/mcp-prompts.js +502 -502
  85. package/dist/server/mcp.js +228 -86
  86. package/dist/server/mcp.js.map +1 -1
  87. package/dist/server/test-plan.d.ts +1 -0
  88. package/dist/server/test-plan.js +25 -7
  89. package/dist/server/test-plan.js.map +1 -1
  90. package/dist/server/tools/findings-report.d.ts +3 -0
  91. package/dist/server/tools/findings-report.js +170 -0
  92. package/dist/server/tools/findings-report.js.map +1 -0
  93. package/dist/server/tools/security.js +61 -5
  94. package/dist/server/tools/security.js.map +1 -1
  95. package/dist/server/tools/session.js +37 -17
  96. package/dist/server/tools/session.js.map +1 -1
  97. package/dist/server/tools/testing.js +58 -11
  98. package/dist/server/tools/testing.js.map +1 -1
  99. package/dist/skills.js +307 -301
  100. package/dist/skills.js.map +1 -1
  101. package/dist/store/annotations.d.ts +5 -0
  102. package/dist/store/annotations.js +45 -9
  103. package/dist/store/annotations.js.map +1 -1
  104. package/dist/store/db.d.ts +13 -0
  105. package/dist/store/db.js +318 -205
  106. package/dist/store/db.js.map +1 -1
  107. package/dist/store/registry.d.ts +1 -0
  108. package/dist/store/registry.js +17 -6
  109. package/dist/store/registry.js.map +1 -1
  110. package/dist/store/schema.sql +144 -143
  111. package/dist/store/vectors.js +2 -2
  112. package/dist/types.d.ts +16 -1
  113. package/dist/uninstall.d.ts +1 -0
  114. package/dist/uninstall.js +33 -10
  115. package/dist/uninstall.js.map +1 -1
  116. package/dist/utils.d.ts +1 -1
  117. package/dist/utils.js +0 -0
  118. package/dist/utils.js.map +1 -1
  119. package/package.json +85 -85
package/dist/cli.js CHANGED
@@ -235,6 +235,7 @@ program
235
235
  .description('Show index status')
236
236
  .option('-p, --path <path>', 'Repository root path', '.')
237
237
  .action(async (opts) => {
238
+ const { existsSync } = await import('node:fs');
238
239
  const { Database } = await import('./store/db.js');
239
240
  const { RepoRegistry } = await import('./store/registry.js');
240
241
  const reg = new RepoRegistry();
@@ -243,6 +244,10 @@ program
243
244
  console.log('Not indexed.');
244
245
  return;
245
246
  }
247
+ if (!existsSync(entry.dbPath)) {
248
+ console.log(`Not indexed. Run \`milens analyze\` first.\n(Registry entry stale — directory may have been deleted: ${entry.rootPath})`);
249
+ return;
250
+ }
246
251
  const db = new Database(entry.dbPath);
247
252
  const stats = db.getStats();
248
253
  console.log(`Repository: ${entry.rootPath}`);
@@ -1279,10 +1284,11 @@ program
1279
1284
  console.log(' (run `milens analyze -p . --force` manually if this fails)');
1280
1285
  }
1281
1286
  console.log('Step 2/4: Generating AGENTS.md...');
1287
+ let db = null;
1282
1288
  try {
1283
1289
  const dbPath = new RepoRegistry().findDbPath(root);
1284
1290
  if (dbPath) {
1285
- const db = new Database(dbPath);
1291
+ db = new Database(dbPath);
1286
1292
  const { generateAgentsMd } = await import('./agents-md.js');
1287
1293
  const agentsMd = generateAgentsMd(db, root);
1288
1294
  const { writeFileSync, mkdirSync, existsSync } = await import('node:fs');
@@ -1290,7 +1296,6 @@ program
1290
1296
  mkdirSync(root, { recursive: true });
1291
1297
  writeFileSync(resolve(root, 'AGENTS.md'), agentsMd);
1292
1298
  console.log(' ✓ AGENTS.md created');
1293
- db.close();
1294
1299
  }
1295
1300
  }
1296
1301
  catch (e) {
@@ -1299,56 +1304,69 @@ program
1299
1304
  if (opts.profile !== 'minimal') {
1300
1305
  console.log('Step 3/4: Installing skill files...');
1301
1306
  try {
1302
- const { cpSync, existsSync, mkdirSync } = await import('node:fs');
1303
- const { join: pathJoin } = await import('node:path');
1304
- const { fileURLToPath } = await import('node:url');
1305
- const skillSrc = pathJoin(resolve(fileURLToPath(import.meta.url), '..', '..'), '.agents', 'skills');
1306
- const skillDst = pathJoin(root, '.agents', 'skills');
1307
- if (existsSync(skillSrc)) {
1308
- if (!existsSync(skillDst))
1309
- mkdirSync(skillDst, { recursive: true });
1310
- cpSync(skillSrc, skillDst, { recursive: true });
1311
- console.log(' ✓ Skill files installed');
1307
+ if (db) {
1308
+ const { generateSkills } = await import('./skills.js');
1309
+ const result = generateSkills(db, root, ['agents']);
1310
+ console.log(` ✓ Generated ${result.count} skill files`);
1311
+ for (const d of result.dirs)
1312
+ console.log(` ${d}`);
1312
1313
  }
1313
1314
  else {
1314
- console.log(' ⚠ Skill template not found (run from milens repo)');
1315
+ console.log(' ⚠ Skills skipped: no database available (run analyze first)');
1315
1316
  }
1316
1317
  }
1317
1318
  catch (e) {
1318
1319
  console.log(` ⚠ Skill install skipped: ${e.message}`);
1319
1320
  }
1320
1321
  }
1322
+ if (db)
1323
+ db.close();
1321
1324
  if (opts.profile === 'full' || (opts.with && opts.with.includes('hooks'))) {
1322
1325
  console.log('Step 4/4: Installing git hooks...');
1323
1326
  try {
1324
- const { writeFileSync, existsSync, mkdirSync, chmodSync } = await import('node:fs');
1327
+ const { writeFileSync, existsSync, mkdirSync, chmodSync, readFileSync } = await import('node:fs');
1325
1328
  const hooksDir = resolve(root, '.git', 'hooks');
1326
1329
  if (!existsSync(hooksDir)) {
1327
1330
  mkdirSync(hooksDir, { recursive: true });
1328
1331
  }
1329
- const preCommitContent = `#!/bin/bash
1330
- # Auto-installed by milens init
1331
- echo "Milens: Pre-commit check..."
1332
-
1333
- OUTPUT=$(npx milens workflow review --path . 2>&1)
1334
- echo "$OUTPUT"
1335
-
1336
- # Block commit if CRITICAL risk detected
1337
- if echo "$OUTPUT" | grep -q "CRITICAL"; then
1338
- echo ""
1339
- echo "❌ COMMIT BLOCKED: Critical risk detected."
1340
- echo " Run 'milens workflow review' to see details."
1341
- echo " To override: git commit --no-verify"
1342
- exit 1
1343
- fi
1344
-
1345
- # Warn if HIGH risk but don't block
1346
- if echo "$OUTPUT" | grep -q "HIGH"; then
1347
- echo ""
1348
- echo "⚠️ WARNING: High risk changes detected. Consider adding tests."
1349
- fi
1350
-
1351
- echo "Milens: Done."
1332
+ const hookPath = resolve(hooksDir, 'pre-commit');
1333
+ // Check for existing non-milens hook (e.g. Husky) before overwriting
1334
+ if (existsSync(hookPath)) {
1335
+ const existing = readFileSync(hookPath, 'utf-8');
1336
+ if (!existing.includes('milens') && !existing.includes('Auto-installed by milens')) {
1337
+ const isHusky = existing.includes('husky') || existing.includes('.husky');
1338
+ if (isHusky) {
1339
+ console.log(' ⚠ Skipped pre-commit hook Husky hook already present (.husky/). Install manually with "milens hooks install".');
1340
+ }
1341
+ else {
1342
+ console.log(' Skipped pre-commit hook existing hook found. Install manually with "milens hooks install".');
1343
+ }
1344
+ return;
1345
+ }
1346
+ }
1347
+ const preCommitContent = `#!/bin/bash
1348
+ # Auto-installed by milens init
1349
+ echo "Milens: Pre-commit check..."
1350
+
1351
+ OUTPUT=$(npx milens workflow review --path . 2>&1)
1352
+ echo "$OUTPUT"
1353
+
1354
+ # Block commit if CRITICAL risk detected
1355
+ if echo "$OUTPUT" | grep -q "CRITICAL"; then
1356
+ echo ""
1357
+ echo "❌ COMMIT BLOCKED: Critical risk detected."
1358
+ echo " Run 'milens workflow review' to see details."
1359
+ echo " To override: git commit --no-verify"
1360
+ exit 1
1361
+ fi
1362
+
1363
+ # Warn if HIGH risk but don't block
1364
+ if echo "$OUTPUT" | grep -q "HIGH"; then
1365
+ echo ""
1366
+ echo "⚠️ WARNING: High risk changes detected. Consider adding tests."
1367
+ fi
1368
+
1369
+ echo "Milens: Done."
1352
1370
  `;
1353
1371
  writeFileSync(resolve(hooksDir, 'pre-commit'), preCommitContent);
1354
1372
  try {
@@ -1391,6 +1409,22 @@ echo "Milens: Done."
1391
1409
  }
1392
1410
  }
1393
1411
  }
1412
+ // Ensure .milens/ is gitignored
1413
+ try {
1414
+ const { existsSync, readFileSync, appendFileSync, writeFileSync } = await import('node:fs');
1415
+ const gitignorePath = resolve(root, '.gitignore');
1416
+ const entry = '\n# milens code intelligence index (local, machine-specific)\n.milens/\n';
1417
+ if (existsSync(gitignorePath)) {
1418
+ const content = readFileSync(gitignorePath, 'utf-8');
1419
+ if (!content.includes('.milens')) {
1420
+ appendFileSync(gitignorePath, entry);
1421
+ }
1422
+ }
1423
+ else {
1424
+ writeFileSync(gitignorePath, entry.trimStart());
1425
+ }
1426
+ }
1427
+ catch { /* best-effort */ }
1394
1428
  console.log(`\n✓ Milens ${opts.profile} profile bootstrapped for ${root}`);
1395
1429
  console.log('Next steps:');
1396
1430
  console.log(' 1. Open project in your AI coding agent (Claude Code, OpenCode, etc.)');
@@ -1404,6 +1438,7 @@ program
1404
1438
  .option('--repo <path>', 'Repository root path (alias for --path)')
1405
1439
  .option('--agent <name>', 'Agent name (for session-start/session-end)')
1406
1440
  .option('--hook <hook>', 'Hook name (sessionStart, sessionEnd, preCommit, fileChange, preCompact, postCompact)')
1441
+ .option('--mode <mode>', 'Enforcement mode (warn | strict) for guard-set-mode')
1407
1442
  .action(async (action, opts) => {
1408
1443
  const { HookManager, defaultOnSessionStart, defaultOnSessionEnd, defaultOnPreCompact, defaultOnPostCompact } = await import('./server/hooks.js');
1409
1444
  const manager = new HookManager();
@@ -1536,8 +1571,42 @@ program
1536
1571
  console.log(`Hook profile set to "${profileName}" for ${projectPath}`);
1537
1572
  break;
1538
1573
  }
1574
+ case 'guard-mark-checked': {
1575
+ const chunks = [];
1576
+ for await (const chunk of process.stdin) {
1577
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
1578
+ }
1579
+ const rawStdin = Buffer.concat(chunks).toString();
1580
+ const { handleMarkChecked } = await import('./server/guard-hook.js');
1581
+ handleMarkChecked(rawStdin, projectPath);
1582
+ break;
1583
+ }
1584
+ case 'guard-check-edit': {
1585
+ const chunks = [];
1586
+ for await (const chunk of process.stdin) {
1587
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
1588
+ }
1589
+ const rawStdin = Buffer.concat(chunks).toString();
1590
+ const { handleCheckEdit } = await import('./server/guard-hook.js');
1591
+ const result = handleCheckEdit(rawStdin, projectPath);
1592
+ process.stdout.write(result.stdout + '\n');
1593
+ if (result.exitCode !== 0)
1594
+ process.exit(result.exitCode);
1595
+ break;
1596
+ }
1597
+ case 'guard-set-mode': {
1598
+ const mode = opts.mode;
1599
+ if (mode !== 'warn' && mode !== 'strict') {
1600
+ console.error(`Invalid mode "${mode}". Use "warn" or "strict".`);
1601
+ process.exit(1);
1602
+ }
1603
+ const { writeMode } = await import('./server/guard-hook.js');
1604
+ writeMode(projectPath, mode);
1605
+ console.log(`Guard enforcement mode set to "${mode}" for ${projectPath}`);
1606
+ break;
1607
+ }
1539
1608
  default:
1540
- console.log(`Unknown action: ${action}. Use: enable, disable, list, profile, session-start, session-end, pre-compact, post-compact`);
1609
+ console.log(`Unknown action: ${action}. Use: enable, disable, list, profile, session-start, session-end, pre-compact, post-compact, guard-mark-checked, guard-check-edit, guard-set-mode`);
1541
1610
  }
1542
1611
  });
1543
1612
  const securityCmd = program
@@ -1760,467 +1829,467 @@ function generateDashboardHtml(stats, annotationStats, repoFilter) {
1760
1829
  : 0;
1761
1830
  const fmtNum = (n) => n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' : n >= 1_000 ? (n / 1_000).toFixed(1) + 'K' : String(n);
1762
1831
  const confBars = annotationStats ? renderConfBars(annotationStats.confidenceBands, annotationStats.total) : '';
1763
- const recentAnnots = annotationStats ? annotationStats.recent.map(a => `
1764
- <li>
1765
- <div><span class="annot-sym">${a.symbol}</span><span class="annot-key">${a.key}</span></div>
1766
- <span class="annot-conf ${a.confidence >= 0.7 ? 'hi' : a.confidence >= 0.4 ? 'md' : 'lo'}">${(a.confidence * 100).toFixed(0)}%</span>
1832
+ const recentAnnots = annotationStats ? annotationStats.recent.map(a => `
1833
+ <li>
1834
+ <div><span class="annot-sym">${a.symbol}</span><span class="annot-key">${a.key}</span></div>
1835
+ <span class="annot-conf ${a.confidence >= 0.7 ? 'hi' : a.confidence >= 0.4 ? 'md' : 'lo'}">${(a.confidence * 100).toFixed(0)}%</span>
1767
1836
  </li>`).join('') : '';
1768
- const learningStats = annotationStats ? `
1769
- <div class="card learning-section">
1770
- <div class="card-header">
1771
- <div><div class="card-title">Confidence Distribution</div><div class="card-subtitle">${annotationStats.total} total annotations</div></div>
1772
- </div>
1773
- <div class="tool-bars">${confBars}</div>
1774
- </div>
1775
- <div class="card learning-section">
1776
- <div class="card-header">
1777
- <div><div class="card-title">Recent Annotations</div><div class="card-subtitle">Last ${annotationStats.recent.length}</div></div>
1778
- </div>
1779
- <ul class="annot-list">${recentAnnots}</ul>
1780
- </div>
1837
+ const learningStats = annotationStats ? `
1838
+ <div class="card learning-section">
1839
+ <div class="card-header">
1840
+ <div><div class="card-title">Confidence Distribution</div><div class="card-subtitle">${annotationStats.total} total annotations</div></div>
1841
+ </div>
1842
+ <div class="tool-bars">${confBars}</div>
1843
+ </div>
1844
+ <div class="card learning-section">
1845
+ <div class="card-header">
1846
+ <div><div class="card-title">Recent Annotations</div><div class="card-subtitle">Last ${annotationStats.recent.length}</div></div>
1847
+ </div>
1848
+ <ul class="annot-list">${recentAnnots}</ul>
1849
+ </div>
1781
1850
  ` : '';
1782
- return `<!DOCTYPE html>
1783
- <html lang="en">
1784
- <head>
1785
- <meta charset="UTF-8">
1786
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1787
- <title>milens Dashboard</title>
1788
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
1789
- <style>
1790
- :root {
1791
- --bg: #0a0e14; --surface: #12171e; --card: #161d27; --card-hover: #1a2332;
1792
- --border: #1e2a3a; --border-light: #2a3a4e;
1793
- --text: #e2e8f0; --text-secondary: #94a3b8; --text-muted: #64748b;
1794
- --accent: #60a5fa; --accent-dim: #60a5fa22;
1795
- --green: #34d399; --green-dim: #34d39915;
1796
- --orange: #fbbf24; --orange-dim: #fbbf2415;
1797
- --purple: #a78bfa; --purple-dim: #a78bfa15;
1798
- --red: #f87171;
1799
- --radius: 16px; --radius-sm: 10px;
1800
- }
1801
- * { margin: 0; padding: 0; box-sizing: border-box; }
1802
- body {
1803
- background: var(--bg); color: var(--text);
1804
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1805
- line-height: 1.5; min-height: 100vh;
1806
- }
1807
-
1808
- /* ── Layout ── */
1809
- .wrapper { max-width: 1400px; margin: 0 auto; padding: 32px 24px 80px; }
1810
-
1811
- /* ── Header ── */
1812
- .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; }
1813
- .header-left { display: flex; align-items: center; gap: 14px; }
1814
- .logo { width: 40px; height: 40px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; color: #fff; }
1815
- .header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
1816
- .header h1 span { color: var(--text-muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
1817
- .header-right { display: flex; align-items: center; gap: 12px; }
1818
- .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
1819
- @keyframes pulse { 0%,80%,100% { opacity: 1; } 40% { opacity: 0.4; } }
1820
- .status-text { font-size: 12px; color: var(--text-muted); }
1821
- .refresh-btn {
1822
- background: var(--accent-dim); color: var(--accent); border: 1px solid var(--border);
1823
- border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
1824
- font-weight: 500; font-size: 13px; transition: all 0.2s;
1825
- }
1826
- .refresh-btn:hover { background: var(--accent); color: #0a0e14; }
1827
-
1828
- /* ── KPI Cards ── */
1829
- .kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
1830
- .kpi {
1831
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1832
- padding: 24px; position: relative; overflow: hidden; transition: border-color 0.2s;
1833
- }
1834
- .kpi:hover { border-color: var(--border-light); }
1835
- .kpi-icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; margin-bottom: 16px; }
1836
- .kpi-icon.blue { background: var(--accent-dim); }
1837
- .kpi-icon.green { background: var(--green-dim); }
1838
- .kpi-icon.purple { background: var(--purple-dim); }
1839
- .kpi-icon.orange { background: var(--orange-dim); }
1840
- .kpi .value { font-size: 32px; font-weight: 800; letter-spacing: -1px; line-height: 1; }
1841
- .kpi .value.blue { color: var(--accent); }
1842
- .kpi .value.green { color: var(--green); }
1843
- .kpi .value.purple { color: var(--purple); }
1844
- .kpi .value.orange { color: var(--orange); }
1845
- .kpi .label { color: var(--text-muted); font-size: 13px; margin-top: 6px; font-weight: 500; }
1846
- .kpi .sub { color: var(--text-secondary); font-size: 12px; margin-top: 4px; }
1847
- .kpi-glow {
1848
- position: absolute; top: -40px; right: -40px; width: 120px; height: 120px;
1849
- border-radius: 50%; opacity: 0.06; pointer-events: none;
1850
- }
1851
- .kpi-glow.blue { background: var(--accent); }
1852
- .kpi-glow.green { background: var(--green); }
1853
- .kpi-glow.purple { background: var(--purple); }
1854
- .kpi-glow.orange { background: var(--orange); }
1855
-
1856
- /* ── Charts ── */
1857
- .grid-2 { display: grid; grid-template-columns: 5fr 7fr; gap: 16px; margin-bottom: 16px; }
1858
- .grid-full { margin-bottom: 16px; }
1859
- .card {
1860
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1861
- padding: 24px; transition: border-color 0.2s;
1862
- }
1863
- .card:hover { border-color: var(--border-light); }
1864
- .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
1865
- .card-title { font-size: 14px; font-weight: 600; color: var(--text); }
1866
- .card-subtitle { font-size: 12px; color: var(--text-muted); }
1867
-
1868
- /* ── Chart containers ── */
1869
- .chart-container { position: relative; width: 100%; }
1870
- .chart-container.h-280 { height: 280px; }
1871
- .chart-container.h-300 { height: 300px; }
1872
-
1873
- /* ── Top Tools Bar ── */
1874
- .tool-bars { display: flex; flex-direction: column; gap: 10px; }
1875
- .tool-bar-row { display: flex; align-items: center; gap: 12px; }
1876
- .tool-bar-name { width: 140px; font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace; color: var(--text-secondary); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1877
- .tool-bar-track { flex: 1; height: 28px; background: var(--surface); border-radius: 6px; overflow: hidden; position: relative; }
1878
- .tool-bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; padding: 0 10px; font-size: 11px; font-weight: 600; color: #fff; min-width: fit-content; transition: width 0.6s ease; }
1879
- .tool-bar-count { font-size: 12px; color: var(--text-muted); min-width: 36px; text-align: right; }
1880
-
1881
- /* ── Recent Table ── */
1882
- .table-wrapper { max-height: 400px; overflow-y: auto; border-radius: var(--radius-sm); }
1883
- .table-wrapper::-webkit-scrollbar { width: 6px; }
1884
- .table-wrapper::-webkit-scrollbar-track { background: transparent; }
1885
- .table-wrapper::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
1886
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
1887
- thead { position: sticky; top: 0; z-index: 1; }
1888
- th {
1889
- text-align: left; color: var(--text-muted); font-weight: 500; padding: 10px 16px;
1890
- background: var(--card); border-bottom: 1px solid var(--border); font-size: 11px;
1891
- text-transform: uppercase; letter-spacing: 0.5px;
1892
- }
1893
- td { padding: 10px 16px; border-bottom: 1px solid var(--border); }
1894
- tr:hover td { background: var(--surface); }
1895
- .tool-badge {
1896
- display: inline-flex; align-items: center; gap: 4px;
1897
- background: var(--accent-dim); color: var(--accent); padding: 3px 10px;
1898
- border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 500;
1899
- }
1900
- .when-text { color: var(--text-muted); }
1901
- .duration-text { color: var(--text-secondary); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
1902
- .saved-text { color: var(--green); font-weight: 600; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
1903
-
1904
- /* ── Footer ── */
1905
- .footer { text-align: center; padding: 24px 0 0; color: var(--text-muted); font-size: 12px; }
1906
-
1907
- /* ── Tabs ── */
1908
- .tab-nav { display: flex; gap: 4px; margin-bottom: 28px; border-bottom: 2px solid var(--border); padding-bottom: 0; }
1909
- .tab { padding: 10px 24px; cursor: pointer; font-size: 14px; font-weight: 500; color: var(--text-muted); border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; background: none; border-top: none; border-left: none; border-right: none; outline: none; }
1910
- .tab:hover { color: var(--text-secondary); }
1911
- .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
1912
- .tab-content { display: none; }
1913
- .tab-content.active { display: block; }
1914
-
1915
- /* ── Learning ── */
1916
- .suggestion-box { background: var(--accent-dim); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 20px; text-align: center; margin-bottom: 20px; }
1917
- .suggestion-box code { background: var(--surface); padding: 2px 8px; border-radius: 4px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; color: var(--accent); }
1918
- .learning-section { margin-bottom: 16px; }
1919
- .annot-list { list-style: none; padding: 0; }
1920
- .annot-list li { padding: 10px 0; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
1921
- .annot-list li:last-child { border-bottom: none; }
1922
- .annot-sym { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; color: var(--accent); }
1923
- .annot-key { font-size: 11px; color: var(--text-muted); background: var(--surface); padding: 2px 8px; border-radius: 4px; margin-left: 8px; }
1924
- .annot-conf { font-size: 12px; font-weight: 600; }
1925
- .annot-conf.hi { color: var(--green); }
1926
- .annot-conf.md { color: var(--orange); }
1927
- .annot-conf.lo { color: var(--red); }
1928
-
1929
- /* ── Responsive ── */
1930
- @media (max-width: 1024px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .grid-2 { grid-template-columns: 1fr; } }
1931
- @media (max-width: 640px) { .kpi-grid { grid-template-columns: 1fr; } .wrapper { padding: 16px 12px 80px; } }
1932
- </style>
1933
- </head>
1934
- <body>
1935
- <div class="wrapper">
1936
-
1937
- <!-- Header -->
1938
- <div class="header">
1939
- <div class="header-left">
1940
- <div class="logo">m</div>
1941
- <h1>milens <span>dashboard</span></h1>
1942
- </div>
1943
- <div class="header-right">
1944
- <span class="repo-badge" style="background:rgba(88,166,255,0.12);color:#58a6ff;border:1px solid rgba(88,166,255,0.25);border-radius:20px;padding:4px 12px;font-size:0.78em;font-weight:600;margin-right:12px;">${repoFilter ? repoFilter.replace(/\\\\/g, '/').split('/').pop() || repoFilter : 'All Repos'}</span>
1945
- <div class="status-dot"></div>
1946
- <span class="status-text">Live</span>
1947
- <button class="refresh-btn" onclick="refreshData()">&#8635; Refresh</button>
1948
- </div>
1949
- </div>
1950
-
1951
- <!-- Tab Navigation -->
1952
- <div class="tab-nav">
1953
- <button class="tab active" data-tab="usage" onclick="switchTab('usage')">Usage Analytics</button>
1954
- <button class="tab" data-tab="learning" onclick="switchTab('learning')">Learning</button>
1955
- </div>
1956
-
1957
- <div id="tab-usage" class="tab-content active">
1958
-
1959
- <!-- KPIs -->
1960
- <div class="kpi-grid">
1961
- <div class="kpi">
1962
- <div class="kpi-icon blue">&#9881;</div>
1963
- <div class="value blue" id="kpi-calls">${fmtNum(stats.totalCalls)}</div>
1964
- <div class="label">Tool Calls</div>
1965
- <div class="sub" id="kpi-calls-sub">${stats.totalCalls.toLocaleString()} total invocations</div>
1966
- <div class="kpi-glow blue"></div>
1967
- </div>
1968
- <div class="kpi">
1969
- <div class="kpi-icon green">&#9889;</div>
1970
- <div class="value green" id="kpi-saved">${fmtNum(stats.totalTokensSaved)}</div>
1971
- <div class="label">Tokens Saved</div>
1972
- <div class="sub" id="kpi-saved-sub">${stats.totalTokensSaved.toLocaleString()} tokens not wasted</div>
1973
- <div class="kpi-glow green"></div>
1974
- </div>
1975
- <div class="kpi">
1976
- <div class="kpi-icon purple">&#9733;</div>
1977
- <div class="value purple" id="kpi-pct">${savingsPercent}%</div>
1978
- <div class="label">Token Efficiency</div>
1979
- <div class="sub">${fmtNum(stats.totalTokensOut)} returned vs ${fmtNum(stats.totalTokensSaved)} saved</div>
1980
- <div class="kpi-glow purple"></div>
1981
- </div>
1982
- <div class="kpi">
1983
- <div class="kpi-icon orange">&#9201;</div>
1984
- <div class="value orange" id="kpi-avg">${stats.totalCalls > 0 ? Math.round(stats.totalDurationMs / stats.totalCalls) : 0}ms</div>
1985
- <div class="label">Avg Response Time</div>
1986
- <div class="sub">${(stats.totalDurationMs / 1000).toFixed(1)}s total processing</div>
1987
- <div class="kpi-glow orange"></div>
1988
- </div>
1989
- </div>
1990
-
1991
- <!-- Charts Row -->
1992
- <div class="grid-2">
1993
- <div class="card">
1994
- <div class="card-header">
1995
- <div>
1996
- <div class="card-title">Top Tools</div>
1997
- <div class="card-subtitle">By number of calls</div>
1998
- </div>
1999
- </div>
2000
- <div id="toolBars" class="tool-bars"></div>
2001
- </div>
2002
- <div class="card">
2003
- <div class="card-header">
2004
- <div>
2005
- <div class="card-title">Daily Activity</div>
2006
- <div class="card-subtitle">Calls &amp; tokens saved over the last 30 days</div>
2007
- </div>
2008
- </div>
2009
- <div class="chart-container h-280"><canvas id="dayChart"></canvas></div>
2010
- </div>
2011
- </div>
2012
-
2013
- <!-- Savings Distribution -->
2014
- <div class="grid-2" style="grid-template-columns: 7fr 5fr;">
2015
- <div class="card">
2016
- <div class="card-header">
2017
- <div>
2018
- <div class="card-title">Recent Tool Calls</div>
2019
- <div class="card-subtitle">Last 50 invocations</div>
2020
- </div>
2021
- </div>
2022
- <div class="table-wrapper">
2023
- <table>
2024
- <thead><tr><th>Tool</th><th>When</th><th>Duration</th><th style="text-align:right">Tokens Saved</th></tr></thead>
2025
- <tbody id="recentBody"></tbody>
2026
- </table>
2027
- </div>
2028
- </div>
2029
- <div class="card">
2030
- <div class="card-header">
2031
- <div>
2032
- <div class="card-title">Savings by Tool</div>
2033
- <div class="card-subtitle">Token savings distribution</div>
2034
- </div>
2035
- </div>
2036
- <div class="chart-container h-300"><canvas id="savingsChart"></canvas></div>
2037
- </div>
2038
- </div>
2039
-
2040
- <div class="footer">milens &middot; auto-refreshes every 30s</div>
2041
- </div><!-- /tab-usage -->
2042
-
2043
- <div id="tab-learning" class="tab-content">
2044
- <div class="suggestion-box">
2045
- <h3 style="margin-bottom:8px;font-weight:600;">Metrics &amp; Insights</h3>
2046
- <p style="color:var(--text-secondary);font-size:13px;">Run <code>milens metrics</code> for a full code-quality metrics report.</p>
2047
- </div>
2048
- ${learningStats}
2049
- </div>
2050
-
2051
- </div><!-- /wrapper -->
2052
-
2053
- <script>
2054
- const COLORS = ['#60a5fa','#34d399','#fbbf24','#a78bfa','#f87171','#2dd4bf','#818cf8','#fb923c','#e879f9','#38bdf8','#4ade80','#facc15','#f472b6','#22d3ee','#a3e635','#c084fc'];
2055
- const BAR_COLORS = ['#60a5fa','#34d399','#fbbf24','#a78bfa','#f87171','#2dd4bf','#818cf8','#fb923c','#e879f9','#38bdf8'];
2056
- let byTool = ${byToolJson};
2057
- let byDay = ${byDayJson};
2058
-
2059
- function fmtK(n) { return n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : n; }
2060
-
2061
- /* ── Top Tools Horizontal Bars ── */
2062
- function renderToolBars() {
2063
- const el = document.getElementById('toolBars');
2064
- const sorted = [...byTool].sort((a,b) => b.calls - a.calls).slice(0, 10);
2065
- const maxCalls = sorted[0]?.calls || 1;
2066
- el.innerHTML = sorted.map((t, i) => {
2067
- const pct = Math.max(8, (t.calls / maxCalls) * 100);
2068
- const col = BAR_COLORS[i % BAR_COLORS.length];
2069
- return \`<div class="tool-bar-row">
2070
- <span class="tool-bar-name">\${t.tool}</span>
2071
- <div class="tool-bar-track">
2072
- <div class="tool-bar-fill" style="width:\${pct}%;background:linear-gradient(90deg,\${col}dd,\${col}88)">\${t.calls}</div>
2073
- </div>
2074
- <span class="tool-bar-count">\${fmtK(t.tokensSaved)}</span>
2075
- </div>\`;
2076
- }).join('');
2077
- }
2078
-
2079
- /* ── Daily Activity Chart ── */
2080
- let dayChartInstance = null;
2081
- function renderDayChart() {
2082
- if (dayChartInstance) dayChartInstance.destroy();
2083
- const ctx = document.getElementById('dayChart');
2084
- const labels = byDay.map(d => {
2085
- const parts = d.date.split('-');
2086
- return parts[1] + '/' + parts[2];
2087
- });
2088
- dayChartInstance = new Chart(ctx, {
2089
- type: 'bar',
2090
- data: {
2091
- labels,
2092
- datasets: [
2093
- {
2094
- label: 'Calls', data: byDay.map(d => d.calls),
2095
- backgroundColor: '#60a5fa44', hoverBackgroundColor: '#60a5fa88',
2096
- borderRadius: 4, borderSkipped: false, yAxisID: 'y', barPercentage: 0.7,
2097
- },
2098
- {
2099
- label: 'Tokens Saved', data: byDay.map(d => d.tokensSaved),
2100
- type: 'line', borderColor: '#34d399', pointBackgroundColor: '#34d399',
2101
- pointRadius: 2, pointHoverRadius: 5, borderWidth: 2.5,
2102
- yAxisID: 'y1', tension: 0.4, fill: { target: 'origin', above: '#34d39910' },
2103
- },
2104
- ],
2105
- },
2106
- options: {
2107
- responsive: true, maintainAspectRatio: false,
2108
- interaction: { mode: 'index', intersect: false },
2109
- scales: {
2110
- x: {
2111
- ticks: { color: '#64748b', font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 15 },
2112
- grid: { display: false },
2113
- },
2114
- y: {
2115
- position: 'left', ticks: { color: '#60a5fa', font: { size: 11 } },
2116
- grid: { color: '#1e2a3a' }, title: { display: true, text: 'Calls', color: '#60a5fa', font: { size: 11 } },
2117
- },
2118
- y1: {
2119
- position: 'right', ticks: { color: '#34d399', font: { size: 11 }, callback: v => fmtK(v) },
2120
- grid: { drawOnChartArea: false }, title: { display: true, text: 'Tokens Saved', color: '#34d399', font: { size: 11 } },
2121
- },
2122
- },
2123
- plugins: {
2124
- legend: { labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 16 } },
2125
- tooltip: {
2126
- backgroundColor: '#1e293b', borderColor: '#334155', borderWidth: 1, titleColor: '#e2e8f0',
2127
- bodyColor: '#94a3b8', cornerRadius: 8, padding: 12,
2128
- callbacks: { label: ctx => ctx.dataset.label + ': ' + (ctx.datasetIndex === 1 ? fmtK(ctx.raw) : ctx.raw) },
2129
- },
2130
- },
2131
- },
2132
- });
2133
- }
2134
-
2135
- /* ── Savings Doughnut ── */
2136
- function renderSavingsChart() {
2137
- const ctx = document.getElementById('savingsChart');
2138
- const sorted = [...byTool].sort((a,b) => b.tokensSaved - a.tokensSaved);
2139
- const top = sorted.slice(0, 8);
2140
- const rest = sorted.slice(8);
2141
- if (rest.length) top.push({ tool: 'others', tokensSaved: rest.reduce((s,t) => s + t.tokensSaved, 0), calls: 0 });
2142
- new Chart(ctx, {
2143
- type: 'doughnut',
2144
- data: {
2145
- labels: top.map(t => t.tool),
2146
- datasets: [{
2147
- data: top.map(t => t.tokensSaved),
2148
- backgroundColor: top.map((_, i) => COLORS[i]),
2149
- borderWidth: 0, hoverOffset: 6,
2150
- }],
2151
- },
2152
- options: {
2153
- responsive: true, cutout: '68%',
2154
- plugins: {
2155
- legend: { position: 'right', labels: { color: '#94a3b8', font: { size: 12 }, padding: 8, usePointStyle: true, pointStyleWidth: 10 } },
2156
- tooltip: {
2157
- backgroundColor: '#1e293b', borderColor: '#334155', borderWidth: 1, titleColor: '#e2e8f0',
2158
- bodyColor: '#94a3b8', cornerRadius: 8, padding: 12,
2159
- callbacks: { label: ctx => ' ' + ctx.label + ': ' + fmtK(ctx.raw) + ' tokens' },
2160
- },
2161
- },
2162
- },
2163
- });
2164
- }
2165
-
2166
- /* ── Recent Calls Table ── */
2167
- function renderRecent(data) {
2168
- const tbody = document.getElementById('recentBody');
2169
- tbody.innerHTML = data.map(r => {
2170
- const ago = timeAgo(r.calledAt);
2171
- return \`<tr>
2172
- <td><span class="tool-badge">\${r.tool}</span></td>
2173
- <td class="when-text">\${ago}</td>
2174
- <td class="duration-text">\${r.durationMs}ms</td>
2175
- <td class="saved-text" style="text-align:right">+\${r.tokensSaved.toLocaleString()}</td>
2176
- </tr>\`;
2177
- }).join('');
2178
- }
2179
-
2180
- function timeAgo(iso) {
2181
- const d = new Date(iso.includes('T') ? iso : iso + 'Z');
2182
- const s = Math.floor((Date.now() - d.getTime()) / 1000);
2183
- if (s < 0) return 'just now';
2184
- if (s < 60) return s + 's ago';
2185
- if (s < 3600) return Math.floor(s/60) + 'm ago';
2186
- if (s < 86400) return Math.floor(s/3600) + 'h ago';
2187
- return Math.floor(s/86400) + 'd ago';
2188
- }
2189
-
2190
- /* ── Refresh ── */
2191
- async function refreshData() {
2192
- const btn = document.querySelector('.refresh-btn');
2193
- btn.textContent = '⟳ Loading...';
2194
- try {
2195
- const res = await fetch('/api/stats');
2196
- const data = await res.json();
2197
- byTool = data.byTool; byDay = data.byDay;
2198
- document.getElementById('kpi-calls').textContent = fmtK(data.totalCalls);
2199
- document.getElementById('kpi-calls-sub').textContent = data.totalCalls.toLocaleString() + ' total invocations';
2200
- document.getElementById('kpi-saved').textContent = fmtK(data.totalTokensSaved);
2201
- document.getElementById('kpi-saved-sub').textContent = data.totalTokensSaved.toLocaleString() + ' tokens not wasted';
2202
- const pct = data.totalTokensOut > 0 ? Math.round((data.totalTokensSaved / (data.totalTokensOut + data.totalTokensSaved)) * 100) : 0;
2203
- document.getElementById('kpi-pct').textContent = pct + '%';
2204
- document.getElementById('kpi-avg').textContent = (data.totalCalls > 0 ? Math.round(data.totalDurationMs / data.totalCalls) : 0) + 'ms';
2205
- renderToolBars(); renderDayChart(); renderRecent(data.recentCalls);
2206
- } catch(e) { console.error('Refresh failed', e); }
2207
- btn.innerHTML = '&#8635; Refresh';
2208
- }
2209
-
2210
- /* ── Tab Switching ── */
2211
- function switchTab(tab) {
2212
- document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
2213
- document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
2214
- }
2215
-
2216
- /* ── Init ── */
2217
- renderToolBars();
2218
- renderDayChart();
2219
- renderSavingsChart();
2220
- renderRecent(${recentJson});
2221
- setInterval(refreshData, 30000);
2222
- </script>
2223
- </body>
1851
+ return `<!DOCTYPE html>
1852
+ <html lang="en">
1853
+ <head>
1854
+ <meta charset="UTF-8">
1855
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1856
+ <title>milens Dashboard</title>
1857
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
1858
+ <style>
1859
+ :root {
1860
+ --bg: #0a0e14; --surface: #12171e; --card: #161d27; --card-hover: #1a2332;
1861
+ --border: #1e2a3a; --border-light: #2a3a4e;
1862
+ --text: #e2e8f0; --text-secondary: #94a3b8; --text-muted: #64748b;
1863
+ --accent: #60a5fa; --accent-dim: #60a5fa22;
1864
+ --green: #34d399; --green-dim: #34d39915;
1865
+ --orange: #fbbf24; --orange-dim: #fbbf2415;
1866
+ --purple: #a78bfa; --purple-dim: #a78bfa15;
1867
+ --red: #f87171;
1868
+ --radius: 16px; --radius-sm: 10px;
1869
+ }
1870
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1871
+ body {
1872
+ background: var(--bg); color: var(--text);
1873
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1874
+ line-height: 1.5; min-height: 100vh;
1875
+ }
1876
+
1877
+ /* ── Layout ── */
1878
+ .wrapper { max-width: 1400px; margin: 0 auto; padding: 32px 24px 80px; }
1879
+
1880
+ /* ── Header ── */
1881
+ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; }
1882
+ .header-left { display: flex; align-items: center; gap: 14px; }
1883
+ .logo { width: 40px; height: 40px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; color: #fff; }
1884
+ .header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
1885
+ .header h1 span { color: var(--text-muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
1886
+ .header-right { display: flex; align-items: center; gap: 12px; }
1887
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
1888
+ @keyframes pulse { 0%,80%,100% { opacity: 1; } 40% { opacity: 0.4; } }
1889
+ .status-text { font-size: 12px; color: var(--text-muted); }
1890
+ .refresh-btn {
1891
+ background: var(--accent-dim); color: var(--accent); border: 1px solid var(--border);
1892
+ border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
1893
+ font-weight: 500; font-size: 13px; transition: all 0.2s;
1894
+ }
1895
+ .refresh-btn:hover { background: var(--accent); color: #0a0e14; }
1896
+
1897
+ /* ── KPI Cards ── */
1898
+ .kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
1899
+ .kpi {
1900
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1901
+ padding: 24px; position: relative; overflow: hidden; transition: border-color 0.2s;
1902
+ }
1903
+ .kpi:hover { border-color: var(--border-light); }
1904
+ .kpi-icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; margin-bottom: 16px; }
1905
+ .kpi-icon.blue { background: var(--accent-dim); }
1906
+ .kpi-icon.green { background: var(--green-dim); }
1907
+ .kpi-icon.purple { background: var(--purple-dim); }
1908
+ .kpi-icon.orange { background: var(--orange-dim); }
1909
+ .kpi .value { font-size: 32px; font-weight: 800; letter-spacing: -1px; line-height: 1; }
1910
+ .kpi .value.blue { color: var(--accent); }
1911
+ .kpi .value.green { color: var(--green); }
1912
+ .kpi .value.purple { color: var(--purple); }
1913
+ .kpi .value.orange { color: var(--orange); }
1914
+ .kpi .label { color: var(--text-muted); font-size: 13px; margin-top: 6px; font-weight: 500; }
1915
+ .kpi .sub { color: var(--text-secondary); font-size: 12px; margin-top: 4px; }
1916
+ .kpi-glow {
1917
+ position: absolute; top: -40px; right: -40px; width: 120px; height: 120px;
1918
+ border-radius: 50%; opacity: 0.06; pointer-events: none;
1919
+ }
1920
+ .kpi-glow.blue { background: var(--accent); }
1921
+ .kpi-glow.green { background: var(--green); }
1922
+ .kpi-glow.purple { background: var(--purple); }
1923
+ .kpi-glow.orange { background: var(--orange); }
1924
+
1925
+ /* ── Charts ── */
1926
+ .grid-2 { display: grid; grid-template-columns: 5fr 7fr; gap: 16px; margin-bottom: 16px; }
1927
+ .grid-full { margin-bottom: 16px; }
1928
+ .card {
1929
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1930
+ padding: 24px; transition: border-color 0.2s;
1931
+ }
1932
+ .card:hover { border-color: var(--border-light); }
1933
+ .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
1934
+ .card-title { font-size: 14px; font-weight: 600; color: var(--text); }
1935
+ .card-subtitle { font-size: 12px; color: var(--text-muted); }
1936
+
1937
+ /* ── Chart containers ── */
1938
+ .chart-container { position: relative; width: 100%; }
1939
+ .chart-container.h-280 { height: 280px; }
1940
+ .chart-container.h-300 { height: 300px; }
1941
+
1942
+ /* ── Top Tools Bar ── */
1943
+ .tool-bars { display: flex; flex-direction: column; gap: 10px; }
1944
+ .tool-bar-row { display: flex; align-items: center; gap: 12px; }
1945
+ .tool-bar-name { width: 140px; font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace; color: var(--text-secondary); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1946
+ .tool-bar-track { flex: 1; height: 28px; background: var(--surface); border-radius: 6px; overflow: hidden; position: relative; }
1947
+ .tool-bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; padding: 0 10px; font-size: 11px; font-weight: 600; color: #fff; min-width: fit-content; transition: width 0.6s ease; }
1948
+ .tool-bar-count { font-size: 12px; color: var(--text-muted); min-width: 36px; text-align: right; }
1949
+
1950
+ /* ── Recent Table ── */
1951
+ .table-wrapper { max-height: 400px; overflow-y: auto; border-radius: var(--radius-sm); }
1952
+ .table-wrapper::-webkit-scrollbar { width: 6px; }
1953
+ .table-wrapper::-webkit-scrollbar-track { background: transparent; }
1954
+ .table-wrapper::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
1955
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
1956
+ thead { position: sticky; top: 0; z-index: 1; }
1957
+ th {
1958
+ text-align: left; color: var(--text-muted); font-weight: 500; padding: 10px 16px;
1959
+ background: var(--card); border-bottom: 1px solid var(--border); font-size: 11px;
1960
+ text-transform: uppercase; letter-spacing: 0.5px;
1961
+ }
1962
+ td { padding: 10px 16px; border-bottom: 1px solid var(--border); }
1963
+ tr:hover td { background: var(--surface); }
1964
+ .tool-badge {
1965
+ display: inline-flex; align-items: center; gap: 4px;
1966
+ background: var(--accent-dim); color: var(--accent); padding: 3px 10px;
1967
+ border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 500;
1968
+ }
1969
+ .when-text { color: var(--text-muted); }
1970
+ .duration-text { color: var(--text-secondary); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
1971
+ .saved-text { color: var(--green); font-weight: 600; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
1972
+
1973
+ /* ── Footer ── */
1974
+ .footer { text-align: center; padding: 24px 0 0; color: var(--text-muted); font-size: 12px; }
1975
+
1976
+ /* ── Tabs ── */
1977
+ .tab-nav { display: flex; gap: 4px; margin-bottom: 28px; border-bottom: 2px solid var(--border); padding-bottom: 0; }
1978
+ .tab { padding: 10px 24px; cursor: pointer; font-size: 14px; font-weight: 500; color: var(--text-muted); border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; background: none; border-top: none; border-left: none; border-right: none; outline: none; }
1979
+ .tab:hover { color: var(--text-secondary); }
1980
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
1981
+ .tab-content { display: none; }
1982
+ .tab-content.active { display: block; }
1983
+
1984
+ /* ── Learning ── */
1985
+ .suggestion-box { background: var(--accent-dim); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 20px; text-align: center; margin-bottom: 20px; }
1986
+ .suggestion-box code { background: var(--surface); padding: 2px 8px; border-radius: 4px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; color: var(--accent); }
1987
+ .learning-section { margin-bottom: 16px; }
1988
+ .annot-list { list-style: none; padding: 0; }
1989
+ .annot-list li { padding: 10px 0; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
1990
+ .annot-list li:last-child { border-bottom: none; }
1991
+ .annot-sym { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; color: var(--accent); }
1992
+ .annot-key { font-size: 11px; color: var(--text-muted); background: var(--surface); padding: 2px 8px; border-radius: 4px; margin-left: 8px; }
1993
+ .annot-conf { font-size: 12px; font-weight: 600; }
1994
+ .annot-conf.hi { color: var(--green); }
1995
+ .annot-conf.md { color: var(--orange); }
1996
+ .annot-conf.lo { color: var(--red); }
1997
+
1998
+ /* ── Responsive ── */
1999
+ @media (max-width: 1024px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .grid-2 { grid-template-columns: 1fr; } }
2000
+ @media (max-width: 640px) { .kpi-grid { grid-template-columns: 1fr; } .wrapper { padding: 16px 12px 80px; } }
2001
+ </style>
2002
+ </head>
2003
+ <body>
2004
+ <div class="wrapper">
2005
+
2006
+ <!-- Header -->
2007
+ <div class="header">
2008
+ <div class="header-left">
2009
+ <div class="logo">m</div>
2010
+ <h1>milens <span>dashboard</span></h1>
2011
+ </div>
2012
+ <div class="header-right">
2013
+ <span class="repo-badge" style="background:rgba(88,166,255,0.12);color:#58a6ff;border:1px solid rgba(88,166,255,0.25);border-radius:20px;padding:4px 12px;font-size:0.78em;font-weight:600;margin-right:12px;">${repoFilter ? repoFilter.replace(/\\\\/g, '/').split('/').pop() || repoFilter : 'All Repos'}</span>
2014
+ <div class="status-dot"></div>
2015
+ <span class="status-text">Live</span>
2016
+ <button class="refresh-btn" onclick="refreshData()">&#8635; Refresh</button>
2017
+ </div>
2018
+ </div>
2019
+
2020
+ <!-- Tab Navigation -->
2021
+ <div class="tab-nav">
2022
+ <button class="tab active" data-tab="usage" onclick="switchTab('usage')">Usage Analytics</button>
2023
+ <button class="tab" data-tab="learning" onclick="switchTab('learning')">Learning</button>
2024
+ </div>
2025
+
2026
+ <div id="tab-usage" class="tab-content active">
2027
+
2028
+ <!-- KPIs -->
2029
+ <div class="kpi-grid">
2030
+ <div class="kpi">
2031
+ <div class="kpi-icon blue">&#9881;</div>
2032
+ <div class="value blue" id="kpi-calls">${fmtNum(stats.totalCalls)}</div>
2033
+ <div class="label">Tool Calls</div>
2034
+ <div class="sub" id="kpi-calls-sub">${stats.totalCalls.toLocaleString()} total invocations</div>
2035
+ <div class="kpi-glow blue"></div>
2036
+ </div>
2037
+ <div class="kpi">
2038
+ <div class="kpi-icon green">&#9889;</div>
2039
+ <div class="value green" id="kpi-saved">${fmtNum(stats.totalTokensSaved)}</div>
2040
+ <div class="label">Tokens Saved</div>
2041
+ <div class="sub" id="kpi-saved-sub">${stats.totalTokensSaved.toLocaleString()} tokens not wasted</div>
2042
+ <div class="kpi-glow green"></div>
2043
+ </div>
2044
+ <div class="kpi">
2045
+ <div class="kpi-icon purple">&#9733;</div>
2046
+ <div class="value purple" id="kpi-pct">${savingsPercent}%</div>
2047
+ <div class="label">Token Efficiency</div>
2048
+ <div class="sub">${fmtNum(stats.totalTokensOut)} returned vs ${fmtNum(stats.totalTokensSaved)} saved</div>
2049
+ <div class="kpi-glow purple"></div>
2050
+ </div>
2051
+ <div class="kpi">
2052
+ <div class="kpi-icon orange">&#9201;</div>
2053
+ <div class="value orange" id="kpi-avg">${stats.totalCalls > 0 ? Math.round(stats.totalDurationMs / stats.totalCalls) : 0}ms</div>
2054
+ <div class="label">Avg Response Time</div>
2055
+ <div class="sub">${(stats.totalDurationMs / 1000).toFixed(1)}s total processing</div>
2056
+ <div class="kpi-glow orange"></div>
2057
+ </div>
2058
+ </div>
2059
+
2060
+ <!-- Charts Row -->
2061
+ <div class="grid-2">
2062
+ <div class="card">
2063
+ <div class="card-header">
2064
+ <div>
2065
+ <div class="card-title">Top Tools</div>
2066
+ <div class="card-subtitle">By number of calls</div>
2067
+ </div>
2068
+ </div>
2069
+ <div id="toolBars" class="tool-bars"></div>
2070
+ </div>
2071
+ <div class="card">
2072
+ <div class="card-header">
2073
+ <div>
2074
+ <div class="card-title">Daily Activity</div>
2075
+ <div class="card-subtitle">Calls &amp; tokens saved over the last 30 days</div>
2076
+ </div>
2077
+ </div>
2078
+ <div class="chart-container h-280"><canvas id="dayChart"></canvas></div>
2079
+ </div>
2080
+ </div>
2081
+
2082
+ <!-- Savings Distribution -->
2083
+ <div class="grid-2" style="grid-template-columns: 7fr 5fr;">
2084
+ <div class="card">
2085
+ <div class="card-header">
2086
+ <div>
2087
+ <div class="card-title">Recent Tool Calls</div>
2088
+ <div class="card-subtitle">Last 50 invocations</div>
2089
+ </div>
2090
+ </div>
2091
+ <div class="table-wrapper">
2092
+ <table>
2093
+ <thead><tr><th>Tool</th><th>When</th><th>Duration</th><th style="text-align:right">Tokens Saved</th></tr></thead>
2094
+ <tbody id="recentBody"></tbody>
2095
+ </table>
2096
+ </div>
2097
+ </div>
2098
+ <div class="card">
2099
+ <div class="card-header">
2100
+ <div>
2101
+ <div class="card-title">Savings by Tool</div>
2102
+ <div class="card-subtitle">Token savings distribution</div>
2103
+ </div>
2104
+ </div>
2105
+ <div class="chart-container h-300"><canvas id="savingsChart"></canvas></div>
2106
+ </div>
2107
+ </div>
2108
+
2109
+ <div class="footer">milens &middot; auto-refreshes every 30s</div>
2110
+ </div><!-- /tab-usage -->
2111
+
2112
+ <div id="tab-learning" class="tab-content">
2113
+ <div class="suggestion-box">
2114
+ <h3 style="margin-bottom:8px;font-weight:600;">Metrics &amp; Insights</h3>
2115
+ <p style="color:var(--text-secondary);font-size:13px;">Run <code>milens metrics</code> for a full code-quality metrics report.</p>
2116
+ </div>
2117
+ ${learningStats}
2118
+ </div>
2119
+
2120
+ </div><!-- /wrapper -->
2121
+
2122
+ <script>
2123
+ const COLORS = ['#60a5fa','#34d399','#fbbf24','#a78bfa','#f87171','#2dd4bf','#818cf8','#fb923c','#e879f9','#38bdf8','#4ade80','#facc15','#f472b6','#22d3ee','#a3e635','#c084fc'];
2124
+ const BAR_COLORS = ['#60a5fa','#34d399','#fbbf24','#a78bfa','#f87171','#2dd4bf','#818cf8','#fb923c','#e879f9','#38bdf8'];
2125
+ let byTool = ${byToolJson};
2126
+ let byDay = ${byDayJson};
2127
+
2128
+ function fmtK(n) { return n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : n; }
2129
+
2130
+ /* ── Top Tools Horizontal Bars ── */
2131
+ function renderToolBars() {
2132
+ const el = document.getElementById('toolBars');
2133
+ const sorted = [...byTool].sort((a,b) => b.calls - a.calls).slice(0, 10);
2134
+ const maxCalls = sorted[0]?.calls || 1;
2135
+ el.innerHTML = sorted.map((t, i) => {
2136
+ const pct = Math.max(8, (t.calls / maxCalls) * 100);
2137
+ const col = BAR_COLORS[i % BAR_COLORS.length];
2138
+ return \`<div class="tool-bar-row">
2139
+ <span class="tool-bar-name">\${t.tool}</span>
2140
+ <div class="tool-bar-track">
2141
+ <div class="tool-bar-fill" style="width:\${pct}%;background:linear-gradient(90deg,\${col}dd,\${col}88)">\${t.calls}</div>
2142
+ </div>
2143
+ <span class="tool-bar-count">\${fmtK(t.tokensSaved)}</span>
2144
+ </div>\`;
2145
+ }).join('');
2146
+ }
2147
+
2148
+ /* ── Daily Activity Chart ── */
2149
+ let dayChartInstance = null;
2150
+ function renderDayChart() {
2151
+ if (dayChartInstance) dayChartInstance.destroy();
2152
+ const ctx = document.getElementById('dayChart');
2153
+ const labels = byDay.map(d => {
2154
+ const parts = d.date.split('-');
2155
+ return parts[1] + '/' + parts[2];
2156
+ });
2157
+ dayChartInstance = new Chart(ctx, {
2158
+ type: 'bar',
2159
+ data: {
2160
+ labels,
2161
+ datasets: [
2162
+ {
2163
+ label: 'Calls', data: byDay.map(d => d.calls),
2164
+ backgroundColor: '#60a5fa44', hoverBackgroundColor: '#60a5fa88',
2165
+ borderRadius: 4, borderSkipped: false, yAxisID: 'y', barPercentage: 0.7,
2166
+ },
2167
+ {
2168
+ label: 'Tokens Saved', data: byDay.map(d => d.tokensSaved),
2169
+ type: 'line', borderColor: '#34d399', pointBackgroundColor: '#34d399',
2170
+ pointRadius: 2, pointHoverRadius: 5, borderWidth: 2.5,
2171
+ yAxisID: 'y1', tension: 0.4, fill: { target: 'origin', above: '#34d39910' },
2172
+ },
2173
+ ],
2174
+ },
2175
+ options: {
2176
+ responsive: true, maintainAspectRatio: false,
2177
+ interaction: { mode: 'index', intersect: false },
2178
+ scales: {
2179
+ x: {
2180
+ ticks: { color: '#64748b', font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 15 },
2181
+ grid: { display: false },
2182
+ },
2183
+ y: {
2184
+ position: 'left', ticks: { color: '#60a5fa', font: { size: 11 } },
2185
+ grid: { color: '#1e2a3a' }, title: { display: true, text: 'Calls', color: '#60a5fa', font: { size: 11 } },
2186
+ },
2187
+ y1: {
2188
+ position: 'right', ticks: { color: '#34d399', font: { size: 11 }, callback: v => fmtK(v) },
2189
+ grid: { drawOnChartArea: false }, title: { display: true, text: 'Tokens Saved', color: '#34d399', font: { size: 11 } },
2190
+ },
2191
+ },
2192
+ plugins: {
2193
+ legend: { labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 16 } },
2194
+ tooltip: {
2195
+ backgroundColor: '#1e293b', borderColor: '#334155', borderWidth: 1, titleColor: '#e2e8f0',
2196
+ bodyColor: '#94a3b8', cornerRadius: 8, padding: 12,
2197
+ callbacks: { label: ctx => ctx.dataset.label + ': ' + (ctx.datasetIndex === 1 ? fmtK(ctx.raw) : ctx.raw) },
2198
+ },
2199
+ },
2200
+ },
2201
+ });
2202
+ }
2203
+
2204
+ /* ── Savings Doughnut ── */
2205
+ function renderSavingsChart() {
2206
+ const ctx = document.getElementById('savingsChart');
2207
+ const sorted = [...byTool].sort((a,b) => b.tokensSaved - a.tokensSaved);
2208
+ const top = sorted.slice(0, 8);
2209
+ const rest = sorted.slice(8);
2210
+ if (rest.length) top.push({ tool: 'others', tokensSaved: rest.reduce((s,t) => s + t.tokensSaved, 0), calls: 0 });
2211
+ new Chart(ctx, {
2212
+ type: 'doughnut',
2213
+ data: {
2214
+ labels: top.map(t => t.tool),
2215
+ datasets: [{
2216
+ data: top.map(t => t.tokensSaved),
2217
+ backgroundColor: top.map((_, i) => COLORS[i]),
2218
+ borderWidth: 0, hoverOffset: 6,
2219
+ }],
2220
+ },
2221
+ options: {
2222
+ responsive: true, cutout: '68%',
2223
+ plugins: {
2224
+ legend: { position: 'right', labels: { color: '#94a3b8', font: { size: 12 }, padding: 8, usePointStyle: true, pointStyleWidth: 10 } },
2225
+ tooltip: {
2226
+ backgroundColor: '#1e293b', borderColor: '#334155', borderWidth: 1, titleColor: '#e2e8f0',
2227
+ bodyColor: '#94a3b8', cornerRadius: 8, padding: 12,
2228
+ callbacks: { label: ctx => ' ' + ctx.label + ': ' + fmtK(ctx.raw) + ' tokens' },
2229
+ },
2230
+ },
2231
+ },
2232
+ });
2233
+ }
2234
+
2235
+ /* ── Recent Calls Table ── */
2236
+ function renderRecent(data) {
2237
+ const tbody = document.getElementById('recentBody');
2238
+ tbody.innerHTML = data.map(r => {
2239
+ const ago = timeAgo(r.calledAt);
2240
+ return \`<tr>
2241
+ <td><span class="tool-badge">\${r.tool}</span></td>
2242
+ <td class="when-text">\${ago}</td>
2243
+ <td class="duration-text">\${r.durationMs}ms</td>
2244
+ <td class="saved-text" style="text-align:right">+\${r.tokensSaved.toLocaleString()}</td>
2245
+ </tr>\`;
2246
+ }).join('');
2247
+ }
2248
+
2249
+ function timeAgo(iso) {
2250
+ const d = new Date(iso.includes('T') ? iso : iso + 'Z');
2251
+ const s = Math.floor((Date.now() - d.getTime()) / 1000);
2252
+ if (s < 0) return 'just now';
2253
+ if (s < 60) return s + 's ago';
2254
+ if (s < 3600) return Math.floor(s/60) + 'm ago';
2255
+ if (s < 86400) return Math.floor(s/3600) + 'h ago';
2256
+ return Math.floor(s/86400) + 'd ago';
2257
+ }
2258
+
2259
+ /* ── Refresh ── */
2260
+ async function refreshData() {
2261
+ const btn = document.querySelector('.refresh-btn');
2262
+ btn.textContent = '⟳ Loading...';
2263
+ try {
2264
+ const res = await fetch('/api/stats');
2265
+ const data = await res.json();
2266
+ byTool = data.byTool; byDay = data.byDay;
2267
+ document.getElementById('kpi-calls').textContent = fmtK(data.totalCalls);
2268
+ document.getElementById('kpi-calls-sub').textContent = data.totalCalls.toLocaleString() + ' total invocations';
2269
+ document.getElementById('kpi-saved').textContent = fmtK(data.totalTokensSaved);
2270
+ document.getElementById('kpi-saved-sub').textContent = data.totalTokensSaved.toLocaleString() + ' tokens not wasted';
2271
+ const pct = data.totalTokensOut > 0 ? Math.round((data.totalTokensSaved / (data.totalTokensOut + data.totalTokensSaved)) * 100) : 0;
2272
+ document.getElementById('kpi-pct').textContent = pct + '%';
2273
+ document.getElementById('kpi-avg').textContent = (data.totalCalls > 0 ? Math.round(data.totalDurationMs / data.totalCalls) : 0) + 'ms';
2274
+ renderToolBars(); renderDayChart(); renderRecent(data.recentCalls);
2275
+ } catch(e) { console.error('Refresh failed', e); }
2276
+ btn.innerHTML = '&#8635; Refresh';
2277
+ }
2278
+
2279
+ /* ── Tab Switching ── */
2280
+ function switchTab(tab) {
2281
+ document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
2282
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
2283
+ }
2284
+
2285
+ /* ── Init ── */
2286
+ renderToolBars();
2287
+ renderDayChart();
2288
+ renderSavingsChart();
2289
+ renderRecent(${recentJson});
2290
+ setInterval(refreshData, 30000);
2291
+ </script>
2292
+ </body>
2224
2293
  </html>`;
2225
2294
  }
2226
2295
  function renderConfBars(bands, total) {