Bläddra i källkod

refactor: Remove semantic search and vector embedding functionality

Removes @xenova/transformers dependency, vector storage tables, embedding generation, and semantic search APIs. Simplifies context building to use only FTS search. Eliminates visualizer server, postinstall model download, and related CLI commands. Reduces package size and complexity while maintaining core static analysis capabilities.
Colby McHenry 2 månader sedan
förälder
incheckning
453c39d774

+ 1 - 9
CLAUDE.md

@@ -58,10 +58,6 @@ src/
 │   ├── index.ts          # GraphQueryManager
 │   ├── index.ts          # GraphQueryManager
 │   ├── traversal.ts      # GraphTraverser (BFS/DFS, impact radius)
 │   ├── traversal.ts      # GraphTraverser (BFS/DFS, impact radius)
 │   └── queries.ts        # High-level graph queries
 │   └── queries.ts        # High-level graph queries
-├── vectors/              # Semantic search with embeddings
-│   ├── index.ts          # VectorManager
-│   ├── embedder.ts       # ONNX runtime + model loading
-│   └── search.ts         # Similarity search
 ├── context/              # Context building for AI assistants
 ├── context/              # Context building for AI assistants
 │   ├── index.ts          # ContextBuilder
 │   ├── index.ts          # ContextBuilder
 │   └── formatter.ts      # Markdown/JSON output formatting
 │   └── formatter.ts      # Markdown/JSON output formatting
@@ -83,14 +79,12 @@ src/
 
 
 ### Key Classes
 ### Key Classes
 
 
-- **CodeGraph** (`src/index.ts`): Main entry point. Lifecycle methods (`init`, `open`, `close`), indexing (`indexAll`, `sync`), graph queries (`traverse`, `getCallGraph`, `getImpactRadius`), semantic search (`semanticSearch`, `findSimilar`), context building (`buildContext`)
+- **CodeGraph** (`src/index.ts`): Main entry point. Lifecycle methods (`init`, `open`, `close`), indexing (`indexAll`, `sync`), graph queries (`traverse`, `getCallGraph`, `getImpactRadius`), context building (`buildContext`)
 
 
 - **ExtractionOrchestrator** (`src/extraction/index.ts`): Coordinates file scanning, parsing, and storing. Uses tree-sitter native bindings for each supported language
 - **ExtractionOrchestrator** (`src/extraction/index.ts`): Coordinates file scanning, parsing, and storing. Uses tree-sitter native bindings for each supported language
 
 
 - **GraphTraverser** (`src/graph/traversal.ts`): BFS/DFS traversal, call graph construction, impact radius calculation, path finding
 - **GraphTraverser** (`src/graph/traversal.ts`): BFS/DFS traversal, call graph construction, impact radius calculation, path finding
 
 
-- **VectorManager** (`src/vectors/manager.ts`): Manages embeddings using `@xenova/transformers` for ONNX inference. Stores vectors in SQLite BLOB format
-
 - **ReferenceResolver** (`src/resolution/index.ts`): Resolves unresolved references after full indexing using framework patterns, import resolution, and name matching
 - **ReferenceResolver** (`src/resolution/index.ts`): Resolves unresolved references after full indexing using framework patterns, import resolution, and name matching
 
 
 ### Database Schema
 ### Database Schema
@@ -100,7 +94,6 @@ SQLite database with:
 - `edges`: Relationships (calls, imports, extends, contains, etc.)
 - `edges`: Relationships (calls, imports, extends, contains, etc.)
 - `files`: Tracked source files with content hashes
 - `files`: Tracked source files with content hashes
 - `unresolved_refs`: References pending resolution
 - `unresolved_refs`: References pending resolution
-- `vectors`: Embeddings stored as BLOBs
 - `nodes_fts`: FTS5 virtual table for full-text search
 - `nodes_fts`: FTS5 virtual table for full-text search
 
 
 ### Supported Languages
 ### Supported Languages
@@ -153,7 +146,6 @@ Tests are in `__tests__/` directory with files mirroring the module structure:
 - `extraction.test.ts` - Tree-sitter parsing for all languages
 - `extraction.test.ts` - Tree-sitter parsing for all languages
 - `resolution.test.ts` - Reference resolution
 - `resolution.test.ts` - Reference resolution
 - `graph.test.ts` - Traversal and graph queries
 - `graph.test.ts` - Traversal and graph queries
-- `vectors.test.ts` - Embedding and semantic search
 - `context.test.ts` - Context building
 - `context.test.ts` - Context building
 - `sync.test.ts` - Incremental updates and git hooks
 - `sync.test.ts` - Incremental updates and git hooks
 
 

+ 0 - 12
__tests__/foundation.test.ts

@@ -275,18 +275,6 @@ describe('CodeGraph Foundation', () => {
       cg.close();
       cg.close();
     });
     });
 
 
-    it('should require embedding initialization for semantic search', async () => {
-      const cg = CodeGraph.initSync(tempDir);
-
-      // Semantic search requires embeddings to be initialized first
-      await expect(cg.semanticSearch('test')).rejects.toThrow(/not initialized/i);
-      await expect(cg.findSimilar('test')).rejects.toThrow(/not initialized/i);
-
-      // Check embedding status
-      expect(cg.isEmbeddingsInitialized()).toBe(false);
-
-      cg.close();
-    });
   });
   });
 });
 });
 
 

+ 0 - 303
__tests__/vectors.test.ts

@@ -1,303 +0,0 @@
-/**
- * Vector Embedding Tests
- *
- * Tests for vector embedding and semantic search functionality.
- * Note: Full embedding tests require the model to be downloaded,
- * which can take time on first run.
- */
-
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import * as fs from 'fs';
-import * as path from 'path';
-import * as os from 'os';
-import CodeGraph from '../src/index';
-import { TextEmbedder } from '../src/vectors/embedder';
-import { VectorSearchManager, createVectorSearch } from '../src/vectors/search';
-import { DatabaseConnection } from '../src/db';
-
-describe('Vector Embeddings', () => {
-  describe('TextEmbedder', () => {
-    describe('createNodeText', () => {
-      it('should create text representation from node', () => {
-        const node = {
-          name: 'processPayment',
-          kind: 'function',
-          qualifiedName: 'PaymentService.processPayment',
-          signature: '(amount: number) => Promise<Receipt>',
-          docstring: 'Process a payment and return a receipt.',
-          filePath: 'src/services/payment.ts',
-        };
-
-        const text = TextEmbedder.createNodeText(node);
-
-        expect(text).toContain('function: processPayment');
-        expect(text).toContain('path: PaymentService.processPayment');
-        expect(text).toContain('file: src/services/payment.ts');
-        expect(text).toContain('signature: (amount: number) => Promise<Receipt>');
-        expect(text).toContain('documentation: Process a payment');
-      });
-
-      it('should handle minimal node data', () => {
-        const node = {
-          name: 'helper',
-          kind: 'function',
-          filePath: 'src/utils.ts',
-        };
-
-        const text = TextEmbedder.createNodeText(node);
-
-        expect(text).toContain('function: helper');
-        expect(text).toContain('file: src/utils.ts');
-        expect(text).not.toContain('signature:');
-        expect(text).not.toContain('documentation:');
-      });
-    });
-
-    describe('cosineSimilarity', () => {
-      it('should compute similarity between identical vectors', () => {
-        const vec = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5]);
-        const similarity = TextEmbedder.cosineSimilarity(vec, vec);
-
-        expect(similarity).toBeCloseTo(1.0, 5);
-      });
-
-      it('should compute similarity between orthogonal vectors', () => {
-        const vec1 = new Float32Array([1, 0, 0]);
-        const vec2 = new Float32Array([0, 1, 0]);
-        const similarity = TextEmbedder.cosineSimilarity(vec1, vec2);
-
-        expect(similarity).toBeCloseTo(0.0, 5);
-      });
-
-      it('should compute similarity between opposite vectors', () => {
-        const vec1 = new Float32Array([1, 0, 0]);
-        const vec2 = new Float32Array([-1, 0, 0]);
-        const similarity = TextEmbedder.cosineSimilarity(vec1, vec2);
-
-        expect(similarity).toBeCloseTo(-1.0, 5);
-      });
-
-      it('should throw for vectors of different dimensions', () => {
-        const vec1 = new Float32Array([1, 2, 3]);
-        const vec2 = new Float32Array([1, 2]);
-
-        expect(() => TextEmbedder.cosineSimilarity(vec1, vec2)).toThrow(
-          'Embeddings must have the same dimension'
-        );
-      });
-
-      it('should handle zero vectors', () => {
-        const vec1 = new Float32Array([0, 0, 0]);
-        const vec2 = new Float32Array([1, 2, 3]);
-        const similarity = TextEmbedder.cosineSimilarity(vec1, vec2);
-
-        expect(similarity).toBe(0);
-      });
-    });
-  });
-
-  describe('VectorSearchManager', () => {
-    let tempDir: string;
-    let db: DatabaseConnection;
-    let searchManager: VectorSearchManager;
-    const TEST_DIMENSION = 3; // Use small dimension for tests
-
-    beforeEach(() => {
-      tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-vector-test-'));
-      const dbPath = path.join(tempDir, 'test.db');
-      db = DatabaseConnection.initialize(dbPath);
-      searchManager = createVectorSearch(db.getDb(), TEST_DIMENSION);
-    });
-
-    afterEach(() => {
-      db.close();
-      if (fs.existsSync(tempDir)) {
-        fs.rmSync(tempDir, { recursive: true, force: true });
-      }
-    });
-
-    it('should store and retrieve vectors', async () => {
-      await searchManager.initialize();
-
-      const embedding = new Float32Array([0.1, 0.2, 0.3]);
-      searchManager.storeVector('node1', embedding, 'test-model');
-
-      const retrieved = searchManager.getVector('node1');
-
-      expect(retrieved).not.toBeNull();
-      expect(retrieved?.length).toBe(3);
-      expect(retrieved?.[0]).toBeCloseTo(0.1, 5);
-    });
-
-    it('should return null for non-existent vectors', async () => {
-      await searchManager.initialize();
-
-      const retrieved = searchManager.getVector('non-existent');
-
-      expect(retrieved).toBeNull();
-    });
-
-    it('should check if vector exists', async () => {
-      await searchManager.initialize();
-
-      const embedding = new Float32Array([0.1, 0.2, 0.3]);
-      searchManager.storeVector('node1', embedding, 'test-model');
-
-      expect(searchManager.hasVector('node1')).toBe(true);
-      expect(searchManager.hasVector('node2')).toBe(false);
-    });
-
-    it('should delete vectors', async () => {
-      await searchManager.initialize();
-
-      const embedding = new Float32Array([0.1, 0.2, 0.3]);
-      searchManager.storeVector('node1', embedding, 'test-model');
-
-      expect(searchManager.hasVector('node1')).toBe(true);
-
-      searchManager.deleteVector('node1');
-
-      expect(searchManager.hasVector('node1')).toBe(false);
-    });
-
-    it('should count vectors', async () => {
-      await searchManager.initialize();
-
-      expect(searchManager.getVectorCount()).toBe(0);
-
-      searchManager.storeVector('node1', new Float32Array([0.1, 0.2, 0.3]), 'test');
-      searchManager.storeVector('node2', new Float32Array([0.4, 0.5, 0.6]), 'test');
-
-      expect(searchManager.getVectorCount()).toBe(2);
-    });
-
-    it('should clear all vectors', async () => {
-      await searchManager.initialize();
-
-      searchManager.storeVector('node1', new Float32Array([0.1, 0.2, 0.3]), 'test');
-      searchManager.storeVector('node2', new Float32Array([0.4, 0.5, 0.6]), 'test');
-
-      expect(searchManager.getVectorCount()).toBe(2);
-
-      searchManager.clear();
-
-      expect(searchManager.getVectorCount()).toBe(0);
-    });
-
-    it('should perform brute-force similarity search', async () => {
-      await searchManager.initialize();
-
-      // Store some test vectors
-      searchManager.storeVector('node1', new Float32Array([1, 0, 0]), 'test');
-      searchManager.storeVector('node2', new Float32Array([0.9, 0.1, 0]), 'test');
-      searchManager.storeVector('node3', new Float32Array([0, 1, 0]), 'test');
-
-      // Search for similar to [1, 0, 0]
-      const query = new Float32Array([1, 0, 0]);
-      const results = searchManager.search(query, { limit: 3 });
-
-      expect(results.length).toBe(3);
-      expect(results[0].nodeId).toBe('node1'); // Most similar
-      expect(results[0].score).toBeCloseTo(1.0, 5);
-      expect(results[1].nodeId).toBe('node2'); // Second most similar
-    });
-
-    it('should respect minScore in search', async () => {
-      await searchManager.initialize();
-
-      searchManager.storeVector('node1', new Float32Array([1, 0, 0]), 'test');
-      searchManager.storeVector('node2', new Float32Array([0, 1, 0]), 'test');
-
-      const query = new Float32Array([1, 0, 0]);
-      const results = searchManager.search(query, { limit: 10, minScore: 0.5 });
-
-      // Only node1 should match with score >= 0.5
-      expect(results.length).toBe(1);
-      expect(results[0].nodeId).toBe('node1');
-    });
-
-    it('should store vectors in batch', async () => {
-      await searchManager.initialize();
-
-      // Use normalized 3-dimensional vectors
-      const entries = [
-        { nodeId: 'node1', embedding: new Float32Array([1.0, 0.0, 0.0]) },
-        { nodeId: 'node2', embedding: new Float32Array([0.0, 1.0, 0.0]) },
-        { nodeId: 'node3', embedding: new Float32Array([0.0, 0.0, 1.0]) },
-      ];
-
-      searchManager.storeVectorBatch(entries, 'test-model');
-
-      expect(searchManager.getVectorCount()).toBe(3);
-      expect(searchManager.hasVector('node1')).toBe(true);
-      expect(searchManager.hasVector('node2')).toBe(true);
-      expect(searchManager.hasVector('node3')).toBe(true);
-    });
-
-    it('should get indexed node IDs', async () => {
-      await searchManager.initialize();
-
-      searchManager.storeVector('node1', new Float32Array([0.1, 0.2, 0.3]), 'test');
-      searchManager.storeVector('node2', new Float32Array([0.4, 0.5, 0.6]), 'test');
-
-      const ids = searchManager.getIndexedNodeIds();
-
-      expect(ids).toContain('node1');
-      expect(ids).toContain('node2');
-      expect(ids.length).toBe(2);
-    });
-  });
-
-  describe('CodeGraph Embedding Integration', () => {
-    let testDir: string;
-    let cg: CodeGraph;
-
-    beforeEach(() => {
-      testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-embed-integration-'));
-
-      // Create a simple test file
-      fs.writeFileSync(
-        path.join(testDir, 'test.ts'),
-        `
-export function processData(input: string): string {
-  return input.toUpperCase();
-}
-`
-      );
-
-      cg = CodeGraph.initSync(testDir, {
-        config: {
-          include: ['**/*.ts'],
-          exclude: [],
-        },
-      });
-    });
-
-    afterEach(() => {
-      if (cg) {
-        cg.destroy();
-      }
-      if (fs.existsSync(testDir)) {
-        fs.rmSync(testDir, { recursive: true, force: true });
-      }
-    });
-
-    it('should report embeddings not initialized', () => {
-      expect(cg.isEmbeddingsInitialized()).toBe(false);
-    });
-
-    it('should return embedding stats even before initialization', () => {
-      const stats = cg.getEmbeddingStats();
-      expect(stats).not.toBeNull();
-      expect(stats!.totalVectors).toBe(0);
-    });
-
-    it('should throw when calling semanticSearch without initialization', async () => {
-      await expect(cg.semanticSearch('test')).rejects.toThrow(/not initialized/i);
-    });
-
-    it('should throw when calling findSimilar without initialization', async () => {
-      await expect(cg.findSimilar('test-id')).rejects.toThrow(/not initialized/i);
-    });
-  });
-});

+ 3 - 7
package.json

@@ -14,9 +14,8 @@
   ],
   ],
   "scripts": {
   "scripts": {
     "build": "tsc && npm run copy-assets",
     "build": "tsc && npm run copy-assets",
-    "postinstall": "node scripts/postinstall.js",
     "preuninstall": "node dist/bin/uninstall.js",
     "preuninstall": "node dist/bin/uninstall.js",
-    "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f));fs.mkdirSync('dist/visualizer/public',{recursive:true});fs.copyFileSync('src/visualizer/public/index.html','dist/visualizer/public/index.html')\"",
+    "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
     "dev": "tsc --watch",
     "dev": "tsc --watch",
     "cli": "npm run build && node dist/bin/codegraph.js",
     "cli": "npm run build && node dist/bin/codegraph.js",
     "test": "vitest run",
     "test": "vitest run",
@@ -28,14 +27,12 @@
   "keywords": [
   "keywords": [
     "code-intelligence",
     "code-intelligence",
     "knowledge-graph",
     "knowledge-graph",
-    "static-analysis",
-    "semantic-search"
+    "static-analysis"
   ],
   ],
   "author": "",
   "author": "",
   "license": "MIT",
   "license": "MIT",
   "dependencies": {
   "dependencies": {
     "@clack/prompts": "^1.2.0",
     "@clack/prompts": "^1.2.0",
-    "@xenova/transformers": "^2.17.0",
     "commander": "^14.0.2",
     "commander": "^14.0.2",
     "node-sqlite3-wasm": "^0.8.30",
     "node-sqlite3-wasm": "^0.8.30",
     "picomatch": "^4.0.3",
     "picomatch": "^4.0.3",
@@ -50,8 +47,7 @@
     "vitest": "^2.1.9"
     "vitest": "^2.1.9"
   },
   },
   "optionalDependencies": {
   "optionalDependencies": {
-    "better-sqlite3": "^11.0.0",
-    "sqlite-vss": "^0.1.2"
+    "better-sqlite3": "^11.0.0"
   },
   },
   "engines": {
   "engines": {
     "node": ">=18.0.0 <25.0.0"
     "node": ">=18.0.0 <25.0.0"

+ 0 - 68
scripts/postinstall.js

@@ -1,68 +0,0 @@
-#!/usr/bin/env node
-/**
- * Postinstall script - downloads the embedding model to ~/.codegraph/models
- * This runs after `npm install` or `npx @colbymchenry/codegraph`
- */
-const { existsSync, mkdirSync } = require('fs');
-const { join } = require('path');
-const { homedir } = require('os');
-
-const CODEGRAPH_DIR = join(homedir(), '.codegraph');
-const MODELS_DIR = join(CODEGRAPH_DIR, 'models');
-const MODEL_ID = 'nomic-ai/nomic-embed-text-v1.5';
-
-async function downloadModel() {
-  // Ensure directories exist
-  if (!existsSync(CODEGRAPH_DIR)) {
-    mkdirSync(CODEGRAPH_DIR, { recursive: true });
-  }
-  if (!existsSync(MODELS_DIR)) {
-    mkdirSync(MODELS_DIR, { recursive: true });
-  }
-
-  // Check if model is already cached
-  const modelCachePath = join(MODELS_DIR, MODEL_ID.replace('/', '/'));
-  if (existsSync(modelCachePath)) {
-    console.log('Embedding model already downloaded.');
-    return;
-  }
-
-  console.log('Downloading embedding model (~130MB)...');
-  console.log('This is a one-time download for semantic code search.\n');
-
-  try {
-    // Dynamic import for @xenova/transformers (ESM-only package)
-    const { pipeline, env } = await import('@xenova/transformers');
-
-    // Configure cache directory
-    env.cacheDir = MODELS_DIR;
-
-    // Download with progress
-    await pipeline('feature-extraction', MODEL_ID, {
-      progress_callback: (progress) => {
-        if (progress.status === 'progress' && progress.file && progress.progress !== undefined) {
-          const fileName = progress.file.split('/').pop();
-          const percent = Math.round(progress.progress);
-          process.stdout.write(`\rDownloading ${fileName}... ${percent}%   `);
-        } else if (progress.status === 'done') {
-          process.stdout.write('\n');
-        }
-      },
-    });
-
-    console.log('\nEmbedding model ready!');
-  } catch (error) {
-    // Don't fail the install if model download fails
-    // User can still use codegraph without semantic search
-    console.log('\nNote: Could not download embedding model.');
-    console.log('Semantic search will download it on first use.');
-    if (process.env.DEBUG) {
-      console.error(error);
-    }
-  }
-}
-
-downloadModel().catch(() => {
-  // Silent exit - don't break npm install
-  process.exit(0);
-});

+ 0 - 63
src/bin/codegraph.ts

@@ -1078,69 +1078,6 @@ program
     }
     }
   });
   });
 
 
