codesearch-mcp 0.0.7-alpha → 0.0.9-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -36,6 +36,14 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  require("./src/env-loader");
38
38
  const path = __importStar(require("path"));
39
+ const help_1 = require("./src/help");
40
+ function formatEta(sec) {
41
+ if (sec < 60)
42
+ return `${sec}s`;
43
+ const m = Math.floor(sec / 60);
44
+ const s = sec % 60;
45
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
46
+ }
39
47
  function printSummary(allStats) {
40
48
  const cols = { name: 30, num: 8 };
41
49
  const line = '─'.repeat(cols.name + cols.num * 5 + 5);
@@ -54,6 +62,16 @@ function printSummary(allStats) {
54
62
  elapsedSec: acc.elapsedSec + s.elapsedSec,
55
63
  }), { indexed: 0, skipped: 0, failed: 0, chunks: 0, elapsedSec: 0 });
56
64
  console.log('TOTAL'.padEnd(cols.name), String(total.indexed).padStart(cols.num), String(total.skipped).padStart(cols.num), String(total.failed).padStart(cols.num), String(total.chunks).padStart(cols.num), String(total.elapsedSec.toFixed(1)).padStart(cols.num));
65
+ // Reprint warnings at the end — progress bar activity scrolls per-project warnings out of
66
+ // view, so we collect them on IndexStats and surface them here as an explicit section.
67
+ // const projectsWithWarnings = allStats.filter(s => s.warnings.length > 0);
68
+ // if (projectsWithWarnings.length > 0) {
69
+ // console.log('\n=== Warnings ===');
70
+ // for (const s of projectsWithWarnings) {
71
+ // console.log(`\n${s.serviceName}:`);
72
+ // for (const w of s.warnings) console.log(` ⚠ ${w}`);
73
+ // }
74
+ // }
57
75
  }
