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