-/**
- * codegraph visualize [path]
- */
-program
-  .command('visualize [path]')
-  .description('Open interactive graph visualization in your browser')
-  .option('-p, --port <port>', 'Port to listen on (default: auto)', parseInt)
-  .option('--no-open', 'Do not open browser automatically')
-  .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => {
-    const projectPath = resolveProjectPath(pathArg);
-
-    try {
-      if (!isInitialized(projectPath)) {
-        error(`CodeGraph not initialized in ${projectPath}`);
-        info('Run "codegraph init -i" first');
-        process.exit(1);
-      }
-
-      const { default: CodeGraph } = await loadCodeGraph();
-      const cg = await CodeGraph.open(projectPath);
-      const stats = cg.getStats();
-
-      console.log(chalk.bold('\n  CodeGraph Explorer\n'));
-      info(`Project: ${projectPath}`);
-      info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`);
-
-      const { VisualizerServer } = await import('../visualizer/server');
-      const server = new VisualizerServer(cg);
-      const { url } = await server.start({ port: options.port, openBrowser: options.open !== false });
-
-      success(`Visualizer running at ${chalk.cyan(url)}`);
-      console.log(chalk.dim('  Press Ctrl+C to stop\n'));
-
-      // Open browser
-      if (options.open !== false) {
-        const openCmd = process.platform === 'darwin' ? 'open' :
-                        process.platform === 'win32' ? 'start' : 'xdg-open';
-        spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
-      }
-
-      // Handle shutdown — force exit on second Ctrl+C
-      let shuttingDown = false;
-      const shutdown = () => {
-        if (shuttingDown) {
-          process.exit(1);
-        }
-        shuttingDown = true;
-        console.log(chalk.dim('\n  Shutting down...'));
-        server.stop().then(() => {
-          cg.close();
-          process.exit(0);
-        }).catch(() => process.exit(1));
-        // Force exit after 2s if graceful shutdown hangs
-        setTimeout(() => process.exit(1), 2000).unref();
-      };
-      process.on('SIGINT', shutdown);
-      process.on('SIGTERM', shutdown);
-    } catch (err) {
-      error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
-    }
-  });
-
 /**
 /**
  * codegraph mark-dirty [path]
  * codegraph mark-dirty [path]
  *
  *

+ 6 - 33
src/context/index.ts

@@ -1,7 +1,7 @@
 /**
 /**
  * Context Builder
  * Context Builder
  *
  *
- * Builds rich context for tasks by combining semantic search with graph traversal.
+ * Builds rich context for tasks by combining FTS search with graph traversal.
  * Outputs structured context ready to inject into Claude.
  * Outputs structured context ready to inject into Claude.
  */
  */
 
 
@@ -22,7 +22,6 @@ import {
 } from '../types';
 } from '../types';
 import { QueryBuilder } from '../db/queries';
 import { QueryBuilder } from '../db/queries';
 import { GraphTraverser } from '../graph';
 import { GraphTraverser } from '../graph';
-import { VectorManager } from '../vectors';
 import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
 import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
 import { logDebug } from '../errors';
 import { logDebug } from '../errors';
 import { validatePathWithinRoot } from '../utils';
 import { validatePathWithinRoot } from '../utils';
@@ -185,18 +184,15 @@ export class ContextBuilder {
   private projectRoot: string;
   private projectRoot: string;
   private queries: QueryBuilder;
   private queries: QueryBuilder;
   private traverser: GraphTraverser;
   private traverser: GraphTraverser;
-  private vectorManager: VectorManager | null;
 
 
   constructor(
   constructor(
     projectRoot: string,
     projectRoot: string,
     queries: QueryBuilder,
     queries: QueryBuilder,
-    traverser: GraphTraverser,
-    vectorManager: VectorManager | null
+    traverser: GraphTraverser
   ) {
   ) {
     this.projectRoot = projectRoot;
     this.projectRoot = projectRoot;
     this.queries = queries;
     this.queries = queries;
     this.traverser = traverser;
     this.traverser = traverser;
-    this.vectorManager = vectorManager;
   }
   }
 
 
   /**
   /**
@@ -394,21 +390,7 @@ export class ContextBuilder {
       exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3));
       exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3));
     }
     }
 
 
-    // Step 3: Try semantic search if vector manager is available
-    let semanticResults: SearchResult[] = [];
-    if (this.vectorManager && this.vectorManager.isInitialized()) {
-      try {
-        semanticResults = await this.vectorManager.search(query, {
-          limit: opts.searchLimit,
-          kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
-        });
-        logDebug('Semantic search results', { count: semanticResults.length });
-      } catch (error) {
-        logDebug('Semantic search failed, falling back to text search', { query, error: String(error) });
-      }
-    }
-
-    // Step 4: Always run text search for natural language term matching
+    // Step 3: Run text search for natural language term matching
     // This catches file-name and node-name matches that semantic search may miss,
     // This catches file-name and node-name matches that semantic search may miss,
     // which is critical for template-heavy codebases (e.g., Liquid/Shopify themes)
     // which is critical for template-heavy codebases (e.g., Liquid/Shopify themes)
     // where file names are the primary identifiers.
     // where file names are the primary identifiers.
@@ -457,7 +439,7 @@ export class ContextBuilder {
       logDebug('Text search failed', { query, error: String(error) });
       logDebug('Text search failed', { query, error: String(error) });
     }
     }
 
 
-    // Step 5: Merge results, prioritizing exact matches, then text (path-boosted), then semantic
+    // Step 4: Merge results, prioritizing exact matches, then text (path-boosted)
     const seenIds = new Set<string>();
     const seenIds = new Set<string>();
     let searchResults: SearchResult[] = [];
     let searchResults: SearchResult[] = [];
 
 
@@ -477,14 +459,6 @@ export class ContextBuilder {
       }
       }
     }
     }
 
 
-    // Add semantic results
-    for (const result of semanticResults) {
-      if (!seenIds.has(result.node.id)) {
-        seenIds.add(result.node.id);
-        searchResults.push(result);
-      }
-    }
-
     const queryLower = query.toLowerCase();
     const queryLower = query.toLowerCase();
     const isTestQuery = queryLower.includes('test') || queryLower.includes('spec');
     const isTestQuery = queryLower.includes('test') || queryLower.includes('spec');
 
 
@@ -1121,10 +1095,9 @@ export class ContextBuilder {
 export function createContextBuilder(
 export function createContextBuilder(
   projectRoot: string,
   projectRoot: string,
   queries: QueryBuilder,
   queries: QueryBuilder,
-  traverser: GraphTraverser,
-  vectorManager: VectorManager | null
+  traverser: GraphTraverser
 ): ContextBuilder {
 ): ContextBuilder {
-  return new ContextBuilder(projectRoot, queries, traverser, vectorManager);
+  return new ContextBuilder(projectRoot, queries, traverser);
 }
 }
 
 
 // Re-export formatter
 // Re-export formatter

+ 0 - 1
src/db/queries.ts

@@ -1290,7 +1290,6 @@ export class QueryBuilder {
     this.nodeCache.clear();
     this.nodeCache.clear();
     this.db.transaction(() => {
     this.db.transaction(() => {
       this.db.exec('DELETE FROM unresolved_refs');
       this.db.exec('DELETE FROM unresolved_refs');
-      this.db.exec('DELETE FROM vectors');
       this.db.exec('DELETE FROM edges');
       this.db.exec('DELETE FROM edges');
       this.db.exec('DELETE FROM nodes');
       this.db.exec('DELETE FROM nodes');
       this.db.exec('DELETE FROM files');
       this.db.exec('DELETE FROM files');

+ 0 - 16
src/db/schema.sql

@@ -140,22 +140,6 @@ CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path
 CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name);
 CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name);
 CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
 CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
 
 
--- =============================================================================
--- Vector Storage (for future semantic search)
--- =============================================================================
-
--- Vector embeddings for semantic search
--- Note: No foreign key constraint to allow standalone vector testing
--- The VectorManager handles node-vector relationship at the application level
-CREATE TABLE IF NOT EXISTS vectors (
-    node_id TEXT PRIMARY KEY,
-    embedding BLOB NOT NULL, -- Float32 array stored as blob
-    model TEXT NOT NULL, -- Model used to generate embedding
-    created_at INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_vectors_model ON vectors(model);
-
 -- Project metadata for version/provenance tracking
 -- Project metadata for version/provenance tracking
 CREATE TABLE IF NOT EXISTS project_metadata (
 CREATE TABLE IF NOT EXISTS project_metadata (
     key TEXT PRIMARY KEY,
     key TEXT PRIMARY KEY,

+ 2 - 124
src/index.ts

@@ -46,7 +46,6 @@ import {
   ResolutionResult,
   ResolutionResult,
 } from './resolution';
 } from './resolution';
 import { GraphTraverser, GraphQueryManager } from './graph';
 import { GraphTraverser, GraphQueryManager } from './graph';
-import { VectorManager, createVectorManager, EmbeddingProgress } from './vectors';
 import { ContextBuilder, createContextBuilder } from './context';
 import { ContextBuilder, createContextBuilder } from './context';
 import { Mutex, FileLock } from './utils';
 import { Mutex, FileLock } from './utils';
 
 
@@ -63,7 +62,6 @@ export {
 export { IndexProgress, IndexResult, SyncResult } from './extraction';
 export { IndexProgress, IndexResult, SyncResult } from './extraction';
 export { detectLanguage, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './extraction';
 export { detectLanguage, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './extraction';
 export { ResolutionResult } from './resolution';
 export { ResolutionResult } from './resolution';
-export { EmbeddingProgress } from './vectors';
 export {
 export {
   CodeGraphError,
   CodeGraphError,
   FileError,
   FileError,
@@ -134,7 +132,6 @@ export class CodeGraph {
   private resolver: ReferenceResolver;
   private resolver: ReferenceResolver;
   private graphManager: GraphQueryManager;
   private graphManager: GraphQueryManager;
   private traverser: GraphTraverser;
   private traverser: GraphTraverser;
-  private vectorManager: VectorManager | null = null;
   private contextBuilder: ContextBuilder;
   private contextBuilder: ContextBuilder;
 
 
   // Mutex for preventing concurrent indexing operations (in-process)
   // Mutex for preventing concurrent indexing operations (in-process)
@@ -160,14 +157,10 @@ export class CodeGraph {
     this.resolver = createResolver(projectRoot, queries);
     this.resolver = createResolver(projectRoot, queries);
     this.graphManager = new GraphQueryManager(queries);
     this.graphManager = new GraphQueryManager(queries);
     this.traverser = new GraphTraverser(queries);
     this.traverser = new GraphTraverser(queries);
-    // Vector manager — always created, embeddings generated lazily on first use
-    this.vectorManager = createVectorManager(db.getDb(), queries, {});
-    // Context builder (uses vector manager for semantic search)
     this.contextBuilder = createContextBuilder(
     this.contextBuilder = createContextBuilder(
       projectRoot,
       projectRoot,
       queries,
       queries,
-      this.traverser,
-      this.vectorManager
+      this.traverser
     );
     );
   }
   }
 
 
@@ -328,11 +321,6 @@ export class CodeGraph {
   close(): void {
   close(): void {
     // Release file lock if held
     // Release file lock if held
     this.fileLock.release();
     this.fileLock.release();
-    // Dispose vector manager first to release ONNX workers
-    if (this.vectorManager) {
-      this.vectorManager.dispose();
-      this.vectorManager = null;
-    }
     this.db.close();
     this.db.close();
   }
   }
 
 
@@ -838,102 +826,6 @@ export class CodeGraph {
     return this.graphManager.getNodeMetrics(nodeId);
     return this.graphManager.getNodeMetrics(nodeId);
   }
   }
 
 
-  // ===========================================================================
-  // Semantic Search (Vector Embeddings)
-  // ===========================================================================
-
-  /**
-   * Initialize the embedding system
-   *
-   * This downloads the embedding model on first use and initializes
-   * the vector search system. Must be called before using semantic search.
-   */
-  async initializeEmbeddings(): Promise<void> {
-    if (!this.vectorManager) {
-      this.vectorManager = createVectorManager(this.db.getDb(), this.queries, {
-        embedder: {
-          showProgress: true,
-        },
-      });
-    }
-    await this.vectorManager.initialize();
-  }
-
-  /**
-   * Check if embeddings are initialized
-   */
-  isEmbeddingsInitialized(): boolean {
-    return this.vectorManager?.isInitialized() ?? false;
-  }
-
-  /**
-   * Generate embeddings for all eligible nodes
-   *
-   * @param onProgress - Optional progress callback
-   * @returns Number of nodes embedded
-   */
-  async generateEmbeddings(
-    onProgress?: (progress: EmbeddingProgress) => void
-  ): Promise<number> {
-    if (!this.vectorManager) {
-      await this.initializeEmbeddings();
-    }
-    return this.vectorManager!.embedAllNodes(onProgress);
-  }
-
-  /**
-   * Semantic search using embeddings
-   *
-   * Searches for code nodes semantically similar to the query.
-   * Requires embeddings to be initialized first.
-   *
-   * @param query - Natural language search query
-   * @param limit - Maximum number of results (default: 10)
-   * @returns Array of search results with similarity scores
-   */
-  async semanticSearch(query: string, limit: number = 10): Promise<SearchResult[]> {
-    if (!this.vectorManager || !this.vectorManager.isInitialized()) {
-      throw new Error(
-        'Embeddings not initialized. Call initializeEmbeddings() first.'
-      );
-    }
-    return this.vectorManager.search(query, { limit });
-  }
-
-  /**
-   * Find similar code blocks
-   *
-   * Finds nodes semantically similar to a given node.
-   * Requires embeddings to be initialized first.
-   *
-   * @param nodeId - ID of the node to find similar nodes for
-   * @param limit - Maximum number of results (default: 10)
-   * @returns Array of similar nodes with similarity scores
-   */
-  async findSimilar(nodeId: string, limit: number = 10): Promise<SearchResult[]> {
-    if (!this.vectorManager || !this.vectorManager.isInitialized()) {
-      throw new Error(
-        'Embeddings not initialized. Call initializeEmbeddings() first.'
-      );
-    }
-    return this.vectorManager.findSimilar(nodeId, { limit });
-  }
-
-  /**
-   * Get vector embedding statistics
-   */
-  getEmbeddingStats(): {
-    totalVectors: number;
-    vssEnabled: boolean;
-    modelId: string;
-    dimension: number;
-  } | null {
-    if (!this.vectorManager) {
-      return null;
-    }
-    return this.vectorManager.getStats();
-  }
-
   // ===========================================================================
   // ===========================================================================
   // Context Building
   // Context Building
   // ===========================================================================
   // ===========================================================================
@@ -964,13 +856,6 @@ export class CodeGraph {
     query: string,
     query: string,
     options?: FindRelevantContextOptions
     options?: FindRelevantContextOptions
   ): Promise<Subgraph> {
   ): Promise<Subgraph> {
-    // Update context builder with current vector manager
-    this.contextBuilder = createContextBuilder(
-      this.projectRoot,
-      this.queries,
-      this.traverser,
-      this.vectorManager
-    );
     return this.contextBuilder.findRelevantContext(query, options);
     return this.contextBuilder.findRelevantContext(query, options);
   }
   }
 
 
@@ -978,7 +863,7 @@ export class CodeGraph {
    * Build context for a task
    * Build context for a task
    *
    *
    * Creates comprehensive context by:
    * Creates comprehensive context by:
-   * 1. Running semantic search to find entry points
+   * 1. Running FTS search to find entry points
    * 2. Expanding the graph around entry points
    * 2. Expanding the graph around entry points
    * 3. Extracting code blocks for key nodes
    * 3. Extracting code blocks for key nodes
    * 4. Formatting output for Claude
    * 4. Formatting output for Claude
@@ -991,13 +876,6 @@ export class CodeGraph {
     input: TaskInput,
     input: TaskInput,
     options?: BuildContextOptions
     options?: BuildContextOptions
   ): Promise<TaskContext | string> {
   ): Promise<TaskContext | string> {
-    // Update context builder with current vector manager
-    this.contextBuilder = createContextBuilder(
-      this.projectRoot,
-      this.queries,
-      this.traverser,
-      this.vectorManager
-    );
     return this.contextBuilder.buildContext(input, options);
     return this.contextBuilder.buildContext(input, options);
   }
   }
 
 

+ 0 - 410
src/vectors/embedder.ts

@@ -1,410 +0,0 @@
-/**
- * Text Embedder
- *
- * Generates vector embeddings using the nomic-embed-text model via Transformers.js.
- * Uses ONNX runtime under the hood for fast local inference.
- */
-
-import * as path from 'path';
-import * as fs from 'fs';
-import { homedir } from 'os';
-
-// Global model cache directory - uses codegraph's models directory for shared embedding models
-const GLOBAL_MODELS_DIR = path.join(homedir(), '.codegraph', 'models');
-
-// Dynamic import for @xenova/transformers (ESM-only package)
-// We use dynamic import to support CommonJS builds
-let transformersModule: typeof import('@xenova/transformers') | null = null;
-
-async function getTransformers() {
-  if (!transformersModule) {
-    transformersModule = await import('@xenova/transformers');
-  }
-  return transformersModule;
-}
-
-// Type for the feature extraction pipeline
-type FeatureExtractionPipeline = any;
-
-/**
- * Default model for embeddings
- * nomic-embed-text-v1.5 produces 384-dimensional embeddings
- */
-export const DEFAULT_MODEL = 'nomic-ai/nomic-embed-text-v1.5';
-export const EMBEDDING_DIMENSION = 768; // nomic-embed-text-v1.5 uses 768 dimensions
-
-/**
- * Options for the embedder
- */
-export interface EmbedderOptions {
-  /** Model ID to use (default: nomic-ai/nomic-embed-text-v1.5) */
-  modelId?: string;
-
-  /** Directory to cache the model (default: ~/.codegraph/models) */
-  cacheDir?: string;
-
-  /** Whether to show progress during model download */
-  showProgress?: boolean;
-}
-
-/**
- * Text embedding result
- */
-export interface EmbeddingResult {
-  /** The embedding vector */
-  embedding: Float32Array;
-
-  /** Dimension of the embedding */
-  dimension: number;
-
-  /** Model used to generate the embedding */
-  model: string;
-}
-
-/**
- * Batch embedding result
- */
-export interface BatchEmbeddingResult {
-  /** Array of embeddings in same order as input */
-  embeddings: Float32Array[];
-
-  /** Dimension of each embedding */
-  dimension: number;
-
-  /** Model used to generate embeddings */
-  model: string;
-
-  /** Processing time in milliseconds */
-  durationMs: number;
-}
-
-/**
- * Text Embedder using Transformers.js
- *
- * Uses the nomic-embed-text-v1.5 model to generate embeddings for code
- * and natural language queries.
- */
-export class TextEmbedder {
-  private modelId: string;
-  private cacheDir: string;
-  private pipeline: FeatureExtractionPipeline | null = null;
-  private initialized = false;
-  private showProgress: boolean;
-
-  constructor(options: EmbedderOptions = {}) {
-    this.modelId = options.modelId || DEFAULT_MODEL;
-    this.cacheDir = options.cacheDir || GLOBAL_MODELS_DIR;
-    this.showProgress = options.showProgress ?? false;
-  }
-
-  /**
-   * Initialize the embedder by loading the model
-   *
-   * This will download the model on first use if not already cached.
-   */
-  async initialize(): Promise<void> {
-    if (this.initialized) {
-      return;
-    }
-
-    // Load transformers.js dynamically (ESM-only package)
-    const { pipeline, env } = await getTransformers();
-
-    // Configure transformers.js to use local cache
-    env.cacheDir = this.cacheDir;
-
-    // Ensure cache directory exists
-    if (!fs.existsSync(this.cacheDir)) {
-      fs.mkdirSync(this.cacheDir, { recursive: true });
-    }
-
-    // Disable remote model checking if model is already cached
-    // This speeds up initialization significantly
-    const modelCacheExists = fs.existsSync(
-      path.join(this.cacheDir, this.modelId.replace('/', '--'))
-    );
-    if (modelCacheExists) {
-      env.allowRemoteModels = false;
-    }
-
-    // Load the pipeline with quantized model to reduce WASM memory pressure.
-    // Quantized (int8/uint8) is ~4x smaller than FP32 with minimal quality loss.
-    this.pipeline = await pipeline('feature-extraction', this.modelId, {
-      quantized: true,
-      progress_callback: this.showProgress
-        ? (progress: { status: string; file?: string; progress?: number }) => {
-            if (progress.status === 'progress' && progress.file && progress.progress) {
-              const pct = Math.round(progress.progress);
-              process.stdout.write(`\rDownloading ${progress.file}: ${pct}%\x1b[K`);
-            } else if (progress.status === 'done') {
-              process.stdout.write('\n');
-            }
-          }
-        : undefined,
-    });
-
-    this.initialized = true;
-  }
-
-  /**
-   * Check if the embedder is initialized
-   */
-  isInitialized(): boolean {
-    return this.initialized;
-  }
-
-  /**
-   * Get the model ID being used
-   */
-  getModelId(): string {
-    return this.modelId;
-  }
-
-  /**
-   * Get the embedding dimension
-   */
-  getDimension(): number {
-    return EMBEDDING_DIMENSION;
-  }
-
-  /**
-   * Generate embedding for a single text
-   *
-   * @param text - Text to embed
-   * @returns Embedding result
-   */
-  async embed(text: string): Promise<EmbeddingResult> {
-    if (!this.initialized || !this.pipeline) {
-      throw new Error('Embedder not initialized. Call initialize() first.');
-    }
-
-    // Prepare text for nomic-embed-text (it expects specific prefixes)
-    const preparedText = this.prepareText(text, 'document');
-
-    // Generate embedding
-    const output = await this.pipeline(preparedText, {
-      pooling: 'mean',
-      normalize: true,
-    });
-
-    // Extract the embedding array - handle various data formats
-    const data = output.data as unknown;
-    const embedding = this.toFloat32Array(data);
-
-    return {
-      embedding,
-      dimension: embedding.length,
-      model: this.modelId,
-    };
-  }
-
-  /**
-   * Generate embedding for a query (uses different prefix)
-   *
-   * @param query - Query text to embed
-   * @returns Embedding result
-   */
-  async embedQuery(query: string): Promise<EmbeddingResult> {
-    if (!this.initialized || !this.pipeline) {
-      throw new Error('Embedder not initialized. Call initialize() first.');
-    }
-
-    // Prepare text for nomic-embed-text query
-    const preparedText = this.prepareText(query, 'search_query');
-
-    // Generate embedding
-    const output = await this.pipeline(preparedText, {
-      pooling: 'mean',
-      normalize: true,
-    });
-
-    // Extract the embedding array - handle various data formats
-    const data = output.data as unknown;
-    const embedding = this.toFloat32Array(data);
-
-    return {
-      embedding,
-      dimension: embedding.length,
-      model: this.modelId,
-    };
-  }
-
-  /**
-   * Generate embeddings for multiple texts in a batch
-   *
-   * @param texts - Array of texts to embed
-   * @param type - Type of text (document or search_query)
-   * @returns Batch embedding result
-   */
-  async embedBatch(
-    texts: string[],
-    type: 'document' | 'search_query' = 'document'
-  ): Promise<BatchEmbeddingResult> {
-    if (!this.initialized || !this.pipeline) {
-      throw new Error('Embedder not initialized. Call initialize() first.');
-    }
-
-    if (texts.length === 0) {
-      return {
-        embeddings: [],
-        dimension: EMBEDDING_DIMENSION,
-        model: this.modelId,
-        durationMs: 0,
-      };
-    }
-
-    const startTime = Date.now();
-
-    // Prepare all texts
-    const preparedTexts = texts.map((t) => this.prepareText(t, type));
-
-    // Generate embeddings
-    const outputs = await this.pipeline(preparedTexts, {
-      pooling: 'mean',
-      normalize: true,
-    });
-
-    // Extract embeddings
-    const embeddings: Float32Array[] = [];
-    const dims = outputs.dims as number[];
-    const dimension = dims[1] ?? EMBEDDING_DIMENSION;
-    const data = outputs.data as unknown;
-    const flatData = this.toFloat32Array(data);
-
-    for (let i = 0; i < texts.length; i++) {
-      const start = i * dimension;
-      const end = start + dimension;
-      embeddings.push(flatData.slice(start, end));
-    }
-
-    return {
-      embeddings,
-      dimension,
-      model: this.modelId,
-      durationMs: Date.now() - startTime,
-    };
-  }
-
-  /**
-   * Convert various array formats to Float32Array
-   */
-  private toFloat32Array(data: unknown): Float32Array {
-    if (data instanceof Float32Array) {
-      return data;
-    }
-    if (Array.isArray(data)) {
-      return new Float32Array(data);
-    }
-    if (data && typeof data === 'object' && 'length' in data) {
-      // Handle TypedArray-like objects
-      const arr = data as ArrayLike<number>;
-      return Float32Array.from(Array.from(arr));
-    }
-    throw new Error('Unsupported data format for embedding');
-  }
-
-  /**
-   * Prepare text for the nomic-embed-text model
-   *
-   * The model expects specific prefixes for different tasks:
-   * - "search_document: " for documents to be searched
-   * - "search_query: " for search queries
-   */
-  private prepareText(text: string, type: 'document' | 'search_query'): string {
-    // Truncate very long texts (model has a max token limit)
-    const maxLength = 8192; // nomic-embed-text-v1.5 supports 8192 tokens
-    const truncatedText = text.length > maxLength ? text.slice(0, maxLength) : text;
-
-    // Add appropriate prefix
-    if (type === 'search_query') {
-      return `search_query: ${truncatedText}`;
-    } else {
-      return `search_document: ${truncatedText}`;
-    }
-  }
-
-  /**
-   * Create text representation of a code node for embedding
-   *
-   * Combines name, signature, docstring, and code snippet into
-   * a searchable text representation.
-   */
-  static createNodeText(node: {
-    name: string;
-    kind: string;
-    qualifiedName?: string;
-    signature?: string;
-    docstring?: string;
-    filePath: string;
-  }): string {
-    const parts: string[] = [];
-
-    // Add kind and name
-    parts.push(`${node.kind}: ${node.name}`);
-
-    // Add qualified name if different from name
-    if (node.qualifiedName && node.qualifiedName !== node.name) {
-      parts.push(`path: ${node.qualifiedName}`);
-    }
-
-    // Add file path
-    parts.push(`file: ${node.filePath}`);
-
-    // Add signature if present
-    if (node.signature) {
-      parts.push(`signature: ${node.signature}`);
-    }
-
-    // Add docstring if present
-    if (node.docstring) {
-      parts.push(`documentation: ${node.docstring}`);
-    }
-
-    return parts.join('\n');
-  }
-
-  /**
-   * Compute cosine similarity between two embeddings
-   */
-  static cosineSimilarity(a: Float32Array, b: Float32Array): number {
-    if (a.length !== b.length) {
-      throw new Error('Embeddings must have the same dimension');
-    }
-
-    let dotProduct = 0;
-    let normA = 0;
-    let normB = 0;
-
-    for (let i = 0; i < a.length; i++) {
-      const aVal = a[i]!;
-      const bVal = b[i]!;
-      dotProduct += aVal * bVal;
-      normA += aVal * aVal;
-      normB += bVal * bVal;
-    }
-
-    normA = Math.sqrt(normA);
-    normB = Math.sqrt(normB);
-
-    if (normA === 0 || normB === 0) {
-      return 0;
-    }
-
-    return dotProduct / (normA * normB);
-  }
-
-  /**
-   * Release resources
-   */
-  dispose(): void {
-    this.pipeline = null;
-    this.initialized = false;
-  }
-}
-
-/**
- * Create a text embedder instance
- */
-export function createEmbedder(options?: EmbedderOptions): TextEmbedder {
-  return new TextEmbedder(options);
-}

+ 0 - 28
src/vectors/index.ts

@@ -1,28 +0,0 @@
-/**
- * Vectors Module
- *
- * Provides text embedding and vector similarity search for semantic code search.
- */
-
-export {
-  TextEmbedder,
-  createEmbedder,
-  DEFAULT_MODEL,
-  EMBEDDING_DIMENSION,
-  EmbedderOptions,
-  EmbeddingResult,
-  BatchEmbeddingResult,
-} from './embedder';
-
-export {
-  VectorSearchManager,
-  createVectorSearch,
-  VectorSearchOptions,
-} from './search';
-
-export {
-  VectorManager,
-  createVectorManager,
-  VectorManagerOptions,
-  EmbeddingProgress,
-} from './manager';

+ 0 - 363
src/vectors/manager.ts

@@ -1,363 +0,0 @@
-/**
- * Vector Manager
- *
- * High-level manager that coordinates embedding generation and vector search.
- */
-
-import { SqliteDatabase } from '../db/sqlite-adapter';
-import { Node, SearchResult, SearchOptions } from '../types';
-import { TextEmbedder, createEmbedder, EmbedderOptions, EMBEDDING_DIMENSION } from './embedder';
-import { VectorSearchManager, createVectorSearch } from './search';
-import { QueryBuilder } from '../db/queries';
-
-/**
- * Progress callback for embedding generation
- */
-export interface EmbeddingProgress {
-  /** Current node index */
-  current: number;
-
-  /** Total nodes to embed */
-  total: number;
-
-  /** Current node being embedded */
-  nodeName?: string;
-}
-
-/**
- * Options for the vector manager
- */
-export interface VectorManagerOptions {
-  /** Embedder options */
-  embedder?: EmbedderOptions;
-
-  /** Node kinds to embed (default: functions, methods, classes, interfaces) */
-  nodeKinds?: Node['kind'][];
-
-  /** Batch size for embedding generation */
-  batchSize?: number;
-}
-
-/**
- * Default node kinds to embed
- */
-const DEFAULT_NODE_KINDS: Node['kind'][] = [
-  'function',
-  'method',
-  'class',
-  'interface',
-  'type_alias',
-  'module',
-  'component',
-];
-
-/**
- * Vector Manager
- *
- * Provides high-level interface for semantic search:
- * - Generates embeddings for code nodes
- * - Stores embeddings in the database
- * - Performs semantic similarity search
- */
-export class VectorManager {
-  private embedder: TextEmbedder;
-  private searchManager: VectorSearchManager;
-  private queries: QueryBuilder;
-  private nodeKinds: Node['kind'][];
-  private batchSize: number;
-  private initialized = false;
-
-  constructor(
-    db: SqliteDatabase,
-    queries: QueryBuilder,
-    options: VectorManagerOptions = {}
-  ) {
-    this.embedder = createEmbedder(options.embedder);
-    this.searchManager = createVectorSearch(db, EMBEDDING_DIMENSION);
-    this.queries = queries;
-    this.nodeKinds = options.nodeKinds || DEFAULT_NODE_KINDS;
-    this.batchSize = options.batchSize || 32;
-  }
-
-  /**
-   * Initialize the vector manager
-   *
-   * Loads the embedding model and initializes vector search.
-   */
-  async initialize(): Promise<void> {
-    if (this.initialized) {
-      return;
-    }
-
-    // Initialize embedder (downloads model if needed)
-    await this.embedder.initialize();
-
-    // Initialize vector search (loads sqlite-vss if available)
-    await this.searchManager.initialize();
-
-    this.initialized = true;
-  }
-
-  /**
-   * Check if the vector manager is initialized
-   */
-  isInitialized(): boolean {
-    return this.initialized;
-  }
-
-  /**
-   * Generate embeddings for all eligible nodes
-   *
-   * @param onProgress - Optional progress callback
-   * @returns Number of nodes embedded
-   */
-  async embedAllNodes(onProgress?: (progress: EmbeddingProgress) => void): Promise<number> {
-    if (!this.initialized) {
-      throw new Error('VectorManager not initialized. Call initialize() first.');
-    }
-
-    // Get all nodes that should be embedded
-    const nodesToEmbed: Node[] = [];
-    for (const kind of this.nodeKinds) {
-      const nodes = this.queries.getNodesByKind(kind);
-      nodesToEmbed.push(...nodes);
-    }
-
-    // Filter out nodes that already have embeddings
-    const existingIds = new Set(this.searchManager.getIndexedNodeIds());
-    const newNodes = nodesToEmbed.filter((n) => !existingIds.has(n.id));
-
-    if (newNodes.length === 0) {
-      return 0;
-    }
-
-    // Process in batches
-    let processed = 0;
-    const model = this.embedder.getModelId();
-
-    for (let i = 0; i < newNodes.length; i += this.batchSize) {
-      const batch = newNodes.slice(i, i + this.batchSize);
-
-      // Create text representations
-      const texts = batch.map((node) => TextEmbedder.createNodeText(node));
-
-      // Generate embeddings
-      const result = await this.embedder.embedBatch(texts, 'document');
-
-      // Store embeddings
-      const entries: Array<{ nodeId: string; embedding: Float32Array }> = [];
-      for (let idx = 0; idx < batch.length; idx++) {
-        const node = batch[idx];
-        const embedding = result.embeddings[idx];
-        if (node && embedding) {
-          entries.push({ nodeId: node.id, embedding });
-        }
-      }
-      this.searchManager.storeVectorBatch(entries, model);
-
-      processed += batch.length;
-
-      // Report progress
-      if (onProgress) {
-        onProgress({
-          current: processed,
-          total: newNodes.length,
-          nodeName: batch[batch.length - 1]?.name,
-        });
-      }
-    }
-
-    return processed;
-  }
-
-  /**
-   * Generate embedding for a single node
-   *
-   * @param node - Node to embed
-   */
-  async embedNode(node: Node): Promise<void> {
-    if (!this.initialized) {
-      throw new Error('VectorManager not initialized. Call initialize() first.');
-    }
-
-    const text = TextEmbedder.createNodeText(node);
-    const result = await this.embedder.embed(text);
-    this.searchManager.storeVector(node.id, result.embedding, result.model);
-  }
-
-  /**
-   * Semantic search for nodes matching a query
-   *
-   * @param query - Natural language query
-   * @param options - Search options
-   * @returns Array of search results with similarity scores
-   */
-  async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
-    if (!this.initialized) {
-      throw new Error('VectorManager not initialized. Call initialize() first.');
-    }
-
-    const { limit = 10, kinds } = options;
-
-    // Generate query embedding
-    const queryResult = await this.embedder.embedQuery(query);
-
-    // Search for similar vectors
-    const vectorResults = this.searchManager.search(queryResult.embedding, {
-      limit: limit * 2, // Get more results to filter
-      minScore: 0.3, // Minimum similarity threshold
-    });
-
-    // Get nodes and filter by kind if specified
-    const results: SearchResult[] = [];
-    for (const vr of vectorResults) {
-      const node = this.queries.getNodeById(vr.nodeId);
-      if (!node) {
-        continue;
-      }
-
-      // Filter by node kind if specified
-      if (kinds && kinds.length > 0 && !kinds.includes(node.kind)) {
-        continue;
-      }
-
-      results.push({
-        node,
-        score: vr.score,
-      });
-
-      if (results.length >= limit) {
-        break;
-      }
-    }
-
-    return results;
-  }
-
-  /**
-   * Find nodes similar to a given node
-   *
-   * @param nodeId - ID of the node to find similar nodes for
-   * @param options - Search options
-   * @returns Array of similar nodes with similarity scores
-   */
-  async findSimilar(nodeId: string, options: SearchOptions = {}): Promise<SearchResult[]> {
-    if (!this.initialized) {
-      throw new Error('VectorManager not initialized. Call initialize() first.');
-    }
-
-    const { limit = 10, kinds } = options;
-
-    // Get the node's embedding
-    let embedding = this.searchManager.getVector(nodeId);
-
-    // If no embedding exists, generate one
-    if (!embedding) {
-      const node = this.queries.getNodeById(nodeId);
-      if (!node) {
-        throw new Error(`Node not found: ${nodeId}`);
-      }
-
-      await this.embedNode(node);
-      embedding = this.searchManager.getVector(nodeId);
-
-      if (!embedding) {
-        throw new Error(`Failed to generate embedding for node: ${nodeId}`);
-      }
-    }
-
-    // Search for similar vectors (excluding the source node)
-    const vectorResults = this.searchManager.search(embedding, {
-      limit: limit + 1, // Get one extra to exclude the source
-      minScore: 0.3,
-    });
-
-    // Get nodes and filter
-    const results: SearchResult[] = [];
-    for (const vr of vectorResults) {
-      // Skip the source node
-      if (vr.nodeId === nodeId) {
-        continue;
-      }
-
-      const node = this.queries.getNodeById(vr.nodeId);
-      if (!node) {
-        continue;
-      }
-
-      // Filter by node kind if specified
-      if (kinds && kinds.length > 0 && !kinds.includes(node.kind)) {
-        continue;
-      }
-
-      results.push({
-        node,
-        score: vr.score,
-      });
-
-      if (results.length >= limit) {
-        break;
-      }
-    }
-
-    return results;
-  }
-
-  /**
-   * Delete embedding for a node
-   *
-   * @param nodeId - ID of the node
-   */
-  deleteNodeEmbedding(nodeId: string): void {
-    this.searchManager.deleteVector(nodeId);
-  }
-
-  /**
-   * Get statistics about vector storage
-   */
-  getStats(): {
-    totalVectors: number;
-    vssEnabled: boolean;
-    modelId: string;
-    dimension: number;
-  } {
-    return {
-      totalVectors: this.searchManager.getVectorCount(),
-      vssEnabled: this.searchManager.isVssEnabled(),
-      modelId: this.embedder.getModelId(),
-      dimension: this.embedder.getDimension(),
-    };
-  }
-
-  /**
-   * Clear all vectors
-   */
-  clear(): void {
-    this.searchManager.clear();
-  }
-
-  /**
-   * Rebuild the VSS index
-   */
-  rebuildIndex(): void {
-    this.searchManager.rebuildVssIndex();
-  }
-
-  /**
-   * Release resources
-   */
-  dispose(): void {
-    this.embedder.dispose();
-  }
-}
-
-/**
- * Create a vector manager
- */
-export function createVectorManager(
-  db: SqliteDatabase,
-  queries: QueryBuilder,
-  options?: VectorManagerOptions
-): VectorManager {
-  return new VectorManager(db, queries, options);
-}

+ 0 - 472
src/vectors/search.ts

@@ -1,472 +0,0 @@
-/**
- * Vector Search
- *
- * Provides vector similarity search using sqlite-vss extension.
- * Falls back to brute-force cosine similarity if sqlite-vss is not available.
- */
-
-import { SqliteDatabase } from '../db/sqlite-adapter';
-import { Node } from '../types';
-import { TextEmbedder, EMBEDDING_DIMENSION } from './embedder';
-
-/**
- * Options for vector search
- */
-export interface VectorSearchOptions {
-  /** Maximum number of results to return */
-  limit?: number;
-
-  /** Minimum similarity score (0-1) */
-  minScore?: number;
-
-  /** Node kinds to filter results */
-  nodeKinds?: Node['kind'][];
-}
-
-/**
- * Vector Search Manager
- *
- * Handles vector storage and similarity search for semantic code search.
- */
-export class VectorSearchManager {
-  private db: SqliteDatabase;
-  private vssEnabled = false;
-  private embeddingDimension: number;
-
-  constructor(db: SqliteDatabase, dimension: number = EMBEDDING_DIMENSION) {
-    this.db = db;
-    this.embeddingDimension = dimension;
-  }
-
-  /**
-   * Initialize vector search
-   *
-   * Attempts to load sqlite-vss extension. Falls back to brute-force
-   * search if the extension is not available.
-   */
-  async initialize(): Promise<void> {
-    try {
-      // Try to load sqlite-vss extension
-      await this.loadVssExtension();
-      this.vssEnabled = true;
-      console.log('sqlite-vss extension loaded successfully');
-
-      // Create the VSS virtual table
-      this.createVssTable();
-    } catch (error) {
-      // Fall back to brute-force search
-      console.warn(
-        'sqlite-vss extension not available, falling back to brute-force search:',
-        error instanceof Error ? error.message : String(error)
-      );
-      this.vssEnabled = false;
-    }
-
-    // Ensure the vectors table exists (for both VSS and fallback modes)
-    this.ensureVectorsTable();
-  }
-
-  /**
-   * Load the sqlite-vss extension
-   */
-  private async loadVssExtension(): Promise<void> {
-    try {
-      // The sqlite-vss npm package provides functions to load extensions
-      const vss = await import('sqlite-vss');
-
-      // Use the load function which loads both vector0 and vss0
-      // VSS extension expects the raw better-sqlite3 Database instance
-      if (typeof vss.load === 'function') {
-        vss.load(this.db as any);
-      } else if (typeof vss.default?.load === 'function') {
-        vss.default.load(this.db as any);
-      } else {
-        throw new Error('sqlite-vss load function not found');
-      }
-    } catch (error) {
-      throw new Error(`Failed to load sqlite-vss: ${error instanceof Error ? error.message : String(error)}`);
-    }
-  }
-
-  /**
-   * Create the VSS virtual table for vector search
-   */
-  private createVssTable(): void {
-    // Check if the table already exists
-    const tableExists = this.db
-      .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vss_vectors'")
-      .get();
-
-    if (!tableExists) {
-      // Create VSS virtual table
-      // vss0 is the vector search extension
-      this.db.exec(`
-        CREATE VIRTUAL TABLE IF NOT EXISTS vss_vectors USING vss0(
-          embedding(${this.embeddingDimension})
-        );
-      `);
-
-      // Create mapping table to link VSS rowids to node IDs
-      this.db.exec(`
-        CREATE TABLE IF NOT EXISTS vss_map (
-          rowid INTEGER PRIMARY KEY,
-          node_id TEXT NOT NULL UNIQUE
-        );
-      `);
-
-      // Create index on node_id
-      this.db.exec(`
-        CREATE INDEX IF NOT EXISTS idx_vss_map_node ON vss_map(node_id);
-      `);
-    }
-  }
-
-  /**
-   * Ensure the basic vectors table exists (for fallback mode)
-   */
-  private ensureVectorsTable(): void {
-    this.db.exec(`
-      CREATE TABLE IF NOT EXISTS vectors (
-        node_id TEXT PRIMARY KEY,
-        embedding BLOB NOT NULL,
-        model TEXT NOT NULL,
-        created_at INTEGER NOT NULL
-      );
-    `);
-  }
-
-  /**
-   * Check if VSS extension is enabled
-   */
-  isVssEnabled(): boolean {
-    return this.vssEnabled;
-  }
-
-  /**
-   * Store a vector embedding for a node
-   *
-   * @param nodeId - ID of the node
-   * @param embedding - Vector embedding
-   * @param model - Model used to generate embedding
-   */
-  storeVector(nodeId: string, embedding: Float32Array, model: string): void {
-    const now = Date.now();
-
-    // Store in the vectors table (always, for persistence)
-    const blob = Buffer.from(embedding.buffer);
-    this.db
-      .prepare(
-        `
-        INSERT OR REPLACE INTO vectors (node_id, embedding, model, created_at)
-        VALUES (?, ?, ?, ?)
-      `
-      )
-      .run(nodeId, blob, model, now);
-
-    // Also store in VSS table if enabled
-    if (this.vssEnabled) {
-      this.storeInVss(nodeId, embedding);
-    }
-  }
-
-  /**
-   * Store vector in VSS virtual table
-   */
-  private storeInVss(nodeId: string, embedding: Float32Array): void {
-    try {
-      // Check if already exists
-      const existing = this.db
-        .prepare('SELECT rowid FROM vss_map WHERE node_id = ?')
-        .get(nodeId) as { rowid: number } | undefined;
-
-      if (existing) {
-        // Update existing vector
-        const vectorJson = JSON.stringify(Array.from(embedding));
-        this.db
-          .prepare('UPDATE vss_vectors SET embedding = ? WHERE rowid = ?')
-          .run(vectorJson, existing.rowid);
-      } else {
-        // Insert new vector - get max rowid and increment
-        const maxRow = this.db
-          .prepare('SELECT MAX(rowid) as max FROM vss_map')
-          .get() as { max: number | null } | undefined;
-        const newRowid = (maxRow?.max ?? 0) + 1;
-
-        const vectorJson = JSON.stringify(Array.from(embedding));
-        this.db
-          .prepare('INSERT INTO vss_vectors (rowid, embedding) VALUES (?, ?)')
-          .run(newRowid, vectorJson);
-
-        // Map the rowid to node_id
-        this.db
-          .prepare('INSERT INTO vss_map (rowid, node_id) VALUES (?, ?)')
-          .run(newRowid, nodeId);
-      }
-    } catch (error) {
-      // VSS operations can fail for various reasons (dimension mismatch, etc.)
-      // Fall back to brute-force search silently
-      console.warn(
-        'VSS storage failed, using brute-force search:',
-        error instanceof Error ? error.message : String(error)
-      );
-    }
-  }
-
-  /**
-   * Store multiple vectors in a batch
-   *
-   * @param entries - Array of node IDs and embeddings
-   * @param model - Model used to generate embeddings
-   */
-  storeVectorBatch(
-    entries: Array<{ nodeId: string; embedding: Float32Array }>,
-    model: string
-  ): void {
-    const now = Date.now();
-
-    // Use a transaction for better performance
-    this.db.transaction(() => {
-      for (const entry of entries) {
-        const blob = Buffer.from(entry.embedding.buffer);
-        this.db
-          .prepare(
-            `
-            INSERT OR REPLACE INTO vectors (node_id, embedding, model, created_at)
-            VALUES (?, ?, ?, ?)
-          `
-          )
-          .run(entry.nodeId, blob, model, now);
-
-        if (this.vssEnabled) {
-          this.storeInVss(entry.nodeId, entry.embedding);
-        }
-      }
-    })();
-  }
-
-  /**
-   * Get vector for a node
-   *
-   * @param nodeId - ID of the node
-   * @returns Embedding or null if not found
-   */
-  getVector(nodeId: string): Float32Array | null {
-    const row = this.db
-      .prepare('SELECT embedding FROM vectors WHERE node_id = ?')
-      .get(nodeId) as { embedding: Buffer } | undefined;
-
-    if (!row) {
-      return null;
-    }
-
-    return new Float32Array(row.embedding.buffer.slice(
-      row.embedding.byteOffset,
-      row.embedding.byteOffset + row.embedding.byteLength
-    ));
-  }
-
-  /**
-   * Delete vector for a node
-   *
-   * @param nodeId - ID of the node
-   */
-  deleteVector(nodeId: string): void {
-    this.db.prepare('DELETE FROM vectors WHERE node_id = ?').run(nodeId);
-
-    if (this.vssEnabled) {
-      // Get the rowid before deleting
-      const mapping = this.db
-        .prepare('SELECT rowid FROM vss_map WHERE node_id = ?')
-        .get(nodeId) as { rowid: number } | undefined;
-
-      if (mapping) {
-        this.db.prepare('DELETE FROM vss_vectors WHERE rowid = ?').run(mapping.rowid);
-        this.db.prepare('DELETE FROM vss_map WHERE node_id = ?').run(nodeId);
-      }
-    }
-  }
-
-  /**
-   * Search for similar vectors
-   *
-   * @param queryEmbedding - Query vector to search for
-   * @param options - Search options
-   * @returns Array of node IDs with similarity scores
-   */
-  search(
-    queryEmbedding: Float32Array,
-    options: VectorSearchOptions = {}
-  ): Array<{ nodeId: string; score: number }> {
-    const { limit = 10, minScore = 0 } = options;
-
-    if (this.vssEnabled) {
-      return this.searchWithVss(queryEmbedding, limit, minScore);
-    } else {
-      return this.searchBruteForce(queryEmbedding, limit, minScore);
-    }
-  }
-
-  /**
-   * Search using sqlite-vss KNN search
-   */
-  private searchWithVss(
-    queryEmbedding: Float32Array,
-    limit: number,
-    minScore: number
-  ): Array<{ nodeId: string; score: number }> {
-    try {
-      const vectorJson = JSON.stringify(Array.from(queryEmbedding));
-      // Sanitize limit to prevent SQL injection (ensure it's a positive integer)
-      const safeLimit = Math.max(1, Math.floor(limit));
-
-      // Use VSS KNN search
-      // The distance is L2 (euclidean), we need to convert to similarity score
-      // Note: sqlite-vss requires LIMIT to be a literal, not a parameter
-      const rows = this.db
-        .prepare(
-          `
-          SELECT m.node_id, v.distance
-          FROM (
-            SELECT rowid, distance
-            FROM vss_vectors
-            WHERE vss_search(embedding, ?)
-            LIMIT ${safeLimit}
-          ) v
-          JOIN vss_map m ON m.rowid = v.rowid
-        `
-        )
-        .all(vectorJson) as Array<{ node_id: string; distance: number }>;
-
-      // Convert L2 distance to similarity score (1 / (1 + distance))
-      return rows
-        .map((row) => ({
-          nodeId: row.node_id,
-          score: 1 / (1 + row.distance),
-        }))
-        .filter((r) => r.score >= minScore);
-    } catch (error) {
-      // VSS search failed, fall back to brute force
-      console.warn(
-        'VSS search failed, using brute-force:',
-        error instanceof Error ? error.message : String(error)
-      );
-      return this.searchBruteForce(queryEmbedding, limit, minScore);
-    }
-  }
-
-  /**
-   * Brute-force search using cosine similarity
-   */
-  private searchBruteForce(
-    queryEmbedding: Float32Array,
-    limit: number,
-    minScore: number
-  ): Array<{ nodeId: string; score: number }> {
-    // Get all vectors
-    const rows = this.db
-      .prepare('SELECT node_id, embedding FROM vectors')
-      .all() as Array<{ node_id: string; embedding: Buffer }>;
-
-    // Calculate cosine similarity for each
-    const results: Array<{ nodeId: string; score: number }> = [];
-
-    for (const row of rows) {
-      const embedding = new Float32Array(row.embedding.buffer.slice(
-        row.embedding.byteOffset,
-        row.embedding.byteOffset + row.embedding.byteLength
-      ));
-
-      const score = TextEmbedder.cosineSimilarity(queryEmbedding, embedding);
-
-      if (score >= minScore) {
-        results.push({ nodeId: row.node_id, score });
-      }
-    }
-
-    // Sort by score descending and limit
-    results.sort((a, b) => b.score - a.score);
-    return results.slice(0, limit);
-  }
-
-  /**
-   * Get count of stored vectors
-   */
-  getVectorCount(): number {
-    const result = this.db
-      .prepare('SELECT COUNT(*) as count FROM vectors')
-      .get() as { count: number };
-    return result.count;
-  }
-
-  /**
-   * Check if a node has a vector
-   */
-  hasVector(nodeId: string): boolean {
-    const result = this.db
-      .prepare('SELECT 1 FROM vectors WHERE node_id = ? LIMIT 1')
-      .get(nodeId);
-    return !!result;
-  }
-
-  /**
-   * Get all node IDs that have vectors
-   */
-  getIndexedNodeIds(): string[] {
-    const rows = this.db
-      .prepare('SELECT node_id FROM vectors')
-      .all() as Array<{ node_id: string }>;
-    return rows.map((r) => r.node_id);
-  }
-
-  /**
-   * Clear all vectors
-   */
-  clear(): void {
-    this.db.prepare('DELETE FROM vectors').run();
-
-    if (this.vssEnabled) {
-      this.db.prepare('DELETE FROM vss_vectors').run();
-      this.db.prepare('DELETE FROM vss_map').run();
-    }
-  }
-
-  /**
-   * Rebuild VSS index from vectors table
-   *
-   * Useful after bulk operations or if VSS index gets out of sync.
-   */
-  rebuildVssIndex(): void {
-    if (!this.vssEnabled) {
-      return;
-    }
-
-    // Clear VSS tables
-    this.db.prepare('DELETE FROM vss_vectors').run();
-    this.db.prepare('DELETE FROM vss_map').run();
-
-    // Reload from vectors table
-    const rows = this.db
-      .prepare('SELECT node_id, embedding FROM vectors')
-      .all() as Array<{ node_id: string; embedding: Buffer }>;
-
-    this.db.transaction(() => {
-      for (const row of rows) {
-        const embedding = new Float32Array(row.embedding.buffer.slice(
-          row.embedding.byteOffset,
-          row.embedding.byteOffset + row.embedding.byteLength
-        ));
-        this.storeInVss(row.node_id, embedding);
-      }
-    })();
-  }
-}
-
-/**
- * Create a vector search manager
- */
-export function createVectorSearch(
-  db: SqliteDatabase,
-  dimension?: number
-): VectorSearchManager {
-  return new VectorSearchManager(db, dimension);
-}

+ 0 - 1994
src/visualizer/public/index.html

@@ -1,1994 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta charset="UTF-8" />
-  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-  <title>CodeGraph Explorer</title>
-
-  <!-- Cytoscape.js + Dagre layout -->
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.4/cytoscape.min.js"></script>
-  <script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
-  <script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
-
-  <!-- Highlight.js for code syntax highlighting -->
-  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
-  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
-
-  <style>
-    /* ====================================================================
-       CSS Reset & Base
-       ==================================================================== */
-    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-    :root {
-      --bg-primary: #0d1117;
-      --bg-secondary: #161b22;
-      --bg-tertiary: #1c2128;
-      --bg-hover: #1f2937;
-      --border: #30363d;
-      --border-light: #3d444d;
-      --text-primary: #e6edf3;
-      --text-secondary: #8b949e;
-      --text-muted: #656d76;
-      --accent: #58a6ff;
-      --accent-hover: #79c0ff;
-      --green: #3fb950;
-      --purple: #d2a8ff;
-      --orange: #ffa657;
-      --red: #ff7b72;
-      --yellow: #d29922;
-      --pink: #f778ba;
-      --cyan: #76e3ea;
-      --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace;
-      --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
-      --sidebar-width: 320px;
-      --panel-width: 460px;
-      --header-height: 52px;
-      --radius: 8px;
-      --radius-sm: 6px;
-    }
-
-    html, body {
-      height: 100%;
-      font-family: var(--font-sans);
-      background: var(--bg-primary);
-      color: var(--text-primary);
-      overflow: hidden;
-    }
-
-    /* ====================================================================
-       Layout
-       ==================================================================== */
-    #app {
-      display: flex;
-      flex-direction: column;
-      height: 100vh;
-    }
-
-    /* Header */
-    #header {
-      height: var(--header-height);
-      background: var(--bg-secondary);
-      border-bottom: 1px solid var(--border);
-      display: flex;
-      align-items: center;
-      padding: 0 16px;
-      gap: 16px;
-      flex-shrink: 0;
-      z-index: 10;
-    }
-
-    #header .logo {
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      font-size: 16px;
-      font-weight: 600;
-      color: var(--text-primary);
-      white-space: nowrap;
-    }
-
-    #header .logo span.icon { font-size: 20px; }
-
-    #search-container {
-      flex: 1;
-      max-width: 560px;
-      position: relative;
-    }
-
-    #search-input {
-      width: 100%;
-      height: 34px;
-      background: var(--bg-primary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      color: var(--text-primary);
-      font-size: 13px;
-      padding: 0 12px 0 34px;
-      outline: none;
-      transition: border-color 0.15s;
-      font-family: var(--font-sans);
-    }
-
-    #search-input:focus { border-color: var(--accent); }
-
-    #search-container .search-icon {
-      position: absolute;
-      left: 10px;
-      top: 50%;
-      transform: translateY(-50%);
-      color: var(--text-muted);
-      font-size: 14px;
-      pointer-events: none;
-    }
-
-    #search-results-dropdown {
-      position: absolute;
-      top: 100%;
-      left: 0;
-      right: 0;
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      margin-top: 4px;
-      max-height: 400px;
-      overflow-y: auto;
-      z-index: 100;
-      display: none;
-      box-shadow: 0 8px 24px rgba(0,0,0,0.4);
-    }
-
-    #search-results-dropdown.visible { display: block; }
-
-    .search-result-item {
-      padding: 8px 12px;
-      cursor: pointer;
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      border-bottom: 1px solid var(--border);
-      transition: background 0.1s;
-    }
-
-    .search-result-item:last-child { border-bottom: none; }
-    .search-result-item:hover { background: var(--bg-hover); }
-
-    .search-result-item .kind-badge {
-      font-size: 10px;
-      padding: 2px 6px;
-      border-radius: 4px;
-      font-weight: 600;
-      text-transform: uppercase;
-      white-space: nowrap;
-      flex-shrink: 0;
-    }
-
-    .search-result-item .name {
-      font-weight: 500;
-      font-size: 13px;
-      color: var(--text-primary);
-    }
-
-    .search-result-item .file-path {
-      font-size: 11px;
-      color: var(--text-muted);
-      margin-left: auto;
-      white-space: nowrap;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      max-width: 200px;
-    }
-
-    #header-stats {
-      display: flex;
-      gap: 12px;
-      font-size: 12px;
-      color: var(--text-muted);
-      white-space: nowrap;
-    }
-
-    #header-stats .stat { display: flex; align-items: center; gap: 4px; }
-    #header-stats .stat-value { color: var(--text-secondary); font-weight: 500; }
-
-    /* Main content */
-    #main {
-      display: flex;
-      flex: 1;
-      overflow: hidden;
-    }
-
-    /* Sidebar */
-    #sidebar {
-      width: var(--sidebar-width);
-      background: var(--bg-secondary);
-      border-right: 1px solid var(--border);
-      display: flex;
-      flex-direction: column;
-      flex-shrink: 0;
-      overflow: hidden;
-    }
-
-    .sidebar-section {
-      border-bottom: 1px solid var(--border);
-    }
-
-    .sidebar-header {
-      padding: 10px 14px;
-      font-size: 11px;
-      font-weight: 600;
-      text-transform: uppercase;
-      letter-spacing: 0.5px;
-      color: var(--text-muted);
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      cursor: pointer;
-      user-select: none;
-    }
-
-    .sidebar-header:hover { color: var(--text-secondary); }
-
-    .sidebar-content {
-      overflow-y: auto;
-      max-height: 300px;
-    }
-
-    #file-tree {
-      padding: 4px 0;
-    }
-
-    .file-item {
-      padding: 5px 14px;
-      font-size: 12px;
-      color: var(--text-secondary);
-      cursor: pointer;
-      display: flex;
-      align-items: center;
-      gap: 6px;
-      transition: background 0.1s;
-      white-space: nowrap;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-
-    .file-item:hover { background: var(--bg-hover); color: var(--text-primary); }
-    .file-item.active { background: var(--bg-hover); color: var(--accent); }
-
-    .file-item .file-icon { font-size: 12px; flex-shrink: 0; }
-
-    /* Graph legend */
-    #legend {
-      padding: 10px 14px;
-    }
-
-    .legend-item {
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      padding: 3px 0;
-      font-size: 12px;
-      color: var(--text-secondary);
-    }
-
-    .legend-dot {
-      width: 10px;
-      height: 10px;
-      border-radius: 50%;
-      flex-shrink: 0;
-    }
-
-    /* Graph toolbar */
-    #graph-toolbar {
-      padding: 8px 14px;
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-    }
-
-    .toolbar-btn {
-      padding: 4px 10px;
-      font-size: 11px;
-      background: var(--bg-primary);
-      border: 1px solid var(--border);
-      border-radius: 4px;
-      color: var(--text-secondary);
-      cursor: pointer;
-      transition: all 0.15s;
-      font-family: var(--font-sans);
-    }
-
-    .toolbar-btn:hover {
-      background: var(--bg-hover);
-      color: var(--text-primary);
-      border-color: var(--border-light);
-    }
-
-    .toolbar-btn.active {
-      background: var(--accent);
-      color: #fff;
-      border-color: var(--accent);
-    }
-
-    /* Graph canvas */
-    #graph-container {
-      flex: 1;
-      position: relative;
-      background: var(--bg-primary);
-      overflow: hidden;
-    }
-
-    #cy {
-      width: 100%;
-      height: 100%;
-    }
-
-    #graph-overlay {
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      text-align: center;
-      color: var(--text-muted);
-      pointer-events: none;
-    }
-
-    #graph-overlay .overlay-icon { font-size: 48px; margin-bottom: 12px; }
-    #graph-overlay .overlay-title { font-size: 18px; font-weight: 500; margin-bottom: 6px; }
-    #graph-overlay .overlay-subtitle { font-size: 13px; }
-
-    .example-btn {
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: 20px;
-      color: var(--text-secondary);
-      padding: 6px 16px;
-      font-size: 12px;
-      cursor: pointer;
-      transition: all 0.15s;
-      font-family: var(--font-sans);
-      pointer-events: auto;
-    }
-
-    .example-btn:hover {
-      background: var(--bg-hover);
-      color: var(--accent);
-      border-color: var(--accent);
-    }
-
-    /* Breadcrumbs */
-    #breadcrumbs {
-      position: absolute;
-      top: 10px;
-      left: 10px;
-      display: flex;
-      align-items: center;
-      gap: 4px;
-      font-size: 12px;
-      z-index: 5;
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      padding: 6px 10px;
-      opacity: 0;
-      transition: opacity 0.2s;
-      pointer-events: none;
-    }
-
-    #breadcrumbs.visible { opacity: 1; pointer-events: auto; }
-
-    .breadcrumb-item {
-      color: var(--accent);
-      cursor: pointer;
-    }
-
-    .breadcrumb-item:hover { text-decoration: underline; }
-    .breadcrumb-sep { color: var(--text-muted); }
-
-    /* Graph controls */
-    #graph-controls {
-      position: absolute;
-      bottom: 14px;
-      right: 14px;
-      display: flex;
-      gap: 4px;
-      z-index: 5;
-    }
-
-    .graph-ctrl-btn {
-      width: 32px;
-      height: 32px;
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      color: var(--text-secondary);
-      cursor: pointer;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      font-size: 16px;
-      transition: all 0.15s;
-      font-family: var(--font-sans);
-    }
-
-    .graph-ctrl-btn:hover {
-      background: var(--bg-hover);
-      color: var(--text-primary);
-    }
-
-    /* Context menu */
-    #context-menu {
-      position: fixed;
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      padding: 4px 0;
-      z-index: 200;
-      display: none;
-      box-shadow: 0 8px 24px rgba(0,0,0,0.5);
-      min-width: 180px;
-    }
-
-    #context-menu.visible { display: block; }
-
-    .ctx-item {
-      padding: 7px 14px;
-      font-size: 13px;
-      color: var(--text-primary);
-      cursor: pointer;
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      transition: background 0.1s;
-    }
-
-    .ctx-item:hover { background: var(--bg-hover); }
-    .ctx-item .ctx-icon { font-size: 14px; width: 18px; text-align: center; }
-    .ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
-
-    /* Detail panel */
-    #detail-panel {
-      width: 0;
-      background: var(--bg-secondary);
-      border-left: 1px solid var(--border);
-      flex-shrink: 0;
-      overflow: hidden;
-      transition: width 0.2s ease;
-      display: flex;
-      flex-direction: column;
-    }
-
-    #detail-panel.open { width: var(--panel-width); }
-
-    #detail-header {
-      padding: 12px 16px;
-      border-bottom: 1px solid var(--border);
-      display: flex;
-      align-items: flex-start;
-      justify-content: space-between;
-      gap: 8px;
-      flex-shrink: 0;
-    }
-
-    #detail-header .node-title {
-      font-size: 15px;
-      font-weight: 600;
-      word-break: break-all;
-    }
-
-    #detail-header .close-btn {
-      background: none;
-      border: none;
-      color: var(--text-muted);
-      cursor: pointer;
-      font-size: 18px;
-      padding: 0 4px;
-      line-height: 1;
-      flex-shrink: 0;
-    }
-
-    #detail-header .close-btn:hover { color: var(--text-primary); }
-
-    #detail-body {
-      flex: 1;
-      overflow-y: auto;
-      padding: 0;
-    }
-
-    .detail-section {
-      padding: 12px 16px;
-      border-bottom: 1px solid var(--border);
-    }
-
-    .detail-section-title {
-      font-size: 11px;
-      font-weight: 600;
-      text-transform: uppercase;
-      letter-spacing: 0.5px;
-      color: var(--text-muted);
-      margin-bottom: 8px;
-    }
-
-    .detail-meta {
-      display: grid;
-      grid-template-columns: auto 1fr;
-      gap: 4px 12px;
-      font-size: 12px;
-    }
-
-    .detail-meta .label { color: var(--text-muted); }
-    .detail-meta .value { color: var(--text-secondary); word-break: break-all; }
-    .detail-meta .value.accent { color: var(--accent); }
-
-    /* Code block */
-    .code-block {
-      background: var(--bg-primary);
-      border-radius: var(--radius-sm);
-      overflow-x: auto;
-      font-size: 12px;
-      line-height: 1.5;
-    }
-
-    .code-block pre {
-      margin: 0;
-      padding: 12px;
-    }
-
-    .code-block code {
-      font-family: var(--font-mono);
-    }
-
-    /* Relations list */
-    .relation-list { list-style: none; }
-
-    .relation-item {
-      padding: 5px 0;
-      font-size: 12px;
-      display: flex;
-      align-items: center;
-      gap: 6px;
-      cursor: pointer;
-      transition: color 0.1s;
-      color: var(--text-secondary);
-    }
-
-    .relation-item:hover { color: var(--accent); }
-
-    .relation-item .rel-badge {
-      font-size: 9px;
-      padding: 1px 5px;
-      border-radius: 3px;
-      font-weight: 600;
-      text-transform: uppercase;
-    }
-
-    /* Loading spinner */
-    .spinner {
-      display: inline-block;
-      width: 16px;
-      height: 16px;
-      border: 2px solid var(--border);
-      border-top-color: var(--accent);
-      border-radius: 50%;
-      animation: spin 0.6s linear infinite;
-    }
-
-    @keyframes spin { to { transform: rotate(360deg); } }
-
-    /* Scrollbar */
-    ::-webkit-scrollbar { width: 8px; height: 8px; }
-    ::-webkit-scrollbar-track { background: transparent; }
-    ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
-    ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
-
-    /* Tooltip */
-    .cy-tooltip {
-      position: fixed;
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      padding: 6px 10px;
-      font-size: 12px;
-      color: var(--text-primary);
-      pointer-events: none;
-      z-index: 50;
-      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
-      max-width: 300px;
-      display: none;
-    }
-
-    .cy-tooltip .tip-kind {
-      font-size: 10px;
-      color: var(--text-muted);
-      text-transform: uppercase;
-      margin-bottom: 2px;
-    }
-
-    .cy-tooltip .tip-name { font-weight: 500; }
-    .cy-tooltip .tip-file { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
-
-    /* Notification toast */
-    #toast {
-      position: fixed;
-      bottom: 20px;
-      left: 50%;
-      transform: translateX(-50%) translateY(80px);
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: var(--radius-sm);
-      padding: 10px 20px;
-      font-size: 13px;
-      color: var(--text-primary);
-      z-index: 300;
-      box-shadow: 0 8px 24px rgba(0,0,0,0.4);
-      transition: transform 0.3s ease;
-    }
-
-    #toast.visible { transform: translateX(-50%) translateY(0); }
-
-    /* Embeddings setup dialog */
-    #dialog-overlay {
-      position: fixed;
-      inset: 0;
-      background: rgba(0,0,0,0.7);
-      z-index: 500;
-      display: none;
-      align-items: center;
-      justify-content: center;
-      backdrop-filter: blur(4px);
-    }
-
-    #dialog-overlay.visible { display: flex; }
-
-    #dialog {
-      background: var(--bg-secondary);
-      border: 1px solid var(--border);
-      border-radius: 12px;
-      padding: 32px;
-      max-width: 480px;
-      width: 90%;
-      box-shadow: 0 16px 48px rgba(0,0,0,0.5);
-    }
-
-    #dialog .dialog-icon { font-size: 36px; margin-bottom: 16px; }
-
-    #dialog .dialog-title {
-      font-size: 18px;
-      font-weight: 600;
-      margin-bottom: 8px;
-    }
-
-    #dialog .dialog-body {
-      font-size: 13px;
-      color: var(--text-secondary);
-      line-height: 1.6;
-      margin-bottom: 20px;
-    }
-
-    #dialog .dialog-body strong { color: var(--text-primary); }
-
-    #dialog-progress {
-      display: none;
-      margin-bottom: 20px;
-    }
-
-    #dialog-progress .progress-bar-track {
-      width: 100%;
-      height: 8px;
-      background: var(--bg-primary);
-      border-radius: 4px;
-      overflow: hidden;
-      margin-bottom: 8px;
-    }
-
-    #dialog-progress .progress-bar-fill {
-      height: 100%;
-      background: var(--accent);
-      border-radius: 4px;
-      width: 0%;
-      transition: width 0.2s ease;
-    }
-
-    #dialog-progress .progress-text {
-      font-size: 12px;
-      color: var(--text-muted);
-    }
-
-    #dialog-progress .progress-percent {
-      float: right;
-      color: var(--text-secondary);
-      font-weight: 500;
-    }
-
-    #dialog .dialog-actions {
-      display: flex;
-      gap: 10px;
-      justify-content: flex-end;
-    }
-
-    #dialog .btn {
-      padding: 8px 20px;
-      border-radius: var(--radius-sm);
-      font-size: 13px;
-      font-weight: 500;
-      cursor: pointer;
-      border: 1px solid var(--border);
-      transition: all 0.15s;
-      font-family: var(--font-sans);
-    }
-
-    #dialog .btn-primary {
-      background: var(--accent);
-      color: #fff;
-      border-color: var(--accent);
-    }
-
-    #dialog .btn-primary:hover { background: var(--accent-hover); }
-    #dialog .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
-
-    #dialog .btn-secondary {
-      background: var(--bg-primary);
-      color: var(--text-secondary);
-    }
-
-    #dialog .btn-secondary:hover { color: var(--text-primary); }
-  </style>
-</head>
-<body>
-  <div id="app">
-    <!-- Header -->
-    <div id="header">
-      <div class="logo">
-        <span class="icon">&#x1F52E;</span>
-        <span>CodeGraph</span>
-      </div>
-
-      <div id="search-container">
-        <span class="search-icon">&#x1F50D;</span>
-        <input id="search-input" type="text" placeholder="Search symbols... (Ctrl+K)" autocomplete="off" spellcheck="false" />
-        <div id="search-results-dropdown"></div>
-      </div>
-
-      <div id="header-stats">
-        <div class="stat"><span>Nodes:</span> <span class="stat-value" id="stat-nodes">-</span></div>
-        <div class="stat"><span>Edges:</span> <span class="stat-value" id="stat-edges">-</span></div>
-        <div class="stat"><span>Files:</span> <span class="stat-value" id="stat-files">-</span></div>
-      </div>
-    </div>
-
-    <!-- Main content -->
-    <div id="main">
-      <!-- Sidebar -->
-      <div id="sidebar">
-        <!-- Graph controls section -->
-        <div class="sidebar-section">
-          <div class="sidebar-header">
-            <span>Graph Actions</span>
-          </div>
-          <div id="graph-toolbar">
-            <button class="toolbar-btn" onclick="loadOverview()" title="Show top-level symbols">Overview</button>
-            <button class="toolbar-btn" onclick="clearGraph()" title="Clear the graph">Clear</button>
-            <button class="toolbar-btn" onclick="runLayout()" title="Re-run layout">Layout</button>
-            <button class="toolbar-btn" onclick="fitGraph()" title="Fit graph to view">Fit</button>
-          </div>
-        </div>
-
-        <!-- Legend -->
-        <div class="sidebar-section">
-          <div class="sidebar-header" onclick="toggleSection(this)">
-            <span>Legend</span>
-            <span>&#x25B6;</span>
-          </div>
-          <div class="sidebar-content" id="legend" style="display:none;"></div>
-        </div>
-
-        <!-- Files -->
-        <div class="sidebar-section" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
-          <div class="sidebar-header" onclick="toggleSection(this)">
-            <span>Files</span>
-            <span>&#x25BC;</span>
-          </div>
-          <div class="sidebar-content" id="file-tree" style="flex:1; max-height:none;"></div>
-        </div>
-      </div>
-
-      <!-- Graph area -->
-      <div id="graph-container">
-        <div id="cy"></div>
-        <div id="graph-overlay">
-          <div class="overlay-icon">&#x1F52E;</div>
-          <div class="overlay-title">Search for a starting point</div>
-          <div class="overlay-subtitle">Type a symbol name, pick it, and trace its call chain</div>
-        </div>
-        <div id="breadcrumbs"></div>
-        <div id="graph-controls">
-          <button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() * 1.3); cy.center()" title="Zoom in">+</button>
-          <button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() / 1.3); cy.center()" title="Zoom out">&minus;</button>
-          <button class="graph-ctrl-btn" onclick="fitGraph()" title="Fit to view">&#x2922;</button>
-        </div>
-      </div>
-
-      <!-- Detail panel -->
-      <div id="detail-panel">
-        <div id="detail-header">
-          <div>
-            <div class="node-title" id="detail-title">-</div>
-          </div>
-          <button class="close-btn" onclick="closeDetailPanel()">&times;</button>
-        </div>
-        <div id="detail-body"></div>
-      </div>
-    </div>
-  </div>
-
-  <!-- Embeddings setup dialog -->
-  <div id="dialog-overlay">
-    <div id="dialog">
-      <div class="dialog-icon">&#x1F9E0;</div>
-      <div class="dialog-title" id="dialog-title">Enable Semantic Search</div>
-      <div class="dialog-body" id="dialog-body">
-        CodeGraph Explorer uses <strong>semantic embeddings</strong> to understand your code by meaning, not just keywords.
-        This lets you ask questions like "how does authentication work?" and get accurate results.
-        <br><br>
-        This is a <strong>one-time setup</strong> that generates a local embedding model for this project. No data leaves your machine.
-      </div>
-      <div id="dialog-progress">
-        <div class="progress-bar-track">
-          <div class="progress-bar-fill" id="dialog-progress-fill"></div>
-        </div>
-        <div class="progress-text">
-          <span id="dialog-progress-text">Preparing...</span>
-          <span class="progress-percent" id="dialog-progress-percent">0%</span>
-        </div>
-      </div>
-      <div class="dialog-actions" id="dialog-actions">
-        <button class="btn btn-secondary" id="dialog-skip" onclick="closeDialog()">Skip for now</button>
-        <button class="btn btn-primary" id="dialog-enable" onclick="startEmbeddings()">Enable Semantic Search</button>
-      </div>
-    </div>
-  </div>
-
-  <!-- Context menu -->
-  <div id="context-menu">
-    <div class="ctx-item" onclick="ctxAction('expand-callees')"><span class="ctx-icon">&#x2192;</span> Expand Callees</div>
-    <div class="ctx-item" onclick="ctxAction('expand-callers')"><span class="ctx-icon">&#x2190;</span> Expand Callers</div>
-    <div class="ctx-sep"></div>
-    <div class="ctx-item" onclick="ctxAction('callgraph')"><span class="ctx-icon">&#x1F310;</span> Full Call Graph</div>
-    <div class="ctx-item" onclick="ctxAction('impact')"><span class="ctx-icon">&#x1F4A5;</span> Impact Analysis</div>
-    <div class="ctx-sep"></div>
-    <div class="ctx-item" onclick="ctxAction('children')"><span class="ctx-icon">&#x1F4C2;</span> Show Children</div>
-    <div class="ctx-item" onclick="ctxAction('details')"><span class="ctx-icon">&#x1F4CB;</span> View Details</div>
-    <div class="ctx-sep"></div>
-    <div class="ctx-item" onclick="ctxAction('remove')"><span class="ctx-icon">&#x2716;</span> Remove from Graph</div>
-  </div>
-
-  <!-- Tooltip -->
-  <div class="cy-tooltip" id="tooltip"></div>
-
-  <!-- Toast -->
-  <div id="toast"></div>
-
-  <script>
-    // ====================================================================
-    // State
-    // ====================================================================
-    let cy;
-    let ctxNodeId = null;
-    let searchDebounce = null;
-    const expandedSets = { callers: new Set(), callees: new Set() };
-
-    // Node kind → color mapping
-    const kindColors = {
-      'function':    '#79c0ff',
-      'method':      '#7ee787',
-      'class':       '#d2a8ff',
-      'interface':   '#ffa657',
-      'struct':      '#ffa657',
-      'trait':       '#ffa657',
-      'protocol':    '#ffa657',
-      'component':   '#f778ba',
-      'enum':        '#d29922',
-      'enum_member': '#d29922',
-      'type_alias':  '#d2a8ff',
-      'variable':    '#ff7b72',
-      'constant':    '#ff7b72',
-      'property':    '#76e3ea',
-      'field':       '#76e3ea',
-      'file':        '#8b949e',
-      'module':      '#8b949e',
-      'namespace':   '#8b949e',
-      'import':      '#f0883e',
-      'export':      '#3fb950',
-      'route':       '#f778ba',
-      'parameter':   '#8b949e',
-    };
-
-    const kindShapes = {
-      'class': 'round-rectangle',
-      'interface': 'round-diamond',
-      'struct': 'round-rectangle',
-      'trait': 'round-diamond',
-      'protocol': 'round-diamond',
-      'enum': 'round-hexagon',
-      'component': 'round-pentagon',
-      'file': 'round-rectangle',
-      'module': 'round-rectangle',
-      'namespace': 'round-rectangle',
-    };
-
-    const edgeColors = {
-      'calls':        '#58a6ff',
-      'imports':      '#f0883e',
-      'extends':      '#d2a8ff',
-      'implements':   '#ffa657',
-      'references':   '#8b949e',
-      'contains':     '#3d444d',
-      'type_of':      '#76e3ea',
-      'returns':      '#76e3ea',
-      'instantiates': '#f778ba',
-      'overrides':    '#d29922',
-      'decorates':    '#f778ba',
-      'exports':      '#3fb950',
-    };
-
-    // ====================================================================
-    // API Client
-    // ====================================================================
-    const api = {
-      async get(path) {
-        const res = await fetch('/api/' + path);
-        if (!res.ok) throw new Error(`API error: ${res.status}`);
-        return res.json();
-      },
-      embeddingsStatus: () => api.get('embeddings/status'),
-      status: ()          => api.get('status'),
-      search: (q, kind, limit) => api.get(`search?q=${encodeURIComponent(q)}${kind ? '&kind='+kind : ''}&limit=${limit||30}`),
-      explore: (q) => api.get(`explore?q=${encodeURIComponent(q)}`),
-      overview: (limit)   => api.get(`overview?limit=${limit||60}`),
-      files: ()           => api.get('files'),
-      fileNodes: (p)      => api.get(`file-nodes?path=${encodeURIComponent(p)}`),
-      node: (id)          => api.get(`node/${encodeURIComponent(id)}`),
-      callers: (id, d)    => api.get(`node/${encodeURIComponent(id)}/callers?depth=${d||1}`),
-      callees: (id, d)    => api.get(`node/${encodeURIComponent(id)}/callees?depth=${d||1}`),
-      children: (id)      => api.get(`node/${encodeURIComponent(id)}/children`),
-      impact: (id, d)     => api.get(`node/${encodeURIComponent(id)}/impact?depth=${d||2}`),
-      callgraph: (id, d)  => api.get(`node/${encodeURIComponent(id)}/callgraph?depth=${d||2}`),
-      context: (id)       => api.get(`node/${encodeURIComponent(id)}/context`),
-    };
-
-    // ====================================================================
-    // Cytoscape Initialization
-    // ====================================================================
-    function initCytoscape() {
-      cy = cytoscape({
-        container: document.getElementById('cy'),
-        style: [
-          // Nodes
-          {
-            selector: 'node',
-            style: {
-              'label': 'data(label)',
-              'text-valign': 'center',
-              'text-halign': 'center',
-              'font-size': '12px',
-              'font-weight': 'bold',
-              'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
-              'color': '#ffffff',
-              'text-outline-color': '#000000',
-              'text-outline-width': 2,
-              'text-outline-opacity': 0.6,
-              'background-color': 'data(color)',
-              'background-opacity': 0.85,
-              'border-width': 2,
-              'border-color': 'data(color)',
-              'border-opacity': 0.7,
-              'width': 'label',
-              'height': 'label',
-              'padding': '12px',
-              'shape': 'data(shape)',
-              'text-wrap': 'wrap',
-              'text-max-width': '180px',
-              'transition-property': 'background-opacity, border-color, border-opacity, opacity, text-opacity',
-              'transition-duration': '0.2s',
-            }
-          },
-          // Selected node
-          {
-            selector: 'node:selected',
-            style: {
-              'border-width': 3,
-              'border-color': '#ffffff',
-              'border-opacity': 1,
-              'background-opacity': 1,
-              'z-index': 10,
-            }
-          },
-          // Hovered node
-          {
-            selector: 'node.hover',
-            style: {
-              'border-width': 3,
-              'border-color': '#ffffff',
-              'border-opacity': 0.9,
-              'background-opacity': 1,
-            }
-          },
-          // Faded node — keep text readable
-          {
-            selector: 'node.faded',
-            style: {
-              'background-opacity': 0.3,
-              'border-opacity': 0.2,
-              'text-opacity': 0.7,
-            }
-          },
-          // Highlighted node
-          {
-            selector: 'node.highlighted',
-            style: {
-              'border-width': 3,
-              'border-color': '#f0e68c',
-              'border-opacity': 1,
-              'z-index': 10,
-            }
-          },
-          // Edges
-          {
-            selector: 'edge',
-            style: {
-              'width': 1.5,
-              'line-color': 'data(color)',
-              'target-arrow-color': 'data(color)',
-              'target-arrow-shape': 'triangle',
-              'arrow-scale': 0.8,
-              'curve-style': 'bezier',
-              'opacity': 0.6,
-              'label': 'data(label)',
-              'font-size': '9px',
-              'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
-              'color': '#656d76',
-              'text-rotation': 'autorotate',
-              'text-margin-y': -8,
-              'text-outline-color': '#0d1117',
-              'text-outline-width': 2,
-              'transition-property': 'opacity, line-color',
-              'transition-duration': '0.15s',
-            }
-          },
-          // Selected edge
-          {
-            selector: 'edge:selected',
-            style: { 'opacity': 1, 'width': 2.5 }
-          },
-          // Faded edge
-          {
-            selector: 'edge.faded',
-            style: { 'opacity': 0.15 }
-          },
-          // Highlighted edge
-          {
-            selector: 'edge.highlighted',
-            style: { 'opacity': 1, 'width': 2.5 }
-          },
-        ],
-        layout: { name: 'preset' },
-        minZoom: 0.1,
-        maxZoom: 4,
-        wheelSensitivity: 0.3,
-      });
-
-      // Event handlers
-      cy.on('tap', 'node', (e) => {
-        const nodeId = e.target.data('nodeId');
-        if (nodeId) showNodeDetails(nodeId);
-        highlightNeighborhood(e.target);
-      });
-
-      cy.on('cxttap', 'node', (e) => {
-        e.originalEvent.preventDefault();
-        ctxNodeId = e.target.data('nodeId');
-        showContextMenu(e.originalEvent.clientX, e.originalEvent.clientY);
-      });
-
-      cy.on('tap', (e) => {
-        if (e.target === cy) {
-          clearHighlights();
-          hideContextMenu();
-        }
-      });
-
-      cy.on('mouseover', 'node', (e) => {
-        e.target.addClass('hover');
-        showTooltip(e);
-      });
-
-      cy.on('mouseout', 'node', (e) => {
-        e.target.removeClass('hover');
-        hideTooltip();
-      });
-
-      cy.on('dblclick', 'node', (e) => {
-        const nodeId = e.target.data('nodeId');
-        if (nodeId) expandCallees(nodeId);
-      });
-
-      // Click outside to close context menu
-      document.addEventListener('click', (e) => {
-        if (!e.target.closest('#context-menu')) hideContextMenu();
-      });
-
-      document.addEventListener('contextmenu', (e) => {
-        if (e.target.closest('#cy')) e.preventDefault();
-      });
-    }
-
-    // ====================================================================
-    // Graph Operations
-    // ====================================================================
-    const kindLabels = {
-      'function': 'fn', 'method': 'method', 'class': 'class', 'interface': 'iface',
-      'component': 'comp', 'route': 'route', 'enum': 'enum', 'type_alias': 'type',
-      'struct': 'struct', 'trait': 'trait', 'variable': 'var', 'constant': 'const',
-      'property': 'prop', 'field': 'field', 'file': 'file', 'module': 'mod',
-    };
-
-    function addNodeToGraph(node) {
-      if (cy.getElementById(node.id).length > 0) return;
-      const color = kindColors[node.kind] || '#8b949e';
-      const shape = kindShapes[node.kind] || 'round-rectangle';
-      const kindLabel = kindLabels[node.kind] || node.kind;
-      cy.add({
-        group: 'nodes',
-        data: {
-          id: node.id,
-          nodeId: node.id,
-          label: `${node.name}\n${kindLabel}`,
-          color: color,
-          shape: shape,
-          kind: node.kind,
-          filePath: node.filePath,
-          signature: node.signature || '',
-        },
-      });
-    }
-
-    function addEdgeToGraph(edge) {
-      const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
-      if (cy.getElementById(edgeId).length > 0) return;
-      // Don't add edge if source or target not in graph
-      if (cy.getElementById(edge.source).length === 0 || cy.getElementById(edge.target).length === 0) return;
-      const color = edgeColors[edge.kind] || '#8b949e';
-      cy.add({
-        group: 'edges',
-        data: {
-          id: edgeId,
-          source: edge.source,
-          target: edge.target,
-          kind: edge.kind,
-          label: edge.kind,
-          color: color,
-        },
-      });
-    }
-
-    function addSubgraph(nodes, edges) {
-      const batchElements = [];
-      for (const node of nodes) {
-        if (cy.getElementById(node.id).length > 0) continue;
-        const color = kindColors[node.kind] || '#8b949e';
-        const shape = kindShapes[node.kind] || 'round-rectangle';
-        batchElements.push({
-          group: 'nodes',
-          data: {
-            id: node.id,
-            nodeId: node.id,
-            label: node.name,
-            color: color,
-            shape: shape,
-            kind: node.kind,
-            filePath: node.filePath,
-            signature: node.signature || '',
-          },
-        });
-      }
-      for (const edge of edges) {
-        const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
-        if (cy.getElementById(edgeId).length > 0) continue;
-        // Check source/target will exist
-        const srcExists = cy.getElementById(edge.source).length > 0 || batchElements.some(e => e.data.id === edge.source);
-        const tgtExists = cy.getElementById(edge.target).length > 0 || batchElements.some(e => e.data.id === edge.target);
-        if (!srcExists || !tgtExists) continue;
-        const color = edgeColors[edge.kind] || '#8b949e';
-        batchElements.push({
-          group: 'edges',
-          data: {
-            id: edgeId,
-            source: edge.source,
-            target: edge.target,
-            kind: edge.kind,
-            label: edge.kind,
-            color: color,
-          },
-        });
-      }
-      if (batchElements.length > 0) {
-        cy.add(batchElements);
-      }
-    }
-
-    function clearGraph() {
-      cy.elements().remove();
-      expandedSets.callers.clear();
-      expandedSets.callees.clear();
-      hideOverlay(false);
-      closeDetailPanel();
-    }
-
-    function runLayout() {
-      if (cy.nodes().length === 0) return;
-      const layout = cy.layout({
-        name: 'dagre',
-        rankDir: 'LR',
-        nodeSep: 50,
-        rankSep: 80,
-        edgeSep: 20,
-        animate: true,
-        animationDuration: 300,
-        fit: true,
-        padding: 40,
-      });
-      layout.run();
-    }
-
-    function fitGraph() {
-      if (cy.nodes().length > 0) {
-        cy.animate({ fit: { eles: cy.elements(), padding: 40 } }, { duration: 300 });
-      }
-    }
-
-    function highlightNeighborhood(node) {
-      clearHighlights();
-      const neighborhood = node.closedNeighborhood();
-      cy.elements().not(neighborhood).addClass('faded');
-      neighborhood.edges().addClass('highlighted');
-    }
-
-    function clearHighlights() {
-      cy.elements().removeClass('faded highlighted');
-    }
-
-    function hideOverlay(hide = true) {
-      const overlay = document.getElementById('graph-overlay');
-      overlay.style.display = hide ? 'none' : 'block';
-    }
-
-    // ====================================================================
-    // Data Loading
-    // ====================================================================
-    async function loadOverview() {
-      showToast('Loading overview...');
-      try {
-        const data = await api.overview(60);
-        if (data.nodes.length === 0) {
-          showToast('No symbols found. Is the project indexed?');
-          return;
-        }
-        clearGraph();
-        hideOverlay();
-        for (const node of data.nodes) addNodeToGraph(node);
-        runLayout();
-        showToast(`Loaded ${data.nodes.length} symbols`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function expandCallers(nodeId) {
-      if (expandedSets.callers.has(nodeId)) return;
-      expandedSets.callers.add(nodeId);
-      try {
-        const data = await api.callers(nodeId, 1);
-        if (data.items.length === 0) {
-          showToast('No callers found');
-          return;
-        }
-        for (const item of data.items) {
-          addNodeToGraph(item.node);
-          addEdgeToGraph(item.edge);
-        }
-        runLayout();
-        showToast(`Found ${data.items.length} callers`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function expandCallees(nodeId) {
-      if (expandedSets.callees.has(nodeId)) return;
-      expandedSets.callees.add(nodeId);
-      try {
-        const data = await api.callees(nodeId, 1);
-        if (data.items.length === 0) {
-          showToast('No callees found');
-          return;
-        }
-        for (const item of data.items) {
-          addNodeToGraph(item.node);
-          addEdgeToGraph(item.edge);
-        }
-        runLayout();
-        showToast(`Found ${data.items.length} callees`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function loadCallGraph(nodeId) {
-      showToast('Loading call graph...');
-      try {
-        const data = await api.callgraph(nodeId, 2);
-        addSubgraph(data.nodes, data.edges);
-        runLayout();
-        showToast(`Loaded call graph: ${data.nodes.length} nodes`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function loadImpact(nodeId) {
-      showToast('Analyzing impact...');
-      try {
-        const data = await api.impact(nodeId, 2);
-        addSubgraph(data.nodes, data.edges);
-        runLayout();
-        // Highlight the root
-        const rootEle = cy.getElementById(nodeId);
-        if (rootEle.length > 0) {
-          rootEle.addClass('highlighted');
-        }
-        showToast(`Impact: ${data.nodes.length} nodes potentially affected`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function loadChildren(nodeId) {
-      try {
-        const data = await api.children(nodeId);
-        if (data.children.length === 0) {
-          showToast('No children found');
-          return;
-        }
-        for (const child of data.children) {
-          addNodeToGraph(child);
-          // Add contains edge
-          addEdgeToGraph({ source: nodeId, target: child.id, kind: 'contains' });
-        }
-        runLayout();
-        showToast(`Found ${data.children.length} children`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    async function loadFileNodes(filePath) {
-      showToast('Loading file symbols...');
-      try {
-        const data = await api.fileNodes(filePath);
-        if (data.nodes.length === 0) {
-          showToast('No symbols in this file');
-          return;
-        }
-        clearGraph();
-        hideOverlay();
-        for (const node of data.nodes) addNodeToGraph(node);
-        runLayout();
-        showToast(`Loaded ${data.nodes.length} symbols from file`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    // ====================================================================
-    // Explore — natural language question → graph
-    // ====================================================================
-    async function exploreQuery(question) {
-      hideSearchDropdown();
-      clearGraph();
-      hideOverlay();
-      document.getElementById('search-input').value = question;
-
-      showToast('Finding entry point...');
-
-      try {
-        const data = await api.explore(question);
-        if (data.nodes.length === 0) {
-          showToast('No relevant code found. Try searching for a specific symbol.');
-          hideOverlay(false);
-          return;
-        }
-        addSubgraph(data.nodes, data.edges);
-        runLayout();
-
-        // Center on entry point
-        if (data.entryPoint) {
-          const entryEle = cy.getElementById(data.entryPoint);
-          if (entryEle.length > 0) {
-            entryEle.select();
-            entryEle.addClass('highlighted');
-            setTimeout(() => {
-              cy.animate({ center: { eles: entryEle } }, { duration: 400 });
-              showNodeDetails(data.entryPoint);
-            }, 350);
-          }
-        }
-
-        const source = data.usedClaude ? ' (via Claude)' : '';
-        showToast(`Traced ${data.nodes.length} symbols from entry point${source}`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    // ====================================================================
-    // Search
-    // ====================================================================
-    function onSearchInput(e) {
-      const query = e.target.value.trim();
-      clearTimeout(searchDebounce);
-      if (!query) {
-        hideSearchDropdown();
-        return;
-      }
-      searchDebounce = setTimeout(async () => {
-        try {
-          const data = await api.search(query, null, 20);
-          showSearchResults(data.results);
-        } catch (err) {
-          console.error('Search error:', err);
-        }
-      }, 200);
-    }
-
-    function onSearchKeydown(e) {
-      if (e.key === 'Enter') {
-        e.preventDefault();
-        const query = e.target.value.trim();
-        if (!query) return;
-
-        // If dropdown is visible and has results, select the first one
-        const dropdown = document.getElementById('search-results-dropdown');
-        const firstItem = dropdown.querySelector('.search-result-item');
-        if (dropdown.classList.contains('visible') && firstItem) {
-          firstItem.click();
-        } else {
-          // Trigger a search and auto-select first result
-          (async () => {
-            try {
-              const data = await api.search(query, null, 10);
-              if (data.results.length > 0) {
-                selectSearchResult(data.results[0].node.id);
-              } else {
-                showToast('No symbols found. Try a different search.');
-              }
-            } catch (err) {
-              showToast('Search error: ' + err.message);
-            }
-          })();
-        }
-      }
-    }
-
-    function showSearchResults(results) {
-      const dropdown = document.getElementById('search-results-dropdown');
-      if (results.length === 0) {
-        dropdown.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:13px;">No results found</div>';
-        dropdown.classList.add('visible');
-        return;
-      }
-      dropdown.innerHTML = results.map(r => `
-        <div class="search-result-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
-          <span class="kind-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
-          <span class="name">${escapeHtml(r.node.name)}</span>
-          <span class="file-path">${escapeHtml(r.node.filePath)}</span>
-        </div>
-      `).join('');
-      dropdown.classList.add('visible');
-    }
-
-    function hideSearchDropdown() {
-      document.getElementById('search-results-dropdown').classList.remove('visible');
-    }
-
-    async function selectSearchResult(nodeId) {
-      hideSearchDropdown();
-      document.getElementById('search-input').value = '';
-      hideOverlay();
-      clearGraph();
-      hideOverlay();
-
-      showToast('Tracing call chain...');
-
-      try {
-        // Load the call graph from this entry point (depth 3 forward)
-        const data = await api.callgraph(nodeId, 3);
-        if (data.nodes.length === 0) {
-          // Fallback: just show the node
-          const nodeData = await api.node(nodeId);
-          if (nodeData.node) addNodeToGraph(nodeData.node);
-        } else {
-          addSubgraph(data.nodes, data.edges);
-        }
-
-        runLayout();
-
-        // Select and center on the entry point
-        const ele = cy.getElementById(nodeId);
-        if (ele.length > 0) {
-          ele.select();
-          ele.addClass('highlighted');
-          setTimeout(() => {
-            cy.animate({ center: { eles: ele } }, { duration: 300 });
-          }, 350);
-        }
-
-        showNodeDetails(nodeId);
-        showToast(`Traced ${data.nodes.length} symbols from entry point`);
-      } catch (err) {
-        showToast('Error: ' + err.message);
-      }
-    }
-
-    // ====================================================================
-    // Detail Panel
-    // ====================================================================
-    async function showNodeDetails(nodeId) {
-      const panel = document.getElementById('detail-panel');
-      const body = document.getElementById('detail-body');
-      const title = document.getElementById('detail-title');
-
-      panel.classList.add('open');
-
-      body.innerHTML = '<div style="padding:20px;text-align:center;"><div class="spinner"></div></div>';
-
-      try {
-        const [nodeData, contextData] = await Promise.all([
-          api.node(nodeId),
-          api.context(nodeId),
-        ]);
-
-        const node = nodeData.node;
-        const code = nodeData.code;
-        const ancestors = nodeData.ancestors || [];
-        const ctx = contextData.context;
-
-        title.textContent = node.name;
-
-        let html = '';
-
-        // Quick actions
-        html += `<div class="detail-section" style="padding:8px 16px;">
-          <div style="display:flex;gap:6px;flex-wrap:wrap;">
-            <button class="toolbar-btn" onclick="expandCallees('${escapeAttr(node.id)}')" style="font-size:12px;">Expand Callees &rarr;</button>
-            <button class="toolbar-btn" onclick="expandCallers('${escapeAttr(node.id)}')" style="font-size:12px;">&larr; Expand Callers</button>
-            <button class="toolbar-btn" onclick="loadCallGraph('${escapeAttr(node.id)}')" style="font-size:12px;">Full Call Graph</button>
-            <button class="toolbar-btn" onclick="loadImpact('${escapeAttr(node.id)}')" style="font-size:12px;">Impact Analysis</button>
-          </div>
-        </div>`;
-
-        // Meta info
-        html += `<div class="detail-section">
-          <div class="detail-section-title">Info</div>
-          <div class="detail-meta">
-            <span class="label">Kind</span>
-            <span class="value"><span class="kind-badge" style="background:${kindColors[node.kind] || '#8b949e'}22;color:${kindColors[node.kind] || '#8b949e'};font-size:10px;padding:1px 5px;border-radius:3px;">${node.kind}</span></span>
-            <span class="label">File</span>
-            <span class="value accent">${escapeHtml(node.filePath)}</span>
-            <span class="label">Lines</span>
-            <span class="value">${node.startLine} - ${node.endLine}</span>
-            ${node.signature ? `<span class="label">Signature</span><span class="value" style="font-family:var(--font-mono);font-size:11px;">${escapeHtml(node.signature)}</span>` : ''}
-            ${node.visibility ? `<span class="label">Visibility</span><span class="value">${node.visibility}</span>` : ''}
-            ${node.isExported ? `<span class="label">Exported</span><span class="value">Yes</span>` : ''}
-            ${node.isAsync ? `<span class="label">Async</span><span class="value">Yes</span>` : ''}
-            ${node.decorators && node.decorators.length ? `<span class="label">Decorators</span><span class="value">${escapeHtml(node.decorators.join(', '))}</span>` : ''}
-          </div>
-        </div>`;
-
-        // Breadcrumb ancestors
-        if (ancestors.length > 0) {
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Hierarchy</div>
-            <div style="font-size:12px;color:var(--text-secondary);">
-              ${ancestors.map(a => `<span class="relation-item" onclick="selectSearchResult('${escapeAttr(a.id)}')" style="display:inline;cursor:pointer;color:var(--accent);">${escapeHtml(a.name)}</span>`).join(' <span style="color:var(--text-muted);">&#x203A;</span> ')} <span style="color:var(--text-muted);">&#x203A;</span> <strong>${escapeHtml(node.name)}</strong>
-            </div>
-          </div>`;
-        }
-
-        // Source code
-        if (code) {
-          const lang = langForHighlight(node.language);
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Source Code</div>
-            <div class="code-block"><pre><code class="language-${lang}">${escapeHtml(code)}</code></pre></div>
-          </div>`;
-        }
-
-        // Callers
-        if (ctx.incomingRefs && ctx.incomingRefs.length > 0) {
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Called By (${ctx.incomingRefs.length})</div>
-            <ul class="relation-list">
-              ${ctx.incomingRefs.slice(0, 20).map(r => `
-                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
-                  <span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
-                  ${escapeHtml(r.node.name)}
-                  <span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
-                </li>
-              `).join('')}
-            </ul>
-          </div>`;
-        }
-
-        // Callees
-        if (ctx.outgoingRefs && ctx.outgoingRefs.length > 0) {
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Calls (${ctx.outgoingRefs.length})</div>
-            <ul class="relation-list">
-              ${ctx.outgoingRefs.slice(0, 20).map(r => `
-                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
-                  <span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
-                  ${escapeHtml(r.node.name)}
-                  <span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
-                </li>
-              `).join('')}
-            </ul>
-          </div>`;
-        }
-
-        // Children
-        if (ctx.children && ctx.children.length > 0) {
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Contains (${ctx.children.length})</div>
-            <ul class="relation-list">
-              ${ctx.children.slice(0, 30).map(c => `
-                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(c.id)}')">
-                  <span class="rel-badge" style="background:${kindColors[c.kind] || '#8b949e'}22;color:${kindColors[c.kind] || '#8b949e'}">${c.kind}</span>
-                  ${escapeHtml(c.name)}
-                </li>
-              `).join('')}
-            </ul>
-          </div>`;
-        }
-
-        // Docstring
-        if (node.docstring) {
-          html += `<div class="detail-section">
-            <div class="detail-section-title">Documentation</div>
-            <div style="font-size:12px;color:var(--text-secondary);white-space:pre-wrap;font-family:var(--font-mono);line-height:1.5;">${escapeHtml(node.docstring)}</div>
-          </div>`;
-        }
-
-        body.innerHTML = html;
-
-        // Apply syntax highlighting
-        body.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
-
-      } catch (err) {
-        body.innerHTML = `<div style="padding:20px;color:var(--red);">Error loading details: ${escapeHtml(err.message)}</div>`;
-      }
-    }
-
-    function closeDetailPanel() {
-      document.getElementById('detail-panel').classList.remove('open');
-    }
-
-    // ====================================================================
-    // Context Menu
-    // ====================================================================
-    function showContextMenu(x, y) {
-      const menu = document.getElementById('context-menu');
-      menu.style.left = x + 'px';
-      menu.style.top = y + 'px';
-      menu.classList.add('visible');
-    }
-
-    function hideContextMenu() {
-      document.getElementById('context-menu').classList.remove('visible');
-    }
-
-    function ctxAction(action) {
-      hideContextMenu();
-      if (!ctxNodeId) return;
-      switch (action) {
-        case 'expand-callees': expandCallees(ctxNodeId); break;
-        case 'expand-callers': expandCallers(ctxNodeId); break;
-        case 'callgraph': loadCallGraph(ctxNodeId); break;
-        case 'impact': loadImpact(ctxNodeId); break;
-        case 'children': loadChildren(ctxNodeId); break;
-        case 'details': showNodeDetails(ctxNodeId); break;
-        case 'remove':
-          cy.getElementById(ctxNodeId).remove();
-          expandedSets.callers.delete(ctxNodeId);
-          expandedSets.callees.delete(ctxNodeId);
-          break;
-      }
-    }
-
-    // ====================================================================
-    // Tooltip
-    // ====================================================================
-    function showTooltip(e) {
-      const node = e.target;
-      const tip = document.getElementById('tooltip');
-      const pos = e.originalEvent;
-      tip.innerHTML = `
-        <div class="tip-kind">${node.data('kind')}</div>
-        <div class="tip-name">${escapeHtml(node.data('label'))}</div>
-        ${node.data('signature') ? `<div class="tip-file" style="font-family:var(--font-mono);">${escapeHtml(node.data('signature'))}</div>` : ''}
-        <div class="tip-file">${escapeHtml(node.data('filePath') || '')}</div>
-      `;
-      tip.style.left = (pos.clientX + 12) + 'px';
-      tip.style.top = (pos.clientY + 12) + 'px';
-      tip.style.display = 'block';
-    }
-
-    function hideTooltip() {
-      document.getElementById('tooltip').style.display = 'none';
-    }
-
-    // ====================================================================
-    // Toast notifications
-    // ====================================================================
-    function showToast(msg) {
-      const toast = document.getElementById('toast');
-      toast.textContent = msg;
-      toast.classList.add('visible');
-      clearTimeout(toast._timeout);
-      toast._timeout = setTimeout(() => toast.classList.remove('visible'), 2500);
-    }
-
-    // ====================================================================
-    // Sidebar
-    // ====================================================================
-    function toggleSection(header) {
-      const content = header.nextElementSibling;
-      const arrow = header.querySelector('span:last-child');
-      if (content.style.display === 'none') {
-        content.style.display = '';
-        arrow.innerHTML = '&#x25BC;';
-      } else {
-        content.style.display = 'none';
-        arrow.innerHTML = '&#x25B6;';
-      }
-    }
-
-    async function loadFileTree() {
-      try {
-        const data = await api.files();
-        const tree = document.getElementById('file-tree');
-        if (data.files.length === 0) {
-          tree.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:12px;">No files indexed</div>';
-          return;
-        }
-        // Group by directory
-        const dirs = {};
-        for (const f of data.files) {
-          const dir = f.filePath.split('/').slice(0, -1).join('/') || '.';
-          if (!dirs[dir]) dirs[dir] = [];
-          dirs[dir].push(f);
-        }
-        let html = '';
-        const sortedDirs = Object.keys(dirs).sort();
-        for (const dir of sortedDirs) {
-          html += `<div class="file-item" style="color:var(--text-muted);font-weight:500;padding-top:8px;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'">
-            <span class="file-icon">&#x1F4C1;</span> ${escapeHtml(dir)}/
-          </div><div>`;
-          for (const f of dirs[dir].sort((a, b) => a.filePath.localeCompare(b.filePath))) {
-            const fileName = f.filePath.split('/').pop();
-            html += `<div class="file-item" style="padding-left:28px;" onclick="loadFileNodes('${escapeAttr(f.filePath)}')">
-              <span class="file-icon">&#x1F4C4;</span> ${escapeHtml(fileName)}
-            </div>`;
-          }
-          html += '</div>';
-        }
-        tree.innerHTML = html;
-      } catch (err) {
-        console.error('Failed to load file tree:', err);
-      }
-    }
-
-    function buildLegend() {
-      const legendEl = document.getElementById('legend');
-      const kinds = ['function', 'method', 'class', 'interface', 'component', 'enum', 'variable', 'constant', 'property', 'type_alias', 'import', 'export'];
-      legendEl.innerHTML = kinds.map(k => `
-        <div class="legend-item">
-          <div class="legend-dot" style="background:${kindColors[k]};"></div>
-          <span>${k.replace('_', ' ')}</span>
-        </div>
-      `).join('');
-    }
-
-    // ====================================================================
-    // Helpers
-    // ====================================================================
-    function escapeHtml(str) {
-      if (!str) return '';
-      return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
-    }
-
-    function escapeAttr(str) {
-      if (!str) return '';
-      return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
-    }
-
-    function langForHighlight(lang) {
-      const map = {
-        'typescript': 'typescript', 'javascript': 'javascript', 'tsx': 'typescript',
-        'jsx': 'javascript', 'python': 'python', 'go': 'go', 'rust': 'rust',
-        'java': 'java', 'c': 'c', 'cpp': 'cpp', 'csharp': 'csharp',
-        'php': 'php', 'ruby': 'ruby', 'swift': 'swift', 'kotlin': 'kotlin',
-        'dart': 'dart', 'svelte': 'xml', 'liquid': 'xml', 'pascal': 'delphi',
-      };
-      return map[lang] || 'plaintext';
-    }
-
-    // ====================================================================
-    // Keyboard Shortcuts
-    // ====================================================================
-    document.addEventListener('keydown', (e) => {
-      // Ctrl+K or Cmd+K → focus search
-      if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
-        e.preventDefault();
-        document.getElementById('search-input').focus();
-      }
-      // Escape → close things
-      if (e.key === 'Escape') {
-        hideSearchDropdown();
-        hideContextMenu();
-        closeDetailPanel();
-        clearHighlights();
-        document.getElementById('search-input').blur();
-      }
-      // Delete/Backspace → remove selected nodes
-      if ((e.key === 'Delete' || e.key === 'Backspace') && document.activeElement.tagName !== 'INPUT') {
-        const selected = cy.$(':selected');
-        if (selected.length > 0) {
-          selected.remove();
-        }
-      }
-    });
-
-    // ====================================================================
-    // Embeddings Setup Dialog
-    // ====================================================================
-    function showDialog() {
-      document.getElementById('dialog-overlay').classList.add('visible');
-    }
-
-    function closeDialog() {
-      document.getElementById('dialog-overlay').classList.remove('visible');
-    }
-
-    async function checkEmbeddings() {
-      try {
-        const data = await api.embeddingsStatus();
-        if (!data.isReady) {
-          showDialog();
-        }
-      } catch (err) {
-        console.error('Failed to check embeddings status:', err);
-      }
-    }
-
-    function startEmbeddings() {
-      const btnEnable = document.getElementById('dialog-enable');
-      const btnSkip = document.getElementById('dialog-skip');
-      const progress = document.getElementById('dialog-progress');
-      const progressFill = document.getElementById('dialog-progress-fill');
-      const progressText = document.getElementById('dialog-progress-text');
-      const progressPercent = document.getElementById('dialog-progress-percent');
-      const title = document.getElementById('dialog-title');
-      const body = document.getElementById('dialog-body');
-
-      // Update UI to progress mode
-      btnEnable.disabled = true;
-      btnEnable.textContent = 'Setting up...';
-      btnSkip.style.display = 'none';
-      progress.style.display = 'block';
-      body.innerHTML = 'Setting up semantic search for your project. This only needs to happen once.';
-
-      const evtSource = new EventSource('/api/embeddings/generate');
-
-      evtSource.addEventListener('status', (e) => {
-        const data = JSON.parse(e.data);
-        progressText.textContent = data.message;
-        title.textContent = data.phase === 'model' ? 'Downloading Model...' :
-                           data.phase === 'embedding' ? 'Generating Embeddings...' :
-                           'Setting Up...';
-      });
-
-      evtSource.addEventListener('progress', (e) => {
-        const data = JSON.parse(e.data);
-        progressFill.style.width = data.percent + '%';
-        progressPercent.textContent = data.percent + '%';
-        progressText.textContent = data.nodeName
-          ? `Embedding: ${data.nodeName} (${data.current}/${data.total})`
-          : `Processing ${data.current} of ${data.total}...`;
-      });
-
-      evtSource.addEventListener('complete', (e) => {
-        const data = JSON.parse(e.data);
-        evtSource.close();
-        title.textContent = 'Ready!';
-        body.innerHTML = `<strong>${data.message}</strong><br><br>Semantic search is now active. Your explore queries will understand code meaning, not just keywords.`;
-        progressFill.style.width = '100%';
-        progressPercent.textContent = '100%';
-        progressText.textContent = 'Complete';
-
-        // Change actions to just a close button
-        document.getElementById('dialog-actions').innerHTML =
-          '<button class="btn btn-primary" onclick="closeDialog()">Start Exploring</button>';
-      });
-
-      evtSource.addEventListener('error', (e) => {
-        let msg = 'An error occurred during setup.';
-        try {
-          const data = JSON.parse(e.data);
-          msg = data.message || msg;
-        } catch {}
-        evtSource.close();
-        title.textContent = 'Setup Error';
-        body.innerHTML = `<span style="color:var(--red);">${escapeHtml(msg)}</span><br><br>You can still use the explorer with keyword-based search.`;
-        document.getElementById('dialog-actions').innerHTML =
-          '<button class="btn btn-secondary" onclick="closeDialog()">Close</button>';
-      });
-
-      // SSE connection error (different from app error event)
-      evtSource.onerror = () => {
-        // EventSource reconnects automatically; if it closes, we handle via the error event above
-      };
-    }
-
-    // ====================================================================
-    // Init
-    // ====================================================================
-    async function init() {
-      initCytoscape();
-      buildLegend();
-
-      // Load stats
-      try {
-        const data = await api.status();
-        document.getElementById('stat-nodes').textContent = data.stats.nodeCount.toLocaleString();
-        document.getElementById('stat-edges').textContent = data.stats.edgeCount.toLocaleString();
-        document.getElementById('stat-files').textContent = data.stats.fileCount.toLocaleString();
-        document.title = `CodeGraph - ${data.projectName}`;
-      } catch (err) {
-        console.error('Failed to load status:', err);
-      }
-
-      // Load file tree
-      loadFileTree();
-
-      // Embeddings dialog available but not auto-shown
-      // (local embedding model quality is insufficient for natural language queries)
-
-      // Search input
-      document.getElementById('search-input').addEventListener('input', onSearchInput);
-      document.getElementById('search-input').addEventListener('keydown', onSearchKeydown);
-      document.getElementById('search-input').addEventListener('focus', () => {
-        if (document.getElementById('search-input').value.trim()) {
-          document.getElementById('search-results-dropdown').classList.add('visible');
-        }
-      });
-
-      // Click outside search dropdown to close
-      document.addEventListener('click', (e) => {
-        if (!e.target.closest('#search-container')) hideSearchDropdown();
-      });
-    }
-
-    // Start
-    init();
-  </script>
-</body>
-</html>