58
76
  async function main() {
59
77
  const argv1 = path.basename(process.argv[1] ?? '');
@@ -62,6 +80,10 @@ async function main() {
62
80
  const isSetup = argv1 === 'rag-setup' || process.argv[2] === 'setup';
63
81
  const isBackfill = argv1 === 'rag-index' && process.argv[2] === 'backfill';
64
82
  const mode = isBackfill ? 'backfill' : isIndex ? 'index' : isMcp ? 'mcp' : isSetup ? 'setup' : (process.argv[2] ?? '');
83
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
84
+ (0, help_1.printHelp)(argv1 !== 'app.js' ? argv1 : mode);
85
+ process.exit(0);
86
+ }
65
87
  if (mode === 'index') {
66
88
  const { initDbWithSchema } = await Promise.resolve().then(() => __importStar(require('./src/db')));
67
89
  const { discoverProjects, scanAndIndex } = await Promise.resolve().then(() => __importStar(require('./src/indexer')));
@@ -82,27 +104,41 @@ async function main() {
82
104
  process.env.WORKSPACE_ROOT ??= workspacePath;
83
105
  await initDbWithSchema();
84
106
  setupInterrupt();
85
- const discovered = discoverProjects(workspacePath);
86
- const projects = await selectProjects(discovered, workspacePath, forceSelect);
87
- if (projects.length === 0) {
88
- teardownInterrupt();
89
- process.exit(0);
90
- }
91
- const allStats = [];
92
- if (force)
93
- console.log('Reindex mode: all files will be re-indexed.');
94
- for (const proj of projects) {
107
+ try {
108
+ const { collectFiles, loadIgnore } = await Promise.resolve().then(() => __importStar(require('./src/file-utils')));
109
+ const discovered = discoverProjects(workspacePath);
110
+ const projects = await selectProjects(discovered, workspacePath, forceSelect);
111
+ if (projects.length === 0)
112
+ process.exit(0);
113
+ const filesPerProject = projects.map(p => collectFiles(p.path, loadIgnore(p.path), p.path));
114
+ const totalFiles = filesPerProject.reduce((sum, f) => sum + f.length, 0);
115
+ console.log(`\n${projects.length} projects — ${totalFiles} files total`);
116
+ if (force)
117
+ console.log('Reindex mode: all files will be re-indexed.');
118
+ const allStats = [];
119
+ for (let i = 0; i < projects.length; i++) {
120
+ if (signal.aborted)
121
+ break;
122
+ const stats = await scanAndIndex(projects[i].path, projects[i].name, force, signal, filesPerProject[i]);
123
+ allStats.push(stats);
124
+ const remaining = projects.length - allStats.length;
125
+ if (remaining > 0 && !signal.aborted) {
126
+ const doneFiles = allStats.reduce((s, r) => s + r.indexed + r.skipped + r.failed, 0);
127
+ const elapsedSec = allStats.reduce((s, r) => s + r.elapsedSec, 0);
128
+ const leftFiles = filesPerProject.slice(allStats.length).reduce((sum, f) => sum + f.length, 0);
129
+ const eta = doneFiles > 0 ? formatEta(Math.round(elapsedSec / doneFiles * leftFiles)) : '?';
130
+ console.log(` ${allStats.length}/${projects.length} projects done — ${remaining} left (~${leftFiles} files) — ETA ~${eta}`);
131
+ }
132
+ }
95
133
  if (signal.aborted)
96
- break;
97
- const stats = await scanAndIndex(proj.path, proj.name, force, signal);
98
- allStats.push(stats);
134
+ console.log('\nIndexing interrupted.');
135
+ printSummary(allStats);
136
+ if (!signal.aborted)
137
+ generateProjectGraph(projects, workspacePath);
138
+ }
139
+ finally {
140
+ teardownInterrupt();
99
141
  }
100
- teardownInterrupt();
101
- if (signal.aborted)
102
- console.log('\nIndexing interrupted.');
103
- printSummary(allStats);
104
- if (!signal.aborted)
105
- generateProjectGraph(projects, workspacePath);
106
142
  process.exit(0);
107
143
  }
108
144
  else if (mode === 'mcp') {
@@ -125,8 +161,8 @@ async function main() {
125
161
  await runSetup();
126
162
  }
127
163
  else if (mode === 'backfill') {
128
- const { initDbWithSchema, db, CodeDependency } = await Promise.resolve().then(() => __importStar(require('./src/db')));
129
- const { extractImportEdges } = await Promise.resolve().then(() => __importStar(require('./src/ast')));
164
+ const { initDbWithSchema, db } = await Promise.resolve().then(() => __importStar(require('./src/db')));
165
+ const { collectDepEdges, writeDepEdges } = await Promise.resolve().then(() => __importStar(require('./src/indexer')));
130
166
  const { config } = await Promise.resolve().then(() => __importStar(require('./src/config')));
131
167
  const fs = await Promise.resolve().then(() => __importStar(require('fs')));
132
168
  const path = await Promise.resolve().then(() => __importStar(require('path')));
@@ -141,16 +177,8 @@ async function main() {
141
177
  skipped++;
142
178
  continue;
143
179
  }
144
- const rawEdges = await extractImportEdges(absPath, serviceName, projectRoot);
145
- const edges = rawEdges
146
- .filter(e => e.toFile.startsWith(config.workspaceRoot))
147
- .map(e => ({ ...e, fromFile: path.relative(config.workspaceRoot, e.fromFile), toFile: path.relative(config.workspaceRoot, e.toFile) }));
148
- await db.transaction(async (manager) => {
149
- await manager.delete(CodeDependency, { project: serviceName, fromFile: filePath });
150
- if (edges.length > 0) {
151
- await manager.createQueryBuilder().insert().into(CodeDependency).values(edges).execute();
152
- }
153
- });
180
+ const edges = await collectDepEdges(absPath, serviceName, projectRoot);
181
+ await writeDepEdges(serviceName, filePath, edges);
154
182
  totalEdges += edges.length;
155
183
  processed++;
156
184
  if (processed % 50 === 0)
@@ -161,11 +189,13 @@ async function main() {
161
189
  process.exit(0);
162
190
  }
163
191
  else {
164
- console.log('Usage: rag-index [path] [--reindex] [--select]');
165
- console.log(' rag-index backfill');
166
- console.log(' rag-mcp');
167
- console.log(' rag-setup');
168
- process.exit(1);
192
+ // No command → show help and exit cleanly. Unknown command → error + help + exit 1.
193
+ const hasUnknownArg = mode !== '';
194
+ const out = hasUnknownArg ? console.error : console.log;
195
+ if (hasUnknownArg)
196
+ out(`Unknown command: ${mode}\n`);
197
+ (0, help_1.printHelp)('default');
198
+ process.exit(hasUnknownArg ? 1 : 0);
169
199
  }
170
200
  }
171
201
  main().catch(err => { console.error(err); process.exit(1); });
package/dist/src/ast.js CHANGED
@@ -37,23 +37,19 @@ exports.extractImportEdges = extractImportEdges;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const ts_morph_1 = require("ts-morph");
40
- // Per-project ts-morph instances keyed by tsconfig.json path ('' = no tsconfig fallback).
40
+ // Per-project ts-morph instances keyed by projectRoot. Each project gets its own instance so that
41
+ // projects without a tsconfig (key previously shared as '') don't leak files into each other.
41
42
  // Loading the tsconfig gives ts-morph the compilerOptions.paths needed to resolve aliases like @core/*.
42
43
  const morphProjects = new Map();
43
- // Secondary cache: projectRoot → tsconfig key. Avoids one fs.existsSync per file after first lookup.
44
- const projectRootKeys = new Map();
45
44
  const FALLBACK_OPTS = { skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: false, compilerOptions: { allowJs: true, noEmit: true } };
46
45
  function getMorphProject(projectRoot) {
47
- if (!projectRootKeys.has(projectRoot)) {
46
+ if (!morphProjects.has(projectRoot)) {
48
47
  const tsconfigPath = path.join(projectRoot, 'tsconfig.json');
49
- projectRootKeys.set(projectRoot, fs.existsSync(tsconfigPath) ? tsconfigPath : '');
50
- }
51
- const key = projectRootKeys.get(projectRoot);
52
- if (!morphProjects.has(key)) {
48
+ const hasTsconfig = fs.existsSync(tsconfigPath);
53
49
  let project;
54
50
  try {
55
- const opts = key
56
- ? { tsConfigFilePath: key, skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: false }
51
+ const opts = hasTsconfig
52
+ ? { tsConfigFilePath: tsconfigPath, skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: false }
57
53
  : FALLBACK_OPTS;
58
54
  project = new ts_morph_1.Project(opts);
59
55
  }
@@ -61,9 +57,9 @@ function getMorphProject(projectRoot) {
61
57
  // tsconfig is invalid — fall back so other files in the project can still be processed
62
58
  project = new ts_morph_1.Project(FALLBACK_OPTS);
63
59
  }
64
- morphProjects.set(key, project);
60
+ morphProjects.set(projectRoot, project);
65
61
  }
66
- return morphProjects.get(key);
62
+ return morphProjects.get(projectRoot);
67
63
  }
68
64
  // Extract import edges from a TS/JS file using ts-morph.
69
65
  // projectRoot must be the project root (where tsconfig.json lives) for alias resolution to work.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.config = void 0;
4
4
  const zod_1 = require("zod");
5
+ const constants_1 = require("./constants");
5
6
  const schema = zod_1.z.object({
6
7
  // required — no defaults, process exits if missing
7
8
  DB_HOST: zod_1.z.string(),
@@ -11,7 +12,7 @@ const schema = zod_1.z.object({
11
12
  DB_NAME: zod_1.z.string(),
12
13
  // optional with defaults
13
14
  OLLAMA_HOST: zod_1.z.string().url().default('http://localhost:11434'),
14
- OLLAMA_MODEL: zod_1.z.string().default('bge-m3'),
15
+ OLLAMA_MODEL: zod_1.z.string().default(constants_1.DEFAULT_EMBED_MODEL),
15
16
  CHUNK_SIZE: zod_1.z.coerce.number().default(1500),
16
17
  CHUNK_OVERLAP: zod_1.z.coerce.number().default(200),
17
18
  EMBED_BATCH_SIZE: zod_1.z.coerce.number().default(5),
@@ -0,0 +1 @@
1
+ export declare const DEFAULT_EMBED_MODEL = "bge-m3";
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_EMBED_MODEL = void 0;
4
+ // Embedding model is tied to the vector column dimension (bge-m3 → 1024).
5
+ // Change both together: update this constant, drop/recreate the `embedding` column,
6
+ // and re-run indexing.
7
+ exports.DEFAULT_EMBED_MODEL = 'bge-m3';
@@ -7,7 +7,8 @@ const data_source_1 = require("./data-source");
7
7
  async function initDb() {
8
8
  await data_source_1.db.initialize();
9
9
  }
10
- // For indexer — connect + create schema only if tables don't exist yet
10
+ // For indexer — connect + create schema only if tables don't exist yet.
11
+ // All secondary indexes use IF NOT EXISTS so existing installs pick up new indexes on re-run.
11
12
  async function initDbWithSchema() {
12
13
  await data_source_1.db.initialize();
13
14
  // code_chunk table
@@ -24,6 +25,9 @@ async function initDbWithSchema() {
24
25
  ON code_chunk USING hnsw (embedding vector_cosine_ops)
25
26
  `);
26
27
  }
28
+ // Composite btree for all per-service lookups (scanAndIndex stale-sweep, ingest upsert, search filter).
29
+ // Applied idempotently so existing installs pick it up on next indexing run.
30
+ await data_source_1.db.query(`CREATE INDEX IF NOT EXISTS idx_code_chunk_service_file ON code_chunk ("serviceName", "filePath")`);
27
31
  // code_dependency table
28
32
  const [{ existsDep }] = await data_source_1.db.query(`
29
33
  SELECT EXISTS (
@@ -43,8 +47,16 @@ async function initDbWithSchema() {
43
47
  "depType" TEXT NOT NULL
44
48
  )
45
49
  `);
46
- await data_source_1.db.query(`CREATE INDEX idx_dep_from ON code_dependency ("fromFile")`);
47
- await data_source_1.db.query(`CREATE INDEX idx_dep_to ON code_dependency ("toFile")`);
48
- await data_source_1.db.query(`CREATE INDEX idx_dep_project ON code_dependency (project)`);
49
50
  }
51
+ // Composite indexes for the two query shapes used everywhere:
52
+ // WHERE project = $1 AND "fromFile" = $2 (get_dependencies, ingest delete-by-from)
53
+ // WHERE project = $1 AND "toFile" = $2 (get_dependents, stale-file sweep)
54
+ // IF NOT EXISTS lets existing installs upgrade without a manual migration.
55
+ await data_source_1.db.query(`CREATE INDEX IF NOT EXISTS idx_dep_project_from ON code_dependency (project, "fromFile")`);
56
+ await data_source_1.db.query(`CREATE INDEX IF NOT EXISTS idx_dep_project_to ON code_dependency (project, "toFile")`);
57
+ // Drop redundant legacy single-column indexes (covered by the composites above).
58
+ // Safe on fresh installs too — DROP IF EXISTS is a no-op when absent.
59
+ await data_source_1.db.query(`DROP INDEX IF EXISTS idx_dep_from`);
60
+ await data_source_1.db.query(`DROP INDEX IF EXISTS idx_dep_to`);
61
+ await data_source_1.db.query(`DROP INDEX IF EXISTS idx_dep_project`);
50
62
  }
@@ -0,0 +1 @@
1
+ export declare function printHelp(command: string): void;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printHelp = printHelp;
4
+ function printHelp(command) {
5
+ switch (command) {
6
+ case 'rag-index':
7
+ case 'index':
8
+ console.log('Usage: rag-index [path] [options]');
9
+ console.log(' rag-index backfill');
10
+ console.log('');
11
+ console.log('Index a workspace of JS/TS projects into the vector database.');
12
+ console.log('');
13
+ console.log('Arguments:');
14
+ console.log(' path Path to workspace (defaults to WORKSPACE_ROOT env var)');
15
+ console.log('');
16
+ console.log('Options:');
17
+ console.log(' --reindex Force re-index all files (ignores cached mtimes)');
18
+ console.log(' --select Interactively select which projects to index');
19
+ console.log(' -h, --help Show this help message');
20
+ console.log('');
21
+ console.log('Subcommands:');
22
+ console.log(' backfill Populate dependency graph for already-indexed files');
23
+ break;
24
+ case 'rag-mcp':
25
+ case 'mcp':
26
+ console.log('Usage: rag-mcp');
27
+ console.log('');
28
+ console.log('Start the MCP server for semantic code search.');
29
+ console.log('Requires WORKSPACE_ROOT to be set (run rag-setup first).');
30
+ console.log('');
31
+ console.log('Options:');
32
+ console.log(' -h, --help Show this help message');
33
+ break;
34
+ case 'rag-setup':
35
+ case 'setup':
36
+ console.log('Usage: rag-setup');
37
+ console.log('');
38
+ console.log('Interactive setup: configure DB, Ollama, and WORKSPACE_ROOT.');
39
+ console.log('Run once before using rag-index or rag-mcp.');
40
+ console.log('');
41
+ console.log('Options:');
42
+ console.log(' -h, --help Show this help message');
43
+ break;
44
+ default:
45
+ console.log('Usage: rag-index [path] [--reindex] [--select]');
46
+ console.log(' rag-index backfill');
47
+ console.log(' rag-mcp');
48
+ console.log(' rag-setup');
49
+ console.log('');
50
+ console.log('Run any command with -h or --help for details.');
51
+ }
52
+ }
@@ -1,3 +1,4 @@
1
+ import { CodeDependency } from './db';
1
2
  export interface IndexStats {
2
3
  serviceName: string;
3
4
  indexed: number;
@@ -5,9 +6,12 @@ export interface IndexStats {
5
6
  failed: number;
6
7
  chunks: number;
7
8
  elapsedSec: number;
9
+ warnings: string[];
8
10
  }
9
11
  export declare function discoverProjects(rootDir: string): {
10
12
  name: string;
11
13
  path: string;
12
14
  }[];
13
- export declare function scanAndIndex(dir: string, serviceName: string, force?: boolean, signal?: AbortSignal): Promise<IndexStats>;
15
+ export declare function collectDepEdges(absFilePath: string, serviceName: string, projectRoot: string): Promise<Omit<CodeDependency, 'id'>[]>;
16
+ export declare function writeDepEdges(serviceName: string, relFilePath: string, edges: Omit<CodeDependency, 'id'>[], manager?: import('typeorm').EntityManager): Promise<void>;
17
+ export declare function scanAndIndex(dir: string, serviceName: string, force?: boolean, signal?: AbortSignal, preCollectedFiles?: string[]): Promise<IndexStats>;
@@ -34,17 +34,18 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.discoverProjects = discoverProjects;
37
+ exports.collectDepEdges = collectDepEdges;
38
+ exports.writeDepEdges = writeDepEdges;
37
39
  exports.scanAndIndex = scanAndIndex;
38
40
  const fs = __importStar(require("fs"));
39
41
  const path = __importStar(require("path"));
40
42
  const textsplitters_1 = require("@langchain/textsplitters");
41
- const ollama_1 = require("ollama");
42
43
  const cli_progress_1 = require("cli-progress");
43
44
  const db_1 = require("./db");
44
45
  const ast_1 = require("./ast");
45
46
  const config_1 = require("./config");
47
+ const ollama_1 = require("./ollama");
46
48
  const file_utils_1 = require("./file-utils");
47
- const ollama = new ollama_1.Ollama({ host: config_1.config.ollama.host });
48
49
  // JS/TS splitter uses language-aware separators (functions, classes, blocks)
49
50
  const jsSplitter = textsplitters_1.RecursiveCharacterTextSplitter.fromLanguage('js', {
50
51
  chunkSize: config_1.config.indexer.chunkSize,
@@ -69,12 +70,39 @@ function discoverProjects(rootDir) {
69
70
  }
70
71
  // Embed a pre-formatted prompt (caller is responsible for prefix and context)
71
72
  async function embedText(prompt) {
72
- const { embedding } = await ollama.embeddings({
73
+ const { embedding } = await ollama_1.ollama.embeddings({
73
74
  model: config_1.config.ollama.model,
74
75
  prompt,
75
76
  });
76
77
  return embedding;
77
78
  }
79
+ // Shared helpers for dependency edge writes — used by ingestFile (incremental) and backfill CLI.
80
+ // Extract edges via ts-morph (disk I/O) and normalize to workspace-relative paths.
81
+ // Call this OUTSIDE any DB transaction — disk/AST work must not hold a DB connection open.
82
+ async function collectDepEdges(absFilePath, serviceName, projectRoot) {
83
+ const rawEdges = await (0, ast_1.extractImportEdges)(absFilePath, serviceName, projectRoot);
84
+ return rawEdges
85
+ .filter(e => e.toFile.startsWith(config_1.config.workspaceRoot))
86
+ .map(e => ({
87
+ ...e,
88
+ fromFile: path.relative(config_1.config.workspaceRoot, e.fromFile),
89
+ toFile: path.relative(config_1.config.workspaceRoot, e.toFile),
90
+ }));
91
+ }
92
+ // Replace edge rows for a file. When `manager` is provided, runs inside that transaction;
93
+ // otherwise opens its own. Keeps delete + insert atomic either way.
94
+ async function writeDepEdges(serviceName, relFilePath, edges, manager) {
95
+ const run = async (mgr) => {
96
+ await mgr.delete(db_1.CodeDependency, { project: serviceName, fromFile: relFilePath });
97
+ if (edges.length > 0) {
98
+ await mgr.createQueryBuilder().insert().into(db_1.CodeDependency).values(edges).execute();
99
+ }
100
+ };
101
+ if (manager)
102
+ await run(manager);
103
+ else
104
+ await db_1.db.transaction(run);
105
+ }
78
106
  // Ingest a single file: skip if unchanged, otherwise delete old chunks and re-index.
79
107
  // Returns status and chunk count. Never throws — failures are caught and reported.
80
108
  async function ingestFile(filePath, serviceName, mtimeMs, projectRoot) {
@@ -90,14 +118,7 @@ async function ingestFile(filePath, serviceName, mtimeMs, projectRoot) {
90
118
  return { status: 'skipped', chunks: 0 };
91
119
  }
92
120
  // Extract import edges before the transaction — disk I/O must not hold a DB connection open
93
- const rawEdges = await (0, ast_1.extractImportEdges)(filePath, serviceName, projectRoot);
94
- const edges = rawEdges
95
- .filter(e => e.toFile.startsWith(config_1.config.workspaceRoot))
96
- .map(e => ({
97
- ...e,
98
- fromFile: path.relative(config_1.config.workspaceRoot, e.fromFile),
99
- toFile: path.relative(config_1.config.workspaceRoot, e.toFile),
100
- }));
121
+ const edges = await collectDepEdges(filePath, serviceName, projectRoot);
101
122
  // Read and embed outside the transaction — Ollama calls must not hold a DB transaction open
102
123
  const code = fs.readFileSync(filePath, 'utf-8');
103
124
  const chunks = await getSplitter(filePath).createDocuments([code]);
@@ -129,14 +150,7 @@ async function ingestFile(filePath, serviceName, mtimeMs, projectRoot) {
129
150
  .values(records.slice(i, i + config_1.config.indexer.batchSize))
130
151
  .execute();
131
152
  }
132
- await manager.delete(db_1.CodeDependency, { project: serviceName, fromFile: relFilePath });
133
- if (edges.length > 0) {
134
- await manager.createQueryBuilder()
135
- .insert()
136
- .into(db_1.CodeDependency)
137
- .values(edges)
138
- .execute();
139
- }
153
+ await writeDepEdges(serviceName, relFilePath, edges, manager);
140
154
  });
141
155
  return { status: 'indexed', chunks: chunks.length };
142
156
  }
@@ -149,48 +163,58 @@ async function ingestFile(filePath, serviceName, mtimeMs, projectRoot) {
149
163
  // Removes chunks for deleted files, skips unchanged files, re-indexes modified ones.
150
164
  // Pass force=true to invalidate all cached mtimes and force full re-index (e.g. after embedding strategy change).
151
165
  // Pass signal to support graceful interruption — stops between files, never mid-transaction.
152
- async function scanAndIndex(dir, serviceName, force = false, signal) {
166
+ async function scanAndIndex(dir, serviceName, force = false, signal, preCollectedFiles) {
153
167
  const startTime = Date.now();
154
168
  const repo = db_1.db.getRepository(db_1.CodeChunk);
155
- // Clean up chunks whose source files no longer exist on disk
169
+ // Force mode: wipe lastModified so all files are treated as changed
170
+ if (force) {
171
+ await db_1.db.query(`UPDATE code_chunk SET "lastModified" = NULL WHERE "serviceName" = $1`, [serviceName]);
172
+ }
173
+ const files = preCollectedFiles ?? (0, file_utils_1.collectFiles)(dir, (0, file_utils_1.loadIgnore)(dir), dir);
174
+ // Clean up chunks for files no longer in the collected set — covers both deleted files and
175
+ // files newly excluded by .ragignore (they exist on disk but should no longer be indexed).
176
+ const collectedRelPaths = new Set(files.map(f => path.relative(config_1.config.workspaceRoot, f)));
156
177
  const dbPaths = await db_1.db.query(`SELECT DISTINCT "filePath" FROM code_chunk WHERE "serviceName" = $1`, [serviceName]);
157
178
  for (const { filePath } of dbPaths) {
158
- if (!fs.existsSync(path.join(config_1.config.workspaceRoot, filePath))) {
179
+ if (!collectedRelPaths.has(filePath)) {
159
180
  await repo.delete({ serviceName, filePath });
160
- // Remove dependency edges on both sides — this file no longer exists as importer or target
161
181
  await db_1.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "fromFile" = $2`, [serviceName, filePath]);
162
182
  await db_1.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "toFile" = $2`, [serviceName, filePath]);
163
183
  console.log(` Removed stale: ${path.basename(filePath)}`);
164
184
  }
165
185
  }
166
- // Force mode: wipe lastModified so all files are treated as changed
167
- if (force) {
168
- await db_1.db.query(`UPDATE code_chunk SET "lastModified" = NULL WHERE "serviceName" = $1`, [serviceName]);
169
- }
170
- const ig = (0, file_utils_1.loadIgnore)(dir);
171
- const files = (0, file_utils_1.collectFiles)(dir, ig, dir);
172
186
  const bar = new cli_progress_1.SingleBar({
173
- format: `{serviceName} {bar} {percentage}% | {value}/{total} | {filename}`,
187
+ format: `{serviceName} {bar} {percentage}% | {value}/{total} | ETA {eta_formatted} | {filename}`,
174
188
  barCompleteChar: '█',
175
189
  barIncompleteChar: '░',
176
190
  hideCursor: true,
177
191
  clearOnComplete: false,
178
192
  }, cli_progress_1.Presets.shades_grey);
193
+ const warnings = [];
179
194
  console.log(`\nIndexing ${serviceName}: ${files.length} files`);
180
195
  const meta = (0, file_utils_1.readRagMeta)(dir);
181
196
  if (meta === null && fs.existsSync(path.join(dir, '.ragmeta.json'))) {
182
- console.log(` ⚠ .ragmeta.json found but invalid JSON`);
197
+ const msg = `.ragmeta.json found but invalid JSON`;
198
+ console.log(` ⚠ ${msg}`);
199
+ warnings.push(msg);
183
200
  }
184
201
  else if (meta) {
185
202
  const parts = [meta.description, meta.stack && `[${meta.stack}]`].filter(Boolean).join(' ');
186
203
  console.log(` ✓ .ragmeta.json: ${parts || '(no fields)'}`);
187
204
  }
188
205
  else {
189
- console.log(` ⚠ No .ragmeta.json — add {"description":"...","stack":"...","notes":"..."} so list_projects can describe this project`);
206
+ const msg = `No .ragmeta.json — add {"description":"...","stack":"...","notes":"..."} so list_projects can describe this project`;
207
+ console.log(` ⚠ ${msg}`);
208
+ warnings.push(msg);
209
+ }
210
+ if (!fs.existsSync(path.join(dir, '.ragignore'))) {
211
+ const msg = `No .ragignore — create one to exclude generated/codegen files (e.g. src/gql/graphql.ts, **/__snapshots__/**)`;
212
+ console.log(` ⚠ ${msg}`);
213
+ warnings.push(msg);
190
214
  }
191
215
  bar.start(files.length, 0, { serviceName, filename: '' });
192
216
  const stats = {
193
- serviceName, indexed: 0, skipped: 0, failed: 0, chunks: 0, elapsedSec: 0,
217
+ serviceName, indexed: 0, skipped: 0, failed: 0, chunks: 0, elapsedSec: 0, warnings,
194
218
  };
195
219
  for (const filePath of files) {
196
220
  if (signal?.aborted) {
@@ -212,6 +236,7 @@ async function scanAndIndex(dir, serviceName, force = false, signal) {
212
236
  bar.increment();
213
237
  }
214
238
  bar.stop();
239
+ process.stdout.write('\x1B[J'); // erase any blank lines cli-progress left below the bar
215
240
  stats.elapsedSec = Math.round((Date.now() - startTime) / 100) / 10;
216
241
  return stats;
217
242
  }
package/dist/src/mcp.js CHANGED
@@ -1,11 +1,54 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.startMCP = startMCP;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
4
39
  const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
5
40
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
41
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
7
42
  const config_1 = require("./config");
8
43
  const searcher_1 = require("./searcher");
44
+ const PKG_VERSION = (() => {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8')).version ?? '0.0.0';
47
+ }
48
+ catch {
49
+ return '0.0.0';
50
+ }
51
+ })();
9
52
  const SERVER_INSTRUCTIONS = `
10
53
  You have access to a local semantic code search server. Use these tools to navigate unfamiliar codebases.
11
54
 
@@ -14,13 +57,18 @@ You have access to a local semantic code search server. Use these tools to navig
14
57
  - **list_projects** — call this first to see what projects are indexed and their exact names.
15
58
  - **search_codebase** — semantic search across indexed source files. Returns file path, line number, and a code chunk.
16
59
  - **read_file_range** — read a specific line range from any indexed file. Use this to expand context around a search result.
60
+ - **get_dependencies** — show what files a given file imports (direct or transitive). Use to understand what a file depends on.
61
+ - **get_dependents** — show which files import a given file (reverse graph). Use before refactoring to find all callers.
17
62
 
18
63
  ## Recommended workflow
19
64
 
20
65
  1. Use \`list_projects\` to discover available projects before filtering by \`project\`.
21
66
  2. Use \`search_codebase\` to find relevant code by meaning.
22
- 3. If a result looks **truncated or incomplete** (e.g. you see only the middle of a function), call \`read_file_range\` with the returned \`file_path\` and lines around \`startLine\` to get the full picture.
67
+ 3. If a result looks **truncated or incomplete** (e.g. you see only the middle of a function), call \`read_file_range\` with the returned \`file_path\` and \`start_line\` set to the **Line** number shown in the result. Example: if the result shows **Line:** 150, pass \`start_line: 150\`.
23
68
  4. For exploratory searches where you expect results to span chunk boundaries, pass \`include_neighbors: true\` to automatically include the chunk before and after each match.
69
+ 5. Use \`find_symbol\` when you know the exact name of a function or class and want its definition location immediately — faster than semantic search.
70
+ 6. Use \`list_files\` to explore project structure (src/, components/, etc.) before diving into files.
71
+ 7. Use \`read_file\` to read a complete file when you have its absolute path from \`search_codebase\` results.
24
72
 
25
73
  ## When to use read_file_range vs include_neighbors
26
74
 
@@ -39,6 +87,9 @@ Lower score = better match (cosine distance). Results above the configured thres
39
87
 
40
88
  - **get_dependencies** — show what files a given file imports (direct or transitive). Use when you need to understand what a file depends on.
41
89
  - **get_dependents** — show which files import a given file (reverse graph). Use before refactoring to find all callers and avoid breaking changes.
90
+ - **find_symbol** — find where a symbol (function, class, type, interface, variable) is defined by exact name. Faster than semantic search when you know the name.
91
+ - **list_files** — browse the directory tree of a project path. Use to understand project structure before reading files.
92
+ - **read_file** — read a complete file by absolute path with line numbers. Truncates at max_lines with a hint to continue.
42
93
 
43
94
  Both tools require \`file_path\` (as returned by \`search_codebase\`) and \`project\` (from \`list_projects\`). Pass \`transitive: true\` for the full dependency chain.
44
95
  `.trim();
@@ -79,7 +130,7 @@ function formatChunk(r, label) {
79
130
  return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
80
131
  }
81
132
  async function startMCP() {
82
- const server = new index_js_1.Server({ name: 'local-rag', version: '1.0.0' }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
133
+ const server = new index_js_1.Server({ name: 'local-rag', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
83
134
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
84
135
  tools: [
85
136
  {
@@ -108,8 +159,8 @@ async function startMCP() {
108
159
  type: 'object',
109
160
  properties: {
110
161
  file_path: { type: 'string', description: 'File path as returned by search_codebase' },
111
- start_line: { type: 'number', description: 'First line to read (1-based, inclusive). Default: 1' },
112
- end_line: { type: 'number', description: 'Last line to read (1-based, inclusive). Default: start_line + 99' },
162
+ start_line: { type: 'number', description: 'First line to read (1-based). Use the **Line** number from search_codebase results. Default: 1.' },
163
+ end_line: { type: 'number', description: 'Last line to read (1-based, inclusive). Default: start_line + 99.' },
113
164
  },
114
165
  required: ['file_path'],
115
166
  },
@@ -136,12 +187,51 @@ async function startMCP() {
136
187
  properties: {
137
188
  file_path: { type: 'string', description: 'File path as returned by search_codebase' },
138
189
  project: { type: 'string', description: 'Project name (use list_projects to find it)' },
139
- symbol: { type: 'string', description: 'Filter to a specific exported symbol name. When the graph returns no results, also triggers a text-search fallback to find runtime usages (e.g. via singleton or container).' },
190
+ symbol: { type: 'string', description: 'Exported symbol name. Two behaviors: (1) if graph dependents exist, narrows them to imports of this symbol; (2) if the import graph returns zero results, triggers a whole-word text-search fallback (grep -F -w) across all .ts/.tsx/.js/.jsx files in the project to find runtime usages — DI containers, singletons, dynamic lookups, string references. Capped at 200 matches.' },
140
191
  transitive: { type: 'boolean', description: 'Include transitive (indirect) dependents. Default: false.' },
141
192
  },
142
193
  required: ['file_path', 'project'],
143
194
  },
144
195
  },
196
+ {
197
+ name: 'find_symbol',
198
+ description: 'Find TypeScript/JavaScript symbol definitions (functions, classes, types, interfaces, variables) by exact name. Use when you know the symbol name and want its definition location fast.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ name: { type: 'string', description: 'Exact symbol name (case-sensitive, whole-word match)' },
203
+ project: { type: 'string', description: 'Limit search to this project (optional). Use list_projects to find names.' },
204
+ kind: { type: 'string', enum: ['function', 'class', 'type', 'interface', 'variable', 'all'], description: 'Symbol kind filter. Default: all.' },
205
+ },
206
+ required: ['name'],
207
+ },
208
+ },
209
+ {
210
+ name: 'list_files',
211
+ description: 'List files and directories in a project path as an indented tree. Use to understand project structure before reading files.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ path: { type: 'string', description: 'Directory path relative to the project root (e.g. "src/components" or "." for root)' },
216
+ project: { type: 'string', description: 'Project name (from list_projects)' },
217
+ depth: { type: 'number', description: 'Tree depth: 1 = immediate children only, up to 5. Default: 2.' },
218
+ pattern: { type: 'string', description: 'Optional filename filter (substring or extension like ".ts"). Only matching files are shown.' },
219
+ },
220
+ required: ['path', 'project'],
221
+ },
222
+ },
223
+ {
224
+ name: 'read_file',
225
+ description: 'Read a file from the workspace by absolute path (as returned by search_codebase). Returns contents with line numbers. Truncates at max_lines with a hint to continue with read_file_range.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ file_path: { type: 'string', description: 'Absolute path to the file (as returned in filePath by search_codebase)' },
230
+ max_lines: { type: 'number', description: 'Maximum lines to return (default 300, max 1000)' },
231
+ },
232
+ required: ['file_path'],
233
+ },
234
+ },
145
235
  ],
146
236
  }));
147
237
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
@@ -203,18 +293,17 @@ async function startMCP() {
203
293
  const endLine = Number(req.params.arguments?.end_line) || startLine + 99;
204
294
  const range = await (0, searcher_1.readFileRange)(filePath, startLine, endLine);
205
295
  if (!range.ok) {
206
- const message = range.reason === 'not_indexed'
207
- ? `File is not indexed: ${filePath}. Run indexing first.`
208
- : `File was deleted from disk after indexing: ${filePath}. Re-run indexing to update.`;
296
+ const message = range.reason === 'invalid_path' ? `Invalid file path (outside workspace): ${filePath}` :
297
+ range.reason === 'not_indexed' ? `File is not indexed: ${filePath}. Run indexing first.` :
298
+ `File was deleted from disk after indexing: ${filePath}. Re-run indexing to update.`;
209
299
  return { content: [{ type: 'text', text: message }] };
210
300
  }
211
301
  const numbered = range.lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
212
- return {
213
- content: [{
214
- type: 'text',
215
- text: `**File:** ${range.filePath}\n**Lines:** ${range.startLine}–${range.endLine}\n\`\`\`\n${numbered}\n\`\`\``,
216
- }],
217
- };
302
+ let text = `**File:** ${range.filePath}\n**Lines:** ${range.startLine}–${range.endLine}\n\`\`\`\n${numbered}\n\`\`\``;
303
+ if (range.hasMore) {
304
+ text += `\n\n[file has more lines — call read_file_range again with start_line: ${range.endLine + 1}]`;
305
+ }
306
+ return { content: [{ type: 'text', text }] };
218
307
  }
219
308
  if (req.params.name === 'get_dependencies') {
220
309
  const filePath = String(req.params.arguments?.file_path);
@@ -256,6 +345,54 @@ async function startMCP() {
256
345
  }
257
346
  return { content: [{ type: 'text', text: header + fallbackLines.join('\n') }] };
258
347
  }
348
+ if (req.params.name === 'find_symbol') {
349
+ const name = String(req.params.arguments?.name ?? '');
350
+ const project = req.params.arguments?.project ? String(req.params.arguments.project) : null;
351
+ const rawKind = req.params.arguments?.kind;
352
+ const validKinds = ['function', 'class', 'type', 'interface', 'variable', 'all'];
353
+ const kind = validKinds.includes(rawKind) ? rawKind : 'all';
354
+ const { hits, truncated } = (0, searcher_1.findSymbol)(name, project, kind);
355
+ if (hits.length === 0) {
356
+ return { content: [{ type: 'text', text: `No definitions found for symbol: ${name}` }] };
357
+ }
358
+ const lines = [];
359
+ if (truncated)
360
+ lines.push(`*(truncated to ${hits.length} results)*\n`);
361
+ for (const h of hits) {
362
+ lines.push(`**${h.kind}** \`${h.filePath}\` line ${h.line}, col ${h.column}`);
363
+ lines.push(`\`\`\`\n${h.signature}\n\`\`\``);
364
+ }
365
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
366
+ }
367
+ if (req.params.name === 'list_files') {
368
+ const relPath = String(req.params.arguments?.path ?? '.');
369
+ const project = String(req.params.arguments?.project ?? '');
370
+ const rawDepth = Number(req.params.arguments?.depth);
371
+ const depth = isNaN(rawDepth) || rawDepth < 1 ? 2 : Math.min(5, rawDepth);
372
+ const pattern = req.params.arguments?.pattern ? String(req.params.arguments.pattern) : null;
373
+ const { tree, error } = (0, searcher_1.listFiles)(relPath, project, depth, pattern);
374
+ if (error) {
375
+ return { content: [{ type: 'text', text: `list_files error: ${error}` }] };
376
+ }
377
+ return { content: [{ type: 'text', text: `**${project}/${relPath}**\n\`\`\`\n${tree}\n\`\`\`` }] };
378
+ }
379
+ if (req.params.name === 'read_file') {
380
+ const filePath = String(req.params.arguments?.file_path ?? '');
381
+ const rawMax = Number(req.params.arguments?.max_lines);
382
+ const maxLines = isNaN(rawMax) || rawMax < 1 ? 300 : Math.min(1000, rawMax);
383
+ const result = await (0, searcher_1.readFileAbsolute)(filePath, maxLines);
384
+ if (!result.ok) {
385
+ const message = result.reason === 'invalid_path' ? `Invalid path (outside workspace): ${filePath}` :
386
+ `File not found on disk: ${filePath}`;
387
+ return { content: [{ type: 'text', text: message }] };
388
+ }
389
+ const numbered = result.lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
390
+ let text = `**File:** ${result.filePath}\n**Lines:** 1–${result.endLine}\n\`\`\`\n${numbered}\n\`\`\``;
391
+ if (result.hasMore) {
392
+ text += `\n\n[truncated: file has more lines — use read_file_range with file_path: "${result.filePath}", start_line: ${result.endLine + 1} to continue]`;
393
+ }
394
+ return { content: [{ type: 'text', text }] };
395
+ }
259
396
  throw new Error(`Unknown tool: ${req.params.name}`);
260
397
  }
261
398
  catch (err) {
@@ -0,0 +1,2 @@
1
+ import { Ollama } from 'ollama';
2
+ export declare const ollama: Ollama;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ollama = void 0;
4
+ const ollama_1 = require("ollama");
5
+ const config_1 = require("./config");
6
+ exports.ollama = new ollama_1.Ollama({ host: config_1.config.ollama.host });
@@ -45,8 +45,16 @@ function loadLines(filePath) {
45
45
  .map(l => l.trim())
46
46
  .filter(l => l && !l.startsWith('#')));
47
47
  }
48
+ // Write the set if non-empty, otherwise delete the file so state is deterministic.
49
+ // Previously .ragskip could be left stale (last entry removed but file not rewritten);
50
+ // treating both files the same way avoids that drift.
48
51
  function saveLines(filePath, names) {
49
- fs.writeFileSync(filePath, [...names].sort().join('\n') + '\n');
52
+ if (names.size > 0) {
53
+ fs.writeFileSync(filePath, [...names].sort().join('\n') + '\n');
54
+ }
55
+ else if (fs.existsSync(filePath)) {
56
+ fs.unlinkSync(filePath);
57
+ }
50
58
  }
51
59
  function loadConfig(workspaceDir) {
52
60
  return {
@@ -56,8 +64,7 @@ function loadConfig(workspaceDir) {
56
64
  }
57
65
  function saveConfig(workspaceDir, config) {
58
66
  saveLines(path.join(workspaceDir, '.ragprojects'), config.included);
59
- if (config.skipped.size > 0)
60
- saveLines(path.join(workspaceDir, '.ragskip'), config.skipped);
67
+ saveLines(path.join(workspaceDir, '.ragskip'), config.skipped);
61
68
  }
62
69
  async function selectProjects(allProjects, workspaceDir, forceSelect) {
63
70
  const config = loadConfig(workspaceDir);
@@ -1,5 +1,4 @@
1
- export type { Relations } from './file-utils';
2
- export type { ProjectMeta } from './file-utils';
1
+ import { type ProjectMeta } from './file-utils';
3
2
  export interface ChunkRow {
4
3
  serviceName: string;
5
4
  filePath: string;
@@ -20,9 +19,10 @@ export type FileRangeResult = {
20
19
  startLine: number;
21
20
  endLine: number;
22
21
  lines: string[];
22
+ hasMore: boolean;
23
23
  } | {
24
24
  ok: false;
25
- reason: 'not_indexed' | 'not_on_disk';
25
+ reason: 'not_indexed' | 'not_on_disk' | 'invalid_path';
26
26
  };
27
27
  export interface DependencyRow {
28
28
  fromFile: string;
@@ -37,7 +37,6 @@ export declare function prepareQuery(raw: string): Promise<{
37
37
  query: string;
38
38
  translated: boolean;
39
39
  }>;
40
- import type { ProjectMeta } from './file-utils';
41
40
  export interface ProjectRow extends ProjectMeta {
42
41
  serviceName: string;
43
42
  chunks: number;
@@ -55,4 +54,21 @@ export declare function grepSymbol(symbol: string, project: string, excludeFile:
55
54
  matches: GrepMatch[];
56
55
  truncated: boolean;
57
56
  };
57
+ export type SymbolKind = 'function' | 'class' | 'type' | 'interface' | 'variable' | 'all';
58
+ export interface SymbolHit {
59
+ filePath: string;
60
+ line: number;
61
+ column: number;
62
+ kind: Exclude<SymbolKind, 'all'>;
63
+ signature: string;
64
+ }
65
+ export declare function findSymbol(name: string, project: string | null, kind: SymbolKind): {
66
+ hits: SymbolHit[];
67
+ truncated: boolean;
68
+ };
69
+ export declare function listFiles(relativePath: string, project: string, depth: number, pattern: string | null): {
70
+ tree: string;
71
+ error?: string;
72
+ };
73
+ export declare function readFileAbsolute(absPath: string, maxLines: number): Promise<FileRangeResult>;
58
74
  export declare function readFileRange(filePath: string, startLine: number, endLine: number): Promise<FileRangeResult>;
@@ -40,22 +40,25 @@ exports.fetchNeighbors = fetchNeighbors;
40
40
  exports.getDependencies = getDependencies;
41
41
  exports.getDependents = getDependents;
42
42
  exports.grepSymbol = grepSymbol;
43
+ exports.findSymbol = findSymbol;
44
+ exports.listFiles = listFiles;
45
+ exports.readFileAbsolute = readFileAbsolute;
43
46
  exports.readFileRange = readFileRange;
44
47
  const fs = __importStar(require("fs"));
45
48
  const path = __importStar(require("path"));
49
+ const readline = __importStar(require("readline"));
46
50
  const child_process_1 = require("child_process");
47
- const ollama_1 = require("ollama");
48
51
  const db_1 = require("./db");
49
52
  const config_1 = require("./config");
53
+ const ollama_1 = require("./ollama");
50
54
  const file_utils_1 = require("./file-utils");
51
- const ollama = new ollama_1.Ollama({ host: config_1.config.ollama.host });
52
55
  // Detect non-English input by presence of non-ASCII characters (Cyrillic, etc.)
53
56
  function isNonEnglish(text) {
54
57
  return /[^\x00-\x7F]/.test(text);
55
58
  }
56
59
  // Translate query to English using a local Ollama model
57
60
  async function translateToEnglish(text) {
58
- const response = await ollama.chat({
61
+ const response = await ollama_1.ollama.chat({
59
62
  model: config_1.config.search.translateModel,
60
63
  messages: [{ role: 'user', content: `Translate the following text to English. Return only the translation, no explanations:\n${text}` }],
61
64
  });
@@ -81,7 +84,7 @@ async function listProjects() {
81
84
  }
82
85
  // Embed query and run vector similarity search
83
86
  async function searchChunks(query, limit, project) {
84
- const { embedding } = await ollama.embeddings({ model: config_1.config.ollama.model, prompt: query });
87
+ const { embedding } = await ollama_1.ollama.embeddings({ model: config_1.config.ollama.model, prompt: query });
85
88
  const params = [`[${embedding}]`, config_1.config.search.similarityThreshold, limit];
86
89
  let projectFilter = '';
87
90
  if (project) {
@@ -97,20 +100,32 @@ async function searchChunks(query, limit, project) {
97
100
  ORDER BY embedding <=> $1
98
101
  LIMIT $3`, params);
99
102
  }
100
- // Fetch the chunk immediately before and after each result in the same file (Small-to-Big)
103
+ // Fetch the chunk immediately before and after each result in the same file (Small-to-Big).
104
+ // Single query with LATERAL joins — avoids 2N round-trips.
101
105
  async function fetchNeighbors(results) {
102
- return Promise.all(results.map(async (result) => {
103
- const [prevRows, nextRows] = await Promise.all([
104
- db_1.db.query(`SELECT "serviceName", "filePath", "startLine", content
105
- FROM code_chunk
106
- WHERE "filePath" = $1 AND "startLine" < $2
107
- ORDER BY "startLine" DESC LIMIT 1`, [result.filePath, result.startLine]),
108
- db_1.db.query(`SELECT "serviceName", "filePath", "startLine", content
109
- FROM code_chunk
110
- WHERE "filePath" = $1 AND "startLine" > $2
111
- ORDER BY "startLine" ASC LIMIT 1`, [result.filePath, result.startLine]),
112
- ]);
113
- return { result, prevChunk: prevRows[0] ?? null, nextChunk: nextRows[0] ?? null };
106
+ if (results.length === 0)
107
+ return [];
108
+ const values = results.map((_, i) => `(${i}, $${i * 2 + 1}, $${i * 2 + 2}::int)`).join(',');
109
+ const params = results.flatMap(r => [r.filePath, r.startLine]);
110
+ const rows = await db_1.db.query(`SELECT r.idx,
111
+ prev."serviceName" AS prev_sn, prev."filePath" AS prev_fp, prev."startLine" AS prev_sl, prev.content AS prev_c,
112
+ next."serviceName" AS next_sn, next."filePath" AS next_fp, next."startLine" AS next_sl, next.content AS next_c
113
+ FROM (VALUES ${values}) r(idx, fp, sl)
114
+ LEFT JOIN LATERAL (
115
+ SELECT "serviceName", "filePath", "startLine", content
116
+ FROM code_chunk WHERE "filePath" = r.fp AND "startLine" < r.sl
117
+ ORDER BY "startLine" DESC LIMIT 1
118
+ ) prev ON true
119
+ LEFT JOIN LATERAL (
120
+ SELECT "serviceName", "filePath", "startLine", content
121
+ FROM code_chunk WHERE "filePath" = r.fp AND "startLine" > r.sl
122
+ ORDER BY "startLine" ASC LIMIT 1
123
+ ) next ON true
124
+ ORDER BY r.idx`, params);
125
+ return rows.map((row, i) => ({
126
+ result: results[i],
127
+ prevChunk: row.prev_fp ? { serviceName: row.prev_sn, filePath: row.prev_fp, startLine: row.prev_sl, content: row.prev_c } : null,
128
+ nextChunk: row.next_fp ? { serviceName: row.next_sn, filePath: row.next_fp, startLine: row.next_sl, content: row.next_c } : null,
114
129
  }));
115
130
  }
116
131
  // Return all files imported by filePath (direct or transitive)
@@ -228,15 +243,204 @@ function grepSymbol(symbol, project, excludeFile) {
228
243
  });
229
244
  return { matches: all.slice(0, GREP_MAX_RESULTS), truncated: all.length > GREP_MAX_RESULTS };
230
245
  }
246
+ const FIND_SYMBOL_LIMIT = 50;
247
+ const FIND_SYMBOL_RAW_LIMIT = FIND_SYMBOL_LIMIT * 10;
248
+ function buildFindSymbolPattern(name, kind) {
249
+ const patterns = {
250
+ function: `(export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+${name}[[:space:](<]`,
251
+ class: `(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+${name}[[:space:]{<]`,
252
+ type: `(export[[:space:]]+)?type[[:space:]]+${name}[[:space:]=<]`,
253
+ interface: `(export[[:space:]]+)?interface[[:space:]]+${name}[[:space:]{<]`,
254
+ variable: `(export[[:space:]]+)?(const|let|var)[[:space:]]+${name}[[:space:]=:]`,
255
+ };
256
+ if (kind === 'all')
257
+ return Object.values(patterns).join('|');
258
+ return patterns[kind];
259
+ }
260
+ function inferKind(line, name) {
261
+ const before = line.slice(0, line.indexOf(name));
262
+ if (/\bfunction\s+$/.test(before))
263
+ return 'function';
264
+ if (/\bclass\s+$/.test(before))
265
+ return 'class';
266
+ if (/\binterface\s+$/.test(before))
267
+ return 'interface';
268
+ if (/\btype\s+$/.test(before))
269
+ return 'type';
270
+ return 'variable';
271
+ }
272
+ function findSymbol(name, project, kind) {
273
+ if (!/^[\w$]+$/.test(name))
274
+ throw new Error('Invalid symbol name');
275
+ const searchDir = project
276
+ ? path.join(config_1.config.workspaceRoot, project)
277
+ : config_1.config.workspaceRoot;
278
+ const pattern = buildFindSymbolPattern(name, kind);
279
+ const result = (0, child_process_1.spawnSync)('grep', [
280
+ '-rn', '-E',
281
+ '-m', String(FIND_SYMBOL_RAW_LIMIT),
282
+ '--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
283
+ ...GREP_EXCLUDE_DIRS,
284
+ pattern,
285
+ searchDir,
286
+ ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
287
+ if (result.error) {
288
+ console.error(`[findSymbol] grep failed: ${result.error.message}`);
289
+ return { hits: [], truncated: false };
290
+ }
291
+ if (!result.stdout)
292
+ return { hits: [], truncated: false };
293
+ const hits = [];
294
+ for (const raw of result.stdout.split('\n')) {
295
+ if (hits.length >= FIND_SYMBOL_LIMIT)
296
+ break;
297
+ // grep -n format: "/abs/path/file.ts:42: export function Foo(...)"
298
+ // File paths can contain colons on some systems, so split carefully:
299
+ // find first colon after a path separator, use lastIndexOf on the line number part
300
+ const m = raw.match(/^(.+?):(\d+):(.*)$/);
301
+ if (!m)
302
+ continue;
303
+ const [, absFile, lineNumStr, matchedLine] = m;
304
+ const lineNum = parseInt(lineNumStr, 10);
305
+ if (isNaN(lineNum))
306
+ continue;
307
+ const col = matchedLine.indexOf(name);
308
+ if (col === -1)
309
+ continue;
310
+ hits.push({
311
+ filePath: path.relative(config_1.config.workspaceRoot, absFile),
312
+ line: lineNum,
313
+ column: col + 1,
314
+ kind: inferKind(matchedLine, name),
315
+ signature: matchedLine.trim(),
316
+ });
317
+ }
318
+ return { hits, truncated: hits.length >= FIND_SYMBOL_LIMIT };
319
+ }
320
+ // ── list_files ────────────────────────────────────────────────────────────────
321
+ function walkDir(absDir, currentDepth, maxDepth, pattern, lines) {
322
+ let entries;
323
+ try {
324
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
325
+ }
326
+ catch {
327
+ return;
328
+ }
329
+ entries.sort((a, b) => {
330
+ const ad = a.isDirectory() ? 0 : 1;
331
+ const bd = b.isDirectory() ? 0 : 1;
332
+ if (ad !== bd)
333
+ return ad - bd;
334
+ return a.name.localeCompare(b.name);
335
+ });
336
+ for (const entry of entries) {
337
+ if (file_utils_1.IGNORE_DIRS.has(entry.name))
338
+ continue;
339
+ const indent = ' '.repeat(currentDepth);
340
+ if (entry.isDirectory()) {
341
+ lines.push(`${indent}${entry.name}/`);
342
+ if (currentDepth < maxDepth) {
343
+ walkDir(path.join(absDir, entry.name), currentDepth + 1, maxDepth, pattern, lines);
344
+ }
345
+ }
346
+ else {
347
+ if (pattern && !entry.name.includes(pattern))
348
+ continue;
349
+ lines.push(`${indent}${entry.name}`);
350
+ }
351
+ }
352
+ }
353
+ function listFiles(relativePath, project, depth, pattern) {
354
+ const projectRoot = path.join(config_1.config.workspaceRoot, project);
355
+ const resolvedTarget = path.resolve(projectRoot, relativePath);
356
+ const resolvedProject = path.resolve(projectRoot);
357
+ if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + path.sep)) {
358
+ return { tree: '', error: 'Path escapes project root' };
359
+ }
360
+ if (!fs.existsSync(resolvedTarget)) {
361
+ return { tree: '', error: `Path not found: ${relativePath}` };
362
+ }
363
+ if (!fs.statSync(resolvedTarget).isDirectory()) {
364
+ return { tree: '', error: `Not a directory: ${relativePath}` };
365
+ }
366
+ const clampedDepth = Math.min(5, Math.max(1, depth));
367
+ const lines = [];
368
+ walkDir(resolvedTarget, 0, clampedDepth - 1, pattern, lines);
369
+ if (lines.length === 0) {
370
+ return { tree: pattern ? `No files matching "${pattern}" found.` : '(empty directory)' };
371
+ }
372
+ return { tree: lines.join('\n') };
373
+ }
374
+ // ── readFileAbsolute ──────────────────────────────────────────────────────────
375
+ // Variant of readFileRange that accepts an absolute path and skips the DB index check.
376
+ // Used by the read_file MCP tool, which receives absolute paths from search_codebase.
377
+ async function readFileAbsolute(absPath, maxLines) {
378
+ const resolvedRoot = path.resolve(config_1.config.workspaceRoot);
379
+ const resolved = path.resolve(absPath);
380
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
381
+ return { ok: false, reason: 'invalid_path' };
382
+ }
383
+ if (!fs.existsSync(resolved))
384
+ return { ok: false, reason: 'not_on_disk' };
385
+ const lines = [];
386
+ const stream = fs.createReadStream(resolved, { encoding: 'utf-8' });
387
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
388
+ let lineNo = 0;
389
+ let hasMore = false;
390
+ try {
391
+ for await (const line of rl) {
392
+ lineNo++;
393
+ if (lineNo <= maxLines)
394
+ lines.push(line);
395
+ if (lineNo >= maxLines) {
396
+ hasMore = true;
397
+ rl.close();
398
+ stream.destroy();
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ catch (err) {
404
+ rl.close();
405
+ stream.destroy();
406
+ throw err;
407
+ }
408
+ const relPath = path.relative(resolvedRoot, resolved);
409
+ return { ok: true, filePath: relPath, startLine: 1, endLine: lineNo, lines, hasMore };
410
+ }
231
411
  // Read a line range from a file that is present in the index
232
412
  async function readFileRange(filePath, startLine, endLine) {
413
+ // Defensive: reject paths that escape the workspace root (e.g. "../../etc/passwd")
414
+ const resolvedRoot = path.resolve(config_1.config.workspaceRoot);
415
+ const absPath = path.resolve(resolvedRoot, filePath);
416
+ if (absPath !== resolvedRoot && !absPath.startsWith(resolvedRoot + path.sep)) {
417
+ return { ok: false, reason: 'invalid_path' };
418
+ }
233
419
  const [{ exists }] = await db_1.db.query(`SELECT EXISTS (SELECT 1 FROM code_chunk WHERE "filePath" = $1) AS exists`, [filePath]);
234
420
  if (!exists)
235
421
  return { ok: false, reason: 'not_indexed' };
236
- const absPath = path.join(config_1.config.workspaceRoot, filePath);
237
422
  if (!fs.existsSync(absPath))
238
423
  return { ok: false, reason: 'not_on_disk' };
239
- const all = fs.readFileSync(absPath, 'utf-8').split('\n');
240
- const lines = all.slice(startLine - 1, endLine);
241
- return { ok: true, filePath, startLine, endLine: Math.min(endLine, all.length), lines };
424
+ // Stream line-by-line — avoids loading entire file into memory.
425
+ // Stops reading as soon as we pass endLine.
426
+ const lines = [];
427
+ const stream = fs.createReadStream(absPath, { encoding: 'utf-8' });
428
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
429
+ let lineNo = 0;
430
+ let lastRead = 0;
431
+ let hasMore = false;
432
+ for await (const line of rl) {
433
+ lineNo++;
434
+ lastRead = lineNo;
435
+ if (lineNo >= startLine && lineNo <= endLine)
436
+ lines.push(line);
437
+ if (lineNo >= endLine) {
438
+ hasMore = true;
439
+ rl.close();
440
+ stream.destroy();
441
+ break;
442
+ }
443
+ }
444
+ // If we broke out early, lastRead is endLine. If file was shorter, lastRead is file length.
445
+ return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
242
446
  }
package/dist/src/setup.js CHANGED
@@ -39,6 +39,7 @@ const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const os = __importStar(require("os"));
41
41
  const child_process_1 = require("child_process");
42
+ const constants_1 = require("./constants");
42
43
  const CONFIG_DIR = path.join(os.homedir(), '.config', 'codesearch-mcp');
43
44
  const CONFIG_FILE = path.join(CONFIG_DIR, '.env');
44
45
  const DOCKER_COMPOSE_SRC = path.join(__dirname, '..', '..', 'docker', 'docker-compose.yml');
@@ -133,8 +134,18 @@ async function setupLocalDocker(ollamaHost) {
133
134
  }
134
135
  await waitForDb('localhost', 5433, 'dev', 'dev', 'rag');
135
136
  await waitForOllama(ollamaHost);
136
- console.log('Pulling bge-m3 model (this may take a while)...');
137
- (0, child_process_1.spawnSync)('docker', ['exec', 'codesearch-mcp-ollama-1', 'ollama', 'pull', 'bge-m3'], { stdio: 'inherit' });
137
+ // Resolve the ollama container ID via compose — compose project name (and thus the
138
+ // default container name) depends on the parent directory, so hardcoding it breaks when
139
+ // users install via npm -g or move the compose file around.
140
+ const ollamaId = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'ps', '-q', 'ollama'], { encoding: 'utf-8' })
141
+ .stdout?.trim();
142
+ if (!ollamaId) {
143
+ console.error('Could not resolve ollama container ID from compose. Pull the model manually:');
144
+ console.error(` docker exec <ollama-container> ollama pull ${constants_1.DEFAULT_EMBED_MODEL}`);
145
+ process.exit(1);
146
+ }
147
+ console.log(`Pulling ${constants_1.DEFAULT_EMBED_MODEL} model (this may take a while)...`);
148
+ (0, child_process_1.spawnSync)('docker', ['exec', ollamaId, 'ollama', 'pull', constants_1.DEFAULT_EMBED_MODEL], { stdio: 'inherit' });
138
149
  return {
139
150
  DB_HOST: 'localhost',
140
151
  DB_PORT: '5433',
@@ -166,13 +177,12 @@ async function setupRemoteDb(rl) {
166
177
  async function runSetup() {
167
178
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
168
179
  console.log('=== codesearch-mcp setup ===\n');
169
- const dbChoice = await ask(rl, 'Database type:\n 1. Local Docker (auto-start)\n 2. Remote (Supabase, Railway, self-hosted)\n\nEnter choice (1): ');
180
+ const dbChoice = await ask(rl, 'Infrastructure:\n 1. Local Docker (auto-start PostgreSQL + Ollama)\n 2. Remote (Supabase, Railway, self-hosted DB + local Ollama)\n\nEnter choice (1): ');
170
181
  const useDocker = (dbChoice.trim() || '1') === '1';
171
182
  const defaultWorkspace = path.join(os.homedir(), 'Projects');
172
183
  const workspaceInput = await ask(rl, `\nWorkspace root — directory that contains all your projects\n Example: ${os.homedir()}/WebstormProjects\n Path (${defaultWorkspace}): `);
173
184
  const workspaceRoot = expandHome(workspaceInput.trim() || defaultWorkspace);
174
185
  const ollamaHost = await prompt(rl, 'Ollama API URL', 'http://localhost:11434');
175
- const ollamaModel = await prompt(rl, 'Embedding model', 'bge-m3');
176
186
  const dbVars = useDocker
177
187
  ? await setupLocalDocker(ollamaHost)
178
188
  : await setupRemoteDb(rl);
@@ -180,7 +190,6 @@ async function runSetup() {
180
190
  ...dbVars,
181
191
  WORKSPACE_ROOT: workspaceRoot,
182
192
  OLLAMA_HOST: ollamaHost,
183
- OLLAMA_MODEL: ollamaModel,
184
193
  });
185
194
  rl.close();
186
195
  console.log('\nInitializing database schema...');
@@ -7,6 +7,8 @@ services:
7
7
  POSTGRES_DB: rag
8
8
  ports:
9
9
  - "5433:5432"
10
+ volumes:
11
+ - ${HOME}/.local/share/codesearch-mcp/pgdata:/var/lib/postgresql/data
10
12
 
11
13
  ollama:
12
14
  image: ollama/ollama
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codesearch-mcp",
3
- "version": "0.0.7-alpha",
3
+ "version": "0.0.9-alpha",
4
4
  "description": "Local semantic codebase search via MCP — indexes projects into pgvector, exposes search to Claude",
5
5
  "license": "MIT",
6
6
  "main": "dist/app.js",