milens 0.6.2 → 0.6.3

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.
Files changed (96) hide show
  1. package/README.md +157 -14
  2. package/dist/analyzer/engine.d.ts +1 -0
  3. package/dist/analyzer/engine.d.ts.map +1 -1
  4. package/dist/analyzer/engine.js +27 -8
  5. package/dist/analyzer/engine.js.map +1 -1
  6. package/dist/analyzer/review.d.ts +23 -0
  7. package/dist/analyzer/review.d.ts.map +1 -0
  8. package/dist/analyzer/review.js +143 -0
  9. package/dist/analyzer/review.js.map +1 -0
  10. package/dist/analyzer/testplan.d.ts +59 -0
  11. package/dist/analyzer/testplan.d.ts.map +1 -0
  12. package/dist/analyzer/testplan.js +218 -0
  13. package/dist/analyzer/testplan.js.map +1 -0
  14. package/dist/cli.js +2 -0
  15. package/dist/cli.js.map +1 -1
  16. package/dist/gateway/analyzer.d.ts +6 -0
  17. package/dist/gateway/analyzer.d.ts.map +1 -0
  18. package/dist/gateway/analyzer.js +218 -0
  19. package/dist/gateway/analyzer.js.map +1 -0
  20. package/dist/gateway/cache.d.ts +35 -0
  21. package/dist/gateway/cache.d.ts.map +1 -0
  22. package/dist/gateway/cache.js +175 -0
  23. package/dist/gateway/cache.js.map +1 -0
  24. package/dist/gateway/config.d.ts +10 -0
  25. package/dist/gateway/config.d.ts.map +1 -0
  26. package/dist/gateway/config.js +167 -0
  27. package/dist/gateway/config.js.map +1 -0
  28. package/dist/gateway/context-memory.d.ts +68 -0
  29. package/dist/gateway/context-memory.d.ts.map +1 -0
  30. package/dist/gateway/context-memory.js +157 -0
  31. package/dist/gateway/context-memory.js.map +1 -0
  32. package/dist/gateway/observability.d.ts +83 -0
  33. package/dist/gateway/observability.d.ts.map +1 -0
  34. package/dist/gateway/observability.js +152 -0
  35. package/dist/gateway/observability.js.map +1 -0
  36. package/dist/gateway/privacy.d.ts +27 -0
  37. package/dist/gateway/privacy.d.ts.map +1 -0
  38. package/dist/gateway/privacy.js +139 -0
  39. package/dist/gateway/privacy.js.map +1 -0
  40. package/dist/gateway/providers.d.ts +66 -0
  41. package/dist/gateway/providers.d.ts.map +1 -0
  42. package/dist/gateway/providers.js +377 -0
  43. package/dist/gateway/providers.js.map +1 -0
  44. package/dist/gateway/router.d.ts +18 -0
  45. package/dist/gateway/router.d.ts.map +1 -0
  46. package/dist/gateway/router.js +102 -0
  47. package/dist/gateway/router.js.map +1 -0
  48. package/dist/gateway/server.d.ts +20 -0
  49. package/dist/gateway/server.d.ts.map +1 -0
  50. package/dist/gateway/server.js +387 -0
  51. package/dist/gateway/server.js.map +1 -0
  52. package/dist/gateway/translator.d.ts +19 -0
  53. package/dist/gateway/translator.d.ts.map +1 -0
  54. package/dist/gateway/translator.js +340 -0
  55. package/dist/gateway/translator.js.map +1 -0
  56. package/dist/gateway/types.d.ts +215 -0
  57. package/dist/gateway/types.d.ts.map +1 -0
  58. package/dist/gateway/types.js +3 -0
  59. package/dist/gateway/types.js.map +1 -0
  60. package/dist/parser/extract.d.ts +1 -0
  61. package/dist/parser/extract.d.ts.map +1 -1
  62. package/dist/parser/extract.js +8 -0
  63. package/dist/parser/extract.js.map +1 -1
  64. package/dist/parser/lang-go.d.ts.map +1 -1
  65. package/dist/parser/lang-go.js +41 -5
  66. package/dist/parser/lang-go.js.map +1 -1
  67. package/dist/parser/lang-java.d.ts.map +1 -1
  68. package/dist/parser/lang-java.js +1 -0
  69. package/dist/parser/lang-java.js.map +1 -1
  70. package/dist/parser/lang-py.d.ts.map +1 -1
  71. package/dist/parser/lang-py.js +22 -0
  72. package/dist/parser/lang-py.js.map +1 -1
  73. package/dist/parser/lang-ruby.d.ts.map +1 -1
  74. package/dist/parser/lang-ruby.js +1 -0
  75. package/dist/parser/lang-ruby.js.map +1 -1
  76. package/dist/server/mcp.d.ts.map +1 -1
  77. package/dist/server/mcp.js +615 -106
  78. package/dist/server/mcp.js.map +1 -1
  79. package/dist/skills.js +32 -0
  80. package/dist/skills.js.map +1 -1
  81. package/dist/store/db.d.ts +44 -0
  82. package/dist/store/db.d.ts.map +1 -1
  83. package/dist/store/db.js +142 -25
  84. package/dist/store/db.js.map +1 -1
  85. package/dist/store/gateway-schema.sql +53 -0
  86. package/dist/store/schema.sql +33 -0
  87. package/dist/store/vectors.d.ts +65 -0
  88. package/dist/store/vectors.d.ts.map +1 -0
  89. package/dist/store/vectors.js +212 -0
  90. package/dist/store/vectors.js.map +1 -0
  91. package/dist/utils.d.ts +3 -0
  92. package/dist/utils.d.ts.map +1 -0
  93. package/dist/utils.js +9 -0
  94. package/dist/utils.js.map +1 -0
  95. package/docs/diagram2.svg +1 -1
  96. package/package.json +2 -1
@@ -6,11 +6,16 @@ import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { resolve, relative, join, dirname } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
9
+ import { readFileSync, mkdirSync } from 'node:fs';
10
+ import { readdir, readFile, stat } from 'node:fs/promises';
10
11
  import { homedir } from 'node:os';
11
12
  import ignore from 'ignore';
12
13
  import { Database } from '../store/db.js';
13
14
  import { RepoRegistry } from '../store/registry.js';
15
+ import { isTestFile } from '../utils.js';
16
+ import { reviewPr, reviewSymbol } from '../analyzer/review.js';
17
+ import { generateTestPlan, findCoverageGaps, analyzeTestImpact } from '../analyzer/testplan.js';
18
+ import { TfIdfProvider, EmbeddingStore, buildEmbeddingText } from '../store/vectors.js';
14
19
  import { getParser, loadLanguage } from '../parser/loader.js';
15
20
  import { ALL_LANGS } from '../parser/languages.js';
16
21
  import { fileURLToPath } from 'node:url';
