smartcontext-proxy 0.1.0 → 0.2.1

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 (64) hide show
  1. package/PLAN-v2.md +390 -0
  2. package/dist/src/context/ab-test.d.ts +32 -0
  3. package/dist/src/context/ab-test.js +133 -0
  4. package/dist/src/index.js +99 -78
  5. package/dist/src/proxy/classifier.d.ts +14 -0
  6. package/dist/src/proxy/classifier.js +63 -0
  7. package/dist/src/proxy/connect-proxy.d.ts +37 -0
  8. package/dist/src/proxy/connect-proxy.js +234 -0
  9. package/dist/src/proxy/server.js +10 -1
  10. package/dist/src/proxy/tls-interceptor.d.ts +23 -0
  11. package/dist/src/proxy/tls-interceptor.js +211 -0
  12. package/dist/src/proxy/transparent-listener.d.ts +31 -0
  13. package/dist/src/proxy/transparent-listener.js +285 -0
  14. package/dist/src/proxy/tunnel.d.ts +7 -0
  15. package/dist/src/proxy/tunnel.js +33 -0
  16. package/dist/src/system/dns-redirect.d.ts +28 -0
  17. package/dist/src/system/dns-redirect.js +141 -0
  18. package/dist/src/system/installer.d.ts +25 -0
  19. package/dist/src/system/installer.js +180 -0
  20. package/dist/src/system/linux.d.ts +11 -0
  21. package/dist/src/system/linux.js +60 -0
  22. package/dist/src/system/macos.d.ts +24 -0
  23. package/dist/src/system/macos.js +98 -0
  24. package/dist/src/system/pf-redirect.d.ts +25 -0
  25. package/dist/src/system/pf-redirect.js +177 -0
  26. package/dist/src/system/watchdog.d.ts +7 -0
  27. package/dist/src/system/watchdog.js +115 -0
  28. package/dist/src/test/connect-proxy.test.d.ts +1 -0
  29. package/dist/src/test/connect-proxy.test.js +147 -0
  30. package/dist/src/test/dashboard.test.js +1 -0
  31. package/dist/src/tls/ca-manager.d.ts +9 -0
  32. package/dist/src/tls/ca-manager.js +117 -0
  33. package/dist/src/tls/trust-store.d.ts +11 -0
  34. package/dist/src/tls/trust-store.js +121 -0
  35. package/dist/src/tray/bridge.d.ts +8 -0
  36. package/dist/src/tray/bridge.js +66 -0
  37. package/dist/src/ui/dashboard.d.ts +10 -1
  38. package/dist/src/ui/dashboard.js +119 -34
  39. package/dist/src/ui/ws-feed.d.ts +8 -0
  40. package/dist/src/ui/ws-feed.js +30 -0
  41. package/native/macos/SmartContextTray/Package.swift +13 -0
  42. package/native/macos/SmartContextTray/Sources/main.swift +206 -0
  43. package/package.json +6 -2
  44. package/src/context/ab-test.ts +172 -0
  45. package/src/index.ts +104 -74
  46. package/src/proxy/classifier.ts +71 -0
  47. package/src/proxy/connect-proxy.ts +251 -0
  48. package/src/proxy/server.ts +11 -2
  49. package/src/proxy/tls-interceptor.ts +261 -0
  50. package/src/proxy/transparent-listener.ts +328 -0
  51. package/src/proxy/tunnel.ts +32 -0
  52. package/src/system/dns-redirect.ts +144 -0
  53. package/src/system/installer.ts +148 -0
  54. package/src/system/linux.ts +57 -0
  55. package/src/system/macos.ts +89 -0
  56. package/src/system/pf-redirect.ts +175 -0
  57. package/src/system/watchdog.ts +76 -0
  58. package/src/test/connect-proxy.test.ts +170 -0
  59. package/src/test/dashboard.test.ts +1 -0
  60. package/src/tls/ca-manager.ts +140 -0
  61. package/src/tls/trust-store.ts +123 -0
  62. package/src/tray/bridge.ts +61 -0
  63. package/src/ui/dashboard.ts +129 -35
  64. package/src/ui/ws-feed.ts +32 -0
