vecfinder

Document searching tool based on embedding vectors
git clone git://git.finwo.net/app/vecfinder
Log | Files | Refs

vecfinder.js (23199B)


      1 #!/usr/bin/env node
      2 
      3 const fs = require('node:fs/promises');
      4 const path = require('node:path');
      5 const http = require('node:http');
      6 const readline = require('node:readline');
      7 const { stdin, stdout, stderr } = process;
      8 const EventEmitter = require('node:events');
      9 const { execSync, spawn } = require('node:child_process');
     10 
     11 const ONE_GIB = 1073741824;
     12 
     13 let config = {
     14   endpoint: 'http://localhost:1234/v1/embeddings',
     15   model: 'text-embedding-qwen3-0.6b-text-embedding',
     16   apiKey: '',
     17   parallelism: 2,
     18   cacheMaxSize: ONE_GIB,
     19 };
     20 
     21 let CACHE_DIR = null;
     22 let CONFIG_PATH = null;
     23 
     24 async function getWritableCacheDir(cacheBase) {
     25   try {
     26     await fs.mkdir(cacheBase, { recursive: true });
     27     await fs.access(cacheBase, fs.constants.W_OK);
     28     return cacheBase;
     29   } catch {
     30     return null;
     31   }
     32 }
     33 
     34 async function createSystemCacheDir() {
     35   const cacheBase = '/var/cache/vecfinder';
     36 
     37   try {
     38     const vecfinderGroupExists = await new Promise((resolve) => {
     39       execSync('getent group vecfinder > /dev/null 2>&1', { stdio: 'ignore' });
     40       resolve(true);
     41     }).catch(() => false);
     42 
     43     if (!vecfinderGroupExists) {
     44       return null;
     45     }
     46 
     47     const stat = await fs.stat('/var/cache').catch(() => null);
     48     if (!stat || !(stat.mode & fs.constants.S_IWUSR)) {
     49       return null;
     50     }
     51 
     52     try {
     53       await fs.mkdir(cacheBase, { recursive: true });
     54     } catch (err) {
     55       if (err.code !== 'EEXIST') return null;
     56     }
     57 
     58     const cacheStat = await fs.stat(cacheBase).catch(() => null);
     59     if (!cacheStat) return null;
     60 
     61     const mode = cacheStat.mode & 0o777;
     62     const isGroupWritable = !!(mode & fs.constants.S_IWGRP);
     63 
     64     if (isGroupWritable) {
     65       return await getWritableCacheDir(cacheBase);
     66     }
     67 
     68     try {
     69       execSync(`chmod 0770 "${cacheBase}"`, { stdio: 'ignore' });
     70     } catch {
     71       return null;
     72     }
     73 
     74     return await getWritableCacheDir(cacheBase);
     75   } catch {
     76     return null;
     77   }
     78 }
     79 
     80 async function discoverConfig() {
     81   const userConfig = path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'vecfinder', 'config.json');
     82   const systemConfig1 = '/etc/vecfinder/config.json';
     83   const systemConfig2 = '/etc/vecfinder.json';
     84 
     85   if (await fileExists(userConfig)) {
     86     CONFIG_PATH = userConfig;
     87     CACHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.cache', 'vecfinder');
     88     return;
     89   }
     90 
     91   if (await fileExists(systemConfig1) || await fileExists(systemConfig2)) {
     92     const systemConfig = await fileExists(systemConfig1) ? systemConfig1 : systemConfig2;
     93     CONFIG_PATH = systemConfig;
     94 
     95     try {
     96       const data = JSON.parse(await fs.readFile(systemConfig, 'utf8'));
     97       if (data.cacheDir && typeof data.cacheDir === 'string') {
     98         CACHE_DIR = await getWritableCacheDir(data.cacheDir);
     99       }
    100 
    101       if (!CACHE_DIR) {
    102         CACHE_DIR = await createSystemCacheDir();
    103       }
    104     } catch {}
    105 
    106     if (!CACHE_DIR) {
    107       CACHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.cache', 'vecfinder');
    108     }
    109     return;
    110   }
    111 
    112   CONFIG_PATH = userConfig;
    113   CACHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.cache', 'vecfinder');
    114 }
    115 
    116 async function loadConfig() {
    117   await discoverConfig();
    118 
    119   if (!CONFIG_PATH || !(await fileExists(CONFIG_PATH))) {
    120     return;
    121   }
    122 
    123   try {
    124     const data = await fs.readFile(CONFIG_PATH, 'utf8');
    125     config = { ...config, ...JSON.parse(data) };
    126   } catch {}
    127 }
    128 
    129 let hasBat = null;
    130 
    131 function checkBat() {
    132   if (hasBat !== null) return hasBat;
    133   try {
    134     execSync('which bat', { stdio: 'ignore' });
    135     hasBat = true;
    136   } catch {
    137     hasBat = false;
    138   }
    139   return hasBat;
    140 }
    141 
    142 function stripAnsi(str) {
    143   return str.replace(/\x1b\[[0-9;]*m/g, '');
    144 }
    145 
    146 async function fileExists(filePath) {
    147   try {
    148     await fs.access(filePath);
    149     return true;
    150   } catch {
    151     return false;
    152   }
    153 }
    154 
    155 // Cache
    156 function cachePath(filePath) {
    157   return path.join(CACHE_DIR, filePath);
    158 }
    159 
    160 async function getCachedVector(filePath) {
    161   try {
    162     const cp = cachePath(filePath);
    163     const [cacheStat, fileStat] = await Promise.all([fs.stat(cp), fs.stat(filePath)]);
    164     if (fileStat.mtimeMs > cacheStat.mtimeMs) return null;
    165     const buf = await fs.readFile(cp);
    166     return Array.from(new Float64Array(buf.buffer, buf.byteOffset, buf.byteLength / 8));
    167   } catch {
    168     return null;
    169   }
    170 }
    171 
    172 async function setCachedVector(filePath, vector) {
    173   try {
    174     const cp = cachePath(filePath);
    175     await fs.mkdir(path.dirname(cp), { recursive: true });
    176     await fs.writeFile(cp, Buffer.from(new Float64Array(vector).buffer));
    177     enforceCacheLimit();
    178   } catch {}
    179 }
    180 
    181 async function enforceCacheLimit() {
    182   if (!CACHE_DIR) return;
    183 
    184   try {
    185     const files = [];
    186     let totalSize = 0;
    187 
    188     const pending = [CACHE_DIR];
    189     while (pending.length) {
    190       const current = pending.pop();
    191       try {
    192         const entries = await fs.readdir(current, { withFileTypes: true });
    193         for (const entry of entries) {
    194           const fullPath = path.join(current, entry.name);
    195           if (entry.isDirectory()) {
    196             pending.push(fullPath);
    197           } else {
    198             const stat = await fs.stat(fullPath);
    199             files.push({ path: fullPath, size: stat.size });
    200             totalSize += stat.size;
    201           }
    202         }
    203       } catch {}
    204     }
    205 
    206     while (totalSize > config.cacheMaxSize && files.length > 0) {
    207       const i = Math.floor(Math.random() * files.length);
    208       const [removed] = files.splice(i, 1);
    209       await fs.unlink(removed.path);
    210       totalSize -= removed.size;
    211     }
    212   } catch {}
    213 }
    214 
    215 // Embedding
    216 let currentQueryId = 0;
    217 
    218 function normalize(vec) {
    219   let norm = 0;
    220   for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
    221   norm = Math.sqrt(norm);
    222   if (norm === 0) return vec;
    223   return vec.map(v => v / norm);
    224 }
    225 
    226 function dot(a, b) {
    227   let sum = 0;
    228   for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
    229   return sum;
    230 }
    231 
    232 async function fetchEmbedding(text) {
    233   return new Promise((resolve, reject) => {
    234     const req = http.request(config.endpoint, {
    235       method: 'POST',
    236       headers: {
    237         'Content-Type': 'application/json',
    238         ...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
    239       },
    240     }, (res) => {
    241       let data = '';
    242       res.on('data', chunk => data += chunk);
    243       res.on('end', () => {
    244         try {
    245           const json = JSON.parse(data);
    246           if (!json.data?.[0]?.embedding) {
    247             return reject(new Error('Invalid embedding response'));
    248           }
    249           resolve(json.data[0].embedding);
    250         } catch (e) {
    251           reject(e);
    252         }
    253       });
    254     });
    255     req.on('error', reject);
    256     req.write(JSON.stringify({ model: config.model, input: text }));
    257     req.end();
    258   });
    259 }
    260 
    261 // Document store with events
    262 class DocumentStore extends EventEmitter {
    263   constructor() {
    264     super();
    265     this.documents = new Map();
    266     this.queryVec = null;
    267     this.consumersActive = 0;
    268   }
    269 
    270   async addDocument(id, content, metadata = {}, vector) {
    271     if (!vector) {
    272       const raw = await fetchEmbedding(content);
    273       vector = normalize(raw);
    274     }
    275     this.documents.set(id, { content, vector, metadata });
    276     this.emit('documentAdded');
    277   }
    278 
    279   setQuery(queryText) {
    280     currentQueryId++;
    281     const queryId = currentQueryId;
    282 
    283     if (queryText.trim() === '') {
    284       this.queryVec = null;
    285       this.emit('queryUpdated');
    286       return;
    287     }
    288 
    289     fetchEmbedding(queryText).then(raw => {
    290       if (queryId !== currentQueryId) return;
    291       this.queryVec = normalize(raw);
    292       this.emit('queryUpdated');
    293     }).catch(err => {
    294       console.error(`Embedding error: ${err.message}`);
    295     });
    296   }
    297 
    298   getResults() {
    299     const results = [];
    300     for (const [id, doc] of this.documents.entries()) {
    301       const score = this.queryVec ? dot(this.queryVec, doc.vector) : 0;
    302       results.push({
    303         id,
    304         score,
    305         content: doc.content,
    306         metadata: doc.metadata,
    307       });
    308     }
    309 
    310     if (this.queryVec) {
    311       results.sort((a, b) => b.score - a.score);
    312     } else {
    313       results.sort((a, b) => a.id.localeCompare(b.id));
    314     }
    315 
    316     return results;
    317   }
    318 }
    319 
    320 // File indexing
    321 async function isTextFile(filePath) {
    322   try {
    323     const chunk = await fs.readFile(filePath, { length: 512 });
    324     for (let i = 0; i < chunk.length; i++) {
    325       if (chunk[i] === 0) return false;
    326     }
    327     return true;
    328   } catch {
    329     return false;
    330   }
    331 }
    332 
    333 async function walkDir(dirPath) {
    334   const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt']);
    335   const files = [];
    336   const pending = [dirPath];
    337 
    338   while (pending.length) {
    339     const current = pending.pop();
    340     try {
    341       const entries = await fs.readdir(current, { withFileTypes: true });
    342       for (const entry of entries) {
    343         const fullPath = path.join(current, entry.name);
    344         if (entry.isDirectory()) {
    345           if (!ignore.has(entry.name)) pending.push(fullPath);
    346         } else {
    347           files.push(fullPath);
    348         }
    349       }
    350     } catch {}
    351   }
    352 
    353   return files;
    354 }
    355 
    356 async function indexDirectory(store, dirPath) {
    357   const files = await walkDir(dirPath);
    358   const queue = [];
    359   let totalFiles = 0;
    360   let completedFiles = 0;
    361 
    362   for (const filePath of files) {
    363     const stats = await fs.stat(filePath).catch(() => null);
    364     if (!stats || stats.size > 1024 * 1024) continue;
    365     const text = await isTextFile(filePath);
    366     if (!text) continue;
    367     queue.push(filePath);
    368     totalFiles++;
    369   }
    370 
    371   function consumer() {
    372     if (queue.length === 0 && store.consumersActive === 0) return;
    373 
    374     if (store.consumersActive >= config.parallelism || queue.length === 0) return;
    375 
    376     store.consumersActive++;
    377     const filePath = queue.shift();
    378 
    379     (async () => {
    380       try {
    381         const content = await fs.readFile(filePath, 'utf8');
    382         const id = path.relative(dirPath, filePath);
    383 
    384         let vector = await getCachedVector(filePath);
    385         if (vector) {
    386           await store.addDocument(id, content, { path: filePath }, vector);
    387         } else {
    388           const raw = await fetchEmbedding(content);
    389           vector = normalize(raw);
    390           await store.addDocument(id, content, { path: filePath }, vector);
    391           await setCachedVector(filePath, vector);
    392         }
    393       } catch {}
    394 
    395       completedFiles++;
    396       store.consumersActive--;
    397       store.emit('documentAdded');
    398       if (queue.length > 0 || store.consumersActive > 0) {
    399         setImmediate(consumer);
    400       }
    401     })();
    402   }
    403 
    404   for (let i = 0; i < Math.min(config.parallelism, totalFiles); i++) {
    405     setImmediate(consumer);
    406   }
    407 
    408   return new Promise(resolve => {
    409     const checkComplete = () => {
    410       if (completedFiles >= totalFiles && store.consumersActive === 0 && queue.length === 0) {
    411         store.removeListener('documentAdded', checkComplete);
    412         resolve();
    413       }
    414     };
    415     store.on('documentAdded', checkComplete);
    416     checkComplete();
    417   });
    418 }
    419 
    420 // UI
    421 class UI {
    422   constructor(store, useBatFlag = null) {
    423     this.store = store;
    424     this.query = '';
    425     this.selectedIndex = 0;
    426     this.listOffset = 0;
    427     this.previewOffset = 0;
    428     this.rl = null;
    429     this._previewWrapped = null;
    430     this._previewFileId = null;
    431     this._previewWidth = null;
    432     this.spinnerFrame = 0;
    433     this.spinnerInterval = null;
    434     this.useBat = useBatFlag !== null ? useBatFlag : checkBat();
    435     this._batOutput = null;
    436     this._batFileId = null;
    437     this._lastRenderState = null;
    438     this._needsFullRedraw = false;
    439 
    440     this.store.on('documentAdded', () => {
    441       this._needsFullRedraw = true;
    442     });
    443     this.store.on('queryUpdated', () => {
    444       this.selectedIndex = 0;
    445       this.listOffset = 0;
    446       this.previewOffset = 0;
    447       this._needsFullRedraw = true;
    448       this.render();
    449     });
    450   }
    451 
    452   init() {
    453     this.rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
    454     stdin.on('keypress', (str, key) => this.handleKeyPress(str, key));
    455     this.rl.on('line', () => this.handleEnter());
    456     this.rl.on('close', () => this.cleanup());
    457     this.spinnerInterval = setInterval(() => {
    458       this.spinnerFrame++;
    459       this.render();
    460     }, 80);
    461     this.render();
    462   }
    463 
    464   cleanup() {
    465     clearInterval(this.spinnerInterval);
    466     stdin.removeAllListeners('keypress');
    467     if (this.rl) this.rl.close();
    468   }
    469 
    470   adjustListOffset(maxHeight) {
    471     const results = this.store.getResults();
    472     const topThreshold = this.listOffset + 2;
    473     const bottomThreshold = this.listOffset + maxHeight - 3;
    474 
    475     if (this.selectedIndex < topThreshold) {
    476       this.listOffset = Math.max(0, this.selectedIndex - 2);
    477     } else if (this.selectedIndex > bottomThreshold) {
    478       this.listOffset = Math.min(results.length - maxHeight, this.selectedIndex - (maxHeight - 3));
    479     }
    480 
    481     this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, results.length - maxHeight)));
    482   }
    483 
    484   getMaxPreviewOffset(maxHeight) {
    485     if (this.useBat && this._batOutput) {
    486       return Math.max(0, this._batOutput.split('\n').length - maxHeight);
    487     }
    488     return this._previewWrapped ? Math.max(0, this._previewWrapped.length - maxHeight) : 0;
    489   }
    490 
    491   handleKeyPress(str, key) {
    492     if (!key) return;
    493 
    494     if (key.name === 'enter') {
    495       this.handleEnter();
    496       return;
    497     }
    498 
    499     if (key.name === 'up') {
    500       if (this.selectedIndex > 0) {
    501         this.selectedIndex--;
    502         this.previewOffset = 0;
    503         const height = process.stdout.rows || 24;
    504         const maxHeight = height - 7;
    505         this.adjustListOffset(maxHeight);
    506         this.render();
    507       }
    508       return;
    509     }
    510 
    511     if (key.name === 'down') {
    512       const results = this.store.getResults();
    513       if (this.selectedIndex < results.length - 1) {
    514         this.selectedIndex++;
    515         this.previewOffset = 0;
    516         const height = process.stdout.rows || 24;
    517         const maxHeight = height - 7;
    518         this.adjustListOffset(maxHeight);
    519         this.render();
    520       }
    521       return;
    522     }
    523 
    524     if (key.name === 'home') {
    525       this.selectedIndex = 0;
    526       this.previewOffset = 0;
    527       const height = process.stdout.rows || 24;
    528       const maxHeight = height - 7;
    529       this.adjustListOffset(maxHeight);
    530       this.render();
    531       return;
    532     }
    533 
    534     if (key.name === 'end') {
    535       const results = this.store.getResults();
    536       this.selectedIndex = results.length - 1;
    537       this.previewOffset = 0;
    538       const height = process.stdout.rows || 24;
    539       const maxHeight = height - 7;
    540       this.adjustListOffset(maxHeight);
    541       this.render();
    542       return;
    543     }
    544 
    545     if (key.name === 'pageup') {
    546       this.previewOffset = Math.max(0, this.previewOffset - 10);
    547       this.render();
    548       return;
    549     }
    550 
    551     if (key.name === 'pagedown') {
    552       const height = process.stdout.rows || 24;
    553       const maxHeight = height - 7;
    554       const maxPreviewOffset = this.getMaxPreviewOffset(maxHeight);
    555       this.previewOffset = Math.min(maxPreviewOffset, this.previewOffset + 10);
    556       this.render();
    557       return;
    558     }
    559 
    560     if (key.name === 'backspace' || key.name === 'delete') {
    561       this.query = this.query.slice(0, -1);
    562       this.store.setQuery(this.query);
    563       return;
    564     }
    565 
    566     if (str && str.length === 1 && !key.ctrl && !key.meta) {
    567       this.query += str;
    568       this.store.setQuery(this.query);
    569       return;
    570     }
    571   }
    572 
    573   handleEnter() {
    574     this.cleanup();
    575     const results = this.store.getResults();
    576     if (results.length > 0 && this.selectedIndex < results.length) {
    577       stdout.write(results[this.selectedIndex].id + '\n');
    578     } else {
    579       stdout.write('\n');
    580     }
    581     process.exit(0);
    582   }
    583 
    584   render() {
    585     const width = process.stdout.columns || 80;
    586     const height = process.stdout.rows || 24;
    587 
    588     const leftWidth = Math.floor(width * 0.35);
    589     const rightWidth = width - leftWidth - 1;
    590     const headerHeight = 3;
    591     const maxHeight = height - 4 - headerHeight;
    592 
    593     const results = this.store.getResults();
    594     const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
    595     const spinner = this.store.consumersActive > 0 ? frames[this.spinnerFrame % frames.length] : '\u2713';
    596 
    597     const titleDecorWidth = '┤ VecFinder ├'.length;
    598     const dashSpace = leftWidth - titleDecorWidth;
    599     const titleCenterLeft = Math.floor(dashSpace / 2);
    600     const titleCenterRight = dashSpace - titleCenterLeft;
    601 
    602     const selectedResult = results[this.selectedIndex];
    603 
    604     const visibleIds = [];
    605     for (let row = 0; row < maxHeight; row++) {
    606       const result = results[row + this.listOffset];
    607       visibleIds.push(result ? result.id : null);
    608     }
    609 
    610     const currentState = {
    611       width,
    612       height,
    613       leftWidth,
    614       rightWidth,
    615       query: this.query,
    616       selectedIndex: this.selectedIndex,
    617       listOffset: this.listOffset,
    618       previewOffset: this.previewOffset,
    619       spinner,
    620       consumersActive: this.store.consumersActive,
    621       selectedFileId: selectedResult?.id,
    622       visibleIds,
    623       batFileId: this._batFileId,
    624       batOutputHash: this._batOutput ? this._batOutput.slice(0, 100) : null,
    625     };
    626 
    627     if (!this._needsFullRedraw && this._lastRenderState) {
    628       const s = this._lastRenderState;
    629       if (
    630         s.width === currentState.width &&
    631         s.height === currentState.height &&
    632         s.leftWidth === currentState.leftWidth &&
    633         s.rightWidth === currentState.rightWidth &&
    634         s.query === currentState.query &&
    635         s.selectedIndex === currentState.selectedIndex &&
    636         s.listOffset === currentState.listOffset &&
    637         s.previewOffset === currentState.previewOffset &&
    638         s.spinner === currentState.spinner &&
    639         s.consumersActive === currentState.consumersActive &&
    640         s.selectedFileId === currentState.selectedFileId &&
    641         s.visibleIds.length === currentState.visibleIds.length &&
    642         s.visibleIds.every((id, i) => id === currentState.visibleIds[i]) &&
    643         s.batFileId === currentState.batFileId
    644       ) {
    645         return;
    646       }
    647     }
    648     this._needsFullRedraw = false;
    649     this._lastRenderState = currentState;
    650 
    651     stderr.write('\x1b[2J\x1b[H');
    652 
    653     stderr.write('─'.repeat(titleCenterLeft) + '┤ VecFinder ├' + '─'.repeat(titleCenterRight) + '┬' + '─'.repeat(rightWidth) + '\n');
    654 
    655     let leftHeader = 'Files ' + spinner;
    656     leftHeader = leftHeader.padEnd(leftWidth, ' ').slice(0, leftWidth);
    657 
    658     let rightHeader = '';
    659     if (selectedResult) {
    660       const title = 'File: ' + selectedResult.id;
    661       rightHeader = title.padEnd(rightWidth, ' ').slice(0, rightWidth);
    662     } else {
    663       rightHeader = ''.padEnd(rightWidth, ' ');
    664     }
    665 
    666     stderr.write(leftHeader + '│' + rightHeader + '\n');
    667 
    668     stderr.write('─'.repeat(leftWidth) + '┼' + '─'.repeat(rightWidth) + '\n');
    669 
    670     for (let row = 0; row < maxHeight; row++) {
    671       const result = results[row + this.listOffset];
    672       const isSelected = (row + this.listOffset === this.selectedIndex);
    673 
    674       let left;
    675       if (result) {
    676         let name = result.id;
    677         const maxName = leftWidth - 10;
    678         if (name.length > maxName) name = '...' + name.slice(-(maxName - 3));
    679         if (this.query && result.score !== undefined) {
    680           const score = result.score.toFixed(3);
    681           left = ' ' + name.padEnd(leftWidth - score.length - 3, ' ') + score + ' ';
    682         } else {
    683           left = ' ' + name;
    684         }
    685       } else {
    686         left = '';
    687       }
    688       left = left.padEnd(leftWidth, ' ').slice(0, leftWidth);
    689 
    690       let right = '';
    691       const isBatMode = this.useBat && selectedResult?.metadata?.path;
    692       if (selectedResult) {
    693         if (isBatMode) {
    694           if (!this._batOutput || this._batFileId !== selectedResult.id) {
    695             try {
    696               this._batOutput = execSync(`bat --color=always --style=numbers --terminal-width=${rightWidth} --wrap=character "${selectedResult.metadata.path}"`, {
    697                 maxBuffer: 1024 * 1024,
    698                 encoding: 'utf8',
    699               });
    700             } catch {
    701               this._batOutput = null;
    702             }
    703             this._batFileId = selectedResult.id;
    704           }
    705           if (this._batOutput) {
    706             const lines = this._batOutput.split('\n');
    707             const visibleLines = lines.slice(this.previewOffset, this.previewOffset + maxHeight);
    708             const line = visibleLines[row] || '';
    709             const stripped = stripAnsi(line);
    710             const padding = rightWidth - stripped.length;
    711             right = line + ' '.repeat(Math.max(0, padding)) + '\x1b[0m';
    712           }
    713         } else {
    714           if (!this._previewWrapped || this._previewFileId !== selectedResult.id || this._previewWidth !== rightWidth) {
    715             const lines = selectedResult.content.split('\n');
    716             this._previewWrapped = [];
    717             for (const line of lines) {
    718               if (line.length === 0) {
    719                 this._previewWrapped.push('');
    720               } else {
    721                 for (let i = 0; i < line.length; i += rightWidth) {
    722                   this._previewWrapped.push(line.slice(i, i + rightWidth));
    723                 }
    724               }
    725             }
    726             this._previewFileId = selectedResult.id;
    727             this._previewWidth = rightWidth;
    728           }
    729           right = this._previewWrapped[row + this.previewOffset] || '';
    730           right = right.padEnd(rightWidth, ' ').slice(0, rightWidth);
    731         }
    732       }
    733 
    734       if (isSelected) {
    735         stderr.write(`\x1b[7m${left}\x1b[0m│${right}`);
    736       } else {
    737         stderr.write(`${left}│${right}`);
    738       }
    739       stderr.write('\n');
    740     }
    741 
    742     stderr.write('─'.repeat(leftWidth) + '┴' + '─'.repeat(rightWidth) + '\n');
    743     stderr.write(`Query: ${this.query}`);
    744   }
    745 }
    746 
    747 function printUsage() {
    748   stdout.write(`Usage: vecfinder [options]
    749        vecfinder --query <text> [-k N]
    750 
    751 Options:
    752   -h, --help          Show this help message
    753   --query <text>      Search directory for best match (non-interactive)
    754   -k <number>         Number of results to return (default: 1, requires --query)
    755   --no-bat            Disable bat syntax highlighting (interactive mode)
    756 `);
    757 }
    758 
    759 // Main
    760 async function main() {
    761   const args = process.argv.slice(2);
    762   const queryArgIdx = args.indexOf('--query');
    763   const hasQueryFlag = queryArgIdx >= 0;
    764   const queryText = hasQueryFlag ? (args[queryArgIdx + 1] || null) : null;
    765   const kArgIdx = args.indexOf('-k');
    766   const k = kArgIdx >= 0 ? Math.max(1, parseInt(args[kArgIdx + 1], 10) || 1) : 1;
    767   const showHelp = args.includes('--help') || args.includes('-h');
    768 
    769   await loadConfig();
    770   await fs.mkdir(CACHE_DIR, { recursive: true });
    771 
    772   if (showHelp) {
    773     printUsage();
    774     process.exit(0);
    775   }
    776 
    777   const store = new DocumentStore();
    778 
    779   if (hasQueryFlag) {
    780     if (!queryText) {
    781       printUsage();
    782       stderr.write('Error: --query requires a text argument\n');
    783       process.exit(1);
    784     }
    785 
    786     await indexDirectory(store, process.cwd());
    787     const raw = await fetchEmbedding(queryText);
    788     store.queryVec = normalize(raw);
    789     const results = store.getResults().slice(0, k);
    790     for (const r of results) {
    791       stdout.write(r.id + '\n');
    792     }
    793     process.exit(0);
    794   }
    795 
    796   const useBatFlag = args.includes('--no-bat') ? false : null;
    797   const ui = new UI(store, useBatFlag);
    798 
    799   ui.init();
    800 
    801   indexDirectory(store, process.cwd());
    802 }
    803 
    804 main().catch(err => {
    805   console.error(`Fatal error: ${err.message}`);
    806   process.exit(1);
    807 });