@@ -26,6 +31,8 @@ class LazyDb {
26
31
  statsCache = null;
27
32
  domainCache = null;
28
33
  static CACHE_TTL = 30_000; // 30s TTL
34
+ // Cached TF-IDF provider (trained once per DB session, reused across semantic_search/find_similar calls)
35
+ tfidfCache = null;
29
36
  constructor(dbPath) {
30
37
  this.dbPath = dbPath;
31
38
  }
@@ -62,6 +69,19 @@ class LazyDb {
62
69
  invalidateCache() {
63
70
  this.statsCache = null;
64
71
  this.domainCache = null;
72
+ this.tfidfCache = null;
73
+ }
74
+ /** Get or build a TF-IDF provider + embedding store, trained on the corpus. */
75
+ getTfidf() {
76
+ if (this.tfidfCache)
77
+ return this.tfidfCache;
78
+ const db = this.get();
79
+ const provider = new TfIdfProvider();
80
+ const allSyms = db.getAllSymbols();
81
+ provider.trainIdf(allSyms.map(s => buildEmbeddingText({ name: s.name, kind: s.kind, filePath: s.filePath, signature: s.signature })));
82
+ const store = new EmbeddingStore(db.getRawDb(), provider.dimensions);
83
+ this.tfidfCache = { provider, store };
84
+ return this.tfidfCache;
65
85
  }
66
86
  resetTimer() {
67
87
  if (this.timer)
@@ -74,6 +94,7 @@ class LazyDb {
74
94
  this.timer = null;
75
95
  this.statsCache = null;
76
96
  this.domainCache = null;
97
+ this.tfidfCache = null;
77
98
  }
78
99
  shutdown() {
79
100
  if (this.timer)
@@ -97,6 +118,21 @@ const TOKEN_SAVINGS_MULTIPLIER = {
97
118
  domains: 3, // vs exploring file structure
98
119
  status: 2, // vs checking multiple stats
99
120
  detect_changes: 4, // vs git diff + manual symbol mapping
121
+ review_pr: 10, // vs detect_changes + impact per symbol + coverage check
122
+ review_symbol: 5, // vs edit_check + impact + coverage
123
+ test_plan: 7, // vs context + outgoing links + mock analysis
124
+ test_coverage_gaps: 5, // vs scanning all symbols + checking test refs
125
+ test_impact: 6, // vs detect_changes + upstream traversal + test file mapping
126
+ annotate: 2, // simple write
127
+ recall: 3, // vs manual grep for notes
128
+ session_start: 1, // simple write
129
+ session_context: 2, // session lookup
130
+ handoff: 3, // session transfer
131
+ codebase_summary: 8, // vs domains + status + query for top symbols
132
+ semantic_search: 5, // vs multiple query + grep calls to find related code
133
+ find_similar: 4, // vs manual comparison of symbol signatures
134
+ ast_explore: 2, // vs reading raw AST manually
135
+ test_query: 2, // vs writing SQL + checking schema
100
136
  explain_relationship: 4, // vs manual path finding
101
137
  find_dead_code: 3, // vs manual export usage search
102
138
  get_file_symbols: 2, // vs reading entire file
@@ -158,14 +194,6 @@ function fmtImpact(items, detail = 'L1') {
158
194
  }
159
195
  return lines.join('\n');
160
196
  }
161
- /** Check if a file path looks like a test/spec file */
162
- function isTestFilePath(filePath) {
163
- return /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
164
- /^tests?[/\\]/.test(filePath) ||
165
- /__tests__[/\\]/.test(filePath) ||
166
- /_test\.(go|py|rb|rs|java|php)$/.test(filePath) ||
167
- /^test_.*\.py$/.test(filePath.split('/').pop() ?? '');
168
- }
169
197
  // ── Text grep across project files ──
170
198
  const GREP_SKIP_DIRS = new Set([
171
199
  'node_modules', '.git', 'dist', 'build', 'out',
@@ -184,7 +212,7 @@ const BINARY_EXTENSIONS = new Set([
184
212
  '.wasm', '.node', '.so', '.dll', '.dylib',
185
213
  '.lock',
186
214
  ]);
187
- function grepFiles(rootPath, pattern, options) {
215
+ async function grepFiles(rootPath, pattern, options) {
188
216
  const { isRegex = false, caseSensitive = false, maxResults = 50, includePattern } = options;
189
217
  const flags = caseSensitive ? '' : 'i';
190
218
  let regex;
@@ -197,12 +225,12 @@ function grepFiles(rootPath, pattern, options) {
197
225
  const ig = loadGrepIgnoreRules(rootPath);
198
226
  const includeRe = includePattern ? globToRegex(includePattern) : null;
199
227
  const results = [];
200
- function walk(dir) {
228
+ async function walk(dir) {
201
229
  if (results.length >= maxResults)
202
230
  return;
203
231
  let entries;
204
232
  try {
205
- entries = readdirSync(dir);
233
+ entries = await readdir(dir);
206
234
  }
207
235
  catch {
208
236
  return;
@@ -218,26 +246,26 @@ function grepFiles(rootPath, pattern, options) {
218
246
  continue;
219
247
  if (ig.ignores(rel))
220
248
  continue;
221
- let stat;
249
+ let st;
222
250
  try {
223
- stat = statSync(abs);
251
+ st = await stat(abs);
224
252
  }
225
253
  catch {
226
254
  continue;
227
255
  }
228
- if (stat.isDirectory()) {
229
- walk(abs);
256
+ if (st.isDirectory()) {
257
+ await walk(abs);
230
258
  }
231
- else if (stat.isFile()) {
259
+ else if (st.isFile()) {
232
260
  const ext = '.' + entry.split('.').pop()?.toLowerCase();
233
261
  if (BINARY_EXTENSIONS.has(ext))
234
262
  continue;
235
- if (stat.size > 512 * 1024)
263
+ if (st.size > 512 * 1024)
236
264
  continue; // skip files > 512KB
237
265
  if (includeRe && !includeRe.test(rel))
238
266
  continue;
239
267
  try {
240
- const content = readFileSync(abs, 'utf-8');
268
+ const content = await readFile(abs, 'utf-8');
241
269
  const lines = content.split('\n');
242
270
  for (let i = 0; i < lines.length && results.length < maxResults; i++) {
243
271
  if (regex.test(lines[i])) {
@@ -249,7 +277,7 @@ function grepFiles(rootPath, pattern, options) {
249
277
  }
250
278
  }
251
279
  }
252
- walk(rootPath);
280
+ await walk(rootPath);
253
281
  return results;
254
282
  }
255
283
  function escapeRegExp(s) {
@@ -259,7 +287,9 @@ function escapeRegExp(s) {
259
287
  function matchesScope(lineText, scope) {
260
288
  const trimmed = lineText.trimStart();
261
289
  if (scope === 'imports') {
262
- return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed);
290
+ return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed)
291
+ || /^"[^"]*"\s*$/.test(trimmed) // Go import block line: "fmt"
292
+ || /^\w+\s+"[^"]*"\s*$/.test(trimmed); // Go aliased import: alias "pkg"
263
293
  }
264
294
  // definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
265
295
  return /^(export\s+)?(async\s+)?(function|class|interface|type|enum|struct|trait|const|let|var|def|fn|pub\s+fn|pub\s+struct|pub\s+enum|module)\s/.test(trimmed);
@@ -268,8 +298,12 @@ function matchesScope(lineText, scope) {
268
298
  function safeRegex(pattern, flags) {
269
299
  if (pattern.length > 200)
270
300
  throw new Error('Pattern too long');
271
- // Reject nested quantifiers like (a+)+, (a*)*, (a{1,})+
272
- if (/([+*}])\)?[+*{]/.test(pattern))
301
+ // Reject nested quantifiers: quantifier applied to a group that contains a quantifier
302
+ // e.g. (a+)+, (a*)+, (a{2,})+, (?:a+)*, (.+)+
303
+ if (/([+*}])\s*\)\s*[+*?{]/.test(pattern))
304
+ throw new Error('Unsafe regex pattern');
305
+ // Reject quantifier directly after quantifier: a++, a*+, a+{2}, but allow lazy quantifiers: a+?, a*?, a??
306
+ if (/[+*}]\s*[+*{]/.test(pattern))
273
307
  throw new Error('Unsafe regex pattern');
274
308
  // Reject overlapping alternation inside quantified groups: (a|a)*, (ab|a)+
275
309
  if (/\((?:[^)]*\|[^)]*)\)[+*{]/.test(pattern))
@@ -292,12 +326,26 @@ function safeRegex(pattern, flags) {
292
326
  return new RegExp(pattern, flags);
293
327
  }
294
328
  function globToRegex(glob) {
295
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
329
+ // Extract brace patterns {a,b,c} as placeholders BEFORE escaping
330
+ const braceGroups = [];
331
+ let expanded = glob.replace(/\{([^}]+)\}/g, (_, inner) => {
332
+ const alts = inner.split(',').map(s => s.trim());
333
+ const idx = braceGroups.length;
334
+ braceGroups.push(alts.map(a => a.replace(/[.+^$|[\]\\]/g, '\\$&')).join('|'));
335
+ return `§BRACE${idx}§`;
336
+ });
337
+ const escaped = expanded
338
+ .replace(/[.+^$|[\]\\]/g, '\\$&')
296
339
  .replace(/\*\*/g, '§STARSTAR§')
297
340
  .replace(/\*/g, '[^/]*')
298
341
  .replace(/§STARSTAR§/g, '.*')
299
342
  .replace(/\?/g, '.');
300
- return new RegExp(`^${escaped}$`, 'i');
343
+ // Restore brace groups after escaping
344
+ let result = escaped;
345
+ for (let i = 0; i < braceGroups.length; i++) {
346
+ result = result.replace(`§BRACE${i}§`, `(${braceGroups[i]})`);
347
+ }
348
+ return new RegExp(`^${result}$`, 'i');
301
349
  }
302
350
  function loadGrepIgnoreRules(rootPath) {
303
351
  const ig = ignore();
@@ -320,11 +368,24 @@ const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codeba
320
368
  - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
321
369
  - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
322
370
  - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
323
- - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
371
+ - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Django, Go, Gin, PHP, Rails, Sinatra, Spring)
324
372
  - \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
325
373
  - \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
326
374
  - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
327
375
  - \`detect_changes\` — git diff → affected symbols
376
+ - \`review_pr\` — PR risk assessment: blast radius + test coverage per changed symbol → LOW/MEDIUM/HIGH/CRITICAL
377
+ - \`review_symbol\` — quick single-symbol risk: role, heat, dependents, test coverage, risk level
378
+ - \`test_plan\` — dependency-aware test plan: deps to mock, mock strategies (stub/spy/fake), suggested tests
379
+ - \`test_coverage_gaps\` — untested exported symbols sorted by risk (hub functions first)
380
+ - \`test_impact\` — which tests to run for current changes (maps changed symbols → test files)
381
+ - \`annotate\` — store observations/notes about a symbol (persists across sessions)
382
+ - \`recall\` — retrieve annotations (filter by symbol, key, agent, session)
383
+ - \`session_start\` — register agent session for multi-agent coordination
384
+ - \`session_context\` — get session metadata + annotations
385
+ - \`handoff\` — transfer context between agent sessions
386
+ - \`codebase_summary\` — high-level bootstrapping context: domains, key symbols, coverage, annotations
387
+ - \`semantic_search\` — hybrid search: FTS5 + vector cosine similarity (requires --embeddings during analyze)
388
+ - \`find_similar\` — find symbols similar to a given symbol by vector embedding proximity
328
389
  - \`explain_relationship\` — shortest path between two symbols
329
390
  - \`find_dead_code\` — unused exports
330
391
  - \`get_file_symbols\` — all symbols in a file
@@ -333,7 +394,9 @@ const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codeba
333
394
  ## Rules
334
395
  - Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
335
396
  - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
336
- - For writing tests: run \`smart_context\` with intent=test shows deps to mock + callers to cover
397
+ - For writing tests: run \`test_plan\` for structured test plan, or \`smart_context\` with intent=test
398
+ - For PR review: run \`review_pr\` for overall risk, \`review_symbol\` for single symbol assessment
399
+ - For agent bootstrapping: run \`codebase_summary\` as the first tool call in a new session
337
400
  - \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
338
401
  - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
339
402
  - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
@@ -439,7 +502,7 @@ export function createMcpServer(rootPath) {
439
502
  const effectiveInclude = scope === 'code' && !include
440
503
  ? '**/*.{ts,tsx,js,jsx,mjs,cjs,vue,py,go,rs,java,php,rb}'
441
504
  : include;
442
- const matches = grepFiles(root, pattern, {
505
+ const matches = await grepFiles(root, pattern, {
443
506
  isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
444
507
  });
445
508
  // Apply scope-specific line filtering
@@ -654,7 +717,7 @@ export function createMcpServer(rootPath) {
654
717
  }
655
718
  }
656
719
  // Section 4: Grep (text references across all files)
657
- const grepMatches = grepFiles(root, name, { maxResults: 20 });
720
+ const grepMatches = await grepFiles(root, name, { maxResults: 20 });
658
721
  if (grepMatches.length > 0) {
659
722
  const grouped = new Map();
660
723
  for (const m of grepMatches) {
@@ -692,19 +755,24 @@ export function createMcpServer(rootPath) {
692
755
  try {
693
756
  const dbPath = registry.findDbPath(entry.rootPath);
694
757
  if (dbPath) {
695
- const tempDb = pools.has(entry.rootPath)
758
+ const fromPool = pools.has(entry.rootPath);
759
+ const tempDb = fromPool
696
760
  ? pools.get(entry.rootPath).get()
697
761
  : new Database(dbPath);
698
- const summary = tempDb.getRepoSummary();
699
- lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
700
- if (summary.domains.length > 0) {
701
- lines.push(` domains: ${summary.domains.join(', ')}`);
762
+ try {
763
+ const summary = tempDb.getRepoSummary();
764
+ lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
765
+ if (summary.domains.length > 0) {
766
+ lines.push(` domains: ${summary.domains.join(', ')}`);
767
+ }
768
+ if (summary.staleCount > 0) {
769
+ lines.push(` ⏳ ${summary.staleCount} stale files`);
770
+ }
702
771
  }
703
- if (summary.staleCount > 0) {
704
- lines.push(` ⏳ ${summary.staleCount} stale files`);
772
+ finally {
773
+ if (!fromPool)
774
+ tempDb.close();
705
775
  }
706
- if (!pools.has(entry.rootPath))
707
- tempDb.close();
708
776
  }
709
777
  }
710
778
  catch {
@@ -726,8 +794,9 @@ export function createMcpServer(rootPath) {
726
794
  }
727
795
  let changedFiles;
728
796
  try {
797
+ // Show both staged and unstaged changes against the ref
729
798
  const output = execFileSync('git', ['diff', '--name-only', ref], { cwd: root, encoding: 'utf-8' });
730
- const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: root, encoding: 'utf-8' });
799
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only', ref], { cwd: root, encoding: 'utf-8' });
731
800
  changedFiles = [...new Set([...output.trim().split('\n'), ...staged.trim().split('\n')])].filter(Boolean);
732
801
  }
733
802
  catch {
@@ -756,6 +825,343 @@ export function createMcpServer(rootPath) {
756
825
  lines.push(`\nTotal direct dependents affected: ${totalAffected}`);
757
826
  return { content: [{ type: 'text', text: lines.join('\n') }] };
758
827
  });
828
+ // ── Tool: review_pr ──
829
+ server.tool('review_pr', 'PR risk assessment: analyzes changed files, scores each symbol by blast radius, test coverage, and role. Returns overall risk level (LOW/MEDIUM/HIGH/CRITICAL) with hotspots and recommendations.', {
830
+ ref: z.string().optional().default('HEAD').describe('Git ref to diff (default: HEAD)'),
831
+ base: z.string().optional().describe('Base ref to compare against (e.g. main, develop)'),
832
+ repo: z.string().optional(),
833
+ }, async ({ ref, repo, base }) => {
834
+ const { db, root } = getDb(repo);
835
+ const result = reviewPr(db, root, ref, base);
836
+ if (result.symbols.length === 0) {
837
+ return { content: [{ type: 'text', text: result.summary }] };
838
+ }
839
+ const lines = [
840
+ `## PR Risk: ${result.risk} (score: ${result.score})`,
841
+ '',
842
+ result.summary,
843
+ '',
844
+ ];
845
+ if (result.hotspots.length > 0) {
846
+ lines.push(`### Hotspots (${result.hotspots.length})\n`);
847
+ for (const h of result.hotspots) {
848
+ const tested = h.tested ? '✓ tested' : '⚠ untested';
849
+ lines.push(`${fmtSymbol(h.symbol)} → ${h.dependents} dependents, ${tested} [${h.riskLevel}]`);
850
+ if (h.reasons.length > 0)
851
+ lines.push(` reasons: ${h.reasons.join(', ')}`);
852
+ }
853
+ lines.push('');
854
+ }
855
+ // Show remaining non-hotspot symbols (compact)
856
+ const nonHotspots = result.symbols.filter(s => s.riskLevel !== 'HIGH' && s.riskLevel !== 'CRITICAL');
857
+ if (nonHotspots.length > 0) {
858
+ lines.push(`### Other changed symbols (${nonHotspots.length})\n`);
859
+ for (const s of nonHotspots.slice(0, 20)) {
860
+ const tested = s.tested ? '✓' : '⚠';
861
+ lines.push(`${tested} ${s.symbol.name} [${s.symbol.kind}] ${s.symbol.filePath}:${s.symbol.startLine} → ${s.dependents} deps [${s.riskLevel}]`);
862
+ }
863
+ if (nonHotspots.length > 20)
864
+ lines.push(` ... and ${nonHotspots.length - 20} more`);
865
+ lines.push('');
866
+ }
867
+ if (result.untestedChanges > 0) {
868
+ lines.push(`⚠ ${result.untestedChanges} exported symbols changed without test coverage`);
869
+ }
870
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
871
+ });
872
+ // ── Tool: review_symbol ──
873
+ server.tool('review_symbol', 'Quick risk assessment for a single symbol: role, heat, dependents count, test coverage, risk level.', {
874
+ name: z.string().describe('Symbol name to assess'),
875
+ repo: z.string().optional(),
876
+ }, async ({ name, repo }) => {
877
+ const { db } = getDb(repo);
878
+ const result = reviewSymbol(db, name);
879
+ if (!result) {
880
+ return { content: [{ type: 'text', text: `"${name}" not found in index. Try \`grep\`.` }] };
881
+ }
882
+ const tested = result.tested ? '✓ tested' : '⚠ untested';
883
+ const lines = [
884
+ `${fmtSymbol(result.symbol)}${result.symbol.exported ? ' (exported)' : ''}`,
885
+ `risk: ${result.riskLevel} (score: ${result.riskScore})`,
886
+ `role: ${result.symbol.role ?? 'unknown'}, heat: ${result.symbol.heat ?? 0}`,
887
+ `dependents: ${result.dependents}, ${tested}`,
888
+ ];
889
+ if (result.reasons.length > 0) {
890
+ lines.push(`reasons: ${result.reasons.join(', ')}`);
891
+ }
892
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
893
+ });
894
+ // ── Tool: test_plan ──
895
+ server.tool('test_plan', 'Generate a structured test plan for a symbol: dependencies to mock, mock strategies (stub/spy/fake), suggested unit/integration/edge-case tests.', {
896
+ name: z.string().describe('Symbol name to generate test plan for'),
897
+ repo: z.string().optional(),
898
+ }, async ({ name, repo }) => {
899
+ const { db } = getDb(repo);
900
+ const plan = generateTestPlan(db, name);
901
+ if (!plan) {
902
+ return { content: [{ type: 'text', text: `"${name}" not found in index. Try \`grep\`.` }] };
903
+ }
904
+ const lines = [
905
+ `## Test Plan: ${plan.target.name} [${plan.target.kind}]`,
906
+ `file: ${plan.target.filePath}${plan.target.role ? `, role: ${plan.target.role}` : ''}`,
907
+ '',
908
+ ];
909
+ if (plan.existingTests.length > 0) {
910
+ lines.push(`### Existing tests`);
911
+ for (const t of plan.existingTests)
912
+ lines.push(` ✓ ${t}`);
913
+ lines.push('');
914
+ }
915
+ else {
916
+ lines.push(`### Existing tests: none\n`);
917
+ }
918
+ if (plan.dependencies.length > 0) {
919
+ lines.push(`### Dependencies (${plan.dependencies.length})`);
920
+ for (const dep of plan.dependencies) {
921
+ lines.push(` ${dep.name} [${dep.kind}] ${dep.filePath} (${dep.linkType})`);
922
+ }
923
+ lines.push('');
924
+ }
925
+ if (plan.mockSuggestions.length > 0) {
926
+ lines.push(`### Mock Suggestions`);
927
+ for (const m of plan.mockSuggestions) {
928
+ lines.push(` ${m.dependency}: ${m.strategy} — ${m.reason}`);
929
+ }
930
+ lines.push('');
931
+ }
932
+ if (plan.suggestedTests.length > 0) {
933
+ lines.push(`### Suggested Tests`);
934
+ for (const t of plan.suggestedTests) {
935
+ lines.push(` [${t.type}] ${t.description}`);
936
+ if (t.mocksNeeded.length > 0)
937
+ lines.push(` mocks: ${t.mocksNeeded.join(', ')}`);
938
+ }
939
+ lines.push('');
940
+ }
941
+ if (plan.callers.length > 0) {
942
+ lines.push(`### Callers (for integration context)`);
943
+ for (const c of plan.callers.slice(0, 10)) {
944
+ lines.push(` ${c.name} [${c.kind}] ${c.filePath}`);
945
+ }
946
+ if (plan.callers.length > 10)
947
+ lines.push(` ... and ${plan.callers.length - 10} more`);
948
+ }
949
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
950
+ });
951
+ // ── Tool: test_coverage_gaps ──
952
+ server.tool('test_coverage_gaps', 'Find exported symbols without test coverage, sorted by risk (hub functions first). Shows what most needs tests.', {
953
+ file: z.string().optional().describe('Scope to a specific file (relative to repo root)'),
954
+ limit: z.number().optional().default(30),
955
+ repo: z.string().optional(),
956
+ }, async ({ file, limit, repo }) => {
957
+ const { db } = getDb(repo);
958
+ const gaps = findCoverageGaps(db, file, limit);
959
+ if (gaps.length === 0) {
960
+ return { content: [{ type: 'text', text: file ? `No coverage gaps in "${file}".` : 'No untested exported symbols found.' }] };
961
+ }
962
+ const lines = [`${gaps.length} untested exported symbols:\n`];
963
+ for (const g of gaps) {
964
+ lines.push(`[${g.riskIfUntested}] ${fmtSymbol(g.symbol)} → ${g.dependents} dependents`);
965
+ }
966
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
967
+ });
968
+ // ── Tool: test_impact ──
969
+ server.tool('test_impact', 'Which tests to run for current changes? Maps changed symbols → test files that reference them (directly or via callers).', {
970
+ ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
971
+ repo: z.string().optional(),
972
+ }, async ({ ref, repo }) => {
973
+ const { db, root } = getDb(repo);
974
+ const result = analyzeTestImpact(db, root, ref);
975
+ if (result.changedSymbols.length === 0 && result.mustRun.length === 0) {
976
+ return { content: [{ type: 'text', text: 'No changed symbols detected.' }] };
977
+ }
978
+ const lines = [];
979
+ if (result.mustRun.length > 0) {
980
+ lines.push(`### Must Run (${result.mustRun.length})`);
981
+ for (const f of result.mustRun)
982
+ lines.push(` ✓ ${f}`);
983
+ lines.push('');
984
+ }
985
+ if (result.shouldRun.length > 0) {
986
+ lines.push(`### Should Run (${result.shouldRun.length}) — indirect coverage`);
987
+ for (const f of result.shouldRun)
988
+ lines.push(` ~ ${f}`);
989
+ lines.push('');
990
+ }
991
+ if (result.coverageGaps.length > 0) {
992
+ lines.push(`### Coverage Gaps (${result.coverageGaps.length})`);
993
+ for (const g of result.coverageGaps) {
994
+ lines.push(` ⚠ ${g.name} (${g.filePath}) — ${g.dependents} dependents, no test`);
995
+ }
996
+ lines.push('');
997
+ }
998
+ lines.push(`${result.changedSymbols.length} symbols changed, ${result.mustRun.length} tests must run, ${result.shouldRun.length} tests should run`);
999
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1000
+ });
1001
+ // ── Tool: annotate ──
1002
+ server.tool('annotate', 'Store an observation/note about a symbol. Annotations persist across sessions and are visible via context() and recall().', {
1003
+ symbol: z.string().describe('Symbol name to annotate'),
1004
+ key: z.string().describe('Annotation key (e.g. "perf", "todo", "note", "risk")'),
1005
+ value: z.string().describe('Annotation value/note'),
1006
+ agent: z.string().optional().describe('Agent name that created this annotation'),
1007
+ session_id: z.string().optional().describe('Session ID for grouping'),
1008
+ ttl_hours: z.number().optional().describe('Time-to-live in hours (default: permanent)'),
1009
+ repo: z.string().optional(),
1010
+ }, async ({ symbol, key, value, agent, session_id, ttl_hours, repo }) => {
1011
+ const { db } = getDb(repo);
1012
+ const syms = db.findSymbolByName(symbol);
1013
+ if (syms.length === 0) {
1014
+ return { content: [{ type: 'text', text: `"${symbol}" not found in index.` }] };
1015
+ }
1016
+ const sym = syms[0];
1017
+ const id = db.addAnnotation(sym.id, key, value, agent, session_id, ttl_hours);
1018
+ return { content: [{ type: 'text', text: `Annotation #${id} stored: ${sym.name}.${key} = "${value}"` }] };
1019
+ });
1020
+ // ── Tool: recall ──
1021
+ server.tool('recall', 'Retrieve annotations/notes about symbols. Filter by symbol, key, agent, or session.', {
1022
+ symbol: z.string().optional().describe('Symbol name to filter by'),
1023
+ key: z.string().optional().describe('Annotation key to filter by'),
1024
+ agent: z.string().optional().describe('Agent name to filter by'),
1025
+ session_id: z.string().optional().describe('Session ID to filter by'),
1026
+ limit: z.number().optional().default(30),
1027
+ repo: z.string().optional(),
1028
+ }, async ({ symbol, key, agent, session_id, limit, repo }) => {
1029
+ const { db } = getDb(repo);
1030
+ let symbolId;
1031
+ if (symbol) {
1032
+ const syms = db.findSymbolByName(symbol);
1033
+ if (syms.length > 0)
1034
+ symbolId = syms[0].id;
1035
+ }
1036
+ const annotations = db.getAnnotations({ symbolId, key, agent, sessionId: session_id, limit });
1037
+ if (annotations.length === 0) {
1038
+ return { content: [{ type: 'text', text: 'No annotations found.' }] };
1039
+ }
1040
+ const lines = [`${annotations.length} annotations:\n`];
1041
+ for (const a of annotations) {
1042
+ const agentTag = a.agent ? ` [${a.agent}]` : '';
1043
+ lines.push(`#${a.id} ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"${agentTag} (${a.createdAt})`);
1044
+ }
1045
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1046
+ });
1047
+ // ── Tool: session_start ──
1048
+ server.tool('session_start', 'Register a new agent session for multi-agent coordination. Returns session ID.', {
1049
+ agent: z.string().describe('Agent name/identifier'),
1050
+ context: z.string().optional().describe('Initial context JSON for the session'),
1051
+ repo: z.string().optional(),
1052
+ }, async ({ agent, context, repo }) => {
1053
+ const { db } = getDb(repo);
1054
+ const id = randomUUID();
1055
+ db.startSession(id, agent, context);
1056
+ return { content: [{ type: 'text', text: `Session started: ${id} (agent: ${agent})` }] };
1057
+ });
1058
+ // ── Tool: session_context ──
1059
+ server.tool('session_context', 'Get full context for an agent session: metadata + all annotations created during it.', {
1060
+ session_id: z.string().describe('Session ID'),
1061
+ repo: z.string().optional(),
1062
+ }, async ({ session_id, repo }) => {
1063
+ const { db } = getDb(repo);
1064
+ const session = db.getSession(session_id);
1065
+ if (!session) {
1066
+ return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
1067
+ }
1068
+ const annotations = db.getAnnotations({ sessionId: session_id, limit: 100 });
1069
+ const lines = [
1070
+ `## Session: ${session.id}`,
1071
+ `agent: ${session.agent}, status: ${session.status}`,
1072
+ `started: ${session.startedAt}${session.endedAt ? `, ended: ${session.endedAt}` : ''}`,
1073
+ ];
1074
+ if (session.context)
1075
+ lines.push(`context: ${session.context}`);
1076
+ if (annotations.length > 0) {
1077
+ lines.push('', `### Annotations (${annotations.length})`);
1078
+ for (const a of annotations) {
1079
+ lines.push(` ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"`);
1080
+ }
1081
+ }
1082
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1083
+ });
1084
+ // ── Tool: handoff ──
1085
+ server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one with carried-over context.', {
1086
+ from_session: z.string().describe('Source session ID to transfer from'),
1087
+ to_agent: z.string().describe('Target agent name'),
1088
+ context: z.string().optional().describe('Additional context to pass'),
1089
+ repo: z.string().optional(),
1090
+ }, async ({ from_session, to_agent, context, repo }) => {
1091
+ const { db } = getDb(repo);
1092
+ const fromSession = db.getSession(from_session);
1093
+ if (!fromSession) {
1094
+ return { content: [{ type: 'text', text: `Source session "${from_session}" not found.` }] };
1095
+ }
1096
+ // End source session
1097
+ db.endSession(from_session, 'completed');
1098
+ // Gather annotations from source
1099
+ const annotations = db.getAnnotations({ sessionId: from_session, limit: 100 });
1100
+ // Build handoff context
1101
+ const handoffContext = JSON.stringify({
1102
+ from: { session: from_session, agent: fromSession.agent },
1103
+ original_context: fromSession.context ? (() => { try {
1104
+ return JSON.parse(fromSession.context);
1105
+ }
1106
+ catch {
1107
+ return fromSession.context;
1108
+ } })() : null,
1109
+ additional_context: context ?? null,
1110
+ annotations_count: annotations.length,
1111
+ });
1112
+ // Create new session
1113
+ const newId = randomUUID();
1114
+ db.startSession(newId, to_agent, handoffContext);
1115
+ return { content: [{ type: 'text', text: `Handoff complete: ${fromSession.agent} → ${to_agent}\nNew session: ${newId}\nCarried: ${annotations.length} annotations` }] };
1116
+ });
1117
+ // ── Tool: codebase_summary ──
1118
+ server.tool('codebase_summary', 'High-level codebase context for agent bootstrapping: domains, key symbols, recent activity, active annotations. Designed as the first tool call for new agent sessions.', {
1119
+ repo: z.string().optional(),
1120
+ }, async ({ repo }) => {
1121
+ const { db, root, lazy } = getDb(repo);
1122
+ const stats = lazy.getCachedStats();
1123
+ const domains = lazy.getCachedDomainStats();
1124
+ const coverage = db.getTestCoverage();
1125
+ const lines = [
1126
+ `## Codebase Summary`,
1127
+ `${stats.symbols} symbols, ${stats.links} links, ${stats.files} files`,
1128
+ '',
1129
+ ];
1130
+ // Domains
1131
+ if (domains.length > 0) {
1132
+ lines.push(`### Domains`);
1133
+ for (const d of domains) {
1134
+ lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
1135
+ }
1136
+ lines.push('');
1137
+ }
1138
+ // Top symbols by heat
1139
+ const allSyms = db.getTopSymbolsByHeat(10);
1140
+ if (allSyms.length > 0) {
1141
+ lines.push(`### Key Symbols (top 10 by heat)`);
1142
+ for (const s of allSyms) {
1143
+ lines.push(` ${fmtSymbol(s, 'L2')}`);
1144
+ }
1145
+ lines.push('');
1146
+ }
1147
+ // Test coverage
1148
+ if (coverage.exportedProductionSymbols > 0) {
1149
+ const pct = Math.round((coverage.testedSymbols / coverage.exportedProductionSymbols) * 100);
1150
+ lines.push(`### Test Coverage: ${pct}% (${coverage.testedSymbols}/${coverage.exportedProductionSymbols} exported symbols tested)`);
1151
+ lines.push('');
1152
+ }
1153
+ // Active annotations
1154
+ const annotations = db.getAnnotations({ limit: 10 });
1155
+ if (annotations.length > 0) {
1156
+ lines.push(`### Recent Annotations (${annotations.length})`);
1157
+ for (const a of annotations) {
1158
+ const agentTag = a.agent ? ` [${a.agent}]` : '';
1159
+ lines.push(` ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"${agentTag}`);
1160
+ }
1161
+ lines.push('');
1162
+ }
1163
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1164
+ });
759
1165
  // ── Tool: explain_relationship ──
760
1166
  server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
761
1167
  from: z.string().describe('Source symbol name'),
@@ -806,10 +1212,13 @@ export function createMcpServer(rootPath) {
806
1212
  const sorted = detail === 'L2'
807
1213
  ? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
808
1214
  : symbols;
1215
+ // Batch-fetch link counts to avoid N+1 queries
1216
+ const linkCounts = detail === 'L0'
1217
+ ? new Map()
1218
+ : db.getLinkCountsForSymbols(sorted.map(s => s.id));
809
1219
  const lines = [`${file}: ${symbols.length} symbols\n`];
810
1220
  for (const sym of sorted) {
811
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
812
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1221
+ const counts = linkCounts.get(sym.id) ?? { incoming: 0, outgoing: 0 };
813
1222
  const exp = sym.exported ? ' (exported)' : '';
814
1223
  if (detail === 'L0') {
815
1224
  lines.push(`${sym.name} [${sym.kind}]${exp}`);
@@ -821,10 +1230,10 @@ export function createMcpServer(rootPath) {
821
1230
  if (sym.heat != null && sym.heat > 0)
822
1231
  meta.push(`heat:${sym.heat}`);
823
1232
  const metaStr = meta.length > 0 ? ` {${meta.join(',')}}` : '';
824
- lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1233
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
825
1234
  }
826
1235
  else {
827
- lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1236
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
828
1237
  }
829
1238
  }
830
1239
  return { content: [{ type: 'text', text: lines.join('\n') }] };
@@ -890,8 +1299,10 @@ export function createMcpServer(rootPath) {
890
1299
  sections.push(`callers: none`);
891
1300
  }
892
1301
  // 3. Export chain — is this re-exported from barrel files?
893
- const grepMatches = grepFiles(root, name, { maxResults: 10, includePattern: '**/index.{ts,js,mjs}' });
894
- const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
1302
+ const grepMatches = await grepFiles(root, name, { maxResults: 10, includePattern: '{**/index.{ts,js,mjs},**/__init__.py}' });
1303
+ const reExportMatches = grepMatches.filter(m => (/export\s*\{[^}]*/.test(m.text) && m.text.includes('from')) ||
1304
+ /from\s+\./.test(m.text) // Python re-export: from .module import X
1305
+ );
895
1306
  if (reExportMatches.length > 0) {
896
1307
  sections.push(`re-exported via:`);
897
1308
  for (const m of reExportMatches) {
@@ -906,30 +1317,27 @@ export function createMcpServer(rootPath) {
906
1317
  sections.push(` ${fmtSymbol(d)}`);
907
1318
  }
908
1319
  }
909
- }
910
- // 5. Unresolved warning (only for internal)
911
- const unresolved = db.getUnresolvedStats();
912
- if (unresolved.imports > 0 || unresolved.calls > 0) {
913
- sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
914
- }
915
- // 6. Test coverage for this symbol
916
- for (const sym of symbols) {
917
- const incoming = db.getIncomingLinks(sym.id);
918
- const testRefs = incoming.filter(l => {
1320
+ // 5. Test coverage (reuse incoming from step 2)
1321
+ const allIncoming = db.getIncomingLinks(sym.id);
1322
+ const testFiles = new Set();
1323
+ for (const l of allIncoming) {
919
1324
  const from = db.findSymbolById(l.fromId);
920
- return from && isTestFilePath(from.filePath);
921
- });
922
- if (testRefs.length > 0) {
923
- const testFiles = [...new Set(testRefs.map(l => {
924
- const from = db.findSymbolById(l.fromId);
925
- return from?.filePath;
926
- }).filter(Boolean))];
927
- sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1325
+ if (from && isTestFile(from.filePath)) {
1326
+ testFiles.add(from.filePath);
1327
+ }
1328
+ }
1329
+ if (testFiles.size > 0) {
1330
+ sections.push(`✓ tested from: ${[...testFiles].join(', ')}`);
928
1331
  }
929
1332
  else if (sym.exported) {
930
1333
  sections.push(`⚠ no test coverage for this exported symbol`);
931
1334
  }
932
1335
  }
1336
+ // 6. Unresolved warning (only for internal)
1337
+ const unresolved = db.getUnresolvedStats();
1338
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
1339
+ sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
1340
+ }
933
1341
  return { content: [{ type: 'text', text: sections.join('\n') }] };
934
1342
  });
935
1343
  // ── Tool: trace ──
@@ -991,9 +1399,9 @@ export function createMcpServer(rootPath) {
991
1399
  return { content: [{ type: 'text', text: sections.join('\n') }] };
992
1400
  });
993
1401
  // ── Tool: routes ──
994
- server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
1402
+ server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Django, Go HTTP, Gin, PHP, Rails, Sinatra, Spring patterns.', {
995
1403
  repo: z.string().optional(),
996
- framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
1404
+ framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, django, go, gin, php, rails, sinatra, spring). Default: auto-detect all.'),
997
1405
  limit: z.number().optional().default(50),
998
1406
  }, async ({ repo, framework, limit }) => {
999
1407
  const root = resolveRoot(repo);
@@ -1003,20 +1411,24 @@ export function createMcpServer(rootPath) {
1003
1411
  { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
1004
1412
  { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1005
1413
  { name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1414
+ { name: 'django', pattern: /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1006
1415
  { name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
1007
1416
  { name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1417
+ { name: 'gin', pattern: /\b\w+\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|Any|Handle)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1008
1418
  { name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
1009
1419
  { name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
1420
+ { name: 'sinatra', pattern: /\b(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s+do/, fileGlob: '**/*.rb' },
1421
+ { name: 'spring', pattern: /@(RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?['"]([^'"]+)['"]/, fileGlob: '**/*.java' },
1010
1422
  ];
1011
1423
  const activePatterns = framework
1012
1424
  ? routePatterns.filter(p => p.name === framework.toLowerCase())
1013
1425
  : routePatterns;
1014
1426
  if (activePatterns.length === 0) {
1015
- return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
1427
+ return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, django, go, gin, php, rails, sinatra, spring` }] };
1016
1428
  }
1017
1429
  const routes = [];
1018
1430
  for (const rp of activePatterns) {
1019
- const matches = grepFiles(root, rp.pattern.source, {
1431
+ const matches = await grepFiles(root, rp.pattern.source, {
1020
1432
  isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
1021
1433
  });
1022
1434
  for (const m of matches) {
@@ -1129,15 +1541,15 @@ export function createMcpServer(rootPath) {
1129
1541
  }
1130
1542
  }
1131
1543
  // Re-export detection
1132
- const reExportMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' })
1133
- .filter(m => /export\s*\{/.test(m.text) && m.text.includes('from'));
1544
+ const reExportMatches = (await grepFiles(root, name, { maxResults: 5, includePattern: '{**/index.{ts,js,mjs},**/__init__.py}' }))
1545
+ .filter(m => (/export\s*\{/.test(m.text) && m.text.includes('from')) || /from\s+\./.test(m.text));
1134
1546
  if (reExportMatches.length > 0) {
1135
1547
  sections.push(`re-exported via: ${reExportMatches.map(m => `${m.file}:${m.line}`).join(', ')}`);
1136
1548
  }
1137
1549
  // Test coverage
1138
1550
  const testRefs = incoming.filter(l => {
1139
1551
  const from = db.findSymbolById(l.fromId);
1140
- return from && isTestFilePath(from.filePath);
1552
+ return from && isTestFile(from.filePath);
1141
1553
  });
1142
1554
  if (testRefs.length > 0) {
1143
1555
  sections.push(`✓ has test coverage`);
@@ -1159,17 +1571,18 @@ export function createMcpServer(rootPath) {
1159
1571
  else {
1160
1572
  sections.push(`no call chains found (may be entrypoint or unreachable)`);
1161
1573
  }
1162
- // What does this call? (downstream immediate)
1163
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type === 'calls');
1164
- if (outgoing.length > 0) {
1165
- sections.push(`calls (${outgoing.length}):`);
1166
- for (const l of outgoing) {
1574
+ // Fetch outgoing once, split by type
1575
+ const allOutgoing = db.getOutgoingLinks(sym.id);
1576
+ const callLinks = allOutgoing.filter(l => l.type === 'calls');
1577
+ if (callLinks.length > 0) {
1578
+ sections.push(`calls (${callLinks.length}):`);
1579
+ for (const l of callLinks) {
1167
1580
  const to = db.findSymbolById(l.toId);
1168
1581
  sections.push(` ${to ? fmtSymbol(to) : l.toId}`);
1169
1582
  }
1170
1583
  }
1171
- // Data types used
1172
- const dataTypes = db.getOutgoingLinks(sym.id)
1584
+ // Data types used (reuse allOutgoing)
1585
+ const dataTypes = allOutgoing
1173
1586
  .filter(l => l.type === 'imports')
1174
1587
  .map(l => db.findSymbolById(l.toId))
1175
1588
  .filter(s => s && (s.kind === 'interface' || s.kind === 'type' || s.kind === 'class'))
@@ -1179,47 +1592,33 @@ export function createMcpServer(rootPath) {
1179
1592
  }
1180
1593
  }
1181
1594
  else if (intent === 'test') {
1182
- // Test coverage + what to mock
1595
+ // Test coverage + what to mock — pre-resolve all link symbols
1183
1596
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1184
- const testRefs = incoming.filter(l => {
1185
- const from = db.findSymbolById(l.fromId);
1186
- return from && isTestFilePath(from.filePath);
1187
- });
1597
+ const incomingResolved = incoming.map(l => ({ link: l, sym: db.findSymbolById(l.fromId) }));
1598
+ const testRefs = incomingResolved.filter(r => r.sym && isTestFile(r.sym.filePath));
1188
1599
  if (testRefs.length > 0) {
1189
- const testFiles = [...new Set(testRefs.map(l => {
1190
- const from = db.findSymbolById(l.fromId);
1191
- return from?.filePath;
1192
- }).filter(Boolean))];
1600
+ const testFiles = [...new Set(testRefs.map(r => r.sym.filePath))];
1193
1601
  sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1194
1602
  }
1195
1603
  else {
1196
1604
  sections.push(`⚠ no existing tests`);
1197
1605
  }
1198
- // Dependencies to mock
1606
+ // Dependencies to mock — pre-resolve outgoing symbols
1199
1607
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1200
- const externalDeps = outgoing.filter(l => {
1201
- const to = db.findSymbolById(l.toId);
1202
- return to && to.filePath !== sym.filePath;
1203
- });
1608
+ const outgoingResolved = outgoing.map(l => ({ link: l, sym: db.findSymbolById(l.toId) }));
1609
+ const externalDeps = outgoingResolved.filter(r => r.sym && r.sym.filePath !== sym.filePath);
1204
1610
  if (externalDeps.length > 0) {
1205
1611
  sections.push(`dependencies to mock (${externalDeps.length}):`);
1206
- for (const l of externalDeps) {
1207
- const to = db.findSymbolById(l.toId);
1208
- if (to)
1209
- sections.push(` ${l.type}: ${fmtSymbol(to)}`);
1612
+ for (const r of externalDeps) {
1613
+ sections.push(` ${r.link.type}: ${fmtSymbol(r.sym)}`);
1210
1614
  }
1211
1615
  }
1212
1616
  // Inputs — what calls this? (test should cover these call patterns)
1213
- const nonTestCallers = incoming.filter(l => {
1214
- const from = db.findSymbolById(l.fromId);
1215
- return from && !isTestFilePath(from.filePath);
1216
- });
1617
+ const nonTestCallers = incomingResolved.filter(r => r.sym && !isTestFile(r.sym.filePath));
1217
1618
  if (nonTestCallers.length > 0) {
1218
1619
  sections.push(`callers to cover (${nonTestCallers.length}):`);
1219
- for (const l of nonTestCallers.slice(0, 5)) {
1220
- const from = db.findSymbolById(l.fromId);
1221
- if (from)
1222
- sections.push(` ${fmtSymbol(from)}`);
1620
+ for (const r of nonTestCallers.slice(0, 5)) {
1621
+ sections.push(` ${fmtSymbol(r.sym)}`);
1223
1622
  }
1224
1623
  }
1225
1624
  }
@@ -1308,6 +1707,116 @@ export function createMcpServer(rootPath) {
1308
1707
  return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
1309
1708
  }
1310
1709
  });
1710
+ // ── Tool: semantic_search ──
1711
+ server.tool('semantic_search', 'Hybrid search combining FTS5 text matching and vector cosine similarity (Reciprocal Rank Fusion). Requires --embeddings flag during analyze. Falls back to FTS5-only if no embeddings exist.', {
1712
+ query: z.string().describe('Natural language or keyword query'),
1713
+ limit: z.number().optional().describe('Max results (default 15)'),
1714
+ repo: z.string().optional(),
1715
+ }, async ({ query, limit, repo }) => {
1716
+ const startMs = Date.now();
1717
+ const maxResults = limit ?? 15;
1718
+ const { db, lazy } = getDb(repo);
1719
+ // FTS5 results
1720
+ const ftsResults = db.searchSymbols(query, maxResults * 2);
1721
+ // Check for embeddings
1722
+ let hasEmbeddings = false;
1723
+ try {
1724
+ const row = db.getRawDb().prepare('SELECT COUNT(*) as c FROM symbol_embeddings').get();
1725
+ hasEmbeddings = row && row.c > 0;
1726
+ }
1727
+ catch { /* table may not exist in old DBs */ }
1728
+ if (!hasEmbeddings) {
1729
+ // FTS-only fallback
1730
+ const lines = ftsResults.slice(0, maxResults).map(s => fmtSymbol(s, 'L2'));
1731
+ const text = lines.length > 0
1732
+ ? `${lines.length} results (FTS only, run \`milens analyze --embeddings\` for hybrid):\n${lines.join('\n')}`
1733
+ : 'No results.';
1734
+ trackToolCall(trackDb, 'semantic_search', startMs, text, repo);
1735
+ return { content: [{ type: 'text', text }] };
1736
+ }
1737
+ // Vector results — use cached TF-IDF provider (trained once per DB session)
1738
+ const { provider, store } = lazy.getTfidf();
1739
+ const queryVec = await provider.embed(query);
1740
+ const vectorResults = store.searchSimilar(queryVec, maxResults * 2);
1741
+ // Reciprocal Rank Fusion (k=60)
1742
+ const k = 60;
1743
+ const scores = new Map();
1744
+ ftsResults.forEach((sym, rank) => {
1745
+ const rrf = 1 / (k + rank + 1);
1746
+ const entry = scores.get(sym.id) ?? { score: 0, symbol: sym };
1747
+ entry.score += rrf;
1748
+ scores.set(sym.id, entry);
1749
+ });
1750
+ vectorResults.forEach((vr, rank) => {
1751
+ const rrf = 1 / (k + rank + 1);
1752
+ const entry = scores.get(vr.symbolId);
1753
+ if (entry) {
1754
+ entry.score += rrf;
1755
+ }
1756
+ else {
1757
+ const sym = db.findSymbolById(vr.symbolId);
1758
+ if (sym)
1759
+ scores.set(vr.symbolId, { score: rrf, symbol: sym });
1760
+ }
1761
+ });
1762
+ const ranked = [...scores.entries()]
1763
+ .sort((a, b) => b[1].score - a[1].score)
1764
+ .slice(0, maxResults);
1765
+ const lines = ranked.map(([, { score, symbol }]) => `${fmtSymbol(symbol, 'L2')} (score: ${score.toFixed(4)})`);
1766
+ const text = lines.length > 0
1767
+ ? `${lines.length} results (hybrid FTS+vector):\n${lines.join('\n')}`
1768
+ : 'No results.';
1769
+ trackToolCall(trackDb, 'semantic_search', startMs, text, repo);
1770
+ return { content: [{ type: 'text', text }] };
1771
+ });
1772
+ // ── Tool: find_similar ──
1773
+ server.tool('find_similar', 'Find symbols similar to a given symbol by vector embedding proximity. Requires --embeddings flag during analyze.', {
1774
+ name: z.string().describe('Symbol name to find similar symbols for'),
1775
+ limit: z.number().optional().describe('Max results (default 10)'),
1776
+ repo: z.string().optional(),
1777
+ }, async ({ name, limit, repo }) => {
1778
+ const startMs = Date.now();
1779
+ const maxResults = limit ?? 10;
1780
+ const { db, lazy } = getDb(repo);
1781
+ const syms = db.findSymbolByName(name);
1782
+ if (syms.length === 0) {
1783
+ const text = `Symbol "${name}" not found.`;
1784
+ trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1785
+ return { content: [{ type: 'text', text }] };
1786
+ }
1787
+ const targetSym = syms[0];
1788
+ // Check for embeddings
1789
+ let hasEmbeddings = false;
1790
+ try {
1791
+ const row = db.getRawDb().prepare('SELECT COUNT(*) as c FROM symbol_embeddings').get();
1792
+ hasEmbeddings = row && row.c > 0;
1793
+ }
1794
+ catch { /* table may not exist */ }
1795
+ if (!hasEmbeddings) {
1796
+ const text = 'No embeddings found. Run `milens analyze --embeddings` first.';
1797
+ trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1798
+ return { content: [{ type: 'text', text }] };
1799
+ }
1800
+ // Use cached TF-IDF provider (trained once per DB session)
1801
+ const { provider, store } = lazy.getTfidf();
1802
+ // Get or compute target embedding
1803
+ let targetVec = store.get(targetSym.id);
1804
+ if (!targetVec) {
1805
+ targetVec = await provider.embed(buildEmbeddingText({
1806
+ name: targetSym.name, kind: targetSym.kind, filePath: targetSym.filePath, signature: targetSym.signature,
1807
+ }));
1808
+ }
1809
+ const similar = store.searchSimilar(targetVec, maxResults, targetSym.id);
1810
+ const lines = [`Similar to ${fmtSymbol(targetSym, 'L1')}:\n`];
1811
+ for (const { symbolId, score } of similar) {
1812
+ const sym = db.findSymbolById(symbolId);
1813
+ if (sym)
1814
+ lines.push(` ${(score * 100).toFixed(1)}% ${fmtSymbol(sym, 'L2')}`);
1815
+ }
1816
+ const text = lines.join('\n');
1817
+ trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1818
+ return { content: [{ type: 'text', text }] };
1819
+ });
1311
1820
  // ══════════════════════════════════════════════
1312
1821
  // ── MCP Resources ──
1313
1822
  // ══════════════════════════════════════════════
@@ -1350,11 +1859,11 @@ export function createMcpServer(rootPath) {
1350
1859
  return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
1351
1860
  }
1352
1861
  const lines = [`${filePath}: ${symbols.length} symbols\n`];
1862
+ const linkCounts = db.getLinkCountsForSymbols(symbols.map(s => s.id));
1353
1863
  for (const sym of symbols) {
1354
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1355
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1864
+ const counts = linkCounts.get(sym.id) ?? { incoming: 0, outgoing: 0 };
1356
1865
  const exp = sym.exported ? ' (exported)' : '';
1357
- lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1866
+ lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
1358
1867
  }
1359
1868
  return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1360
1869
  });