+ 0 - 521
src/visualizer/server.ts

@@ -1,521 +0,0 @@
-/**
- * CodeGraph Visualizer Server
- *
- * Lightweight HTTP server that serves the graph visualization UI
- * and exposes REST API endpoints for querying the CodeGraph database.
- */
-
-import * as http from 'http';
-import * as fs from 'fs';
-import * as path from 'path';
-import * as url from 'url';
-import { execFile } from 'child_process';
-import type CodeGraph from '../index';
-import type { Node, Edge, NodeKind } from '../types';
-
-export interface VisualizerOptions {
-  /** Port to listen on (0 = auto-assign) */
-  port?: number;
-  /** Whether to open browser automatically */
-  openBrowser?: boolean;
-  /** Host to bind to */
-  host?: string;
-}
-
-/**
- * Serialize a Subgraph (which uses Map) to plain JSON
- */
-function serializeSubgraph(subgraph: { nodes: Map<string, Node>; edges: Edge[]; roots: string[] }) {
-  return {
-    nodes: Array.from(subgraph.nodes.values()),
-    edges: subgraph.edges,
-    roots: subgraph.roots,
-  };
-}
-
-export class VisualizerServer {
-  private cg: CodeGraph;
-  private server: http.Server | null = null;
-  private projectRoot: string;
-  private symbolIndexCache: string | null = null;
-  private claudeAvailable: boolean | null = null;
-
-  constructor(cg: CodeGraph) {
-    this.cg = cg;
-    this.projectRoot = cg.getProjectRoot();
-  }
-
-  /**
-   * Build a compact symbol index string for Claude prompts
-   */
-  private buildSymbolIndex(): string {
-    if (this.symbolIndexCache) return this.symbolIndexCache;
-
-    const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route', 'enum', 'type_alias'];
-    const byFile = new Map<string, string[]>();
-
-    for (const kind of validKinds) {
-      for (const node of this.cg.getNodesByKind(kind)) {
-        const symbols = byFile.get(node.filePath) || [];
-        symbols.push(`${node.kind}:${node.name}`);
-        byFile.set(node.filePath, symbols);
-      }
-    }
-
-    const lines: string[] = [];
-    for (const [file, symbols] of byFile) {
-      lines.push(`${file}: ${symbols.join(', ')}`);
-    }
-
-    this.symbolIndexCache = lines.join('\n');
-    return this.symbolIndexCache;
-  }
-
-  /**
-   * Ask Claude CLI to interpret a natural language question into relevant symbol names
-   */
-  private async askClaude(question: string): Promise<string[] | null> {
-    // Check if claude is available (cache result)
-    if (this.claudeAvailable === false) return null;
-
-    const symbolIndex = this.buildSymbolIndex();
-
-    const prompt = `Given the question and codebase symbol index below, identify the single best ENTRY POINT symbol — the one function, component, or route handler where this flow starts.
-
-Rules:
-- Pick ONE symbol that is the starting point a user or request would hit first
-- Prefer page components, route handlers, or top-level functions
-- Do NOT pick utility functions, helpers, or middleware
-
-Return ONLY this JSON, nothing else:
-{"entry": "symbolName"}
-
-Question: "${question}"
-
-Symbol index:
-${symbolIndex}`;
-
-    return new Promise((resolve) => {
-      const timeout = setTimeout(() => {
-        resolve(null);
-      }, 30000);
-
-      execFile('claude', ['-p', prompt, '--output-format', 'text'], {
-        timeout: 30000,
-        maxBuffer: 1024 * 1024,
-      }, (err, stdout) => {
-        clearTimeout(timeout);
-
-        if (err) {
-          this.claudeAvailable = false;
-          resolve(null);
-          return;
-        }
-
-        this.claudeAvailable = true;
-
-        // Parse Claude's response — try object format first, then array fallback
-        try {
-          const text = stdout.trim();
-          // Try to extract JSON object {"entry": ..., "flow": [...]}
-          const objMatch = text.match(/\{[\s\S]*\}/);
-          if (objMatch) {
-            const parsed = JSON.parse(objMatch[0]) as { entry?: string; flow?: string[] };
-            if (parsed.flow && Array.isArray(parsed.flow) && parsed.flow.length > 0) {
-              // Return flow with entry first
-              const names = parsed.flow.map(String);
-              if (parsed.entry && !names.includes(parsed.entry)) {
-                names.unshift(String(parsed.entry));
-              }
-              resolve(names);
-              return;
-            }
-          }
-          // Fallback: try JSON array
-          const arrMatch = text.match(/\[[\s\S]*\]/);
-          if (arrMatch) {
-            const names = JSON.parse(arrMatch[0]) as string[];
-            if (Array.isArray(names) && names.length > 0) {
-              resolve(names.map(String));
-              return;
-            }
-          }
-        } catch {
-          // Parse failed
-        }
-
-        resolve(null);
-      });
-    });
-  }
-
-  /**
-   * Start the visualizer server
-   */
-  async start(options: VisualizerOptions = {}): Promise<{ port: number; url: string }> {
-    const host = options.host || '127.0.0.1';
-    const port = options.port || 0;
-
-    this.server = http.createServer((req, res) => {
-      this.handleRequest(req, res).catch((err) => {
-        console.error('[Visualizer] Request error:', err);
-        res.writeHead(500, { 'Content-Type': 'application/json' });
-        res.end(JSON.stringify({ error: 'Internal server error' }));
-      });
-    });
-
-    return new Promise((resolve, reject) => {
-      this.server!.listen(port, host, () => {
-        const addr = this.server!.address();
-        if (!addr || typeof addr === 'string') {
-          reject(new Error('Failed to get server address'));
-          return;
-        }
-        const serverUrl = `http://${host}:${addr.port}`;
-        resolve({ port: addr.port, url: serverUrl });
-      });
-
-      this.server!.on('error', reject);
-    });
-  }
-
-  /**
-   * Stop the server
-   */
-  stop(): Promise<void> {
-    return new Promise((resolve) => {
-      if (this.server) {
-        this.server.close(() => resolve());
-      } else {
-        resolve();
-      }
-    });
-  }
-
-  private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
-    const parsedUrl = url.parse(req.url || '/', true);
-    const pathname = parsedUrl.pathname || '/';
-
-    // CORS headers for local development
-    res.setHeader('Access-Control-Allow-Origin', '*');
-    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
-    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
-
-    if (req.method === 'OPTIONS') {
-      res.writeHead(204);
-      res.end();
-      return;
-    }
-
-    // API routes
-    if (pathname.startsWith('/api/')) {
-      return this.handleAPI(pathname, parsedUrl.query as Record<string, string>, res);
-    }
-
-    // Static file serving
-    return this.serveStatic(pathname, res);
-  }
-
-  private async handleAPI(
-    pathname: string,
-    query: Record<string, string>,
-    res: http.ServerResponse
-  ): Promise<void> {
-    const json = (data: unknown, status = 200) => {
-      res.writeHead(status, { 'Content-Type': 'application/json' });
-      res.end(JSON.stringify(data));
-    };
-
-    try {
-      // GET /api/status
-      if (pathname === '/api/status') {
-        const stats = this.cg.getStats();
-        json({ stats, projectRoot: this.projectRoot, projectName: path.basename(this.projectRoot) });
-        return;
-      }
-
-      // GET /api/embeddings/status
-      if (pathname === '/api/embeddings/status') {
-        const embeddingStats = this.cg.getEmbeddingStats();
-        const isInitialized = this.cg.isEmbeddingsInitialized();
-        const totalVectors = embeddingStats?.totalVectors ?? 0;
-        const stats = this.cg.getStats();
-        // Consider ready if we have vectors for at least half the eligible nodes
-        const eligibleNodes = stats.nodeCount - (stats.nodesByKind.file ?? 0) - (stats.nodesByKind.import ?? 0);
-        const isReady = totalVectors > 0 && totalVectors >= eligibleNodes * 0.5;
-        json({ isEnabled: true, isInitialized, isReady, totalVectors, eligibleNodes });
-        return;
-      }
-
-      // GET /api/embeddings/generate — SSE stream that enables, initializes, and generates embeddings
-      if (pathname === '/api/embeddings/generate') {
-        res.writeHead(200, {
-          'Content-Type': 'text/event-stream',
-          'Cache-Control': 'no-cache',
-          'Connection': 'keep-alive',
-        });
-
-        const send = (event: string, data: unknown) => {
-          res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
-        };
-
-        try {
-          // Step 1: Initialize embedding model (downloads on first use)
-          send('status', { phase: 'model', message: 'Loading embedding model (first time may download ~30MB)...' });
-          await this.cg.initializeEmbeddings();
-          send('status', { phase: 'model', message: 'Embedding model ready' });
-
-          // Step 3: Generate embeddings with progress
-          send('status', { phase: 'embedding', message: 'Generating embeddings...' });
-          const count = await this.cg.generateEmbeddings((progress) => {
-            send('progress', {
-              current: progress.current,
-              total: progress.total,
-              nodeName: progress.nodeName,
-              percent: progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0,
-            });
-          });
-
-          send('complete', { totalEmbedded: count, message: `Generated ${count} embeddings` });
-        } catch (err) {
-          const message = err instanceof Error ? err.message : String(err);
-          send('error', { message });
-        }
-
-        res.end();
-        return;
-      }
-
-      // GET /api/search?q=...&kind=...&limit=...
-      if (pathname === '/api/search') {
-        const q = query.q || '';
-        const kind = query.kind as NodeKind | undefined;
-        const limit = parseInt(query.limit || '30', 10);
-        if (!q) {
-          json({ results: [] });
-          return;
-        }
-        const results = this.cg.searchNodes(q, { kinds: kind ? [kind] : undefined, limit });
-        json({ results });
-        return;
-      }
-
-      // GET /api/explore?q=...
-      // Find the best entry point, then return its call graph
-      if (pathname === '/api/explore') {
-        const q = query.q || '';
-        if (!q) {
-          json({ nodes: [], edges: [], roots: [], entryPoint: null });
-          return;
-        }
-
-        let entryNodeId: string | null = null;
-        let usedClaude = false;
-        const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
-
-        // Try Claude CLI to find the best entry point
-        const claudeNames = await this.askClaude(q);
-        if (claudeNames && claudeNames.length > 0) {
-          usedClaude = true;
-          // Find the entry point in the graph
-          for (const name of claudeNames) {
-            if (entryNodeId) break;
-            const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
-            for (const r of results) {
-              if (r.node.name.toLowerCase() === name.toLowerCase() ||
-                  r.node.name.toLowerCase().includes(name.toLowerCase()) ||
-                  name.toLowerCase().includes(r.node.name.toLowerCase())) {
-                entryNodeId = r.node.id;
-                break;
-              }
-            }
-          }
-        }
-
-        // Keyword fallback: find best match from query keywords
-        if (!entryNodeId) {
-          const stopWords = new Set(['how', 'does', 'what', 'the', 'is', 'a', 'an', 'and', 'or', 'in', 'to', 'for', 'of', 'with', 'when', 'do', 'it', 'my', 'work', 'works', 'about', 'show', 'me']);
-          const keywords = q.toLowerCase().split(/\s+/)
-            .map(w => w.replace(/[^a-z0-9]/g, ''))
-            .filter(w => w.length >= 2 && !stopWords.has(w));
-
-          for (const kw of keywords) {
-            if (entryNodeId) break;
-            const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 });
-            if (results.length > 0) {
-              entryNodeId = results[0]!.node.id;
-            }
-          }
-        }
-
-        if (!entryNodeId) {
-          json({ nodes: [], edges: [], roots: [], entryPoint: null });
-          return;
-        }
-
-        // Get the call graph from this entry point (depth 3)
-        const callGraph = this.cg.getCallGraph(entryNodeId, 3);
-        const result = serializeSubgraph(callGraph);
-
-        json({
-          nodes: result.nodes,
-          edges: result.edges,
-          roots: [entryNodeId],
-          entryPoint: entryNodeId,
-          usedClaude,
-        });
-        return;
-      }
-
-      // GET /api/overview?limit=...
-      if (pathname === '/api/overview') {
-        const limit = parseInt(query.limit || '50', 10);
-        // Get top-level exported classes, functions, components
-        const kinds: NodeKind[] = ['class', 'function', 'interface', 'component', 'enum', 'type_alias'];
-        const nodes: Node[] = [];
-        for (const kind of kinds) {
-          const kindNodes = this.cg.getNodesByKind(kind);
-          for (const n of kindNodes) {
-            if (n.isExported || n.kind === 'class' || n.kind === 'component') {
-              nodes.push(n);
-            }
-            if (nodes.length >= limit) break;
-          }
-          if (nodes.length >= limit) break;
-        }
-        json({ nodes });
-        return;
-      }
-
-      // GET /api/files
-      if (pathname === '/api/files') {
-        const files = this.cg.getFiles();
-        json({ files });
-        return;
-      }
-
-      // Routes with node ID: /api/node/<id>/...
-      const nodeMatch = pathname.match(/^\/api\/node\/([^/]+)(\/.*)?$/);
-      if (nodeMatch) {
-        const nodeId = decodeURIComponent(nodeMatch[1]!);
-        const sub = nodeMatch[2] || '';
-
-        // GET /api/node/<id>
-        if (!sub || sub === '/') {
-          const node = this.cg.getNode(nodeId);
-          if (!node) {
-            json({ error: 'Node not found' }, 404);
-            return;
-          }
-          const code = await this.cg.getCode(nodeId);
-          const ancestors = this.cg.getAncestors(nodeId);
-          json({ node, code, ancestors });
-          return;
-        }
-
-        // GET /api/node/<id>/callers?depth=...
-        if (sub === '/callers') {
-          const depth = parseInt(query.depth || '1', 10);
-          const items = this.cg.getCallers(nodeId, depth);
-          json({ items });
-          return;
-        }
-
-        // GET /api/node/<id>/callees?depth=...
-        if (sub === '/callees') {
-          const depth = parseInt(query.depth || '1', 10);
-          const items = this.cg.getCallees(nodeId, depth);
-          json({ items });
-          return;
-        }
-
-        // GET /api/node/<id>/children
-        if (sub === '/children') {
-          const children = this.cg.getChildren(nodeId);
-          json({ children });
-          return;
-        }
-
-        // GET /api/node/<id>/impact?depth=...
-        if (sub === '/impact') {
-          const depth = parseInt(query.depth || '2', 10);
-          const subgraph = this.cg.getImpactRadius(nodeId, depth);
-          json(serializeSubgraph(subgraph));
-          return;
-        }
-
-        // GET /api/node/<id>/callgraph?depth=...
-        if (sub === '/callgraph') {
-          const depth = parseInt(query.depth || '2', 10);
-          const subgraph = this.cg.getCallGraph(nodeId, depth);
-          json(serializeSubgraph(subgraph));
-          return;
-        }
-
-        // GET /api/node/<id>/context
-        if (sub === '/context') {
-          const context = this.cg.getContext(nodeId);
-          json({ context });
-          return;
-        }
-
-        json({ error: 'Unknown endpoint' }, 404);
-        return;
-      }
-
-      // GET /api/file-nodes?path=...
-      if (pathname === '/api/file-nodes') {
-        const filePath = query.path || '';
-        if (!filePath) {
-          json({ error: 'path parameter required' }, 400);
-          return;
-        }
-        const nodes = this.cg.getNodesInFile(filePath);
-        json({ nodes });
-        return;
-      }
-
-      json({ error: 'Unknown API endpoint' }, 404);
-    } catch (err) {
-      const message = err instanceof Error ? err.message : String(err);
-      json({ error: message }, 500);
-    }
-  }
-
-  private serveStatic(pathname: string, res: http.ServerResponse): void {
-    if (pathname === '/' || pathname === '/index.html') {
-      pathname = '/index.html';
-    }
-
-    // Resolve from the public directory next to this file
-    const publicDir = path.join(__dirname, 'public');
-    const filePath = path.join(publicDir, pathname);
-
-    // Security: prevent directory traversal
-    if (!filePath.startsWith(publicDir)) {
-      res.writeHead(403);
-      res.end('Forbidden');
-      return;
-    }
-
-    const ext = path.extname(filePath).toLowerCase();
-    const mimeTypes: Record<string, string> = {
-      '.html': 'text/html',
-      '.css': 'text/css',
-      '.js': 'application/javascript',
-      '.json': 'application/json',
-      '.png': 'image/png',
-      '.svg': 'image/svg+xml',
-      '.ico': 'image/x-icon',
-    };
-
-    try {
-      const content = fs.readFileSync(filePath);
-      res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
-      res.end(content);
-    } catch {
-      res.writeHead(404, { 'Content-Type': 'text/plain' });
-      res.end('Not found');
-    }
-  }
-}