voyageai-cli 1.20.0 → 1.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.20.0",
3
+ "version": "1.20.1",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
package/src/cli.js CHANGED
@@ -67,6 +67,9 @@ ${pc.dim('Community tool — not an official MongoDB or Voyage AI product.')}
67
67
  ${pc.dim('Docs: https://www.mongodb.com/docs/voyageai/')}
68
68
  `);
69
69
 
70
+ // Anonymous telemetry (fire-and-forget, non-blocking)
71
+ const telemetry = require('./lib/telemetry');
72
+
70
73
  // If no args (just `vai`), show banner + quick start + help
71
74
  if (process.argv.length <= 2) {
72
75
  showBanner();
@@ -75,4 +78,10 @@ if (process.argv.length <= 2) {
75
78
  process.exit(0);
76
79
  }
77
80
 
81
+ // Track command usage after parsing
82
+ program.hook('preAction', (thisCommand) => {
83
+ const cmd = thisCommand.args?.[0] || thisCommand.name();
84
+ telemetry.send('cli_command', { command: cmd });
85
+ });
86
+
78
87
  program.parse();
@@ -149,6 +149,17 @@ function createPlaygroundServer() {
149
149
 
150
150
  // Parse JSON body for POST routes
151
151
  if (req.method === 'POST') {
152
+ // Check for API key before processing any API calls
153
+ const apiKeyConfigured = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));
154
+ if (!apiKeyConfigured) {
155
+ res.writeHead(401, { 'Content-Type': 'application/json' });
156
+ res.end(JSON.stringify({
157
+ error: 'No API key configured. Run: vai config set api-key <your-key>',
158
+ code: 'NO_API_KEY',
159
+ }));
160
+ return;
161
+ }
162
+
152
163
  const body = await readBody(req);
153
164
  let parsed;
154
165
  try {
@@ -816,7 +816,10 @@ const concepts = {
816
816
  'https://arxiv.org/abs/2203.02053',
817
817
  'https://blog.voyageai.com/2024/11/12/voyage-multimodal-3/',
818
818
  ],
819
- tryIt: [],
819
+ tryIt: [
820
+ 'vai explain multimodal-embeddings',
821
+ 'vai explain cross-modal-search',
822
+ ],
820
823
  },
821
824
 
822
825
  'multimodal-rag': {
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Anonymous telemetry for Vai CLI.
5
+ * Sends minimal, non-identifying data to help improve the tool.
6
+ * Respects: VAI_TELEMETRY=0 env var or `vai config set telemetry false`
7
+ *
8
+ * No API keys, no file contents, no PII. Just:
9
+ * - command name, version, platform, locale
10
+ */
11
+
12
+ const TELEMETRY_URL = 'https://vai.mlynn.org/api/telemetry';
13
+ const TIMEOUT_MS = 3000;
14
+
15
+ /**
16
+ * Check if telemetry is enabled.
17
+ * Disabled by: VAI_TELEMETRY=0, or config telemetry=false
18
+ */
19
+ function isEnabled() {
20
+ // Env var override (highest priority)
21
+ if (process.env.VAI_TELEMETRY === '0' || process.env.VAI_TELEMETRY === 'false') {
22
+ return false;
23
+ }
24
+ // Config file check
25
+ try {
26
+ const { getConfigValue } = require('./config');
27
+ const val = getConfigValue('telemetry');
28
+ if (val === false || val === 'false') return false;
29
+ } catch { /* config not available, default to enabled */ }
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * Send a telemetry event. Fire-and-forget, never throws.
35
+ * @param {string} event - Event name (e.g., 'cli_command')
36
+ * @param {object} [extra] - Additional fields
37
+ */
38
+ function send(event, extra = {}) {
39
+ if (!isEnabled()) return;
40
+
41
+ const { getVersion } = require('./banner');
42
+
43
+ const payload = JSON.stringify({
44
+ event,
45
+ version: getVersion(),
46
+ context: 'cli',
47
+ platform: `${process.platform}-${process.arch}`,
48
+ locale: Intl.DateTimeFormat().resolvedOptions().locale || undefined,
49
+ ...extra,
50
+ });
51
+
52
+ try {
53
+ // Use native https, fire-and-forget
54
+ const { request } = require('https');
55
+ const url = new URL(TELEMETRY_URL);
56
+ const req = request({
57
+ hostname: url.hostname,
58
+ path: url.pathname,
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'Content-Length': Buffer.byteLength(payload),
63
+ },
64
+ timeout: TIMEOUT_MS,
65
+ });
66
+ req.on('error', () => {}); // swallow
67
+ req.on('timeout', () => req.destroy());
68
+ req.end(payload);
69
+ } catch { /* telemetry should never break the CLI */ }
70
+ }
71
+
72
+ module.exports = { send, isEnabled };
@@ -141,6 +141,11 @@ body {
141
141
  padding: 0 4px;
142
142
  }
143
143
  .update-banner-dismiss:hover { color: var(--text); }
144
+ .update-progress { display: flex; align-items: center; gap: 8px; flex: 1; }
145
+ .update-progress-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; overflow: hidden; }
146
+ [data-theme="light"] .update-progress-bar { background: rgba(0,0,0,0.1); }
147
+ .update-progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; width: 0%; }
148
+ .update-progress-label { font-size: 12px; font-family: var(--mono); color: var(--accent); min-width: 40px; text-align: right; }
144
149
 
145
150
  /* ── Onboarding Walkthrough ── */
146
151
  .onboarding-overlay {
@@ -357,6 +362,12 @@ body {
357
362
  align-items: center;
358
363
  gap: 10px;
359
364
  }
365
+ body:not(.is-electron) .sidebar-drag-region {
366
+ height: auto;
367
+ min-height: auto;
368
+ padding-top: 16px;
369
+ padding-bottom: 12px;
370
+ }
360
371
 
361
372
  .sidebar-logo {
362
373
  width: 28px;
@@ -372,8 +383,27 @@ body {
372
383
  color: var(--accent-text);
373
384
  white-space: nowrap;
374
385
  letter-spacing: -0.2px;
386
+ flex: 1;
375
387
  }
376
388
 
389
+ .sidebar-settings-btn {
390
+ -webkit-app-region: no-drag;
391
+ background: none;
392
+ border: none;
393
+ cursor: pointer;
394
+ color: var(--text-muted);
395
+ padding: 4px;
396
+ border-radius: 4px;
397
+ display: flex;
398
+ align-items: center;
399
+ justify-content: center;
400
+ transition: all 0.15s;
401
+ flex-shrink: 0;
402
+ }
403
+ .sidebar-settings-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
404
+ .sidebar-settings-btn.active { color: var(--accent); }
405
+ [data-theme="light"] .sidebar-settings-btn:hover { background: rgba(0,0,0,0.04); }
406
+
377
407
  .sidebar-nav {
378
408
  flex: 1;
379
409
  overflow-y: auto;
@@ -537,6 +567,10 @@ body {
537
567
  flex-shrink: 0;
538
568
  }
539
569
 
570
+ /* Web mode: hide Electron-only elements */
571
+ body:not(.is-electron) .content-drag-region { display: none; }
572
+ body:not(.is-electron) .update-banner { display: none !important; }
573
+
540
574
  /* Main */
541
575
  .main { padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%; }
542
576
 
@@ -2261,7 +2295,7 @@ select:focus { outline: none; border-color: var(--accent); }
2261
2295
  <path fill-rule="evenodd" clip-rule="evenodd" d="M2 3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3ZM4 4V9.586L5.793 7.793C6.183 7.403 6.817 7.403 7.207 7.793L8.5 9.086L10.293 7.293C10.683 6.903 11.317 6.903 11.707 7.293L12 7.586V4H4ZM12 10.414L10.5 8.914L8.207 11.207C7.817 11.597 7.183 11.597 6.793 11.207L5.5 9.914L4 11.414V12H12V10.414ZM10 5.5C10 6.328 10.672 7 11.5 7C11.776 7 12 6.776 12 6.5V5C12 4.724 11.776 4.5 11.5 4.5H10.5C10.224 4.5 10 4.724 10 5V5.5Z" fill="currentColor"/>
2262
2296
  </symbol>
2263
2297
  <symbol id="lg-config" viewBox="0 0 16 16">
2264
- <path d="M12.7 2.56989C12.41 2.56989 12.17 2.80989 12.17 3.09989V4.34989L11.32 3.49989C9.21 1.39989 5.75 1.51989 3.78 3.75989C2.65 5.03989 2.23 6.82989 2.68 8.48989C3.24 10.5299 4.98 12.0099 7.08 12.2299L8.1 12.3399C8.42 13.2799 9.3 13.9499 10.34 13.9499C11.65 13.9499 12.71 12.8899 12.71 11.5799C12.71 10.2699 11.65 9.20989 10.34 9.20989C9.14 9.20989 8.16 10.0999 8 11.2599L7.19 11.1799C5.53 11.0099 4.14 9.82989 3.7 8.21989C3.34 6.90989 3.67 5.48989 4.58 4.46989C6.15 2.68989 8.9 2.59989 10.57 4.26989L11.42 5.11989H10.17C9.88 5.11989 9.64 5.35989 9.64 5.64989C9.64 5.93989 9.88 6.17989 10.17 6.17989H12.71C13 6.17989 13.24 5.93989 13.24 5.64989V3.09989C13.24 2.80989 13 2.56989 12.71 2.56989H12.7ZM10.34 10.3099C11.04 10.3099 11.6 10.8799 11.6 11.5699C11.6 12.2599 11.03 12.8299 10.34 12.8299C9.65 12.8299 9.08 12.2599 9.08 11.5699C9.08 10.8799 9.65 10.3099 10.34 10.3099Z" fill="currentColor"/>
2298
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.5A.5.5 0 0 1 7 1h2a.5.5 0 0 1 .5.5v1.05a5 5 0 0 1 1.37.57l.74-.74a.5.5 0 0 1 .7 0l1.42 1.42a.5.5 0 0 1 0 .7l-.74.74c.25.43.44.89.57 1.37H14.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-1.05a5 5 0 0 1-.57 1.37l.74.74a.5.5 0 0 1 0 .7l-1.42 1.42a.5.5 0 0 1-.7 0l-.74-.74a5 5 0 0 1-1.37.57V14.5a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5v-1.05a5 5 0 0 1-1.37-.57l-.74.74a.5.5 0 0 1-.7 0L2.27 12.2a.5.5 0 0 1 0-.7l.74-.74a5 5 0 0 1-.57-1.37H1.5A.5.5 0 0 1 1 9V7a.5.5 0 0 1 .5-.5h1.05c.13-.48.32-.94.57-1.37l-.74-.74a.5.5 0 0 1 0-.7L3.8 2.27a.5.5 0 0 1 .7 0l.74.74A5 5 0 0 1 6.6 2.44V1.5zM8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z" fill="currentColor"/>
2265
2299
  </symbol>
2266
2300
  </svg>
2267
2301
 
@@ -2272,6 +2306,7 @@ select:focus { outline: none; border-color: var(--accent); }
2272
2306
  <div class="sidebar-drag-region">
2273
2307
  <img class="sidebar-logo" id="sidebarLogo" src="/icons/dark/64.png" alt="Vai">
2274
2308
  <span class="sidebar-title">Vai</span>
2309
+ <button class="sidebar-settings-btn" data-tab="settings" title="Settings"><svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-config"/></svg></button>
2275
2310
  </div>
2276
2311
  <nav class="sidebar-nav">
2277
2312
  <div class="sidebar-nav-group">
@@ -2288,8 +2323,6 @@ select:focus { outline: none; border-color: var(--accent); }
2288
2323
  <button class="tab-btn" data-tab="explore"><span class="tab-btn-icon"><svg><use href="#lg-bulb"/></svg></span><span>Explore</span></button>
2289
2324
  <button class="tab-btn" data-tab="about"><span class="tab-btn-icon"><svg><use href="#lg-info"/></svg></span><span>About</span></button>
2290
2325
  </div>
2291
- <div class="sidebar-nav-divider"></div>
2292
- <button class="tab-btn" data-tab="settings"><span class="tab-btn-icon"><svg><use href="#lg-config"/></svg></span><span>Settings</span></button>
2293
2326
  </nav>
2294
2327
  <div class="sidebar-footer">
2295
2328
  <div style="display:flex;align-items:center;gap:8px;justify-content:space-between;">
@@ -2308,11 +2341,16 @@ select:focus { outline: none; border-color: var(--accent); }
2308
2341
  <div class="content-drag-region"></div>
2309
2342
  <div class="update-banner" id="updateBanner">
2310
2343
  <span class="update-banner-icon">🚀</span>
2311
- <span class="update-banner-text">
2344
+ <span class="update-banner-text" id="updateBannerText">
2312
2345
  <strong>Vai <span id="updateVersion"></span></strong> is available.
2313
2346
  You're on <span id="currentVersion"></span>.
2314
2347
  </span>
2315
- <button class="update-banner-btn" id="updateDownloadBtn">Download</button>
2348
+ <div class="update-progress" id="updateProgress" style="display:none;">
2349
+ <div class="update-progress-bar"><div class="update-progress-fill" id="updateProgressFill"></div></div>
2350
+ <span class="update-progress-label" id="updateProgressLabel">0%</span>
2351
+ </div>
2352
+ <button class="update-banner-btn" id="updateDownloadBtn">Download &amp; Install</button>
2353
+ <button class="update-banner-btn" id="updateInstallBtn" style="display:none;">Restart &amp; Update</button>
2316
2354
  <button class="update-banner-dismiss" id="updateDismissBtn" title="Dismiss">×</button>
2317
2355
  </div>
2318
2356
  <div class="main">
@@ -2966,8 +3004,20 @@ Reranking models rescore initial search results to improve relevance ordering.</
2966
3004
  <strong>⚡ Embed</strong> — Generate vector embeddings for any text<br>
2967
3005
  <strong>⚖️ Compare</strong> — Measure similarity with cosine, dot product &amp; euclidean distance<br>
2968
3006
  <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
3007
+ <strong>🔮 Multimodal</strong> — Compare images and text in the same vector space with voyage-multimodal-3.5<br>
2969
3008
  <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
2970
- <strong>📚 Explore</strong> — Learn about embeddings, vector search, RAG, and more
3009
+ <strong>📚 Explore</strong> — Learn about embeddings, vector search, multimodal, RAG, and more
3010
+ </div>
3011
+ </div>
3012
+
3013
+ <div class="about-section">
3014
+ <div class="about-section-title">Supported Models</div>
3015
+ <div class="about-text" style="font-size:13px;">
3016
+ <strong style="color:var(--accent);">Text Embeddings</strong> — voyage-4-large, voyage-4, voyage-4-lite, voyage-code-3, voyage-finance-2, voyage-law-2<br>
3017
+ <strong style="color:var(--accent);">Multimodal</strong> — voyage-multimodal-3.5 (text + images + video in one vector space)<br>
3018
+ <strong style="color:var(--accent);">Reranking</strong> — rerank-2.5, rerank-2.5-lite<br><br>
3019
+ All models are accessible via the <a href="https://docs.voyageai.com/" target="_blank" style="color:var(--blue);">Voyage AI API</a>
3020
+ or through <a href="https://www.mongodb.com/docs/atlas/atlas-vector-search/" target="_blank" style="color:var(--blue);">MongoDB Atlas Vector Search</a>.
2971
3021
  </div>
2972
3022
  </div>
2973
3023
 
@@ -2983,8 +3033,20 @@ Reranking models rescore initial search results to improve relevance ordering.</
2983
3033
  </div>
2984
3034
  </div>
2985
3035
 
3036
+ <div class="card" style="margin-top:16px;">
3037
+ <div class="about-section" style="padding-bottom:0;">
3038
+ <div class="about-section-title">What's New</div>
3039
+ <div class="about-text" style="font-size:13px;">
3040
+ <strong>v1.2</strong> — Multimodal tab (image ↔ text similarity, cross-modal gallery search),
3041
+ 4 new Explore concepts (multimodal embeddings, cross-modal search, modality gap, multimodal RAG),
3042
+ auto-update with in-app download &amp; restart, and a hidden easter egg 🕹️
3043
+ </div>
3044
+ </div>
3045
+ </div>
3046
+
2986
3047
  <div style="text-align:center;margin-top:16px;font-size:12px;color:var(--text-muted);">
2987
3048
  Made with ☕ and curiosity · <a href="https://github.com/mrlynn/voyageai-cli" target="_blank" style="color:var(--text-dim);">Source on GitHub</a>
3049
+ · <a href="https://github.com/mrlynn/voyageai-cli/releases" target="_blank" style="color:var(--text-dim);">Releases</a>
2988
3050
  </div>
2989
3051
  </div>
2990
3052
  </div>
@@ -3191,6 +3253,15 @@ Reranking models rescore initial search results to improve relevance ordering.</
3191
3253
  <button class="settings-reset-btn" id="settingsClearCache">Clear All Data</button>
3192
3254
  </div>
3193
3255
  </div>
3256
+ <div class="settings-row">
3257
+ <div class="settings-label">
3258
+ <span class="settings-label-text">Anonymous Usage Analytics</span>
3259
+ <span class="settings-label-hint">Help improve Vai by sharing anonymous usage data (version, platform, features used). No API keys or personal data.</span>
3260
+ </div>
3261
+ <div class="settings-control">
3262
+ <button class="settings-toggle active" id="settingsTelemetry" type="button"></button>
3263
+ </div>
3264
+ </div>
3194
3265
  </div>
3195
3266
 
3196
3267
  <!-- Version Info (visible in Electron only) -->
@@ -3313,24 +3384,85 @@ let rerankModels = [];
3313
3384
  let lastEmbedding = null;
3314
3385
 
3315
3386
  // ── Init ──
3387
+ // ── Anonymous Telemetry ──
3388
+ const TELEMETRY_URL = 'https://vai.mlynn.org/api/telemetry';
3389
+ const TELEMETRY_KEY = 'vai-telemetry-enabled';
3390
+
3391
+ function isTelemetryEnabled() {
3392
+ const stored = localStorage.getItem(TELEMETRY_KEY);
3393
+ return stored === null ? true : stored === 'true'; // default on
3394
+ }
3395
+
3396
+ function sendTelemetry(event, extra = {}) {
3397
+ if (!isTelemetryEnabled()) return;
3398
+ try {
3399
+ const isElectron = !!(window.vai && window.vai.isElectron);
3400
+ const payload = {
3401
+ event,
3402
+ version: document.getElementById('appVersionLabel')?.textContent?.replace('v','') || 'web',
3403
+ context: isElectron ? 'electron' : 'web',
3404
+ platform: navigator.platform || undefined,
3405
+ locale: navigator.language || undefined,
3406
+ ...extra,
3407
+ };
3408
+ // Use sendBeacon for fire-and-forget (survives page unload)
3409
+ if (navigator.sendBeacon) {
3410
+ navigator.sendBeacon(TELEMETRY_URL, JSON.stringify(payload));
3411
+ } else {
3412
+ fetch(TELEMETRY_URL, {
3413
+ method: 'POST',
3414
+ headers: { 'Content-Type': 'application/json' },
3415
+ body: JSON.stringify(payload),
3416
+ keepalive: true,
3417
+ }).catch(() => {});
3418
+ }
3419
+ } catch { /* telemetry should never break the app */ }
3420
+ }
3421
+
3422
+ function initTelemetryToggle() {
3423
+ const toggle = document.getElementById('settingsTelemetry');
3424
+ if (!toggle) return;
3425
+ const enabled = isTelemetryEnabled();
3426
+ toggle.classList.toggle('active', enabled);
3427
+ toggle.addEventListener('click', () => {
3428
+ const nowEnabled = !toggle.classList.contains('active');
3429
+ toggle.classList.toggle('active', nowEnabled);
3430
+ localStorage.setItem(TELEMETRY_KEY, String(nowEnabled));
3431
+ showSettingsSaved();
3432
+ });
3433
+ }
3434
+
3316
3435
  async function init() {
3436
+ // Mark body for Electron vs Web styling
3437
+ if (window.vai && window.vai.isElectron) {
3438
+ document.body.classList.add('is-electron');
3439
+ }
3317
3440
  setupTabs();
3318
3441
  await loadConfig();
3319
3442
  await Promise.all([loadModels(), loadConcepts()]);
3320
3443
  populateModelSelects();
3321
3444
  buildExploreCards();
3445
+
3446
+ // Telemetry
3447
+ initTelemetryToggle();
3448
+ sendTelemetry('app_launch');
3322
3449
  }
3323
3450
 
3324
3451
  // ── Tabs ──
3325
3452
  function setupTabs() {
3326
3453
  document.querySelectorAll('.tab-btn').forEach(btn => {
3327
3454
  btn.addEventListener('click', () => {
3328
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
3329
- document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
3330
- btn.classList.add('active');
3331
- document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
3455
+ switchTab(btn.dataset.tab);
3332
3456
  });
3333
3457
  });
3458
+
3459
+ // Settings cog in header
3460
+ const settingsCog = document.querySelector('.sidebar-settings-btn');
3461
+ if (settingsCog) {
3462
+ settingsCog.addEventListener('click', () => {
3463
+ switchTab(settingsCog.dataset.tab);
3464
+ });
3465
+ }
3334
3466
  }
3335
3467
 
3336
3468
  function switchTab(tab) {
@@ -3340,6 +3472,11 @@ function switchTab(tab) {
3340
3472
  document.querySelectorAll('.tab-panel').forEach(p => {
3341
3473
  p.classList.toggle('active', p.id === 'tab-' + tab);
3342
3474
  });
3475
+ // Sync settings cog highlight
3476
+ const settingsBtn = document.querySelector('.sidebar-settings-btn');
3477
+ if (settingsBtn) settingsBtn.classList.toggle('active', tab === 'settings');
3478
+ // Track tab views
3479
+ sendTelemetry('tab_view', { tab });
3343
3480
  }
3344
3481
  // Expose globally so Electron main process can call it
3345
3482
  window.switchTab = switchTab;
@@ -3419,13 +3556,23 @@ function formatBytesUI(bytes) {
3419
3556
  }
3420
3557
 
3421
3558
  async function apiPost(url, body) {
3422
- const res = await fetch(url, {
3423
- method: 'POST',
3424
- headers: { 'Content-Type': 'application/json' },
3425
- body: JSON.stringify(body),
3426
- });
3559
+ let res;
3560
+ try {
3561
+ res = await fetch(url, {
3562
+ method: 'POST',
3563
+ headers: { 'Content-Type': 'application/json' },
3564
+ body: JSON.stringify(body),
3565
+ });
3566
+ } catch (fetchErr) {
3567
+ throw new Error('Failed to connect to server. Is the playground still running?');
3568
+ }
3427
3569
  const data = await res.json();
3428
- if (!res.ok) throw new Error(data.error || data.detail || 'API error');
3570
+ if (!res.ok) {
3571
+ if (data.code === 'NO_API_KEY') {
3572
+ throw new Error('🔑 No API key configured. Open Settings (⚙) to add your Voyage AI API key, or run: vai config set api-key <your-key>');
3573
+ }
3574
+ throw new Error(data.error || data.detail || 'API error');
3575
+ }
3429
3576
  return data;
3430
3577
  }
3431
3578
 
@@ -3456,6 +3603,7 @@ window.doEmbed = async function() {
3456
3603
  hideError('embedError');
3457
3604
  const text = document.getElementById('embedInput').value.trim();
3458
3605
  if (!text) { showError('embedError', 'Enter some text to embed'); return; }
3606
+ sendTelemetry('api_call', { endpoint: 'embed', model: document.getElementById('embedModel').value });
3459
3607
 
3460
3608
  setLoading('embedBtn', true);
3461
3609
  try {
@@ -3538,6 +3686,7 @@ function buildHeatmap(vec, container) {
3538
3686
  // ── Compare ──
3539
3687
  window.doCompare = async function() {
3540
3688
  hideError('compareError');
3689
+ sendTelemetry('api_call', { endpoint: 'compare', model: document.getElementById('compareModel').value });
3541
3690
  const a = document.getElementById('compareA').value.trim();
3542
3691
  const b = document.getElementById('compareB').value.trim();
3543
3692
  if (!a || !b) { showError('compareError', 'Enter text in both fields'); return; }
@@ -3630,6 +3779,7 @@ window.doCompare = async function() {
3630
3779
  // ── Search ──
3631
3780
  window.doSearch = async function(withRerank) {
3632
3781
  hideError('searchError');
3782
+ sendTelemetry('api_call', { endpoint: withRerank ? 'rerank' : 'search' });
3633
3783
  const query = document.getElementById('searchQuery').value.trim();
3634
3784
  const docsText = document.getElementById('searchDocs').value.trim();
3635
3785
  if (!query || !docsText) { showError('searchError', 'Enter a query and documents'); return; }
@@ -5095,15 +5245,12 @@ function checkForAppUpdate() {
5095
5245
  // Show version in sidebar + settings page
5096
5246
  window.vai.getVersion().then(v => {
5097
5247
  if (!v) return;
5098
- // v can be string (old) or { app, cli } (new)
5099
5248
  const appVer = typeof v === 'object' ? v.app : v;
5100
5249
  const cliVer = typeof v === 'object' ? v.cli : null;
5101
5250
 
5102
- // Sidebar label
5103
5251
  const label = document.getElementById('appVersionLabel');
5104
5252
  if (label && appVer) label.textContent = 'v' + appVer;
5105
5253
 
5106
- // Settings version section
5107
5254
  const section = document.getElementById('settingsVersionSection');
5108
5255
  const appBadge = document.getElementById('settingsAppVersion');
5109
5256
  const cliBadge = document.getElementById('settingsCliVersion');
@@ -5117,7 +5264,43 @@ function checkForAppUpdate() {
5117
5264
  const dismissed = localStorage.getItem(DISMISS_KEY);
5118
5265
  if (dismissed) {
5119
5266
  const dismissedAt = parseInt(dismissed, 10);
5120
- if (Date.now() - dismissedAt < 3600000) return; // 1 hour cooldown
5267
+ if (Date.now() - dismissedAt < 3600000) return;
5268
+ }
5269
+
5270
+ // Listen for real-time update events from electron-updater
5271
+ if (window.vai.updates.onEvent) {
5272
+ window.vai.updates.onEvent((data) => {
5273
+ const banner = document.getElementById('updateBanner');
5274
+ const progressWrap = document.getElementById('updateProgress');
5275
+ const progressFill = document.getElementById('updateProgressFill');
5276
+ const progressLabel = document.getElementById('updateProgressLabel');
5277
+ const bannerText = document.getElementById('updateBannerText');
5278
+ const downloadBtn = document.getElementById('updateDownloadBtn');
5279
+ const installBtn = document.getElementById('updateInstallBtn');
5280
+
5281
+ if (data.event === 'download-progress') {
5282
+ const pct = Math.round(data.percent || 0);
5283
+ progressFill.style.width = pct + '%';
5284
+ const mbTransferred = (data.transferred / 1048576).toFixed(1);
5285
+ const mbTotal = (data.total / 1048576).toFixed(1);
5286
+ progressLabel.textContent = `${pct}% (${mbTransferred}/${mbTotal} MB)`;
5287
+ }
5288
+
5289
+ if (data.event === 'update-downloaded') {
5290
+ progressWrap.style.display = 'none';
5291
+ bannerText.innerHTML = `<strong>Vai v${data.latestVersion}</strong> is ready to install.`;
5292
+ bannerText.style.display = '';
5293
+ downloadBtn.style.display = 'none';
5294
+ installBtn.style.display = '';
5295
+ }
5296
+
5297
+ if (data.event === 'update-error') {
5298
+ progressWrap.style.display = 'none';
5299
+ bannerText.innerHTML = `Update failed: ${data.message}. <a href="#" onclick="window.vai.updates.openRelease('https://github.com/mrlynn/voyageai-cli/releases');return false;" style="color:var(--accent);">Download manually</a>`;
5300
+ bannerText.style.display = '';
5301
+ downloadBtn.style.display = 'none';
5302
+ }
5303
+ });
5121
5304
  }
5122
5305
 
5123
5306
  window.vai.updates.check().then(result => {
@@ -5127,7 +5310,10 @@ function checkForAppUpdate() {
5127
5310
  const versionEl = document.getElementById('updateVersion');
5128
5311
  const currentEl = document.getElementById('currentVersion');
5129
5312
  const downloadBtn = document.getElementById('updateDownloadBtn');
5313
+ const installBtn = document.getElementById('updateInstallBtn');
5130
5314
  const dismissBtn = document.getElementById('updateDismissBtn');
5315
+ const bannerText = document.getElementById('updateBannerText');
5316
+ const progressWrap = document.getElementById('updateProgress');
5131
5317
 
5132
5318
  if (!banner) return;
5133
5319
 
@@ -5136,7 +5322,24 @@ function checkForAppUpdate() {
5136
5322
  banner.classList.add('show');
5137
5323
 
5138
5324
  downloadBtn.addEventListener('click', () => {
5139
- window.vai.updates.openRelease(result.releaseUrl);
5325
+ // Start auto-download
5326
+ if (window.vai.updates.download) {
5327
+ downloadBtn.style.display = 'none';
5328
+ bannerText.style.display = 'none';
5329
+ progressWrap.style.display = 'flex';
5330
+ window.vai.updates.download().catch(() => {
5331
+ // Fallback to manual download
5332
+ window.vai.updates.openRelease(result.releaseUrl);
5333
+ });
5334
+ } else {
5335
+ window.vai.updates.openRelease(result.releaseUrl);
5336
+ }
5337
+ });
5338
+
5339
+ installBtn.addEventListener('click', () => {
5340
+ installBtn.textContent = 'Restarting...';
5341
+ installBtn.disabled = true;
5342
+ window.vai.updates.install();
5140
5343
  });
5141
5344
 
5142
5345
  dismissBtn.addEventListener('click', () => {
@@ -5466,6 +5669,7 @@ window.clearMultimodalImage = function() {
5466
5669
 
5467
5670
  window.doMultimodalCompare = async function() {
5468
5671
  hideError('mmError');
5672
+ sendTelemetry('api_call', { endpoint: 'multimodal-compare', model: document.getElementById('mmModel').value });
5469
5673
  const text = document.getElementById('mmText').value.trim();
5470
5674
  if (!mmImageData) { showError('mmError', 'Upload an image first'); return; }
5471
5675
  if (!text) { showError('mmError', 'Enter text to compare against the image'); return; }
@@ -5617,6 +5821,7 @@ window.setMmSearchMode = function(mode) {
5617
5821
 
5618
5822
  window.doMultimodalSearch = async function() {
5619
5823
  hideError('mmSearchError');
5824
+ sendTelemetry('api_call', { endpoint: 'multimodal-search', model: document.getElementById('mmModel').value });
5620
5825
 
5621
5826
  const corpusText = document.getElementById('mmCorpusText').value.trim();
5622
5827
  const textItems = corpusText ? corpusText.split('\n').map(t => t.trim()).filter(Boolean) : [];
@@ -5732,6 +5937,365 @@ init = async function() {
5732
5937
  initOnboarding();
5733
5938
  };
5734
5939
 
5940
+ // ── Start ──
5941
+ init();
5942
+
5943
+ // ── 🕹️ Vector Space Invaders (Easter Egg) ──
5944
+ // Trigger: Konami code (↑↑↓↓←→←→BA) or 7 rapid clicks on sidebar logo
5945
+ (function initEasterEgg() {
5946
+ const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
5947
+ let konamiIdx = 0;
5948
+ let logoClicks = [];
5949
+
5950
+ document.addEventListener('keydown', (e) => {
5951
+ if (document.getElementById('vsiOverlay')?.style.display === 'flex') return;
5952
+ if (e.key === KONAMI[konamiIdx]) { konamiIdx++; if (konamiIdx === KONAMI.length) { konamiIdx = 0; launchVSI(); } }
5953
+ else { konamiIdx = 0; }
5954
+ });
5955
+
5956
+ const logo = document.getElementById('sidebarLogo');
5957
+ if (logo) {
5958
+ logo.addEventListener('click', () => {
5959
+ const now = Date.now();
5960
+ logoClicks.push(now);
5961
+ logoClicks = logoClicks.filter(t => now - t < 2000);
5962
+ if (logoClicks.length >= 7) { logoClicks = []; launchVSI(); }
5963
+ });
5964
+ }
5965
+
5966
+ function launchVSI() {
5967
+ const overlay = document.getElementById('vsiOverlay');
5968
+ const canvas = document.getElementById('vsiCanvas');
5969
+ if (!overlay || !canvas) return;
5970
+ overlay.style.display = 'flex';
5971
+ startVSI(canvas, () => { overlay.style.display = 'none'; });
5972
+ }
5973
+ })();
5974
+
5975
+ function startVSI(canvas, onExit) {
5976
+ const ctx = canvas.getContext('2d');
5977
+ const W = canvas.width = 600;
5978
+ const H = canvas.height = 500;
5979
+
5980
+ // Ship
5981
+ const ship = { x: W / 2, w: 40, h: 16, speed: 5 };
5982
+ const bullets = [];
5983
+ const particles = [];
5984
+ const explosionTexts = [];
5985
+
5986
+ // Enemies — vectors descending
5987
+ const VECTORS = [
5988
+ '[.23,-.41]', '[.95, .12]', '[-.56,.78]',
5989
+ '[.44,-.71]', '[.01,-.99]', '[-.88,.35]',
5990
+ '[.67, .91]', '[-.12,.84]', '[.33, .33]',
5991
+ '[.77,-.64]', '[-.45,.78]', '[.91,-.42]',
5992
+ ];
5993
+ const BOSS_VEC = 'CLIP-ViT-L/14-336';
5994
+
5995
+ let enemies = [];
5996
+ let wave = 1;
5997
+ let score = 0;
5998
+ let lives = 3;
5999
+ let gameOver = false;
6000
+ let bossMode = false;
6001
+ let boss = null;
6002
+ let frame = 0;
6003
+ let lastShot = 0;
6004
+ let waveQueued = false;
6005
+
6006
+ const keys = {};
6007
+ const keyDown = (e) => { keys[e.key] = true; if (['ArrowLeft','ArrowRight',' ','Escape'].includes(e.key)) e.preventDefault(); if (e.key === 'Escape') { cleanup(); onExit(); } };
6008
+ const keyUp = (e) => { keys[e.key] = false; };
6009
+ document.addEventListener('keydown', keyDown);
6010
+ document.addEventListener('keyup', keyUp);
6011
+
6012
+ function cleanup() { document.removeEventListener('keydown', keyDown); document.removeEventListener('keyup', keyUp); gameOver = true; }
6013
+
6014
+ function spawnWave() {
6015
+ enemies = [];
6016
+ if (wave % 5 === 0) {
6017
+ // Boss wave every 5 waves
6018
+ bossMode = true;
6019
+ boss = { x: W / 2, y: 50, w: 180, h: 30, hp: 10 + wave, maxHp: 10 + wave, dx: 2, text: BOSS_VEC };
6020
+ } else {
6021
+ bossMode = false;
6022
+ boss = null;
6023
+ const rows = Math.min(3, 1 + Math.floor(wave / 2));
6024
+ const cols = Math.min(6, 3 + Math.floor(wave / 3));
6025
+ for (let r = 0; r < rows; r++) {
6026
+ for (let c = 0; c < cols; c++) {
6027
+ enemies.push({
6028
+ x: 60 + c * 90,
6029
+ y: 40 + r * 40,
6030
+ w: 78, h: 22,
6031
+ hp: 1 + Math.floor(wave / 4),
6032
+ text: VECTORS[(r * cols + c) % VECTORS.length],
6033
+ dx: 1 + wave * 0.3,
6034
+ dir: 1,
6035
+ });
6036
+ }
6037
+ }
6038
+ }
6039
+ }
6040
+
6041
+ function shoot() {
6042
+ if (frame - lastShot < 10) return;
6043
+ lastShot = frame;
6044
+ bullets.push({ x: ship.x, y: H - 40, dy: -7 });
6045
+ }
6046
+
6047
+ function addExplosion(x, y, sim) {
6048
+ const color = sim > 0.7 ? '#00ED64' : sim > 0.4 ? '#FFC010' : '#FF6960';
6049
+ for (let i = 0; i < 8; i++) {
6050
+ const angle = (Math.PI * 2 / 8) * i + Math.random() * 0.5;
6051
+ const speed = 1.5 + Math.random() * 2;
6052
+ particles.push({ x, y, dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed, life: 30, color });
6053
+ }
6054
+ explosionTexts.push({ x, y, text: sim.toFixed(2), life: 40, color });
6055
+ }
6056
+
6057
+ function update() {
6058
+ // Move ship
6059
+ if (keys['ArrowLeft'] || keys['a']) ship.x = Math.max(ship.w / 2, ship.x - ship.speed);
6060
+ if (keys['ArrowRight'] || keys['d']) ship.x = Math.min(W - ship.w / 2, ship.x + ship.speed);
6061
+ if (keys[' ']) shoot();
6062
+
6063
+ // Move bullets
6064
+ for (let i = bullets.length - 1; i >= 0; i--) {
6065
+ bullets[i].y += bullets[i].dy;
6066
+ if (bullets[i].y < -10) bullets.splice(i, 1);
6067
+ }
6068
+
6069
+ // Boss logic
6070
+ if (bossMode && boss) {
6071
+ boss.x += boss.dx;
6072
+ if (boss.x < boss.w / 2 || boss.x > W - boss.w / 2) boss.dx *= -1;
6073
+ // Boss drops enemy vectors periodically
6074
+ if (frame % 60 === 0) {
6075
+ enemies.push({ x: boss.x, y: boss.y + 30, w: 78, h: 22, hp: 1, text: VECTORS[frame % VECTORS.length], dx: 0, dir: 0, dropping: true, dy: 2 });
6076
+ }
6077
+ // Bullet-boss collision
6078
+ for (let i = bullets.length - 1; i >= 0; i--) {
6079
+ const b = bullets[i];
6080
+ if (b.x > boss.x - boss.w / 2 && b.x < boss.x + boss.w / 2 && b.y > boss.y - boss.h / 2 && b.y < boss.y + boss.h / 2) {
6081
+ boss.hp--;
6082
+ bullets.splice(i, 1);
6083
+ const sim = 0.1 + Math.random() * 0.3;
6084
+ addExplosion(b.x, b.y, sim);
6085
+ score += 50;
6086
+ if (boss.hp <= 0) {
6087
+ addExplosion(boss.x, boss.y, 0.99);
6088
+ score += 500;
6089
+ boss = null;
6090
+ bossMode = false;
6091
+ wave++;
6092
+ waveQueued = true;
6093
+ setTimeout(() => { waveQueued = false; spawnWave(); }, 1000);
6094
+ }
6095
+ }
6096
+ }
6097
+ }
6098
+
6099
+ // Move enemies
6100
+ let edgeHit = false;
6101
+ for (const e of enemies) {
6102
+ if (e.dropping) {
6103
+ e.y += e.dy || 2;
6104
+ } else {
6105
+ e.x += e.dx * e.dir;
6106
+ if (e.x < 30 || e.x > W - 30) edgeHit = true;
6107
+ }
6108
+ }
6109
+ if (edgeHit) {
6110
+ for (const e of enemies) {
6111
+ if (!e.dropping) { e.dir *= -1; e.y += 15; }
6112
+ }
6113
+ }
6114
+
6115
+ // Bullet-enemy collision
6116
+ for (let i = bullets.length - 1; i >= 0; i--) {
6117
+ for (let j = enemies.length - 1; j >= 0; j--) {
6118
+ const b = bullets[i], e = enemies[j];
6119
+ if (!b) break;
6120
+ if (b.x > e.x - e.w / 2 && b.x < e.x + e.w / 2 && b.y > e.y - e.h / 2 && b.y < e.y + e.h / 2) {
6121
+ e.hp--;
6122
+ bullets.splice(i, 1);
6123
+ const sim = 0.5 + Math.random() * 0.5;
6124
+ addExplosion(b.x, b.y, sim);
6125
+ if (e.hp <= 0) {
6126
+ enemies.splice(j, 1);
6127
+ score += 100;
6128
+ }
6129
+ break;
6130
+ }
6131
+ }
6132
+ }
6133
+
6134
+ // Enemy reaching bottom = lose life
6135
+ for (let i = enemies.length - 1; i >= 0; i--) {
6136
+ if (enemies[i].y > H - 50) {
6137
+ enemies.splice(i, 1);
6138
+ lives--;
6139
+ if (lives <= 0) gameOver = true;
6140
+ }
6141
+ }
6142
+
6143
+ // Next wave
6144
+ if (!bossMode && enemies.length === 0 && !gameOver && !waveQueued) {
6145
+ wave++;
6146
+ waveQueued = true;
6147
+ setTimeout(() => { waveQueued = false; spawnWave(); }, 800);
6148
+ }
6149
+
6150
+ // Particles
6151
+ for (let i = particles.length - 1; i >= 0; i--) {
6152
+ const p = particles[i];
6153
+ p.x += p.dx; p.y += p.dy; p.life--;
6154
+ if (p.life <= 0) particles.splice(i, 1);
6155
+ }
6156
+ for (let i = explosionTexts.length - 1; i >= 0; i--) {
6157
+ explosionTexts[i].y -= 0.5;
6158
+ explosionTexts[i].life--;
6159
+ if (explosionTexts[i].life <= 0) explosionTexts.splice(i, 1);
6160
+ }
6161
+ }
6162
+
6163
+ function draw() {
6164
+ ctx.fillStyle = '#001E2B';
6165
+ ctx.fillRect(0, 0, W, H);
6166
+
6167
+ // Stars
6168
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
6169
+ for (let i = 0; i < 40; i++) {
6170
+ const sx = (i * 137 + frame * 0.1) % W;
6171
+ const sy = (i * 97 + frame * 0.05) % H;
6172
+ ctx.fillRect(sx, sy, 1, 1);
6173
+ }
6174
+
6175
+ // Ship (triangle)
6176
+ ctx.fillStyle = '#00ED64';
6177
+ ctx.beginPath();
6178
+ ctx.moveTo(ship.x, H - 40);
6179
+ ctx.lineTo(ship.x - ship.w / 2, H - 24);
6180
+ ctx.lineTo(ship.x + ship.w / 2, H - 24);
6181
+ ctx.closePath();
6182
+ ctx.fill();
6183
+ // Ship label
6184
+ ctx.fillStyle = '#00ED64';
6185
+ ctx.font = '9px monospace';
6186
+ ctx.textAlign = 'center';
6187
+ ctx.fillText('query', ship.x, H - 14);
6188
+
6189
+ // Bullets
6190
+ ctx.fillStyle = '#00ED64';
6191
+ for (const b of bullets) {
6192
+ ctx.fillRect(b.x - 1, b.y, 2, 8);
6193
+ }
6194
+
6195
+ // Boss
6196
+ if (boss) {
6197
+ const hpPct = boss.hp / boss.maxHp;
6198
+ ctx.fillStyle = hpPct > 0.5 ? '#FF6960' : '#FFC010';
6199
+ ctx.strokeStyle = '#FF6960';
6200
+ ctx.lineWidth = 2;
6201
+ ctx.strokeRect(boss.x - boss.w / 2, boss.y - boss.h / 2, boss.w, boss.h);
6202
+ ctx.fillStyle = '#FF6960';
6203
+ ctx.font = 'bold 13px monospace';
6204
+ ctx.textAlign = 'center';
6205
+ ctx.fillText(boss.text, boss.x, boss.y + 5);
6206
+ // HP bar
6207
+ ctx.fillStyle = '#3D4F58';
6208
+ ctx.fillRect(boss.x - boss.w / 2, boss.y - boss.h / 2 - 8, boss.w, 4);
6209
+ ctx.fillStyle = '#FF6960';
6210
+ ctx.fillRect(boss.x - boss.w / 2, boss.y - boss.h / 2 - 8, boss.w * hpPct, 4);
6211
+ }
6212
+
6213
+ // Enemies
6214
+ for (const e of enemies) {
6215
+ ctx.fillStyle = 'rgba(61,79,88,0.6)';
6216
+ ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
6217
+ ctx.strokeStyle = '#0498EC';
6218
+ ctx.lineWidth = 1;
6219
+ ctx.strokeRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
6220
+ ctx.fillStyle = '#E8EDEB';
6221
+ ctx.font = '9px monospace';
6222
+ ctx.textAlign = 'center';
6223
+ ctx.fillText(e.text, e.x, e.y + 3);
6224
+ }
6225
+
6226
+ // Particles
6227
+ for (const p of particles) {
6228
+ ctx.globalAlpha = p.life / 30;
6229
+ ctx.fillStyle = p.color;
6230
+ ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
6231
+ }
6232
+ ctx.globalAlpha = 1;
6233
+
6234
+ // Explosion texts (similarity scores)
6235
+ for (const et of explosionTexts) {
6236
+ ctx.globalAlpha = et.life / 40;
6237
+ ctx.fillStyle = et.color;
6238
+ ctx.font = 'bold 14px monospace';
6239
+ ctx.textAlign = 'center';
6240
+ ctx.fillText(et.text, et.x, et.y);
6241
+ }
6242
+ ctx.globalAlpha = 1;
6243
+
6244
+ // HUD
6245
+ ctx.fillStyle = '#E8EDEB';
6246
+ ctx.font = '13px monospace';
6247
+ ctx.textAlign = 'left';
6248
+ ctx.fillText(`SCORE: ${score}`, 12, 20);
6249
+ ctx.fillText(`WAVE: ${wave}`, 12, 38);
6250
+ ctx.textAlign = 'right';
6251
+ ctx.fillText('❤️'.repeat(lives), W - 12, 20);
6252
+
6253
+ // Title
6254
+ ctx.fillStyle = '#00ED64';
6255
+ ctx.font = 'bold 10px monospace';
6256
+ ctx.textAlign = 'center';
6257
+ ctx.fillText('VECTOR SPACE INVADERS', W / 2, H - 4);
6258
+
6259
+ // Game over
6260
+ if (gameOver) {
6261
+ ctx.fillStyle = 'rgba(0,30,43,0.8)';
6262
+ ctx.fillRect(0, 0, W, H);
6263
+ ctx.fillStyle = '#00ED64';
6264
+ ctx.font = 'bold 28px monospace';
6265
+ ctx.textAlign = 'center';
6266
+ ctx.fillText('GAME OVER', W / 2, H / 2 - 30);
6267
+ ctx.fillStyle = '#E8EDEB';
6268
+ ctx.font = '16px monospace';
6269
+ ctx.fillText(`Final Score: ${score} | Wave: ${wave}`, W / 2, H / 2 + 10);
6270
+ ctx.fillStyle = '#889397';
6271
+ ctx.font = '12px monospace';
6272
+ ctx.fillText('Press SPACE to play again | ESC to exit', W / 2, H / 2 + 40);
6273
+
6274
+ if (keys[' ']) {
6275
+ // Restart
6276
+ score = 0; wave = 1; lives = 3; gameOver = false; waveQueued = false;
6277
+ enemies = []; bullets.length = 0; particles.length = 0; explosionTexts.length = 0;
6278
+ boss = null; bossMode = false; ship.x = W / 2;
6279
+ spawnWave();
6280
+ }
6281
+ }
6282
+ }
6283
+
6284
+ function loop() {
6285
+ if (!document.getElementById('vsiOverlay') || document.getElementById('vsiOverlay').style.display === 'none') {
6286
+ cleanup();
6287
+ return;
6288
+ }
6289
+ frame++;
6290
+ update();
6291
+ draw();
6292
+ requestAnimationFrame(loop);
6293
+ }
6294
+
6295
+ spawnWave();
6296
+ loop();
6297
+ }
6298
+
5735
6299
  // ── Start ──
5736
6300
  init();
5737
6301
  })();
@@ -5815,5 +6379,10 @@ init();
5815
6379
  </p>
5816
6380
  </div>
5817
6381
  </div>
6382
+ <!-- 🕹️ Vector Space Invaders -->
6383
+ <div id="vsiOverlay" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,30,43,0.92);z-index:99999;align-items:center;justify-content:center;flex-direction:column;gap:12px;">
6384
+ <canvas id="vsiCanvas" width="600" height="500" style="border:1px solid #3D4F58;border-radius:8px;image-rendering:pixelated;"></canvas>
6385
+ <div style="color:#889397;font-size:11px;font-family:monospace;text-align:center;">← → move &nbsp;|&nbsp; SPACE shoot &nbsp;|&nbsp; ESC exit</div>
6386
+ </div>
5818
6387
  </body>
5819
6388
  </html>