milens 0.4.1 → 0.4.2

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 (58) hide show
  1. package/LICENSE +75 -75
  2. package/README.md +479 -453
  3. package/dist/analyzer/config.d.ts +8 -0
  4. package/dist/analyzer/config.d.ts.map +1 -0
  5. package/dist/analyzer/config.js +132 -0
  6. package/dist/analyzer/config.js.map +1 -0
  7. package/dist/analyzer/engine.d.ts.map +1 -1
  8. package/dist/analyzer/engine.js +77 -4
  9. package/dist/analyzer/engine.js.map +1 -1
  10. package/dist/analyzer/enrich.d.ts +18 -0
  11. package/dist/analyzer/enrich.d.ts.map +1 -0
  12. package/dist/analyzer/enrich.js +139 -0
  13. package/dist/analyzer/enrich.js.map +1 -0
  14. package/dist/analyzer/resolver.d.ts +10 -1
  15. package/dist/analyzer/resolver.d.ts.map +1 -1
  16. package/dist/analyzer/resolver.js +309 -18
  17. package/dist/analyzer/resolver.js.map +1 -1
  18. package/dist/cli.js +474 -31
  19. package/dist/cli.js.map +1 -1
  20. package/dist/parser/extract.d.ts +2 -0
  21. package/dist/parser/extract.d.ts.map +1 -1
  22. package/dist/parser/extract.js +27 -3
  23. package/dist/parser/extract.js.map +1 -1
  24. package/dist/parser/lang-go.js +22 -22
  25. package/dist/parser/lang-go.js.map +1 -1
  26. package/dist/parser/lang-java.d.ts.map +1 -1
  27. package/dist/parser/lang-java.js +29 -25
  28. package/dist/parser/lang-java.js.map +1 -1
  29. package/dist/parser/lang-js.d.ts.map +1 -1
  30. package/dist/parser/lang-js.js +60 -43
  31. package/dist/parser/lang-js.js.map +1 -1
  32. package/dist/parser/lang-php.d.ts.map +1 -1
  33. package/dist/parser/lang-php.js +39 -33
  34. package/dist/parser/lang-php.js.map +1 -1
  35. package/dist/parser/lang-py.js +31 -31
  36. package/dist/parser/lang-ruby.d.ts +4 -0
  37. package/dist/parser/lang-ruby.d.ts.map +1 -0
  38. package/dist/parser/lang-ruby.js +50 -0
  39. package/dist/parser/lang-ruby.js.map +1 -0
  40. package/dist/parser/lang-rust.js +24 -24
  41. package/dist/parser/lang-ts.d.ts.map +1 -1
  42. package/dist/parser/lang-ts.js +73 -57
  43. package/dist/parser/lang-ts.js.map +1 -1
  44. package/dist/parser/languages.d.ts.map +1 -1
  45. package/dist/parser/languages.js +2 -1
  46. package/dist/parser/languages.js.map +1 -1
  47. package/dist/server/mcp.d.ts.map +1 -1
  48. package/dist/server/mcp.js +860 -98
  49. package/dist/server/mcp.js.map +1 -1
  50. package/dist/skills.js +100 -100
  51. package/dist/store/db.d.ts +62 -0
  52. package/dist/store/db.d.ts.map +1 -1
  53. package/dist/store/db.js +244 -59
  54. package/dist/store/db.js.map +1 -1
  55. package/dist/store/schema.sql +83 -60
  56. package/dist/types.d.ts +14 -0
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +60 -60
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
@@ -6,7 +6,8 @@ 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 } from 'node:fs';
9
+ import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
10
11
  import ignore from 'ignore';
11
12
  import { Database } from '../store/db.js';
12
13
  import { RepoRegistry } from '../store/registry.js';
@@ -45,15 +46,72 @@ class LazyDb {
45
46
  this.instance = null;
46
47
  }
47
48
  }
