commit 41b9f26d7b3233df867723a217ff673c8b7f2f86
Author: finwo <finwo@pm.me>
Date: Sat, 21 Mar 2026 03:53:02 +0100
Project init
Diffstat:
| A | vecfinder.js | | | 351 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 351 insertions(+), 0 deletions(-)
diff --git a/vecfinder.js b/vecfinder.js
@@ -0,0 +1,350 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const http = require('node:http');
+const readline = require('node:readline');
+const { stdin, stdout, stderr } = process;
+const EventEmitter = require('node:events');
+
+// Configuration
+const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'ragfinder.json');
+let config = {
+ endpoint: 'http://localhost:1234/v1/embeddings',
+ model: 'text-embedding-qwen3-0.6b-text-embedding',
+ apiKey: '',
+ parallelism: 2,
+};
+
+async function loadConfig() {
+ try {
+ const data = await fs.readFile(CONFIG_PATH, 'utf8');
+ config = { ...config, ...JSON.parse(data) };
+ } catch {}
+}
+
+// Embedding
+let currentQueryId = 0;
+
+function normalize(vec) {
+ let norm = 0;
+ for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
+ norm = Math.sqrt(norm);
+ if (norm === 0) return vec;
+ return vec.map(v => v / norm);
+}
+
+function dot(a, b) {
+ let sum = 0;
+ for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
+ return sum;
+}
+
+async function fetchEmbedding(text) {
+ return new Promise((resolve, reject) => {
+ const req = http.request(config.endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
+ },
+ }, (res) => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ try {
+ const json = JSON.parse(data);
+ if (!json.data?.[0]?.embedding) {
+ return reject(new Error('Invalid embedding response'));
+ }
+ resolve(json.data[0].embedding);
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+ req.on('error', reject);
+ req.write(JSON.stringify({ model: config.model, input: text }));
+ req.end();
+ });
+}
+
+// Document store with events
+class DocumentStore extends EventEmitter {
+ constructor() {
+ super();
+ this.documents = new Map();
+ this.queryVec = null;
+ }
+
+ async addDocument(id, content, metadata = {}) {
+ const raw = await fetchEmbedding(content);
+ const vector = normalize(raw);
+ this.documents.set(id, { content, vector, metadata });
+ this.emit('documentAdded');
+ }
+
+ setQuery(queryText) {
+ currentQueryId++;
+ const queryId = currentQueryId;
+
+ if (queryText.trim() === '') {
+ this.queryVec = null;
+ this.emit('queryUpdated');
+ return;
+ }
+
+ fetchEmbedding(queryText).then(raw => {
+ if (queryId !== currentQueryId) return;
+ this.queryVec = normalize(raw);
+ this.emit('queryUpdated');
+ }).catch(err => {
+ console.error(`Embedding error: ${err.message}`);
+ });
+ }
+
+ getResults() {
+ const results = [];
+ for (const [id, doc] of this.documents.entries()) {
+ const score = this.queryVec ? dot(this.queryVec, doc.vector) : 0;
+ results.push({
+ id,
+ score,
+ content: doc.content,
+ metadata: doc.metadata,
+ });
+ }
+
+ if (this.queryVec) {
+ results.sort((a, b) => b.score - a.score);
+ } else {
+ results.sort((a, b) => a.id.localeCompare(b.id));
+ }
+
+ return results;
+ }
+}
+
+// File indexing
+async function isTextFile(filePath) {
+ try {
+ const chunk = await fs.readFile(filePath, { length: 512 });
+ for (let i = 0; i < chunk.length; i++) {
+ if (chunk[i] === 0) return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function walkDir(dirPath) {
+ const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt']);
+ const files = [];
+ const pending = [dirPath];
+
+ while (pending.length) {
+ const current = pending.pop();
+ try {
+ const entries = await fs.readdir(current, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ if (!ignore.has(entry.name)) pending.push(fullPath);
+ } else {
+ files.push(fullPath);
+ }
+ }
+ } catch {}
+ }
+
+ return files;
+}
+
+async function indexDirectory(store, dirPath, ui) {
+ const files = await walkDir(dirPath);
+
+ for (const filePath of files) {
+ try {
+ const stats = await fs.stat(filePath);
+ if (stats.size > 1024 * 1024) continue;
+ if (!(await isTextFile(filePath))) continue;
+
+ const content = await fs.readFile(filePath, 'utf8');
+ const id = path.relative(dirPath, filePath);
+ await store.addDocument(id, content, { path: filePath });
+ } catch {}
+ }
+}
+
+// UI
+class UI {
+ constructor(store) {
+ this.store = store;
+ this.query = '';
+ this.selectedIndex = 0;
+ this.previewOffset = 0;
+ this.rl = null;
+
+ this.store.on('documentAdded', () => this.render());
+ this.store.on('queryUpdated', () => {
+ this.selectedIndex = 0;
+ this.previewOffset = 0;
+ this.render();
+ });
+ }
+
+ init() {
+ this.rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
+ stdin.on('keypress', (str, key) => this.handleKeyPress(str, key));
+ this.rl.on('line', () => this.handleEnter());
+ this.rl.on('close', () => this.cleanup());
+ this.render();
+ }
+
+ cleanup() {
+ stdin.removeAllListeners('keypress');
+ if (this.rl) this.rl.close();
+ }
+
+ handleKeyPress(str, key) {
+ if (!key) return;
+
+ if (key.name === 'enter') {
+ this.handleEnter();
+ return;
+ }
+
+ if (key.name === 'up') {
+ if (this.selectedIndex > 0) {
+ this.selectedIndex--;
+ this.previewOffset = 0;
+ this.render();
+ }
+ return;
+ }
+
+ if (key.name === 'down') {
+ const results = this.store.getResults();
+ if (this.selectedIndex < results.length - 1) {
+ this.selectedIndex++;
+ this.previewOffset = 0;
+ this.render();
+ }
+ return;
+ }
+
+ if (key.name === 'pageup') {
+ this.previewOffset = Math.max(0, this.previewOffset - 10);
+ this.render();
+ return;
+ }
+
+ if (key.name === 'pagedown') {
+ this.previewOffset += 10;
+ this.render();
+ return;
+ }
+
+ if (key.name === 'backspace' || key.name === 'delete') {
+ this.query = this.query.slice(0, -1);
+ this.store.setQuery(this.query);
+ return;
+ }
+
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
+ this.query += str;
+ this.store.setQuery(this.query);
+ return;
+ }
+ }
+
+ handleEnter() {
+ this.cleanup();
+ const results = this.store.getResults();
+ if (results.length > 0 && this.selectedIndex < results.length) {
+ stdout.write(results[this.selectedIndex].id + '\n');
+ } else {
+ stdout.write('\n');
+ }
+ process.exit(0);
+ }
+
+ render() {
+ stderr.write('\x1b[2J\x1b[H');
+
+ const width = process.stdout.columns || 80;
+ const height = process.stdout.rows || 24;
+
+ const leftWidth = Math.floor(width * 0.35);
+ const rightWidth = width - leftWidth - 1;
+ const maxHeight = height - 4;
+
+ const results = this.store.getResults();
+
+ // Header
+ stderr.write('RagFinder\n');
+ stderr.write('─'.repeat(leftWidth) + '┬' + '─'.repeat(rightWidth) + '\n');
+
+ // Rows
+ for (let row = 0; row < maxHeight; row++) {
+ const result = results[row];
+ const isSelected = (row === this.selectedIndex);
+
+ // Left pane - name left, score right
+ let left;
+ if (result) {
+ let name = result.id;
+ const maxName = leftWidth - 10;
+ if (name.length > maxName) name = '...' + name.slice(-(maxName - 3));
+ if (this.query && result.score !== undefined) {
+ const score = result.score.toFixed(3);
+ left = ' ' + name.padEnd(leftWidth - score.length - 3, ' ') + score + ' ';
+ } else {
+ left = ' ' + name;
+ }
+ } else {
+ left = '';
+ }
+ left = left.padEnd(leftWidth, ' ').slice(0, leftWidth);
+
+ // Right pane - preview for selected file, limited by pane height
+ let right = '';
+ const selectedResult = results[this.selectedIndex];
+ if (selectedResult) {
+ const lines = selectedResult.content.split('\n');
+ right = lines[row + this.previewOffset] || '';
+ }
+ right = right.padEnd(rightWidth, ' ').slice(0, rightWidth);
+
+ // Write
+ if (isSelected) {
+ stderr.write(`\x1b[7m${left}\x1b[0m│${right}`);
+ } else {
+ stderr.write(`${left}│${right}`);
+ }
+ stderr.write('\n');
+ }
+
+ // Footer
+ stderr.write('─'.repeat(leftWidth) + '┴' + '─'.repeat(rightWidth) + '\n');
+ stderr.write(`Query: ${this.query}`);
+ }
+}
+
+// Main
+async function main() {
+ await loadConfig();
+
+ const store = new DocumentStore();
+ const ui = new UI(store);
+
+ ui.init();
+
+ indexDirectory(store, process.cwd(), ui);
+}
+
+main().catch(err => {
+ console.error(`Fatal error: ${err.message}`);
+ process.exit(1);
+});
+\ No newline at end of file