@@ -0,0 +1,140 @@
1
+ import forge from 'node-forge';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const CA_DIR = path.join(process.env['HOME'] || '.', '.smartcontext', 'ca');
6
+ const HOSTS_DIR = path.join(CA_DIR, 'hosts');
7
+ const CA_CERT_PATH = path.join(CA_DIR, 'smartcontext-ca.crt');
8
+ const CA_KEY_PATH = path.join(CA_DIR, 'smartcontext-ca.key');
9
+
10
+ export interface CertPair {
11
+ cert: string; // PEM
12
+ key: string; // PEM
13
+ }
14
+
15
+ let cachedCA: { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey } | null = null;
16
+ const hostCertCache = new Map<string, CertPair>();
17
+
18
+ /** Ensure root CA exists, generate if not */
19
+ export function ensureCA(): CertPair {
20
+ fs.mkdirSync(CA_DIR, { recursive: true });
21
+ fs.mkdirSync(HOSTS_DIR, { recursive: true });
22
+
23
+ if (fs.existsSync(CA_CERT_PATH) && fs.existsSync(CA_KEY_PATH)) {
24
+ const cert = fs.readFileSync(CA_CERT_PATH, 'utf-8');
25
+ const key = fs.readFileSync(CA_KEY_PATH, 'utf-8');
26
+ cachedCA = {
27
+ cert: forge.pki.certificateFromPem(cert),
28
+ key: forge.pki.privateKeyFromPem(key) as forge.pki.rsa.PrivateKey,
29
+ };
30
+ return { cert, key };
31
+ }
32
+
33
+ // Generate new root CA
34
+ const keys = forge.pki.rsa.generateKeyPair(2048);
35
+ const cert = forge.pki.createCertificate();
36
+
37
+ cert.publicKey = keys.publicKey;
38
+ cert.serialNumber = generateSerial();
39
+ cert.validity.notBefore = new Date();
40
+ cert.validity.notAfter = new Date();
41
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
42
+
43
+ const attrs = [
44
+ { name: 'commonName', value: 'SmartContext Proxy CA' },
45
+ { name: 'organizationName', value: 'SmartContext' },
46
+ ];
47
+
48
+ cert.setSubject(attrs);
49
+ cert.setIssuer(attrs);
50
+
51
+ cert.setExtensions([
52
+ { name: 'basicConstraints', cA: true, critical: true },
53
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
54
+ { name: 'subjectKeyIdentifier' },
55
+ ]);
56
+
57
+ cert.sign(keys.privateKey, forge.md.sha256.create());
58
+
59
+ const certPem = forge.pki.certificateToPem(cert);
60
+ const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
61
+
62
+ fs.writeFileSync(CA_CERT_PATH, certPem);
63
+ fs.writeFileSync(CA_KEY_PATH, keyPem, { mode: 0o600 });
64
+
65
+ cachedCA = { cert, key: keys.privateKey };
66
+
67
+ return { cert: certPem, key: keyPem };
68
+ }
69
+
70
+ /** Generate a TLS certificate for a specific hostname, signed by our CA */
71
+ export function getCertForHost(hostname: string): CertPair {
72
+ // Check memory cache
73
+ const cached = hostCertCache.get(hostname);
74
+ if (cached) return cached;
75
+
76
+ // Check disk cache
77
+ const certPath = path.join(HOSTS_DIR, `${hostname}.crt`);
78
+ const keyPath = path.join(HOSTS_DIR, `${hostname}.key`);
79
+
80
+ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
81
+ const pair = {
82
+ cert: fs.readFileSync(certPath, 'utf-8'),
83
+ key: fs.readFileSync(keyPath, 'utf-8'),
84
+ };
85
+ hostCertCache.set(hostname, pair);
86
+ return pair;
87
+ }
88
+
89
+ // Generate new cert
90
+ if (!cachedCA) ensureCA();
91
+
92
+ const keys = forge.pki.rsa.generateKeyPair(2048);
93
+ const cert = forge.pki.createCertificate();
94
+
95
+ cert.publicKey = keys.publicKey;
96
+ cert.serialNumber = generateSerial();
97
+ cert.validity.notBefore = new Date();
98
+ cert.validity.notAfter = new Date();
99
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
100
+
101
+ cert.setSubject([{ name: 'commonName', value: hostname }]);
102
+ cert.setIssuer(cachedCA!.cert.subject.attributes);
103
+
104
+ cert.setExtensions([
105
+ { name: 'basicConstraints', cA: false },
106
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
107
+ { name: 'extKeyUsage', serverAuth: true },
108
+ {
109
+ name: 'subjectAltName',
110
+ altNames: [
111
+ { type: 2, value: hostname }, // DNS
112
+ ...(isIP(hostname) ? [{ type: 7, ip: hostname }] : []),
113
+ ],
114
+ },
115
+ ]);
116
+
117
+ cert.sign(cachedCA!.key, forge.md.sha256.create());
118
+
119
+ const certPem = forge.pki.certificateToPem(cert);
120
+ const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
121
+
122
+ fs.writeFileSync(certPath, certPem);
123
+ fs.writeFileSync(keyPath, keyPem, { mode: 0o600 });
124
+
125
+ const pair = { cert: certPem, key: keyPem };
126
+ hostCertCache.set(hostname, pair);
127
+ return pair;
128
+ }
129
+
130
+ export function getCACertPath(): string {
131
+ return CA_CERT_PATH;
132
+ }
133
+
134
+ function generateSerial(): string {
135
+ return Date.now().toString(16) + Math.random().toString(16).slice(2, 10);
136
+ }
137
+
138
+ function isIP(str: string): boolean {
139
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(str) || str.includes(':');
140
+ }
@@ -0,0 +1,123 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getCACertPath } from './ca-manager.js';
5
+
6
+ const CA_NAME = 'SmartContext Proxy CA';
7
+
8
+ export interface TrustStoreResult {
9
+ success: boolean;
10
+ message: string;
11
+ requiresSudo: boolean;
12
+ }
13
+
14
+ /** Install CA cert into system trust store */
15
+ export function installCA(): TrustStoreResult {
16
+ const certPath = getCACertPath();
17
+ if (!fs.existsSync(certPath)) {
18
+ return { success: false, message: 'CA cert not found. Run ensureCA() first.', requiresSudo: false };
19
+ }
20
+
21
+ if (process.platform === 'darwin') {
22
+ return installMacOS(certPath);
23
+ } else if (process.platform === 'linux') {
24
+ return installLinux(certPath);
25
+ }
26
+
27
+ return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
28
+ }
29
+
30
+ /** Remove CA cert from system trust store */
31
+ export function uninstallCA(): TrustStoreResult {
32
+ if (process.platform === 'darwin') {
33
+ return uninstallMacOS();
34
+ } else if (process.platform === 'linux') {
35
+ return uninstallLinux();
36
+ }
37
+
38
+ return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
39
+ }
40
+
41
+ /** Check if CA is installed in trust store */
42
+ export function isCAInstalled(): boolean {
43
+ if (process.platform === 'darwin') {
44
+ try {
45
+ const result = execFileSync('security', [
46
+ 'find-certificate', '-c', CA_NAME, '-Z',
47
+ '/Library/Keychains/System.keychain',
48
+ ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
49
+ return result.includes(CA_NAME);
50
+ } catch {
51
+ return false;
52
+ }
53
+ } else if (process.platform === 'linux') {
54
+ const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
55
+ return fs.existsSync(dest);
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function installMacOS(certPath: string): TrustStoreResult {
61
+ try {
62
+ // Add to system keychain (requires admin)
63
+ execFileSync('sudo', [
64
+ 'security', 'add-trusted-cert',
65
+ '-d', '-r', 'trustRoot',
66
+ '-k', '/Library/Keychains/System.keychain',
67
+ certPath,
68
+ ], { stdio: 'pipe' });
69
+
70
+ return { success: true, message: 'CA installed in macOS System Keychain', requiresSudo: true };
71
+ } catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ if (msg.includes('sudo') || msg.includes('password')) {
74
+ return { success: false, message: 'Requires sudo. Run: sudo smartcontext-proxy install', requiresSudo: true };
75
+ }
76
+ return { success: false, message: `Failed to install CA: ${msg}`, requiresSudo: true };
77
+ }
78
+ }
79
+
80
+ function uninstallMacOS(): TrustStoreResult {
81
+ try {
82
+ execFileSync('sudo', [
83
+ 'security', 'remove-trusted-cert',
84
+ '-d', getCACertPath(),
85
+ ], { stdio: 'pipe' });
86
+
87
+ return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
88
+ } catch (err) {
89
+ // Try alternative: delete by name
90
+ try {
91
+ execFileSync('sudo', [
92
+ 'security', 'delete-certificate',
93
+ '-c', CA_NAME,
94
+ '/Library/Keychains/System.keychain',
95
+ ], { stdio: 'pipe' });
96
+ return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
97
+ } catch {
98
+ return { success: false, message: `Failed to remove CA: ${err}`, requiresSudo: true };
99
+ }
100
+ }
101
+ }
102
+
103
+ function installLinux(certPath: string): TrustStoreResult {
104
+ const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
105
+ try {
106
+ execFileSync('sudo', ['cp', certPath, dest], { stdio: 'pipe' });
107
+ execFileSync('sudo', ['update-ca-certificates'], { stdio: 'pipe' });
108
+ return { success: true, message: 'CA installed in Linux trust store', requiresSudo: true };
109
+ } catch (err) {
110
+ return { success: false, message: `Failed: ${err}`, requiresSudo: true };
111
+ }
112
+ }
113
+
114
+ function uninstallLinux(): TrustStoreResult {
115
+ const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
116
+ try {
117
+ execFileSync('sudo', ['rm', '-f', dest], { stdio: 'pipe' });
118
+ execFileSync('sudo', ['update-ca-certificates', '--fresh'], { stdio: 'pipe' });
119
+ return { success: true, message: 'CA removed from Linux trust store', requiresSudo: true };
120
+ } catch (err) {
121
+ return { success: false, message: `Failed: ${err}`, requiresSudo: true };
122
+ }
123
+ }
@@ -0,0 +1,61 @@
1
+ import { execFile, type ChildProcess } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ let trayProcess: ChildProcess | null = null;
6
+
7
+ const TRAY_BINARY_DIR = path.join(__dirname, '..', '..', 'native', 'macos', 'SmartContextTray');
8
+ const TRAY_BUILD_DIR = path.join(TRAY_BINARY_DIR, '.build', 'release');
9
+ const TRAY_BINARY = path.join(TRAY_BUILD_DIR, 'SmartContextTray');
10
+
11
+ /** Build the native tray app if not already built */
12
+ export function buildTray(): boolean {
13
+ if (process.platform !== 'darwin') return false;
14
+
15
+ if (fs.existsSync(TRAY_BINARY)) return true;
16
+
17
+ try {
18
+ const { execFileSync } = require('node:child_process');
19
+ execFileSync('swift', ['build', '-c', 'release'], {
20
+ cwd: TRAY_BINARY_DIR,
21
+ stdio: 'pipe',
22
+ timeout: 60000,
23
+ });
24
+ return fs.existsSync(TRAY_BINARY);
25
+ } catch (err) {
26
+ console.log(`Tray build failed: ${err}`);
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /** Launch the tray app */
32
+ export function startTray(): boolean {
33
+ if (process.platform !== 'darwin') return false;
34
+ if (trayProcess) return true;
35
+
36
+ if (!buildTray()) {
37
+ console.log('Tray app not available (build failed or not macOS)');
38
+ return false;
39
+ }
40
+
41
+ trayProcess = execFile(TRAY_BINARY, [], {}, (err) => {
42
+ if (err) console.log(`Tray exited: ${err.message}`);
43
+ trayProcess = null;
44
+ });
45
+
46
+ trayProcess.unref();
47
+ return true;
48
+ }
49
+
50
+ /** Stop the tray app */
51
+ export function stopTray(): void {
52
+ if (trayProcess) {
53
+ trayProcess.kill('SIGTERM');
54
+ trayProcess = null;
55
+ }
56
+ }
57
+
58
+ /** Check if tray is running */
59
+ export function isTrayRunning(): boolean {
60
+ return trayProcess !== null && !trayProcess.killed;
61
+ }
@@ -1,17 +1,28 @@
1
1
  import type { MetricsCollector } from '../metrics/collector.js';
2
- import type { StorageAdapter } from '../storage/types.js';
3
2
 
4
- export function renderDashboard(metrics: MetricsCollector, paused: boolean): string {
3
+ export interface DashboardState {
4
+ paused: boolean;
5
+ mode: 'transparent' | 'optimizing';
6
+ proxyType: 'connect' | 'legacy';
7
+ abTestEnabled: boolean;
8
+ debugHeaders: boolean;
9
+ caInstalled: boolean;
10
+ systemProxyActive: boolean;
11
+ }
12
+
13
+ export function renderDashboard(metrics: MetricsCollector, state: DashboardState): string {
5
14
  const stats = metrics.getStats();
6
15
  const recent = metrics.getRecent(20);
7
- const uptime = metrics.getUptime();
8
- const uptimeStr = formatDuration(uptime);
16
+ const uptimeStr = formatDuration(metrics.getUptime());
17
+ const savingsAmount = estimateCostSaved(stats.totalOriginalTokens - stats.totalOptimizedTokens);
9
18
 
10
- const statusBadge = paused
19
+ const stateBadge = state.paused
11
20
  ? '<span class="badge paused">PAUSED</span>'
12
21
  : '<span class="badge running">RUNNING</span>';
13
22
 
14
- const savingsAmount = estimateCostSaved(stats.totalOriginalTokens - stats.totalOptimizedTokens);
23
+ const modeBadge = state.mode === 'optimizing'
24
+ ? '<span class="badge opt">OPTIMIZING</span>'
25
+ : '<span class="badge transparent">TRANSPARENT</span>';
15
26
 
16
27
  return `<!DOCTYPE html>
17
28
  <html lang="en">
@@ -24,17 +35,23 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
24
35
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e1e4e8; }
25
36
  .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
26
37
  header { display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid #21262d; margin-bottom: 24px; }
27
- h1 { font-size: 20px; font-weight: 600; }
38
+ h1 { font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
28
39
  h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #8b949e; }
29
- .badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
40
+ .badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
30
41
  .badge.running { background: #238636; color: #fff; }
31
42
  .badge.paused { background: #d29922; color: #000; }
32
- .controls { display: flex; gap: 8px; }
33
- .btn { padding: 6px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; font-size: 13px; }
34
- .btn:hover { background: #30363d; }
43
+ .badge.opt { background: #1f6feb; color: #fff; }
44
+ .badge.transparent { background: #30363d; color: #8b949e; }
45
+ .badge.on { background: #238636; color: #fff; }
46
+ .badge.off { background: #30363d; color: #8b949e; }
47
+ .controls { display: flex; gap: 8px; align-items: center; }
48
+ .btn { padding: 6px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; font-size: 13px; transition: all 0.15s; }
49
+ .btn:hover { background: #30363d; border-color: #484f58; }
35
50
  .btn.primary { background: #238636; border-color: #238636; }
51
+ .btn.primary:hover { background: #2ea043; }
36
52
  .btn.warn { background: #d29922; border-color: #d29922; color: #000; }
37
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; }
53
+ .btn.danger { background: #da3633; border-color: #da3633; }
54
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
38
55
  .card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; }
39
56
  .card .value { font-size: 32px; font-weight: 700; color: #58a6ff; }
40
57
  .card .label { font-size: 13px; color: #8b949e; margin-top: 4px; }
@@ -50,20 +67,46 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
50
67
  .tab.active { color: #e1e4e8; border-bottom-color: #58a6ff; }
51
68
  .tab-content { display: none; }
52
69
  .tab-content.active { display: block; }
70
+ .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
71
+ .setting { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
72
+ .setting-info { flex: 1; }
73
+ .setting-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
74
+ .setting-desc { font-size: 12px; color: #8b949e; }
75
+ .toggle { position: relative; width: 44px; height: 24px; cursor: pointer; }
76
+ .toggle input { display: none; }
77
+ .toggle .slider { position: absolute; inset: 0; background: #30363d; border-radius: 12px; transition: 0.2s; }
78
+ .toggle .slider:before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #8b949e; border-radius: 50%; transition: 0.2s; }
79
+ .toggle input:checked + .slider { background: #238636; }
80
+ .toggle input:checked + .slider:before { transform: translateX(20px); background: #fff; }
81
+ .status-row { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
82
+ .status-pill { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #8b949e; }
83
+ .dot { width: 8px; height: 8px; border-radius: 50%; }
84
+ .dot.green { background: #3fb950; }
85
+ .dot.yellow { background: #d29922; }
86
+ .dot.red { background: #f85149; }
87
+ .dot.gray { background: #484f58; }
53
88
  .refresh-note { font-size: 11px; color: #484f58; text-align: right; margin-top: 8px; }
54
89
  </style>
55
90
  </head>
56
91
  <body>
57
92
  <div class="container">
58
93
  <header>
59
- <h1>SmartContext Proxy ${statusBadge}</h1>
94
+ <h1>SmartContext Proxy ${stateBadge} ${modeBadge}</h1>
60
95
  <div class="controls">
61
- ${paused
96
+ ${state.paused
62
97
  ? '<button class="btn primary" onclick="api(\'/_sc/resume\')">Resume</button>'
63
98
  : '<button class="btn warn" onclick="api(\'/_sc/pause\')">Pause</button>'}
64
99
  </div>
65
100
  </header>
66
101
 
102
+ <div class="status-row">
103
+ <div class="status-pill"><div class="dot ${state.caInstalled ? 'green' : 'red'}"></div>CA Certificate</div>
104
+ <div class="status-pill"><div class="dot ${state.systemProxyActive ? 'green' : 'gray'}"></div>System Proxy</div>
105
+ <div class="status-pill"><div class="dot ${state.mode === 'optimizing' ? 'green' : 'gray'}"></div>Context Optimization</div>
106
+ <div class="status-pill"><div class="dot ${state.abTestEnabled ? 'yellow' : 'gray'}"></div>A/B Test</div>
107
+ <div class="status-pill"><div class="dot ${state.proxyType === 'connect' ? 'green' : 'gray'}"></div>${state.proxyType === 'connect' ? 'Transparent' : 'Legacy'} Mode</div>
108
+ </div>
109
+
67
110
  <div class="grid">
68
111
  <div class="card savings">
69
112
  <div class="value">$${savingsAmount}</div>
@@ -84,9 +127,10 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
84
127
  </div>
85
128
 
86
129
  <div class="tab-bar">
87
- <div class="tab active" onclick="switchTab('feed')">Live Feed</div>
88
- <div class="tab" onclick="switchTab('providers')">By Provider</div>
89
- <div class="tab" onclick="switchTab('models')">By Model</div>
130
+ <div class="tab active" onclick="switchTab('feed',this)">Live Feed</div>
131
+ <div class="tab" onclick="switchTab('providers',this)">By Provider</div>
132
+ <div class="tab" onclick="switchTab('models',this)">By Model</div>
133
+ <div class="tab" onclick="switchTab('settings',this)">Settings</div>
90
134
  </div>
91
135
 
92
136
  <div id="tab-feed" class="tab-content active">
@@ -116,12 +160,7 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
116
160
  <thead><tr><th>Provider</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
117
161
  <tbody>
118
162
  ${Object.entries(stats.byProvider).map(([name, s]) => `
119
- <tr>
120
- <td>${name}</td>
121
- <td class="mono">${s.requests}</td>
122
- <td class="mono">${formatTokens(s.tokensSaved)}</td>
123
- <td class="savings-pct">${s.savingsPercent}%</td>
124
- </tr>
163
+ <tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
125
164
  `).join('')}
126
165
  </tbody>
127
166
  </table>
@@ -133,31 +172,88 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
133
172
  <thead><tr><th>Model</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
134
173
  <tbody>
135
174
  ${Object.entries(stats.byModel).map(([name, s]) => `
136
- <tr>
137
- <td>${name}</td>
138
- <td class="mono">${s.requests}</td>
139
- <td class="mono">${formatTokens(s.tokensSaved)}</td>
140
- <td class="savings-pct">${s.savingsPercent}%</td>
141
- </tr>
175
+ <tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
142
176
  `).join('')}
143
177
  </tbody>
144
178
  </table>
145
179
  </div>
146
180
 
147
- <div class="refresh-note">Uptime: ${uptimeStr} | Auto-refresh: 10s</div>
181
+ <div id="tab-settings" class="tab-content">
182
+ <h2>Controls</h2>
183
+ <div class="settings-grid">
184
+ <div class="setting">
185
+ <div class="setting-info">
186
+ <div class="setting-name">Context Optimization</div>
187
+ <div class="setting-desc">Optimize LLM context windows to reduce token usage</div>
188
+ </div>
189
+ <label class="toggle">
190
+ <input type="checkbox" ${!state.paused ? 'checked' : ''} onchange="api(this.checked ? '/_sc/resume' : '/_sc/pause')">
191
+ <span class="slider"></span>
192
+ </label>
193
+ </div>
194
+
195
+ <div class="setting">
196
+ <div class="setting-info">
197
+ <div class="setting-name">A/B Test Mode</div>
198
+ <div class="setting-desc">Send each request twice to compare quality</div>
199
+ </div>
200
+ <label class="toggle">
201
+ <input type="checkbox" ${state.abTestEnabled ? 'checked' : ''} onchange="api(this.checked ? '/_sc/ab-test/enable' : '/_sc/ab-test/disable')">
202
+ <span class="slider"></span>
203
+ </label>
204
+ </div>
205
+
206
+ <div class="setting">
207
+ <div class="setting-info">
208
+ <div class="setting-name">Debug Headers</div>
209
+ <div class="setting-desc">Add X-SmartContext-* headers to responses</div>
210
+ </div>
211
+ <label class="toggle">
212
+ <input type="checkbox" ${state.debugHeaders ? 'checked' : ''} onchange="api(this.checked ? '/_sc/debug-headers/enable' : '/_sc/debug-headers/disable')">
213
+ <span class="slider"></span>
214
+ </label>
215
+ </div>
216
+
217
+ <div class="setting">
218
+ <div class="setting-info">
219
+ <div class="setting-name">System Proxy</div>
220
+ <div class="setting-desc">Auto-intercept all LLM traffic system-wide</div>
221
+ </div>
222
+ <label class="toggle">
223
+ <input type="checkbox" ${state.systemProxyActive ? 'checked' : ''} onchange="api(this.checked ? '/_sc/system-proxy/enable' : '/_sc/system-proxy/disable')">
224
+ <span class="slider"></span>
225
+ </label>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <div class="refresh-note">v0.2.0 | Uptime: ${uptimeStr} | Auto-refresh: 5s</div>
148
231
  </div>
149
232
  <script>
150
- function switchTab(name) {
233
+ function switchTab(name, el) {
151
234
  document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
152
235
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
153
236
  document.getElementById('tab-' + name).classList.add('active');
154
- event.target.classList.add('active');
237
+ if (el) el.classList.add('active');
238
+ location.hash = name;
155
239
  }
156
240
  async function api(path) {
157
241
  await fetch(path, { method: 'POST' });
158
242
  location.reload();
159
243
  }
160
- setTimeout(() => location.reload(), 10000);
244
+ // Restore tab from URL hash
245
+ (function() {
246
+ var hash = location.hash.replace('#', '');
247
+ if (hash && document.getElementById('tab-' + hash)) {
248
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
249
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
250
+ document.getElementById('tab-' + hash).classList.add('active');
251
+ document.querySelectorAll('.tab').forEach(t => {
252
+ if (t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'" + hash + "'") !== -1) t.classList.add('active');
253
+ });
254
+ }
255
+ })();
256
+ setTimeout(() => { var h = location.hash; location.reload(); if (h) location.hash = h; }, 5000);
161
257
  </script>
162
258
  </body>
163
259
  </html>`;
@@ -176,9 +272,7 @@ function formatDuration(ms: number): string {
176
272
  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
177
273
  }
178
274
 
179
- /** Rough cost estimate based on Anthropic/OpenAI pricing */
180
275
  function estimateCostSaved(tokensSaved: number): string {
181
- // Assume avg $15/1M input tokens (Opus pricing)
182
276
  const cost = (tokensSaved / 1000000) * 15;
183
277
  return cost.toFixed(2);
184
278
  }
@@ -0,0 +1,32 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import type { Server } from 'node:http';
3
+ import type { RequestMetric } from '../metrics/collector.js';
4
+
5
+ let wss: WebSocketServer | null = null;
6
+
7
+ /** Attach WebSocket server to existing HTTP server */
8
+ export function attachWebSocketFeed(server: Server): void {
9
+ wss = new WebSocketServer({ server, path: '/_sc/ws' });
10
+
11
+ wss.on('connection', (ws) => {
12
+ ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
13
+ });
14
+ }
15
+
16
+ /** Broadcast a new request metric to all connected clients */
17
+ export function broadcastMetric(metric: RequestMetric): void {
18
+ if (!wss) return;
19
+
20
+ const data = JSON.stringify({ type: 'request', data: metric });
21
+
22
+ for (const client of wss.clients) {
23
+ if (client.readyState === WebSocket.OPEN) {
24
+ client.send(data);
25
+ }
26
+ }
27
+ }
28
+
29
+ /** Get count of connected WebSocket clients */
30
+ export function getConnectedClients(): number {
31
+ return wss?.clients.size || 0;
32
+ }