48
- // ── Compact formatters (token-efficient for AI agents) ──
49
- function fmtSymbol(s) {
50
- return `${s.name} [${s.kind}] ${s.filePath}:${s.startLine}`;
49
+ // ── Tool usage tracking ──
50
+ // Estimated tokens an agent would spend WITHOUT milens (manual exploration cost per tool)
51
+ const TOKEN_SAVINGS_MULTIPLIER = {
52
+ query: 3, // vs 3+ separate grep/file reads
53
+ grep: 2, // vs terminal grep + manual filtering
54
+ context: 5, // vs incoming + outgoing + file reads
55
+ impact: 6, // vs recursive manual upstream/downstream exploration
56
+ edit_check: 8, // vs context + impact + grep + coverage check
57
+ smart_context: 6, // vs multiple tool calls based on intent
58
+ overview: 8, // vs context + impact + grep combined
59
+ trace: 5, // vs manually tracing call chains
60
+ routes: 3, // vs searching for route patterns
61
+ domains: 3, // vs exploring file structure
62
+ status: 2, // vs checking multiple stats
63
+ detect_changes: 4, // vs git diff + manual symbol mapping
64
+ explain_relationship: 4, // vs manual path finding
65
+ find_dead_code: 3, // vs manual export usage search
66
+ get_file_symbols: 2, // vs reading entire file
67
+ get_type_hierarchy: 4, // vs manual inheritance traversal
68
+ repos: 1, // simple listing
69
+ };
70
+ function estimateTokens(text) {
71
+ // Rough token estimate: ~4 chars per token for English/code
72
+ return Math.ceil(text.length / 4);
51
73
  }
52
- function fmtImpact(items) {
74
+ function getTrackingDb() {
75
+ try {
76
+ const dir = join(homedir(), '.milens');
77
+ mkdirSync(dir, { recursive: true });
78
+ return new Database(join(dir, 'tracking.db'));
79
+ }
80
+ catch {
81
+ return null; // tracking is best-effort, never block
82
+ }
83
+ }
84
+ function trackToolCall(trackDb, tool, startMs, responseText, repo) {
85
+ if (!trackDb)
86
+ return;
87
+ try {
88
+ const durationMs = Date.now() - startMs;
89
+ const tokensOut = estimateTokens(responseText);
90
+ const multiplier = TOKEN_SAVINGS_MULTIPLIER[tool] ?? 2;
91
+ const tokensSaved = tokensOut * (multiplier - 1); // net savings = what agent would spend minus what milens returned
92
+ trackDb.logToolUsage(tool, durationMs, tokensOut, tokensSaved, repo);
93
+ }
94
+ catch { /* best-effort */ }
95
+ }
96
+ function fmtSymbol(s, detail = 'L1') {
97
+ const base = `${s.name} [${s.kind}] ${s.filePath}:${s.startLine}`;
98
+ if (detail === 'L0')
99
+ return `${s.name} [${s.kind}]`;
100
+ if (detail === 'L2') {
101
+ const meta = [];
102
+ if (s.role)
103
+ meta.push(s.role);
104
+ if (s.heat != null && s.heat > 0)
105
+ meta.push(`heat:${s.heat}`);
106
+ return meta.length > 0 ? `${base} {${meta.join(',')}}` : base;
107
+ }
108
+ return base;
109
+ }
110
+ function fmtImpact(items, detail = 'L1') {
53
111
  const grouped = new Map();
54
112
  for (const { symbol, depth, via } of items) {
55
113
  const arr = grouped.get(depth) ?? [];
56
- arr.push(`${fmtSymbol(symbol)} (${via})`);
114
+ arr.push(`${fmtSymbol(symbol, detail)} (${via})`);
57
115
  grouped.set(depth, arr);
58
116
  }
59
117
  const lines = [];
@@ -64,6 +122,14 @@ function fmtImpact(items) {
64
122
  }
65
123
  return lines.join('\n');
66
124
  }
125
+ /** Check if a file path looks like a test/spec file */
126
+ function isTestFilePath(filePath) {
127
+ return /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
128
+ /^tests?[/\\]/.test(filePath) ||
129
+ /__tests__[/\\]/.test(filePath) ||
130
+ /_test\.(go|py|rb|rs|java|php)$/.test(filePath) ||
131
+ /^test_.*\.py$/.test(filePath.split('/').pop() ?? '');
132
+ }
67
133
  // ── Text grep across project files ──
68
134
  const GREP_SKIP_DIRS = new Set([
69
135
  'node_modules', '.git', 'dist', 'build', 'out',
@@ -153,6 +219,15 @@ function grepFiles(rootPath, pattern, options) {
153
219
  function escapeRegExp(s) {
154
220
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
155
221
  }
222
+ /** Line-level scope matching for scoped grep */
223
+ function matchesScope(lineText, scope) {
224
+ const trimmed = lineText.trimStart();
225
+ if (scope === 'imports') {
226
+ return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed);
227
+ }
228
+ // definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
229
+ 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);
230
+ }
156
231
  /** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
157
232
  function safeRegex(pattern, flags) {
158
233
  if (pattern.length > 200)
@@ -199,58 +274,70 @@ function loadGrepIgnoreRules(rootPath) {
199
274
  return ig;
200
275
  }
201
276
  // ── Server instructions (sent to client via MCP protocol on initialize) ──
202
- const MILENS_INSTRUCTIONS = `milens is a code intelligence engine. It indexes codebases into knowledge graphs and provides tools for navigating and understanding code.
203
-
204
- ## Critical Workflow Rules
205
-
206
- ### Always combine \`impact\` with \`grep\`
207
- \`impact\` only tracks code-level symbol dependencies (calls, imports, extends).
208
- \`grep\` finds ALL text references including templates, styles, configs, routes, and docs.
209
- **After running \`impact\`, always run \`grep\` for the same symbol to catch non-code references.**
210
-
211
- ### Before editing a symbol
212
- 1. Run \`context\` to see all incoming/outgoing relationships
213
- 2. Run \`impact\` with direction "upstream" to find what depends on it
214
- 3. Run \`grep\` to find ALL text references (templates, SCSS, configs, routes, docs)
215
-
216
- ### When deleting a feature or renaming
217
- 1. Run \`grep\` first finds every text occurrence across all file types
218
- 2. Run \`impact\` — finds code-level dependency graph
219
- 3. Combine both results grep catches what impact misses and vice versa
220
-
221
- ### Tool selection guide
222
- - **Find symbol definitions** → \`query\`
223
- - **Find ALL text references** (templates, styles, configs, docs) \`grep\`
224
- - **Understand a symbol's relationships** \`context\`
225
- - **What breaks if I change this?** \`impact\` (upstream) + \`grep\`
226
- - **What does this call/depend on?** \`impact\` (downstream)
227
- - **How are two symbols connected?** \`explain_relationship\`
228
- - **What changed recently?** \`detect_changes\`
229
- - **Find unused exports** \`find_dead_code\`
230
- - **See all symbols in a file** \`get_file_symbols\`
231
- - **Class inheritance tree** \`get_type_hierarchy\`
232
-
233
- ### \`query\` vs \`grep\` — choosing correctly on first call
234
- - If the search term contains **spaces** or looks like a **UI label/display string** → use \`grep\`
235
- - If the search term is **camelCase/PascalCase/snake_case** (a code identifier) use \`query\`
236
- - When in doubt use \`grep\` first (it searches everything)
237
-
238
- ### Impact depth guide
239
- - depth 1: WILL BREAK — direct callers/importers → must update
240
- - depth 2: LIKELY AFFECTED — indirect dependents → should test
241
- - depth 3: MAY NEED TESTING — transitive → test if critical path
277
+ const MILENS_INSTRUCTIONS = `milens code intelligence engine. Indexes codebases into symbol graphs.
278
+
279
+ ## Tool selection
280
+ - \`query\` — find symbol definitions (code identifiers only)
281
+ - \`grep\` text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
282
+ - \`context\` 360° view: incoming + outgoing for a symbol
283
+ - \`impact\` blast radius: what breaks if symbol changes
284
+ - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
285
+ - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
286
+ - \`trace\` execution flow: call chains from entrypoints to a symbol (or downstream from it)
287
+ - \`routes\` detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
288
+ - \`smart_context\` intent-aware context: understand/edit/debug/test (returns only what matters for intent)
289
+ - \`domains\` show domain clusters: groups of files forming logical modules based on dependency graph
290
+ - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
291
+ - \`detect_changes\` git diff affected symbols
292
+ - \`explain_relationship\` — shortest path between two symbols
293
+ - \`find_dead_code\` — unused exports
294
+ - \`get_file_symbols\`all symbols in a file
295
+ - \`get_type_hierarchy\` — inheritance tree
296
+
297
+ ## Rules
298
+ - Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
299
+ - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
300
+ - For writing tests: run \`smart_context\` with intent=test shows deps to mock + callers to cover
301
+ - \`impact\` only tracks code deps always pair with \`grep\` for templates/configs
302
+ - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
303
+ - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
304
+ - markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
305
+ - test coverage shown on edit_check — symbols with no test coverage get a warning
306
+ - staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
307
+
308
+ ## Resources (MCP Resources protocol)
309
+ - \`milens://overview\` index overview (stats, domains, coverage, staleness)
310
+ - \`milens://symbol/{name}\` symbol context by name
311
+ - \`milens://file/{path}\` all symbols in a file
312
+ - \`milens://domain/{name}\` — domain cluster details
242
313
  `;
243
314
  // ── Server setup ──
244
315
  export function createMcpServer(rootPath) {
245
316
  const registry = new RepoRegistry();
246
317
  const pools = new Map();
318
+ const trackDb = getTrackingDb();
247
319
  function resolveRoot(repoPath) {
248
- const root = resolve(repoPath ?? rootPath ?? '.');
249
- // Prevent path traversal — repo must be registered in the index
250
- const entry = registry.findByRoot(root);
251
- if (!entry)
252
- throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
253
- return root;
320
+ if (repoPath) {
321
+ const root = resolve(repoPath);
322
+ const entry = registry.findByRoot(root);
323
+ if (!entry)
324
+ throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
325
+ return root;
326
+ }
327
+ if (rootPath) {
328
+ const root = resolve(rootPath);
329
+ const entry = registry.findByRoot(root);
330
+ if (!entry)
331
+ throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
332
+ return root;
333
+ }
334
+ // Auto-resolve: if exactly 1 repo is indexed, use it
335
+ const all = registry.listAll();
336
+ if (all.length === 1)
337
+ return all[0].rootPath;
338
+ if (all.length === 0)
339
+ throw new Error('No indexed repositories. Run `milens analyze` first.');
340
+ throw new Error(`Multiple repos indexed (${all.length}). Specify \`repo\` parameter.`);
254
341
  }
255
342
  function getDb(repoPath) {
256
343
  const root = resolveRoot(repoPath);
@@ -262,9 +349,25 @@ export function createMcpServer(rootPath) {
262
349
  return { db: pools.get(root).get(), root };
263
350
  }
264
351
  const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
352
+ // Auto-wrap every tool handler with usage tracking
353
+ const origTool = server.tool.bind(server);
354
+ server.tool = ((...args) => {
355
+ const toolName = args[0];
356
+ const handler = args[args.length - 1];
357
+ if (typeof handler === 'function') {
358
+ args[args.length - 1] = async (...handlerArgs) => {
359
+ const start = Date.now();
360
+ const result = await handler(...handlerArgs);
361
+ const responseText = result?.content?.map((c) => c.text).join('\n') ?? '';
362
+ const repo = handlerArgs[0]?.repo;
363
+ trackToolCall(trackDb, toolName, start, responseText, repo);
364
+ return result;
365
+ };
366
+ }
367
+ return origTool(...args);
368
+ });
265
369
  // ── Tool: query ──
266
- server.tool('query', 'Search indexed symbol definitions (functions, classes, exports) by name, kind, or file path. ' +
267
- 'Only finds symbols in indexed code files. For text references in templates, SCSS, configs, or docs, use `grep` instead.', {
370
+ server.tool('query', 'Search indexed symbol definitions by name/kind. For text in templates/configs/docs, use `grep`.', {
268
371
  query: z.string().describe('Symbol name, kind, or keyword to search'),
269
372
  repo: z.string().optional().describe('Repository root path (optional if only one indexed)'),
270
373
  limit: z.number().optional().default(15).describe('Max results'),
@@ -272,75 +375,87 @@ export function createMcpServer(rootPath) {
272
375
  const { db } = getDb(repo);
273
376
  const results = db.searchSymbols(query, limit);
274
377
  if (results.length === 0) {
275
- return { content: [{ type: 'text', text: `No symbols matching "${query}". NOTE: query only searches indexed symbol definitions. Use \`grep\` to search ALL project files (templates, styles, configs, docs).` }] };
378
+ return { content: [{ type: 'text', text: `No symbols matching "${query}". Try \`grep\` for non-code references.` }] };
276
379
  }
277
380
  const text = results.map(s => fmtSymbol(s)).join('\n');
278
381
  return { content: [{ type: 'text', text }] };
279
382
  });
280
383
  // ── Tool: grep ──
281
- server.tool('grep', 'Text search across ALL project files (templates, styles, configs, docs, routes, etc.). ' +
282
- 'Unlike `query` which only searches indexed symbol definitions, grep finds every text occurrence. ' +
283
- 'Essential for: deleting features, renaming across templates/SCSS/configs, finding route/config references.', {
384
+ server.tool('grep', 'Text search ALL project files (templates, styles, configs, docs). Finds every text occurrence, not just symbols.', {
284
385
  pattern: z.string().describe('Text or regex pattern to search for'),
285
386
  repo: z.string().optional().describe('Repository root path (optional)'),
286
387
  isRegex: z.boolean().optional().default(false).describe('Treat pattern as regex'),
287
388
  caseSensitive: z.boolean().optional().default(false),
288
389
  include: z.string().optional().describe('Glob filter for file paths (e.g. "**/*.vue", "*.scss")'),
390
+ scope: z.enum(['all', 'code', 'imports', 'definitions']).optional().default('all')
391
+ .describe('Scope: all=everything, code=source files only (no configs/docs), imports=import/require lines only, definitions=function/class/interface declarations only'),
289
392
  limit: z.number().optional().default(50).describe('Max results'),
290
- }, async ({ pattern, repo, isRegex, caseSensitive, include, limit }) => {
393
+ }, async ({ pattern, repo, isRegex, caseSensitive, include, scope, limit }) => {
291
394
  const root = resolveRoot(repo);
395
+ const effectiveInclude = scope === 'code' && !include
396
+ ? '**/*.{ts,tsx,js,jsx,mjs,cjs,vue,py,go,rs,java,php,rb}'
397
+ : include;
292
398
  const matches = grepFiles(root, pattern, {
293
- isRegex, caseSensitive, maxResults: limit, includePattern: include,
399
+ isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
294
400
  });
295
- if (matches.length === 0) {
296
- return { content: [{ type: 'text', text: `No matches for "${pattern}"` }] };
401
+ // Apply scope-specific line filtering
402
+ const filtered = scope === 'all' || scope === 'code'
403
+ ? matches
404
+ : matches.filter(m => matchesScope(m.text, scope));
405
+ if (filtered.length === 0) {
406
+ return { content: [{ type: 'text', text: `No matches for "${pattern}"${scope !== 'all' ? ` (scope: ${scope})` : ''}` }] };
297
407
  }
298
408
  // Group by file for compact output
299
409
  const grouped = new Map();
300
- for (const m of matches) {
410
+ for (const m of filtered) {
301
411
  const arr = grouped.get(m.file) ?? [];
302
412
  arr.push({ line: m.line, text: m.text });
303
413
  grouped.set(m.file, arr);
304
414
  }
305
- const lines = [`${matches.length} matches in ${grouped.size} files:\n`];
415
+ const lines = [`${filtered.length} matches in ${grouped.size} files${scope !== 'all' ? ` (scope: ${scope})` : ''}:\n`];
306
416
  for (const [file, hits] of grouped) {
307
417
  lines.push(file);
308
418
  for (const h of hits) {
309
419
  lines.push(` L${h.line}: ${h.text}`);
310
420
  }
311
421
  }
312
- if (matches.length >= limit) {
422
+ if (filtered.length >= limit) {
313
423
  lines.push(`\n(truncated at ${limit} results — increase limit or narrow pattern)`);
314
424
  }
315
425
  return { content: [{ type: 'text', text: lines.join('\n') }] };
316
426
  });
317
427
  // ── Tool: context ──
318
- server.tool('context', 'Get 360° context of a symbol: incoming refs, outgoing deps, parent, children.', {
428
+ server.tool('context', 'Symbol 360°: incoming refs + outgoing deps. Use `overview` for combined context+impact+grep.', {
319
429
  name: z.string().describe('Symbol name to inspect'),
320
430
  repo: z.string().optional(),
321
- }, async ({ name, repo }) => {
431
+ detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
432
+ }, async ({ name, repo, detail }) => {
322
433
  const { db } = getDb(repo);
323
434
  const symbols = db.findSymbolByName(name);
324
435
  if (symbols.length === 0) {
325
- return { content: [{ type: 'text', text: `Symbol "${name}" not found in index. Use \`grep\` to search ALL project files for text references.` }] };
436
+ return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
326
437
  }
327
438
  const lines = [];
328
439
  for (const sym of symbols) {
329
- lines.push(`## ${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
440
+ lines.push(`## ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
330
441
  const incoming = db.getIncomingLinks(sym.id);
331
442
  if (incoming.length > 0) {
332
443
  lines.push('incoming:');
333
- for (const l of incoming) {
334
- const from = db.findSymbolById(l.fromId);
335
- lines.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
444
+ const inSyms = incoming.map(l => ({ link: l, sym: db.findSymbolById(l.fromId) }));
445
+ if (detail === 'L2')
446
+ inSyms.sort((a, b) => (b.sym?.heat ?? 0) - (a.sym?.heat ?? 0));
447
+ for (const { link: l, sym: from } of inSyms) {
448
+ lines.push(` ${l.type}: ${from ? fmtSymbol(from, detail) : l.fromId}`);
336
449
  }
337
450
  }
338
451
  const outgoing = db.getOutgoingLinks(sym.id);
339
452
  if (outgoing.length > 0) {
340
453
  lines.push('outgoing:');
341
- for (const l of outgoing) {
342
- const to = db.findSymbolById(l.toId);
343
- lines.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
454
+ const outSyms = outgoing.map(l => ({ link: l, sym: db.findSymbolById(l.toId) }));
455
+ if (detail === 'L2')
456
+ outSyms.sort((a, b) => (b.sym?.heat ?? 0) - (a.sym?.heat ?? 0));
457
+ for (const { link: l, sym: to } of outSyms) {
458
+ lines.push(` ${l.type}: ${to ? fmtSymbol(to, detail) : l.toId}`);
344
459
  }
345
460
  }
346
461
  lines.push('');
@@ -348,47 +463,204 @@ export function createMcpServer(rootPath) {
348
463
  return { content: [{ type: 'text', text: lines.join('\n') }] };
349
464
  });
350
465
  // ── Tool: impact ──
351
- server.tool('impact', 'Blast radius analysis via symbol dependency graph. Shows what code-level symbols break if a symbol changes. ' +
352
- 'Note: only tracks code dependencies (calls, imports, extends). For template/SCSS/config references, also use `grep`.', {
466
+ server.tool('impact', 'Blast radius: what symbols break if target changes. Code deps only pair with `grep` for templates/configs.', {
353
467
  target: z.string().describe('Symbol name to analyze'),
354
468
  direction: z.enum(['upstream', 'downstream']).default('upstream'),
355
469
  depth: z.number().optional().default(3),
356
470
  repo: z.string().optional(),
357
- }, async ({ target, direction, depth, repo }) => {
471
+ detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
472
+ }, async ({ target, direction, depth, repo, detail }) => {
358
473
  const { db } = getDb(repo);
359
474
  const symbols = db.findSymbolByName(target);
360
475
  if (symbols.length === 0) {
361
- return { content: [{ type: 'text', text: `Symbol "${target}" not found in index. Use \`grep\` to search ALL project files for text references.` }] };
476
+ return { content: [{ type: 'text', text: `"${target}" not found. Try \`grep\`.` }] };
362
477
  }
363
478
  const lines = [];
364
479
  for (const sym of symbols) {
365
- lines.push(`TARGET: ${fmtSymbol(sym)}`);
480
+ lines.push(`TARGET: ${fmtSymbol(sym, detail)}`);
366
481
  const refs = direction === 'upstream'
367
482
  ? db.findUpstream(sym.id, depth)
368
483
  : db.findDownstream(sym.id, depth);
369
484
  if (refs.length === 0) {
370
- lines.push(`No ${direction} dependencies found in symbol graph. IMPORTANT: Also run \`grep\` for "${target}" to find references in templates, styles, configs, routes, and docs that are not tracked by impact analysis.`);
485
+ lines.push(`No ${direction} deps found.`);
371
486
  }
372
487
  else {
373
488
  lines.push(`${direction} (${refs.length} symbols):`);
374
- lines.push(fmtImpact(refs));
375
- lines.push(`\nNOTE: impact only tracks code-level dependencies. Also run \`grep\` for "${target}" to find template/style/config/doc references.`);
489
+ lines.push(fmtImpact(refs, detail));
376
490
  }
377
491
  lines.push('');
378
492
  }
379
493
  return { content: [{ type: 'text', text: lines.join('\n') }] };
380
494
  });
381
495
  // ── Tool: status ──
382
- server.tool('status', 'Show index stats for a repository.', {
496
+ server.tool('status', 'Index stats for a repository.', {
383
497
  repo: z.string().optional(),
384
498
  }, async ({ repo }) => {
385
499
  const { db, root } = getDb(repo);
386
500
  const stats = db.getStats();
387
- const text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
501
+ const unresolved = db.getUnresolvedStats();
502
+ const coverage = db.getTestCoverage();
503
+ let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
504
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
505
+ text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
506
+ }
507
+ if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
508
+ text += `\nexternal (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`;
509
+ }
510
+ if (coverage.testFiles > 0) {
511
+ const pct = coverage.exportedProductionSymbols > 0
512
+ ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100)
513
+ : 0;
514
+ text += `\ntest coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} exported symbols (${pct}%) from ${coverage.testFiles} test files`;
515
+ }
516
+ const domains = db.getDomainStats();
517
+ if (domains.length > 0) {
518
+ text += `\ndomains: ${domains.map(d => `${d.domain}(${d.files}f/${d.symbols}s)`).join(', ')}`;
519
+ }
520
+ const staleFiles = db.getStaleFiles(24);
521
+ if (staleFiles.length > 0) {
522
+ text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
523
+ }
388
524
  return { content: [{ type: 'text', text }] };
389
525
  });
526
+ // ── Tool: domains ──
527
+ server.tool('domains', 'Show domain clusters — groups of files forming logical modules based on dependency graph. Helps understand codebase structure at a glance.', {
528
+ repo: z.string().optional(),
529
+ }, async ({ repo }) => {
530
+ const { db } = getDb(repo);
531
+ const domains = db.getDomainStats();
532
+ if (domains.length === 0) {
533
+ return { content: [{ type: 'text', text: 'No domains detected. Run `milens analyze` first.' }] };
534
+ }
535
+ const totalFiles = domains.reduce((s, d) => s + d.files, 0);
536
+ const totalSymbols = domains.reduce((s, d) => s + d.symbols, 0);
537
+ const lines = [`${domains.length} domains (${totalFiles} files, ${totalSymbols} symbols):\n`];
538
+ for (const d of domains) {
539
+ const pct = totalSymbols > 0 ? Math.round(d.symbols / totalSymbols * 100) : 0;
540
+ lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols (${pct}%)`);
541
+ }
542
+ const staleFiles = db.getStaleFiles(24);
543
+ if (staleFiles.length > 0) {
544
+ lines.push(`\n⏳ ${staleFiles.length} files stale (>24h) — re-run \`milens analyze\` for fresh clusters`);
545
+ }
546
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
547
+ });
548
+ // ── Tool: overview ──
549
+ server.tool('overview', 'Combined context + impact + grep in ONE call. Preferred before editing/deleting/renaming a symbol. Saves 2-3 round trips.', {
550
+ name: z.string().describe('Symbol name'),
551
+ repo: z.string().optional(),
552
+ depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
553
+ detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail level'),
554
+ }, async ({ name, repo, depth, detail }) => {
555
+ const { db, root } = getDb(repo);
556
+ const symbols = db.findSymbolByName(name);
557
+ const sections = [];
558
+ // Section 1: Symbol definitions
559
+ if (symbols.length === 0) {
560
+ sections.push(`[symbol] "${name}" not found in index.`);
561
+ }
562
+ else {
563
+ for (const sym of symbols) {
564
+ sections.push(`[symbol] ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
565
+ }
566
+ }
567
+ // Section 2: Context (incoming + outgoing) for each symbol
568
+ if (symbols.length > 0) {
569
+ for (const sym of symbols) {
570
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
571
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
572
+ if (incoming.length > 0) {
573
+ sections.push(`[incoming] ${incoming.length} refs:`);
574
+ const inSyms = incoming.map(l => {
575
+ const s = db.findSymbolById(l.fromId);
576
+ return s ? ` ${l.type}: ${fmtSymbol(s, detail)}` : ` ${l.type}: ${l.fromId}`;
577
+ });
578
+ sections.push(...inSyms);
579
+ }
580
+ if (outgoing.length > 0) {
581
+ sections.push(`[outgoing] ${outgoing.length} deps:`);
582
+ const outSyms = outgoing.map(l => {
583
+ const s = db.findSymbolById(l.toId);
584
+ return s ? ` ${l.type}: ${fmtSymbol(s, detail)}` : ` ${l.type}: ${l.toId}`;
585
+ });
586
+ sections.push(...outSyms);
587
+ }
588
+ }
589
+ // Section 3: Impact (upstream)
590
+ for (const sym of symbols) {
591
+ const upstream = db.findUpstream(sym.id, depth);
592
+ if (upstream.length > 0) {
593
+ sections.push(`[impact] ${upstream.length} upstream deps:`);
594
+ sections.push(fmtImpact(upstream, detail));
595
+ }
596
+ else {
597
+ sections.push(`[impact] No upstream deps.`);
598
+ }
599
+ }
600
+ }
601
+ // Section 4: Grep (text references across all files)
602
+ const grepMatches = grepFiles(root, name, { maxResults: 20 });
603
+ if (grepMatches.length > 0) {
604
+ const grouped = new Map();
605
+ for (const m of grepMatches) {
606
+ const arr = grouped.get(m.file) ?? [];
607
+ arr.push({ line: m.line, text: m.text });
608
+ grouped.set(m.file, arr);
609
+ }
610
+ sections.push(`[grep] ${grepMatches.length} text matches in ${grouped.size} files:`);
611
+ for (const [file, hits] of grouped) {
612
+ sections.push(` ${file}`);
613
+ for (const h of hits)
614
+ sections.push(` L${h.line}: ${h.text}`);
615
+ }
616
+ }
617
+ else {
618
+ sections.push(`[grep] No text matches.`);
619
+ }
620
+ // Section 5: Unresolved warnings (only for internal)
621
+ const unresolved = db.getUnresolvedStats();
622
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
623
+ sections.push(`[⚠ unresolved internal] ${unresolved.imports} imports, ${unresolved.calls} calls — some references may be missing`);
624
+ }
625
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
626
+ });
627
+ // ── Tool: repos ──
628
+ server.tool('repos', 'List all indexed repositories with summary stats. Useful for multi-repo workspaces.', {}, async () => {
629
+ const entries = registry.listAll();
630
+ if (entries.length === 0) {
631
+ return { content: [{ type: 'text', text: 'No indexed repositories. Run `milens analyze` first.' }] };
632
+ }
633
+ const lines = [`${entries.length} indexed repositories:\n`];
634
+ for (const entry of entries) {
635
+ lines.push(`${entry.rootPath}`);
636
+ lines.push(` indexed: ${entry.analyzedAt}`);
637
+ try {
638
+ const dbPath = registry.findDbPath(entry.rootPath);
639
+ if (dbPath) {
640
+ const tempDb = pools.has(entry.rootPath)
641
+ ? pools.get(entry.rootPath).get()
642
+ : new Database(dbPath);
643
+ const summary = tempDb.getRepoSummary();
644
+ lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
645
+ if (summary.domains.length > 0) {
646
+ lines.push(` domains: ${summary.domains.join(', ')}`);
647
+ }
648
+ if (summary.staleCount > 0) {
649
+ lines.push(` ⏳ ${summary.staleCount} stale files`);
650
+ }
651
+ if (!pools.has(entry.rootPath))
652
+ tempDb.close();
653
+ }
654
+ }
655
+ catch {
656
+ lines.push(` (unable to read index)`);
657
+ }
658
+ lines.push('');
659
+ }
660
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
661
+ });
390
662
  // ── Tool: detect_changes ──
391
- server.tool('detect_changes', 'Detect changed files via git diff and find affected symbols with upstream impact.', {
663
+ server.tool('detect_changes', 'Git diff affected symbols + direct dependents.', {
392
664
  ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
393
665
  repo: z.string().optional(),
394
666
  }, async ({ ref, repo }) => {
@@ -430,7 +702,7 @@ export function createMcpServer(rootPath) {
430
702
  return { content: [{ type: 'text', text: lines.join('\n') }] };
431
703
  });
432
704
  // ── Tool: explain_relationship ──
433
- server.tool('explain_relationship', 'Explain how two symbols are connected. Finds the shortest path in the dependency graph.', {
705
+ server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
434
706
  from: z.string().describe('Source symbol name'),
435
707
  to: z.string().describe('Target symbol name'),
436
708
  repo: z.string().optional(),
@@ -438,7 +710,7 @@ export function createMcpServer(rootPath) {
438
710
  const { db } = getDb(repo);
439
711
  const path = db.findPath(from, to);
440
712
  if (!path) {
441
- return { content: [{ type: 'text', text: `No relationship found between "${from}" and "${to}" in the symbol graph. Use \`grep\` to search for text references that may connect them.` }] };
713
+ return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
442
714
  }
443
715
  const fromSym = db.findSymbolByName(from)[0];
444
716
  const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
@@ -448,7 +720,7 @@ export function createMcpServer(rootPath) {
448
720
  return { content: [{ type: 'text', text: lines.join('\n') }] };
449
721
  });
450
722
  // ── Tool: find_dead_code ──
451
- server.tool('find_dead_code', 'Find exported symbols with zero incoming references (potentially unused code).', {
723
+ server.tool('find_dead_code', 'Exported symbols with zero incoming references (potentially unused).', {
452
724
  kind: z.string().optional().describe('Filter by symbol kind (function, class, method, etc.)'),
453
725
  limit: z.number().optional().default(30),
454
726
  repo: z.string().optional(),
@@ -465,33 +737,52 @@ export function createMcpServer(rootPath) {
465
737
  return { content: [{ type: 'text', text: lines.join('\n') }] };
466
738
  });
467
739
  // ── Tool: get_file_symbols ──
468
- server.tool('get_file_symbols', 'List all symbols defined in a specific file with their relationships.', {
740
+ server.tool('get_file_symbols', 'All symbols in a file with ref/dep counts.', {
469
741
  file: z.string().describe('File path (relative to repo root)'),
470
742
  repo: z.string().optional(),
471
- }, async ({ file, repo }) => {
743
+ detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
744
+ }, async ({ file, repo, detail }) => {
472
745
  const { db } = getDb(repo);
473
746
  const symbols = db.getSymbolsByFile(file);
474
747
  if (symbols.length === 0) {
475
748
  return { content: [{ type: 'text', text: `No symbols found in "${file}". Is the path relative to repo root?` }] };
476
749
  }
750
+ // Sort by heat descending in L2 mode for relevance-first output
751
+ const sorted = detail === 'L2'
752
+ ? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
753
+ : symbols;
477
754
  const lines = [`${file}: ${symbols.length} symbols\n`];
478
- for (const sym of symbols) {
755
+ for (const sym of sorted) {
479
756
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
480
757
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
481
758
  const exp = sym.exported ? ' (exported)' : '';
482
- lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
759
+ if (detail === 'L0') {
760
+ lines.push(`${sym.name} [${sym.kind}]${exp}`);
761
+ }
762
+ else if (detail === 'L2') {
763
+ const meta = [];
764
+ if (sym.role)
765
+ meta.push(sym.role);
766
+ if (sym.heat != null && sym.heat > 0)
767
+ meta.push(`heat:${sym.heat}`);
768
+ const metaStr = meta.length > 0 ? ` {${meta.join(',')}}` : '';
769
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${incoming.length} refs, → ${outgoing.length} deps`);
770
+ }
771
+ else {
772
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
773
+ }
483
774
  }
484
775
  return { content: [{ type: 'text', text: lines.join('\n') }] };
485
776
  });
486
777
  // ── Tool: get_type_hierarchy ──
487
- server.tool('get_type_hierarchy', 'Show the inheritance/implementation hierarchy of a class, interface, or trait.', {
778
+ server.tool('get_type_hierarchy', 'Inheritance/implementation tree for a class, interface, or trait.', {
488
779
  name: z.string().describe('Symbol name to show hierarchy for'),
489
780
  repo: z.string().optional(),
490
781
  }, async ({ name, repo }) => {
491
782
  const { db } = getDb(repo);
492
783
  const symbols = db.findSymbolByName(name);
493
784
  if (symbols.length === 0) {
494
- return { content: [{ type: 'text', text: `Symbol "${name}" not found in index. Use \`grep\` to search ALL project files for text references.` }] };
785
+ return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
495
786
  }
496
787
  const lines = [];
497
788
  for (const sym of symbols) {
@@ -504,18 +795,489 @@ export function createMcpServer(rootPath) {
504
795
  }
505
796
  }
506
797
  if (descendants.length > 0) {
507
- lines.push('implemented/extended by:');
798
+ lines.push('extended/implemented by:');
508
799
  for (const { symbol: d, depth } of descendants) {
509
800
  lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
510
801
  }
511
802
  }
512
803
  if (ancestors.length === 0 && descendants.length === 0) {
513
- lines.push('No inheritance relationships found.');
804
+ lines.push('No inheritance relationships.');
514
805
  }
515
806
  lines.push('');
516
807
  }
517
808
  return { content: [{ type: 'text', text: lines.join('\n') }] };
518
809
  });
810
+ // ── Tool: edit_check ──
811
+ server.tool('edit_check', 'Pre-edit safety check: callers, export status, re-export chains, ⚠ warnings. Focused for editing intent — no downstream deps, no outgoing calls. Use BEFORE modifying a symbol.', {
812
+ name: z.string().describe('Symbol name to check before editing'),
813
+ repo: z.string().optional(),
814
+ }, async ({ name, repo }) => {
815
+ const { db, root } = getDb(repo);
816
+ const symbols = db.findSymbolByName(name);
817
+ const sections = [];
818
+ if (symbols.length === 0) {
819
+ sections.push(`"${name}" not found in index. Try \`grep\`.`);
820
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
821
+ }
822
+ for (const sym of symbols) {
823
+ // 1. Symbol info
824
+ sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
825
+ // 2. Who calls/uses this? (direct upstream only — what WILL break)
826
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
827
+ if (incoming.length > 0) {
828
+ sections.push(`callers (${incoming.length}):`);
829
+ for (const l of incoming) {
830
+ const from = db.findSymbolById(l.fromId);
831
+ sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
832
+ }
833
+ }
834
+ else {
835
+ sections.push(`callers: none`);
836
+ }
837
+ // 3. Export chain — is this re-exported from barrel files?
838
+ const grepMatches = grepFiles(root, name, { maxResults: 10, includePattern: '**/index.{ts,js,mjs}' });
839
+ const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
840
+ if (reExportMatches.length > 0) {
841
+ sections.push(`re-exported via:`);
842
+ for (const m of reExportMatches) {
843
+ sections.push(` ${m.file}:${m.line}`);
844
+ }
845
+ }
846
+ // 4. Heritage — is this a parent class?
847
+ const descendants = db.getTypeHierarchy(sym.id).descendants;
848
+ if (descendants.length > 0) {
849
+ sections.push(`⚠ inherited by ${descendants.length} types:`);
850
+ for (const { symbol: d } of descendants) {
851
+ sections.push(` ${fmtSymbol(d)}`);
852
+ }
853
+ }
854
+ }
855
+ // 5. Unresolved warning (only for internal)
856
+ const unresolved = db.getUnresolvedStats();
857
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
858
+ sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
859
+ }
860
+ // 6. Test coverage for this symbol
861
+ for (const sym of symbols) {
862
+ const incoming = db.getIncomingLinks(sym.id);
863
+ const testRefs = incoming.filter(l => {
864
+ const from = db.findSymbolById(l.fromId);
865
+ return from && isTestFilePath(from.filePath);
866
+ });
867
+ if (testRefs.length > 0) {
868
+ const testFiles = [...new Set(testRefs.map(l => {
869
+ const from = db.findSymbolById(l.fromId);
870
+ return from?.filePath;
871
+ }).filter(Boolean))];
872
+ sections.push(`✓ tested from: ${testFiles.join(', ')}`);
873
+ }
874
+ else if (sym.exported) {
875
+ sections.push(`⚠ no test coverage for this exported symbol`);
876
+ }
877
+ }
878
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
879
+ });
880
+ // ── Tool: trace ──
881
+ server.tool('trace', 'Trace execution flow: find call chains from entrypoints to a target symbol, or from a symbol downstream. Shows HOW code gets executed.', {
882
+ name: z.string().describe('Symbol name to trace'),
883
+ direction: z.enum(['to', 'from']).optional().default('to')
884
+ .describe('to=trace paths TO this symbol from entrypoints, from=trace paths FROM this symbol downstream'),
885
+ repo: z.string().optional(),
886
+ depth: z.number().optional().default(8).describe('Max chain depth'),
887
+ }, async ({ name, direction, repo, depth }) => {
888
+ const { db } = getDb(repo);
889
+ const symbols = db.findSymbolByName(name);
890
+ if (symbols.length === 0) {
891
+ return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
892
+ }
893
+ const sections = [];
894
+ for (const sym of symbols) {
895
+ if (direction === 'to') {
896
+ // Trace upstream to entrypoints
897
+ const traces = db.traceToEntrypoints(sym.id, depth);
898
+ sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
899
+ if (traces.length === 0) {
900
+ sections.push('No call chains found (symbol may be an entrypoint itself or unreachable).');
901
+ }
902
+ else {
903
+ for (let i = 0; i < traces.length; i++) {
904
+ const chain = traces[i].path;
905
+ sections.push(`path ${i + 1} (${chain.length} steps):`);
906
+ sections.push(chain.map((step, idx) => {
907
+ const arrow = idx === chain.length - 1 ? '→ (target)' : `→ [${chain[idx + 1]?.via ?? ''}]`;
908
+ return ` ${' '.repeat(idx)}${fmtSymbol(step.symbol)} ${arrow}`;
909
+ }).join('\n'));
910
+ sections.push('');
911
+ }
912
+ }
913
+ }
914
+ else {
915
+ // Trace downstream — show call/dependency tree from this symbol
916
+ const downstream = db.findDownstream(sym.id, depth);
917
+ sections.push(`## Execution paths FROM ${fmtSymbol(sym)}\n`);
918
+ if (downstream.length === 0) {
919
+ sections.push('No downstream dependencies (leaf symbol).');
920
+ }
921
+ else {
922
+ // Group by depth and show as tree
923
+ const byDepth = new Map();
924
+ for (const { symbol, depth: d, via } of downstream) {
925
+ const arr = byDepth.get(d) ?? [];
926
+ arr.push(`${' '.repeat(d)}[${via}] ${fmtSymbol(symbol)}`);
927
+ byDepth.set(d, arr);
928
+ }
929
+ for (const [, items] of [...byDepth].sort((a, b) => a[0] - b[0])) {
930
+ sections.push(...items);
931
+ }
932
+ }
933
+ }
934
+ sections.push('');
935
+ }
936
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
937
+ });
938
+ // ── Tool: routes ──
939
+ server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
940
+ repo: z.string().optional(),
941
+ framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
942
+ limit: z.number().optional().default(50),
943
+ }, async ({ repo, framework, limit }) => {
944
+ const root = resolveRoot(repo);
945
+ const { db } = getDb(repo);
946
+ // Route patterns for different frameworks
947
+ const routePatterns = [
948
+ { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
949
+ { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
950
+ { name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
951
+ { name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
952
+ { name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
953
+ { name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
954
+ { name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
955
+ ];
956
+ const activePatterns = framework
957
+ ? routePatterns.filter(p => p.name === framework.toLowerCase())
958
+ : routePatterns;
959
+ if (activePatterns.length === 0) {
960
+ return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
961
+ }
962
+ const routes = [];
963
+ for (const rp of activePatterns) {
964
+ const matches = grepFiles(root, rp.pattern.source, {
965
+ isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
966
+ });
967
+ for (const m of matches) {
968
+ const match = rp.pattern.exec(m.text);
969
+ if (!match)
970
+ continue;
971
+ const method = match[1].toUpperCase();
972
+ const path = match[2] || '/';
973
+ // Try to find the handler symbol on this line or nearby
974
+ const fileSymbols = db.getSymbolsByFile(m.file);
975
+ const handler = fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line && s.kind === 'method') ?? fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line) ?? fileSymbols.find(s => Math.abs(s.startLine - m.line) <= 3 && (s.kind === 'function' || s.kind === 'method'));
976
+ routes.push({
977
+ framework: rp.name,
978
+ method,
979
+ path,
980
+ file: m.file,
981
+ line: m.line,
982
+ handler: handler ? `${handler.name} [${handler.kind}]` : undefined,
983
+ });
984
+ }
985
+ }
986
+ if (routes.length === 0) {
987
+ return { content: [{ type: 'text', text: 'No framework routes detected.' }] };
988
+ }
989
+ // Group by framework
990
+ const grouped = new Map();
991
+ for (const r of routes) {
992
+ const arr = grouped.get(r.framework) ?? [];
993
+ arr.push(r);
994
+ grouped.set(r.framework, arr);
995
+ }
996
+ const lines = [`${routes.length} routes detected:\n`];
997
+ for (const [fw, fwRoutes] of grouped) {
998
+ lines.push(`[${fw}]`);
999
+ for (const r of fwRoutes) {
1000
+ const handlerInfo = r.handler ? ` → ${r.handler}` : '';
1001
+ lines.push(` ${r.method.padEnd(7)} ${r.path} (${r.file}:${r.line})${handlerInfo}`);
1002
+ }
1003
+ lines.push('');
1004
+ }
1005
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1006
+ });
1007
+ // ── Tool: smart_context ──
1008
+ server.tool('smart_context', 'Intent-aware context: returns different information based on what you want to do. Saves tokens by showing only what matters for your intent.', {
1009
+ name: z.string().describe('Symbol name'),
1010
+ intent: z.enum(['understand', 'edit', 'debug', 'test'])
1011
+ .describe('understand=360° view, edit=callers+blast radius, debug=execution paths+data flow, test=coverage+dependencies'),
1012
+ repo: z.string().optional(),
1013
+ }, async ({ name, intent, repo }) => {
1014
+ const { db, root } = getDb(repo);
1015
+ const symbols = db.findSymbolByName(name);
1016
+ if (symbols.length === 0) {
1017
+ return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
1018
+ }
1019
+ const sections = [];
1020
+ for (const sym of symbols) {
1021
+ sections.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}\n`);
1022
+ if (intent === 'understand') {
1023
+ // Full 360° — context + downstream + file structure
1024
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1025
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1026
+ if (incoming.length > 0) {
1027
+ sections.push(`incoming (${incoming.length}):`);
1028
+ for (const l of incoming) {
1029
+ const from = db.findSymbolById(l.fromId);
1030
+ sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1031
+ }
1032
+ }
1033
+ if (outgoing.length > 0) {
1034
+ sections.push(`outgoing (${outgoing.length}):`);
1035
+ for (const l of outgoing) {
1036
+ const to = db.findSymbolById(l.toId);
1037
+ sections.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
1038
+ }
1039
+ }
1040
+ // Heritage
1041
+ const { ancestors, descendants } = db.getTypeHierarchy(sym.id);
1042
+ if (ancestors.length > 0) {
1043
+ sections.push(`extends: ${ancestors.map(a => fmtSymbol(a.symbol)).join(', ')}`);
1044
+ }
1045
+ if (descendants.length > 0) {
1046
+ sections.push(`extended by: ${descendants.map(d => fmtSymbol(d.symbol)).join(', ')}`);
1047
+ }
1048
+ // Siblings — other symbols in same file
1049
+ const siblings = db.getSymbolsByFile(sym.filePath)
1050
+ .filter(s => s.id !== sym.id && !s.parentId)
1051
+ .slice(0, 10);
1052
+ if (siblings.length > 0) {
1053
+ sections.push(`file peers: ${siblings.map(s => `${s.name} [${s.kind}]`).join(', ')}`);
1054
+ }
1055
+ }
1056
+ else if (intent === 'edit') {
1057
+ // Focused: who calls this + blast radius + test coverage
1058
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1059
+ const upstream = db.findUpstream(sym.id, 2);
1060
+ if (incoming.length > 0) {
1061
+ sections.push(`direct callers (${incoming.length}):`);
1062
+ for (const l of incoming) {
1063
+ const from = db.findSymbolById(l.fromId);
1064
+ sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1065
+ }
1066
+ }
1067
+ else {
1068
+ sections.push(`direct callers: none`);
1069
+ }
1070
+ if (upstream.length > incoming.length) {
1071
+ const depth2 = upstream.filter(u => u.depth === 2);
1072
+ if (depth2.length > 0) {
1073
+ sections.push(`indirect dependents (depth 2): ${depth2.length} symbols`);
1074
+ }
1075
+ }
1076
+ // Re-export detection
1077
+ const reExportMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' })
1078
+ .filter(m => /export\s*\{/.test(m.text) && m.text.includes('from'));
1079
+ if (reExportMatches.length > 0) {
1080
+ sections.push(`re-exported via: ${reExportMatches.map(m => `${m.file}:${m.line}`).join(', ')}`);
1081
+ }
1082
+ // Test coverage
1083
+ const testRefs = incoming.filter(l => {
1084
+ const from = db.findSymbolById(l.fromId);
1085
+ return from && isTestFilePath(from.filePath);
1086
+ });
1087
+ if (testRefs.length > 0) {
1088
+ sections.push(`✓ has test coverage`);
1089
+ }
1090
+ else if (sym.exported) {
1091
+ sections.push(`⚠ no test coverage`);
1092
+ }
1093
+ }
1094
+ else if (intent === 'debug') {
1095
+ // Execution paths + data flow
1096
+ const traces = db.traceToEntrypoints(sym.id, 6);
1097
+ if (traces.length > 0) {
1098
+ sections.push(`execution paths (${traces.length}):`);
1099
+ for (let i = 0; i < Math.min(traces.length, 3); i++) {
1100
+ const chain = traces[i].path;
1101
+ sections.push(` ${chain.map(s => s.symbol.name).join(' → ')}`);
1102
+ }
1103
+ }
1104
+ else {
1105
+ sections.push(`no call chains found (may be entrypoint or unreachable)`);
1106
+ }
1107
+ // What does this call? (downstream immediate)
1108
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type === 'calls');
1109
+ if (outgoing.length > 0) {
1110
+ sections.push(`calls (${outgoing.length}):`);
1111
+ for (const l of outgoing) {
1112
+ const to = db.findSymbolById(l.toId);
1113
+ sections.push(` ${to ? fmtSymbol(to) : l.toId}`);
1114
+ }
1115
+ }
1116
+ // Data types used
1117
+ const dataTypes = db.getOutgoingLinks(sym.id)
1118
+ .filter(l => l.type === 'imports')
1119
+ .map(l => db.findSymbolById(l.toId))
1120
+ .filter(s => s && (s.kind === 'interface' || s.kind === 'type' || s.kind === 'class'))
1121
+ .slice(0, 10);
1122
+ if (dataTypes.length > 0) {
1123
+ sections.push(`data types: ${dataTypes.map(s => s.name).join(', ')}`);
1124
+ }
1125
+ }
1126
+ else if (intent === 'test') {
1127
+ // Test coverage + what to mock
1128
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1129
+ const testRefs = incoming.filter(l => {
1130
+ const from = db.findSymbolById(l.fromId);
1131
+ return from && isTestFilePath(from.filePath);
1132
+ });
1133
+ if (testRefs.length > 0) {
1134
+ const testFiles = [...new Set(testRefs.map(l => {
1135
+ const from = db.findSymbolById(l.fromId);
1136
+ return from?.filePath;
1137
+ }).filter(Boolean))];
1138
+ sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1139
+ }
1140
+ else {
1141
+ sections.push(`⚠ no existing tests`);
1142
+ }
1143
+ // Dependencies to mock
1144
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1145
+ const externalDeps = outgoing.filter(l => {
1146
+ const to = db.findSymbolById(l.toId);
1147
+ return to && to.filePath !== sym.filePath;
1148
+ });
1149
+ if (externalDeps.length > 0) {
1150
+ sections.push(`dependencies to mock (${externalDeps.length}):`);
1151
+ for (const l of externalDeps) {
1152
+ const to = db.findSymbolById(l.toId);
1153
+ if (to)
1154
+ sections.push(` ${l.type}: ${fmtSymbol(to)}`);
1155
+ }
1156
+ }
1157
+ // Inputs — what calls this? (test should cover these call patterns)
1158
+ const nonTestCallers = incoming.filter(l => {
1159
+ const from = db.findSymbolById(l.fromId);
1160
+ return from && !isTestFilePath(from.filePath);
1161
+ });
1162
+ if (nonTestCallers.length > 0) {
1163
+ sections.push(`callers to cover (${nonTestCallers.length}):`);
1164
+ for (const l of nonTestCallers.slice(0, 5)) {
1165
+ const from = db.findSymbolById(l.fromId);
1166
+ if (from)
1167
+ sections.push(` ${fmtSymbol(from)}`);
1168
+ }
1169
+ }
1170
+ }
1171
+ sections.push('');
1172
+ }
1173
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
1174
+ });
1175
+ // ══════════════════════════════════════════════
1176
+ // ── MCP Resources ──
1177
+ // ══════════════════════════════════════════════
1178
+ // ── Resource: milens://symbol/{name} ──
1179
+ server.resource('symbol', new ResourceTemplate('milens://symbol/{name}', { list: undefined }), { description: 'Symbol context: definition, incoming refs, outgoing deps, role/heat metadata' }, async (uri, { name }) => {
1180
+ const { db } = getDb();
1181
+ const symbols = db.findSymbolByName(name);
1182
+ if (symbols.length === 0) {
1183
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `"${name}" not found.` }] };
1184
+ }
1185
+ const lines = [];
1186
+ for (const sym of symbols) {
1187
+ lines.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}`);
1188
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1189
+ if (incoming.length > 0) {
1190
+ lines.push(`incoming (${incoming.length}):`);
1191
+ for (const l of incoming) {
1192
+ const from = db.findSymbolById(l.fromId);
1193
+ lines.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1194
+ }
1195
+ }
1196
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1197
+ if (outgoing.length > 0) {
1198
+ lines.push(`outgoing (${outgoing.length}):`);
1199
+ for (const l of outgoing) {
1200
+ const to = db.findSymbolById(l.toId);
1201
+ lines.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
1202
+ }
1203
+ }
1204
+ lines.push('');
1205
+ }
1206
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1207
+ });
1208
+ // ── Resource: milens://file/{path} ──
1209
+ server.resource('file-symbols', new ResourceTemplate('milens://file/{+path}', { list: undefined }), { description: 'All symbols in a file with ref/dep counts' }, async (uri, { path }) => {
1210
+ const { db } = getDb();
1211
+ const filePath = decodeURIComponent(path);
1212
+ const symbols = db.getSymbolsByFile(filePath);
1213
+ if (symbols.length === 0) {
1214
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
1215
+ }
1216
+ const lines = [`${filePath}: ${symbols.length} symbols\n`];
1217
+ for (const sym of symbols) {
1218
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1219
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1220
+ const exp = sym.exported ? ' (exported)' : '';
1221
+ lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1222
+ }
1223
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1224
+ });
1225
+ // ── Resource: milens://domain/{name} ──
1226
+ server.resource('domain', new ResourceTemplate('milens://domain/{name}', { list: undefined }), { description: 'Domain cluster details: files and top symbols in a domain' }, async (uri, { name }) => {
1227
+ const { db } = getDb();
1228
+ const domainName = name;
1229
+ // Find files in this domain
1230
+ const allFiles = db.db_getFilesByZone(domainName);
1231
+ if (allFiles.length === 0) {
1232
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Domain "${domainName}" not found.` }] };
1233
+ }
1234
+ const lines = [`domain: ${domainName} (${allFiles.length} files)\n`];
1235
+ let totalSymbols = 0;
1236
+ for (const file of allFiles) {
1237
+ const syms = db.getSymbolsByFile(file);
1238
+ totalSymbols += syms.length;
1239
+ const exported = syms.filter(s => s.exported);
1240
+ lines.push(`${file}: ${syms.length} symbols (${exported.length} exported)`);
1241
+ }
1242
+ lines.push(`\ntotal: ${totalSymbols} symbols in ${allFiles.length} files`);
1243
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1244
+ });
1245
+ // ── Resource: milens://overview ──
1246
+ server.resource('overview', 'milens://overview', { description: 'Index overview: stats, domains, unresolved, test coverage, staleness' }, async (uri) => {
1247
+ const { db, root } = getDb();
1248
+ const stats = db.getStats();
1249
+ const unresolved = db.getUnresolvedStats();
1250
+ const coverage = db.getTestCoverage();
1251
+ const domains = db.getDomainStats();
1252
+ const staleFiles = db.getStaleFiles(24);
1253
+ const lines = [
1254
+ `repo: ${root}`,
1255
+ `symbols: ${stats.symbols}`,
1256
+ `links: ${stats.links}`,
1257
+ `files: ${stats.files}`,
1258
+ ];
1259
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
1260
+ lines.push(`⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls`);
1261
+ }
1262
+ if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
1263
+ lines.push(`external (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`);
1264
+ }
1265
+ if (coverage.testFiles > 0) {
1266
+ const pct = coverage.exportedProductionSymbols > 0
1267
+ ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0;
1268
+ lines.push(`test coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${pct}%) from ${coverage.testFiles} test files`);
1269
+ }
1270
+ if (domains.length > 0) {
1271
+ lines.push(`\ndomains (${domains.length}):`);
1272
+ for (const d of domains) {
1273
+ lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
1274
+ }
1275
+ }
1276
+ if (staleFiles.length > 0) {
1277
+ lines.push(`\n⏳ ${staleFiles.length} stale files (>24h)`);
1278
+ }
1279
+ return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1280
+ });
519
1281
  // ── Prompt: delete-feature ──
520
1282
  server.prompt('delete-feature', 'Step-by-step workflow for safely deleting a feature from the codebase', { name: z.string().describe('Feature or symbol name to delete') }, ({ name }) => ({
521
1283
  messages: [{