milens 0.4.0 → 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 (59) 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 +478 -32
  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 +883 -95
  49. package/dist/server/mcp.js.map +1 -1
  50. package/dist/skills.js +100 -88
  51. package/dist/skills.js.map +1 -1
  52. package/dist/store/db.d.ts +62 -0
  53. package/dist/store/db.d.ts.map +1 -1
  54. package/dist/store/db.js +244 -59
  55. package/dist/store/db.js.map +1 -1
  56. package/dist/store/schema.sql +83 -60
  57. package/dist/types.d.ts +14 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +60 -60
@@ -1,15 +1,19 @@
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';
5
5
  import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { resolve, relative, join } from 'node:path';
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';
14
+ import { fileURLToPath } from 'node:url';
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
13
17
  // ── Lazy DB connection with idle eviction ──
14
18
  class LazyDb {
15
19
  dbPath;
@@ -42,15 +46,72 @@ class LazyDb {
42
46
  this.instance = null;
43
47
  }
44
48
  }
45
- // ── Compact formatters (token-efficient for AI agents) ──
46
- function fmtSymbol(s) {
47
- 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);
48
73
  }
49
- 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') {
50
111
  const grouped = new Map();
51
112
  for (const { symbol, depth, via } of items) {
52
113
  const arr = grouped.get(depth) ?? [];
53
- arr.push(`${fmtSymbol(symbol)} (${via})`);
114
+ arr.push(`${fmtSymbol(symbol, detail)} (${via})`);
54
115
  grouped.set(depth, arr);
55
116
  }
56
117
  const lines = [];
@@ -61,6 +122,14 @@ function fmtImpact(items) {
61
122
  }
62
123
  return lines.join('\n');
63
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
+ }
64
133
  // ── Text grep across project files ──
