lynkr 9.5.0 → 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/wrap.js ADDED
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lynkr Wrap - Launch CLI tools through Lynkr proxy
4
+ *
5
+ * Usage:
6
+ * lynkr wrap claude # launch Claude Code with defaults
7
+ * lynkr wrap copilot # wrap GitHub Copilot CLI
8
+ * lynkr wrap aider # wrap Aider AI assistant
9
+ * lynkr wrap cursor # wrap Cursor editor
10
+ * lynkr wrap codex # wrap OpenAI Codex CLI
11
+ * lynkr wrap claude --port 9000 # custom port
12
+ * lynkr wrap aider -- --help # pass args to aider
13
+ *
14
+ * This wraps official AI coding tool binaries and routes traffic through Lynkr,
15
+ * giving users access to tier routing, compression, and caching. For Claude Code,
16
+ * Pro/Max subscription users can leverage their OAuth tokens without separate API billing.
17
+ *
18
+ * @module bin/wrap
19
+ */
20
+
21
+ const { spawn } = require('child_process');
22
+ const { existsSync } = require('fs');
23
+ const { execSync } = require('child_process');
24
+ const path = require('path');
25
+
26
+ // Parse arguments
27
+ const args = process.argv.slice(2);
28
+ const target = args[0]; // 'claude', 'codex', etc.
29
+
30
+ if (!target) {
31
+ console.error('Usage: lynkr wrap <target> [options]');
32
+ console.error('');
33
+ console.error('Targets:');
34
+ console.error(' claude Wrap Claude Code CLI');
35
+ console.error(' copilot Wrap GitHub Copilot CLI');
36
+ console.error(' aider Wrap Aider AI coding assistant');
37
+ console.error(' cursor Wrap Cursor editor');
38
+ console.error(' codex Wrap OpenAI Codex CLI');
39
+ console.error('');
40
+ console.error('Options:');
41
+ console.error(' --port N Use port N for Lynkr proxy (default: 8081)');
42
+ console.error('');
43
+ console.error('Examples:');
44
+ console.error(' lynkr wrap claude');
45
+ console.error(' lynkr wrap copilot --port 9000');
46
+ console.error(' lynkr wrap aider -- --help');
47
+ console.error(' lynkr wrap cursor');
48
+ console.error(' lynkr wrap codex');
49
+ process.exit(1);
50
+ }
51
+
52
+ if (target === 'claude') {
53
+ wrapClaude();
54
+ } else if (target === 'copilot') {
55
+ wrapCopilot();
56
+ } else if (target === 'aider') {
57
+ wrapAider();
58
+ } else if (target === 'cursor') {
59
+ wrapCursor();
60
+ } else if (target === 'codex') {
61
+ wrapCodex();
62
+ } else {
63
+ console.error(`Error: 'lynkr wrap ${target}' is not supported yet.`);
64
+ console.error('');
65
+ console.error('Supported targets: claude, copilot, aider, cursor, codex');
66
+ process.exit(1);
67
+ }
68
+
69
+ // ──────────────────────────────────────────────────────────────────────────────
70
+ // Claude Code wrapper
71
+ // ──────────────────────────────────────────────────────────────────────────────
72
+
73
+ async function wrapClaude() {
74
+ console.log('╭─ Lynkr Wrap ─────────────────────────────────────────');
75
+ console.log('│ Starting Claude Code through Lynkr proxy...');
76
+ console.log('╰──────────────────────────────────────────────────────');
77
+ console.log('');
78
+
79
+ // Silence Lynkr logs in wrap mode so they don't bleed into Claude Code's
80
+ // TUI (the child inherits our stdio). Users who need Lynkr logs can set
81
+ // LOG_LEVEL=info|debug explicitly, or tail data/logs/lynkr.log.
82
+ if (!process.env.LOG_LEVEL || process.env.LOG_LEVEL === 'info' || process.env.LOG_LEVEL === 'error' || process.env.LOG_LEVEL === 'warn') {
83
+ process.env.LOG_LEVEL = 'silent';
84
+ }
85
+
86
+ // Enable OAuth passthrough by default for wrap claude. Server reads this
87
+ // env before /v1/messages handlers are wired up, so set it before start().
88
+ if (process.env.LYNKR_OAUTH_PASSTHROUGH == null) {
89
+ process.env.LYNKR_OAUTH_PASSTHROUGH = 'true';
90
+ }
91
+
92
+ // 1. Check for Claude Code binary
93
+ const claudePath = findClaudeBinary();
94
+ if (!claudePath) {
95
+ console.error('✗ Claude Code CLI not found in PATH');
96
+ console.error('');
97
+ console.error('Install it first:');
98
+ console.error(' • macOS: brew install --cask claude-code');
99
+ console.error(' • Or download from: https://claude.ai/code');
100
+ console.error('');
101
+ console.error('Then verify: claude --version');
102
+ process.exit(2);
103
+ }
104
+
105
+ console.log(`✓ Found Claude Code at: ${claudePath}`);
106
+
107
+ // 2. Parse wrap-specific options
108
+ const wrapOpts = parseWrapOptions(args.slice(1));
109
+ const port = wrapOpts.port;
110
+ const claudeArgs = wrapOpts.passthrough;
111
+
112
+ // 3. Start Lynkr server
113
+ console.log(`✓ Starting Lynkr on port ${port}...`);
114
+
115
+ let server;
116
+ try {
117
+ const { start } = require('../src/server');
118
+
119
+ // Override port if specified
120
+ if (port !== 8081) {
121
+ process.env.PORT = String(port);
122
+ }
123
+
124
+ server = await start();
125
+
126
+ // Wait for server to be ready
127
+ await waitForReady(port, 30000);
128
+ console.log(`✓ Lynkr ready on http://localhost:${port}`);
129
+ } catch (err) {
130
+ console.error('✗ Failed to start Lynkr:', err.message);
131
+ console.error('');
132
+ if (err.code === 'EADDRINUSE') {
133
+ console.error('Port already in use. Try:');
134
+ console.error(` lynkr wrap claude --port ${port + 1}`);
135
+ console.error('');
136
+ console.error('Or stop existing Lynkr:');
137
+ console.error(' lynkr stop');
138
+ } else {
139
+ console.error('Check your .env configuration:');
140
+ console.error(' DATABRICKS_API_KEY, OLLAMA_ENDPOINT, etc.');
141
+ console.error('');
142
+ console.error('Debug logs: tail -f data/logs/lynkr.log');
143
+ }
144
+ process.exit(1);
145
+ }
146
+
147
+ console.log('');
148
+ console.log('╭─ Claude Code ────────────────────────────────────────');
149
+ console.log('│ Launching with Lynkr routing enabled...');
150
+ console.log('│ • Tier routing: active');
151
+ console.log('│ • Compression: active');
152
+ console.log('│ • Caching: active');
153
+ if (claudeArgs.length > 0) {
154
+ console.log(`│ • Args: ${claudeArgs.join(' ')}`);
155
+ }
156
+ console.log('╰──────────────────────────────────────────────────────');
157
+ console.log('');
158
+
159
+ // 4. Launch Claude Code with Lynkr as base URL
160
+ // Force interactive mode if no args provided
161
+ const finalArgs = claudeArgs.length === 0 && !process.stdin.isTTY
162
+ ? [] // Let Claude detect TTY and start interactive
163
+ : claudeArgs;
164
+
165
+ // NOTE: We deliberately do NOT set ENABLE_TOOL_SEARCH=true here.
166
+ //
167
+ // When ENABLE_TOOL_SEARCH=true, Claude Code defers MCP/system tool schemas
168
+ // behind a single `tool_search_tool` meta-tool that requires Anthropic's
169
+ // server-side dispatch to resolve. That worked when we sent everything to
170
+ // Anthropic, but it breaks tier routing: when "Can you read this repo" gets
171
+ // routed to Ollama (or any non-Anthropic provider), the model only sees the
172
+ // search meta-tool and has no way to discover Read/Write/Bash — it responds
173
+ // "no file system tools available."
174
+ //
175
+ // Without this env var, Claude Code materializes the full real tool list in
176
+ // every request. That's more tokens on the Anthropic side (passthrough
177
+ // forwards them verbatim, Anthropic accepts them because the UA matches),
178
+ // but Ollama/Moonshot/etc. now see the actual tools and can use them.
179
+ //
180
+ // The original 400 "Input tag does not match expected tags" error this
181
+ // workaround was fighting is no longer reachable — subscription requests
182
+ // now passthrough byte-for-byte, so Anthropic accepts whatever shape
183
+ // Claude Code sends.
184
+ const child = spawn(claudePath, finalArgs, {
185
+ env: {
186
+ ...process.env,
187
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
188
+ },
189
+ stdio: 'inherit',
190
+ shell: false,
191
+ });
192
+
193
+ // Track start time for stats
194
+ const startTime = Date.now();
195
+
196
+ // 5. Handle signals - forward to child
197
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
198
+ const forwardSignal = (signal) => {
199
+ if (!child.killed) {
200
+ child.kill(signal);
201
+ }
202
+ };
203
+
204
+ signals.forEach((signal) => {
205
+ process.on(signal, () => forwardSignal(signal));
206
+ });
207
+
208
+ // 6. Wait for child to exit
209
+ child.on('exit', async (code, signal) => {
210
+ const duration = Date.now() - startTime;
211
+
212
+ console.log('');
213
+ console.log('╭─ Claude Code Exited ─────────────────────────────────');
214
+
215
+ if (signal) {
216
+ console.log(`│ Signal: ${signal}`);
217
+ } else {
218
+ console.log(`│ Exit code: ${code}`);
219
+ }
220
+
221
+ console.log(`│ Duration: ${formatDuration(duration)}`);
222
+ console.log('╰──────────────────────────────────────────────────────');
223
+
224
+ // Show stats if enabled and clean exit
225
+ if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
226
+ try {
227
+ await showSessionStats();
228
+ } catch (err) {
229
+ // Stats are nice-to-have, don't fail on error
230
+ }
231
+ }
232
+
233
+ // Shutdown Lynkr
234
+ console.log('');
235
+ console.log('Shutting down Lynkr...');
236
+
237
+ try {
238
+ if (server && typeof server.close === 'function') {
239
+ await new Promise((resolve) => {
240
+ server.close(() => {
241
+ console.log('✓ Lynkr stopped');
242
+ resolve();
243
+ });
244
+ // Force close after 2s
245
+ setTimeout(() => {
246
+ console.log('✓ Lynkr stopped (forced)');
247
+ resolve();
248
+ }, 2000);
249
+ });
250
+ }
251
+ } catch (err) {
252
+ // Ignore shutdown errors
253
+ }
254
+
255
+ process.exit(code || 0);
256
+ });
257
+
258
+ // Handle child spawn errors
259
+ child.on('error', (err) => {
260
+ console.error('✗ Failed to launch Claude Code:', err.message);
261
+ process.exit(1);
262
+ });
263
+ }
264
+
265
+ // ──────────────────────────────────────────────────────────────────────────────
266
+ // GitHub Copilot CLI wrapper
267
+ // ──────────────────────────────────────────────────────────────────────────────
268
+
269
+ async function wrapCopilot() {
270
+ await wrapGeneric({
271
+ name: 'GitHub Copilot CLI',
272
+ binaryName: 'github-copilot-cli',
273
+ findBinary: findCopilotBinary,
274
+ envVar: 'OPENAI_API_BASE',
275
+ installInstructions: [
276
+ ' • npm install -g @githubnext/github-copilot-cli',
277
+ ' • Or: https://www.npmjs.com/package/@githubnext/github-copilot-cli',
278
+ ],
279
+ });
280
+ }
281
+
282
+ // ──────────────────────────────────────────────────────────────────────────────
283
+ // Aider wrapper
284
+ // ──────────────────────────────────────────────────────────────────────────────
285
+
286
+ async function wrapAider() {
287
+ await wrapGeneric({
288
+ name: 'Aider',
289
+ binaryName: 'aider',
290
+ findBinary: findAiderBinary,
291
+ envVar: 'OPENAI_API_BASE',
292
+ installInstructions: [
293
+ ' • pip install aider-chat',
294
+ ' • Or: https://aider.chat/docs/install.html',
295
+ ],
296
+ });
297
+ }
298
+
299
+ // ──────────────────────────────────────────────────────────────────────────────
300
+ // Cursor wrapper
301
+ // ──────────────────────────────────────────────────────────────────────────────
302
+
303
+ async function wrapCursor() {
304
+ await wrapGeneric({
305
+ name: 'Cursor',
306
+ binaryName: 'cursor',
307
+ findBinary: findCursorBinary,
308
+ envVar: 'ANTHROPIC_BASE_URL',
309
+ installInstructions: [
310
+ ' • Download from: https://cursor.sh',
311
+ ' • macOS: brew install --cask cursor',
312
+ ],
313
+ });
314
+ }
315
+
316
+ // ──────────────────────────────────────────────────────────────────────────────
317
+ // OpenAI Codex CLI wrapper
318
+ // ──────────────────────────────────────────────────────────────────────────────
319
+
320
+ async function wrapCodex() {
321
+ await wrapGeneric({
322
+ name: 'OpenAI Codex CLI',
323
+ binaryName: 'codex',
324
+ findBinary: findCodexBinary,
325
+ envVar: 'OPENAI_API_BASE',
326
+ installInstructions: [
327
+ ' • Install OpenAI CLI: pip install openai',
328
+ ' • Or: npm install -g openai',
329
+ ],
330
+ });
331
+ }
332
+
333
+ // ──────────────────────────────────────────────────────────────────────────────
334
+ // Generic wrapper (used by copilot, aider, cursor, codex)
335
+ // ──────────────────────────────────────────────────────────────────────────────
336
+
337
+ async function wrapGeneric(opts) {
338
+ console.log('╭─ Lynkr Wrap ─────────────────────────────────────────');
339
+ console.log(`│ Starting ${opts.name} through Lynkr proxy...`);
340
+ console.log('╰──────────────────────────────────────────────────────');
341
+ console.log('');
342
+
343
+ // Suppress verbose Lynkr logs in wrap mode
344
+ if (!process.env.LOG_LEVEL || process.env.LOG_LEVEL === 'info') {
345
+ process.env.LOG_LEVEL = 'error';
346
+ }
347
+
348
+ // 1. Check for binary
349
+ const binaryPath = opts.findBinary();
350
+ if (!binaryPath) {
351
+ console.error(`✗ ${opts.name} not found in PATH`);
352
+ console.error('');
353
+ console.error('Install it first:');
354
+ opts.installInstructions.forEach((line) => console.error(line));
355
+ console.error('');
356
+ console.error(`Then verify: ${opts.binaryName} --version`);
357
+ process.exit(2);
358
+ }
359
+
360
+ console.log(`✓ Found ${opts.name} at: ${binaryPath}`);
361
+
362
+ // 2. Parse wrap-specific options
363
+ const wrapOpts = parseWrapOptions(args.slice(1));
364
+ const port = wrapOpts.port;
365
+ const targetArgs = wrapOpts.passthrough;
366
+
367
+ // 3. Start Lynkr server
368
+ console.log(`✓ Starting Lynkr on port ${port}...`);
369
+
370
+ let server;
371
+ try {
372
+ const { start } = require('../src/server');
373
+
374
+ // Override port if specified
375
+ if (port !== 8081) {
376
+ process.env.PORT = String(port);
377
+ }
378
+
379
+ server = await start();
380
+
381
+ // Wait for server to be ready
382
+ await waitForReady(port, 30000);
383
+ console.log(`✓ Lynkr ready on http://localhost:${port}`);
384
+ } catch (err) {
385
+ console.error('✗ Failed to start Lynkr:', err.message);
386
+ console.error('');
387
+ if (err.code === 'EADDRINUSE') {
388
+ console.error('Port already in use. Try:');
389
+ console.error(` lynkr wrap ${opts.binaryName} --port ${port + 1}`);
390
+ console.error('');
391
+ console.error('Or stop existing Lynkr:');
392
+ console.error(' lynkr stop');
393
+ } else {
394
+ console.error('Check your .env configuration:');
395
+ console.error(' TIER_SIMPLE, TIER_COMPLEX, etc.');
396
+ console.error('');
397
+ console.error('Debug logs: tail -f data/logs/lynkr.log');
398
+ }
399
+ process.exit(1);
400
+ }
401
+
402
+ console.log('');
403
+ console.log(`╭─ ${opts.name} ────────────────────────────────────────`);
404
+ console.log('│ Launching with Lynkr routing enabled...');
405
+ console.log('│ • Tier routing: active');
406
+ console.log('│ • Compression: active');
407
+ console.log('│ • Caching: active');
408
+ console.log('╰──────────────────────────────────────────────────────');
409
+ console.log('');
410
+
411
+ // 4. Launch binary with Lynkr as base URL
412
+ const child = spawn(binaryPath, targetArgs, {
413
+ env: {
414
+ ...process.env,
415
+ [opts.envVar]: `http://localhost:${port}`,
416
+ },
417
+ stdio: 'inherit',
418
+ });
419
+
420
+ // Track start time for stats
421
+ const startTime = Date.now();
422
+
423
+ // 5. Handle signals - forward to child
424
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
425
+ const forwardSignal = (signal) => {
426
+ if (!child.killed) {
427
+ child.kill(signal);
428
+ }
429
+ };
430
+
431
+ signals.forEach((signal) => {
432
+ process.on(signal, () => forwardSignal(signal));
433
+ });
434
+
435
+ // 6. Wait for child to exit
436
+ child.on('exit', async (code, signal) => {
437
+ const duration = Date.now() - startTime;
438
+
439
+ console.log('');
440
+ console.log(`╭─ ${opts.name} Exited ─────────────────────────────────`);
441
+
442
+ if (signal) {
443
+ console.log(`│ Signal: ${signal}`);
444
+ } else {
445
+ console.log(`│ Exit code: ${code}`);
446
+ }
447
+
448
+ console.log(`│ Duration: ${formatDuration(duration)}`);
449
+ console.log('╰──────────────────────────────────────────────────────');
450
+
451
+ // Show stats if enabled and clean exit
452
+ if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
453
+ try {
454
+ await showSessionStats();
455
+ } catch (err) {
456
+ // Stats are nice-to-have, don't fail on error
457
+ }
458
+ }
459
+
460
+ // Shutdown Lynkr
461
+ console.log('');
462
+ console.log('Shutting down Lynkr...');
463
+
464
+ try {
465
+ if (server && typeof server.close === 'function') {
466
+ await new Promise((resolve) => {
467
+ server.close(() => {
468
+ console.log('✓ Lynkr stopped');
469
+ resolve();
470
+ });
471
+ // Force close after 2s
472
+ setTimeout(() => {
473
+ console.log('✓ Lynkr stopped (forced)');
474
+ resolve();
475
+ }, 2000);
476
+ });
477
+ }
478
+ } catch (err) {
479
+ // Ignore shutdown errors
480
+ }
481
+
482
+ process.exit(code || 0);
483
+ });
484
+
485
+ // Handle child spawn errors
486
+ child.on('error', (err) => {
487
+ console.error(`✗ Failed to launch ${opts.name}:`, err.message);
488
+ process.exit(1);
489
+ });
490
+ }
491
+
492
+ // ──────────────────────────────────────────────────────────────────────────────
493
+ // Helper functions
494
+ // ──────────────────────────────────────────────────────────────────────────────
495
+
496
+ function findClaudeBinary() {
497
+ return findBinaryHelper('claude', [
498
+ '/usr/local/bin/claude',
499
+ '/opt/homebrew/bin/claude',
500
+ path.join(process.env.HOME || '', '.local', 'bin', 'claude'),
501
+ ]);
502
+ }
503
+
504
+ function findCopilotBinary() {
505
+ return findBinaryHelper('github-copilot-cli', [
506
+ '/usr/local/bin/github-copilot-cli',
507
+ '/opt/homebrew/bin/github-copilot-cli',
508
+ path.join(process.env.HOME || '', '.npm-global', 'bin', 'github-copilot-cli'),
509
+ path.join(process.env.HOME || '', '.local', 'bin', 'github-copilot-cli'),
510
+ ]);
511
+ }
512
+
513
+ function findAiderBinary() {
514
+ return findBinaryHelper('aider', [
515
+ '/usr/local/bin/aider',
516
+ '/opt/homebrew/bin/aider',
517
+ path.join(process.env.HOME || '', '.local', 'bin', 'aider'),
518
+ path.join(process.env.HOME || '', 'Library', 'Python', '3.12', 'bin', 'aider'),
519
+ ]);
520
+ }
521
+
522
+ function findCursorBinary() {
523
+ return findBinaryHelper('cursor', [
524
+ '/usr/local/bin/cursor',
525
+ '/opt/homebrew/bin/cursor',
526
+ '/Applications/Cursor.app/Contents/MacOS/Cursor',
527
+ path.join(process.env.HOME || '', '.local', 'bin', 'cursor'),
528
+ ]);
529
+ }
530
+
531
+ function findCodexBinary() {
532
+ return findBinaryHelper('codex', [
533
+ '/usr/local/bin/codex',
534
+ '/opt/homebrew/bin/codex',
535
+ path.join(process.env.HOME || '', '.local', 'bin', 'codex'),
536
+ ]);
537
+ }
538
+
539
+ function findBinaryHelper(binaryName, commonPaths) {
540
+ try {
541
+ // Try 'which <binary>'
542
+ const result = execSync(`which ${binaryName}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
543
+ const binaryPath = result.trim();
544
+ if (binaryPath && existsSync(binaryPath)) {
545
+ return binaryPath;
546
+ }
547
+ } catch {
548
+ // Fall through to common paths
549
+ }
550
+
551
+ // Try common installation paths
552
+ for (const p of commonPaths) {
553
+ if (existsSync(p)) {
554
+ return p;
555
+ }
556
+ }
557
+
558
+ return null;
559
+ }
560
+
561
+ function parseWrapOptions(args) {
562
+ let port = 8081;
563
+ const passthrough = [];
564
+ let foundSeparator = false;
565
+
566
+ for (let i = 0; i < args.length; i++) {
567
+ const arg = args[i];
568
+
569
+ if (arg === '--') {
570
+ foundSeparator = true;
571
+ continue;
572
+ }
573
+
574
+ if (foundSeparator) {
575
+ // Everything after -- goes to Claude Code
576
+ passthrough.push(arg);
577
+ } else if (arg === '--port' && i + 1 < args.length) {
578
+ port = parseInt(args[i + 1], 10);
579
+ i++; // skip next arg
580
+ } else {
581
+ // Unknown lynkr flag or starts passthrough
582
+ passthrough.push(arg);
583
+ }
584
+ }
585
+
586
+ return { port, passthrough };
587
+ }
588
+
589
+ async function waitForReady(port, timeoutMs) {
590
+ const startTime = Date.now();
591
+ const http = require('http');
592
+
593
+ while (Date.now() - startTime < timeoutMs) {
594
+ try {
595
+ await new Promise((resolve, reject) => {
596
+ const req = http.get(`http://localhost:${port}/health/ready`, (res) => {
597
+ if (res.statusCode === 200) {
598
+ resolve();
599
+ } else {
600
+ reject(new Error(`Health check returned ${res.statusCode}`));
601
+ }
602
+ res.resume(); // consume response
603
+ });
604
+ req.on('error', reject);
605
+ req.setTimeout(1000, () => {
606
+ req.destroy();
607
+ reject(new Error('Timeout'));
608
+ });
609
+ });
610
+ return; // Success
611
+ } catch {
612
+ // Not ready yet, wait and retry
613
+ await new Promise((r) => setTimeout(r, 200));
614
+ }
615
+ }
616
+
617
+ throw new Error(`Lynkr did not become ready within ${timeoutMs}ms`);
618
+ }
619
+
620
+ function formatDuration(ms) {
621
+ const seconds = Math.floor(ms / 1000);
622
+ const minutes = Math.floor(seconds / 60);
623
+ const remainingSeconds = seconds % 60;
624
+
625
+ if (minutes > 0) {
626
+ return `${minutes}m ${remainingSeconds}s`;
627
+ }
628
+ return `${seconds}s`;
629
+ }
630
+
631
+ async function showSessionStats() {
632
+ try {
633
+ const { getMetricsCollector } = require('../src/observability/metrics');
634
+ const metricsCollector = getMetricsCollector();
635
+ const metrics = metricsCollector.getMetrics();
636
+
637
+ // Check if we have any data
638
+ const hasRequests = metrics && (
639
+ (typeof metrics.totalRequests === 'number' && metrics.totalRequests > 0) ||
640
+ (typeof metrics.requestCount === 'number' && metrics.requestCount > 0)
641
+ );
642
+
643
+ if (!hasRequests) {
644
+ console.log('');
645
+ console.log('╭─ Lynkr Session Stats ────────────────────────────────');
646
+ console.log('│ No requests tracked (check dashboard for details)');
647
+ console.log('╰──────────────────────────────────────────────────────');
648
+ return;
649
+ }
650
+
651
+ console.log('');
652
+ console.log('╭─ Lynkr Session Stats ────────────────────────────────');
653
+
654
+ const requestCount = metrics.totalRequests || metrics.requestCount || 0;
655
+ console.log(`│ Requests ${requestCount}`);
656
+
657
+ if (metrics.tokensUsed || metrics.tokensSaved) {
658
+ const tokensUsed = metrics.tokensUsed || 0;
659
+ const tokensSaved = metrics.tokensSaved || 0;
660
+ const originalTokens = tokensUsed + tokensSaved;
661
+ if (originalTokens > 0) {
662
+ const savingsPercent = Math.round((tokensSaved / originalTokens) * 100);
663
+ console.log(`│ Tokens Original: ${originalTokens.toLocaleString()} → Routed: ${tokensUsed.toLocaleString()} (${savingsPercent}% saved)`);
664
+ }
665
+ }
666
+
667
+ if (metrics.tierBreakdown && Object.keys(metrics.tierBreakdown).length > 0) {
668
+ const tiers = Object.entries(metrics.tierBreakdown)
669
+ .map(([tier, count]) => `${tier}: ${count}`)
670
+ .join(' ');
671
+ console.log(`│ Tier Mix ${tiers}`);
672
+ }
673
+
674
+ if (metrics.cacheHits && metrics.cacheHits > 0) {
675
+ console.log(`│ Cache Hits ${metrics.cacheHits}`);
676
+ }
677
+
678
+ console.log('╰──────────────────────────────────────────────────────');
679
+ } catch (err) {
680
+ // Stats are nice-to-have, silently ignore errors
681
+ console.log('');
682
+ console.log('╭─ Lynkr Session Stats ────────────────────────────────');
683
+ console.log('│ Stats unavailable (session data not found)');
684
+ console.log('╰──────────────────────────────────────────────────────');
685
+ }
686
+ }