65
134
  const GREP_SKIP_DIRS = new Set([
66
135
  'node_modules', '.git', 'dist', 'build', 'out',
@@ -150,6 +219,15 @@ function grepFiles(rootPath, pattern, options) {
150
219
  function escapeRegExp(s) {
151
220
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
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
+ }
153
231
  /** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
154
232
  function safeRegex(pattern, flags) {
155
233
  if (pattern.length > 200)
@@ -157,6 +235,24 @@ function safeRegex(pattern, flags) {
157
235
  // Reject nested quantifiers like (a+)+, (a*)*, (a{1,})+
158
236
  if (/([+*}])\)?[+*{]/.test(pattern))
159
237
  throw new Error('Unsafe regex pattern');
238
+ // Reject overlapping alternation inside quantified groups: (a|a)*, (ab|a)+
239
+ if (/\((?:[^)]*\|[^)]*)\)[+*{]/.test(pattern))
240
+ throw new Error('Unsafe regex pattern');
241
+ // Reject backreferences inside quantified groups (exponential matching)
242
+ if (/\((?:[^)]*\\[1-9][^)]*)\)[+*{]/.test(pattern))
243
+ throw new Error('Unsafe regex pattern');
244
+ // Reject deeply nested groups (>3 levels)
245
+ let depth = 0, maxDepth = 0;
246
+ for (const ch of pattern) {
247
+ if (ch === '(') {
248
+ depth++;
249
+ maxDepth = Math.max(maxDepth, depth);
250
+ }
251
+ else if (ch === ')')
252
+ depth--;
253
+ }
254
+ if (maxDepth > 3)
255
+ throw new Error('Unsafe regex pattern');
160
256
  return new RegExp(pattern, flags);
161
257
  }
162
258
  function globToRegex(glob) {
@@ -178,53 +274,70 @@ function loadGrepIgnoreRules(rootPath) {
178
274
  return ig;
179
275
  }
180
276
  // ── Server instructions (sent to client via MCP protocol on initialize) ──
181
- const MILENS_INSTRUCTIONS = `milens is a code intelligence engine. It indexes codebases into knowledge graphs and provides tools for navigating and understanding code.
182
-
183
- ## Critical Workflow Rules
184
-
185
- ### Always combine \`impact\` with \`grep\`
186
- \`impact\` only tracks code-level symbol dependencies (calls, imports, extends).
187
- \`grep\` finds ALL text references including templates, styles, configs, routes, and docs.
188
- **After running \`impact\`, always run \`grep\` for the same symbol to catch non-code references.**
189
-
190
- ### Before editing a symbol
191
- 1. Run \`context\` to see all incoming/outgoing relationships
192
- 2. Run \`impact\` with direction "upstream" to find what depends on it
193
- 3. Run \`grep\` to find ALL text references (templates, SCSS, configs, routes, docs)
194
-
195
- ### When deleting a feature or renaming
196
- 1. Run \`grep\` first finds every text occurrence across all file types
197
- 2. Run \`impact\` — finds code-level dependency graph
198
- 3. Combine both results grep catches what impact misses and vice versa
199
-
200
- ### Tool selection guide
201
- - **Find symbol definitions** → \`query\`
202
- - **Find ALL text references** (templates, styles, configs, docs) \`grep\`
203
- - **Understand a symbol's relationships** \`context\`
204
- - **What breaks if I change this?** \`impact\` (upstream) + \`grep\`
205
- - **What does this call/depend on?** \`impact\` (downstream)
206
- - **How are two symbols connected?** \`explain_relationship\`
207
- - **What changed recently?** \`detect_changes\`
208
- - **Find unused exports** \`find_dead_code\`
209
- - **See all symbols in a file** \`get_file_symbols\`
210
- - **Class inheritance tree** \`get_type_hierarchy\`
211
-
212
- ### Impact depth guide
213
- - depth 1: WILL BREAK direct callers/importers → must update
214
- - depth 2: LIKELY AFFECTED indirect dependents should test
215
- - 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
216
313
  `;
217
314
  // ── Server setup ──
218
315
  export function createMcpServer(rootPath) {
219
316
  const registry = new RepoRegistry();
220
317
  const pools = new Map();
318
+ const trackDb = getTrackingDb();
221
319
  function resolveRoot(repoPath) {
222
- const root = resolve(repoPath ?? rootPath ?? '.');
223
- // Prevent path traversal — repo must be registered in the index
224
- const entry = registry.findByRoot(root);
225
- if (!entry)
226
- throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
227
- 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.`);
228
341
  }
229
342
  function getDb(repoPath) {
230
343
  const root = resolveRoot(repoPath);
@@ -235,10 +348,26 @@ export function createMcpServer(rootPath) {
235
348
  pools.set(root, new LazyDb(dbPath));
236
349
  return { db: pools.get(root).get(), root };
237
350
  }
238
- const server = new McpServer({ name: 'milens', version: '0.3.1' }, { instructions: MILENS_INSTRUCTIONS });
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
+ });
239
369
  // ── Tool: query ──
240
- server.tool('query', 'Search indexed symbol definitions (functions, classes, exports) by name, kind, or file path. ' +
241
- '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`.', {
242
371
  query: z.string().describe('Symbol name, kind, or keyword to search'),
243
372
  repo: z.string().optional().describe('Repository root path (optional if only one indexed)'),
244
373
  limit: z.number().optional().default(15).describe('Max results'),
@@ -246,75 +375,87 @@ export function createMcpServer(rootPath) {
246
375
  const { db } = getDb(repo);
247
376
  const results = db.searchSymbols(query, limit);
248
377
  if (results.length === 0) {
249
- 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.` }] };
250
379
  }
251
380
  const text = results.map(s => fmtSymbol(s)).join('\n');
252
381
  return { content: [{ type: 'text', text }] };
253
382
  });
254
383
  // ── Tool: grep ──
255
- server.tool('grep', 'Text search across ALL project files (templates, styles, configs, docs, routes, etc.). ' +
256
- 'Unlike `query` which only searches indexed symbol definitions, grep finds every text occurrence. ' +
257
- '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.', {
258
385
  pattern: z.string().describe('Text or regex pattern to search for'),
259
386
  repo: z.string().optional().describe('Repository root path (optional)'),
260
387
  isRegex: z.boolean().optional().default(false).describe('Treat pattern as regex'),
261
388
  caseSensitive: z.boolean().optional().default(false),
262
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'),
263
392
  limit: z.number().optional().default(50).describe('Max results'),
264
- }, async ({ pattern, repo, isRegex, caseSensitive, include, limit }) => {
393
+ }, async ({ pattern, repo, isRegex, caseSensitive, include, scope, limit }) => {
265
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;
266
398
  const matches = grepFiles(root, pattern, {
267
- isRegex, caseSensitive, maxResults: limit, includePattern: include,
399
+ isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
268
400
  });
269
- if (matches.length === 0) {
270
- 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})` : ''}` }] };
271
407
  }
272
408
  // Group by file for compact output
273
409
  const grouped = new Map();
274
- for (const m of matches) {
410
+ for (const m of filtered) {
275
411
  const arr = grouped.get(m.file) ?? [];
276
412
  arr.push({ line: m.line, text: m.text });
277
413
  grouped.set(m.file, arr);
278
414
  }
279
- 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`];
280
416
  for (const [file, hits] of grouped) {
281
417
  lines.push(file);
282
418
  for (const h of hits) {
283
419
  lines.push(` L${h.line}: ${h.text}`);
284
420
  }
285
421
  }
286
- if (matches.length >= limit) {
422
+ if (filtered.length >= limit) {
287
423
  lines.push(`\n(truncated at ${limit} results — increase limit or narrow pattern)`);
288
424
  }
289
425
  return { content: [{ type: 'text', text: lines.join('\n') }] };
290
426
  });
291
427
  // ── Tool: context ──
292
- 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.', {
293
429
  name: z.string().describe('Symbol name to inspect'),
294
430
  repo: z.string().optional(),
295
- }, 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 }) => {
296
433
  const { db } = getDb(repo);
297
434
  const symbols = db.findSymbolByName(name);
298
435
  if (symbols.length === 0) {
299
- 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\`.` }] };
300
437
  }
301
438
  const lines = [];
302
439
  for (const sym of symbols) {
303
- lines.push(`## ${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
440
+ lines.push(`## ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
304
441
  const incoming = db.getIncomingLinks(sym.id);
305
442
  if (incoming.length > 0) {
306
443
  lines.push('incoming:');
307
- for (const l of incoming) {
308
- const from = db.findSymbolById(l.fromId);
309
- 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}`);
310
449
  }
311
450
  }
312
451
  const outgoing = db.getOutgoingLinks(sym.id);
313
452
  if (outgoing.length > 0) {
314
453
  lines.push('outgoing:');
315
- for (const l of outgoing) {
316
- const to = db.findSymbolById(l.toId);
317
- 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}`);
318
459
  }
319
460
  }
320
461
  lines.push('');
@@ -322,47 +463,204 @@ export function createMcpServer(rootPath) {
322
463
  return { content: [{ type: 'text', text: lines.join('\n') }] };
323
464
  });
324
465
  // ── Tool: impact ──
325
- server.tool('impact', 'Blast radius analysis via symbol dependency graph. Shows what code-level symbols break if a symbol changes. ' +
326
- '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.', {
327
467
  target: z.string().describe('Symbol name to analyze'),
328
468
  direction: z.enum(['upstream', 'downstream']).default('upstream'),
329
469
  depth: z.number().optional().default(3),
330
470
  repo: z.string().optional(),
331
- }, 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 }) => {
332
473
  const { db } = getDb(repo);
333
474
  const symbols = db.findSymbolByName(target);
334
475
  if (symbols.length === 0) {
335
- 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\`.` }] };
336
477
  }
337
478
  const lines = [];
338
479
  for (const sym of symbols) {
339
- lines.push(`TARGET: ${fmtSymbol(sym)}`);
480
+ lines.push(`TARGET: ${fmtSymbol(sym, detail)}`);
340
481
  const refs = direction === 'upstream'
341
482
  ? db.findUpstream(sym.id, depth)
342
483
  : db.findDownstream(sym.id, depth);
343
484
  if (refs.length === 0) {
344
- 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.`);
345
486
  }
346
487
  else {
347
488
  lines.push(`${direction} (${refs.length} symbols):`);
348
- lines.push(fmtImpact(refs));
349
- 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));
350
490
  }
351
491
  lines.push('');
352
492
  }
353
493
  return { content: [{ type: 'text', text: lines.join('\n') }] };
354
494
  });
355
495
  // ── Tool: status ──
356
- server.tool('status', 'Show index stats for a repository.', {
496
+ server.tool('status', 'Index stats for a repository.', {
357
497
  repo: z.string().optional(),
358
498
  }, async ({ repo }) => {
359
499
  const { db, root } = getDb(repo);
360
500
  const stats = db.getStats();
361
- 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
+ }
362
524
  return { content: [{ type: 'text', text }] };
363
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
+ });
364
662
  // ── Tool: detect_changes ──
365
- 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.', {
366
664
  ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
367
665
  repo: z.string().optional(),
368
666
  }, async ({ ref, repo }) => {
@@ -404,7 +702,7 @@ export function createMcpServer(rootPath) {
404
702
  return { content: [{ type: 'text', text: lines.join('\n') }] };
405
703
  });
406
704
  // ── Tool: explain_relationship ──
407
- 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.', {
408
706
  from: z.string().describe('Source symbol name'),
409
707
  to: z.string().describe('Target symbol name'),
410
708
  repo: z.string().optional(),
@@ -412,7 +710,7 @@ export function createMcpServer(rootPath) {
412
710
  const { db } = getDb(repo);
413
711
  const path = db.findPath(from, to);
414
712
  if (!path) {
415
- 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}".` }] };
416
714
  }
417
715
  const fromSym = db.findSymbolByName(from)[0];
418
716
  const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
@@ -422,7 +720,7 @@ export function createMcpServer(rootPath) {
422
720
  return { content: [{ type: 'text', text: lines.join('\n') }] };
423
721
  });
424
722
  // ── Tool: find_dead_code ──
425
- 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).', {
426
724
  kind: z.string().optional().describe('Filter by symbol kind (function, class, method, etc.)'),
427
725
  limit: z.number().optional().default(30),
428
726
  repo: z.string().optional(),
@@ -439,33 +737,52 @@ export function createMcpServer(rootPath) {
439
737
  return { content: [{ type: 'text', text: lines.join('\n') }] };
440
738
  });
441
739
  // ── Tool: get_file_symbols ──
442
- 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.', {
443
741
  file: z.string().describe('File path (relative to repo root)'),
444
742
  repo: z.string().optional(),
445
- }, 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 }) => {
446
745
  const { db } = getDb(repo);
447
746
  const symbols = db.getSymbolsByFile(file);
448
747
  if (symbols.length === 0) {
449
748
  return { content: [{ type: 'text', text: `No symbols found in "${file}". Is the path relative to repo root?` }] };
450
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;
451
754
  const lines = [`${file}: ${symbols.length} symbols\n`];
452
- for (const sym of symbols) {
755
+ for (const sym of sorted) {
453
756
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
454
757
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
455
758
  const exp = sym.exported ? ' (exported)' : '';
456
- 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
+ }
457
774
  }
458
775
  return { content: [{ type: 'text', text: lines.join('\n') }] };
459
776
  });
460
777
  // ── Tool: get_type_hierarchy ──
461
- 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.', {
462
779
  name: z.string().describe('Symbol name to show hierarchy for'),
463
780
  repo: z.string().optional(),
464
781
  }, async ({ name, repo }) => {
465
782
  const { db } = getDb(repo);
466
783
  const symbols = db.findSymbolByName(name);
467
784
  if (symbols.length === 0) {
468
- 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\`.` }] };
469
786
  }
470
787
  const lines = [];
471
788
  for (const sym of symbols) {
@@ -478,18 +795,489 @@ export function createMcpServer(rootPath) {
478
795
  }
479
796
  }
480
797
  if (descendants.length > 0) {
481
- lines.push('implemented/extended by:');
798
+ lines.push('extended/implemented by:');
482
799
  for (const { symbol: d, depth } of descendants) {
483
800
  lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
484
801
  }
485
802
  }
486
803
  if (ancestors.length === 0 && descendants.length === 0) {
487
- lines.push('No inheritance relationships found.');
804
+ lines.push('No inheritance relationships.');
805
+ }
806
+ lines.push('');
807
+ }
808
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
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}`);
488
1002
  }
489
1003
  lines.push('');
490
1004
  }
491
1005
  return { content: [{ type: 'text', text: lines.join('\n') }] };
492
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
+ });
493
1281
  // ── Prompt: delete-feature ──
494
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 }) => ({
495
1283
  messages: [{