milens 0.6.3 → 0.6.4
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/.agents/skills/adapters/SKILL.md +31 -0
- package/.agents/skills/analyzer/SKILL.md +55 -0
- package/.agents/skills/apps/SKILL.md +42 -0
- package/.agents/skills/docs/SKILL.md +46 -0
- package/.agents/skills/milens/SKILL.md +168 -0
- package/.agents/skills/milens-code-review/SKILL.md +186 -0
- package/.agents/skills/milens-eval/SKILL.md +221 -0
- package/.agents/skills/milens-plan/SKILL.md +227 -0
- package/.agents/skills/milens-refactor-clean/SKILL.md +209 -0
- package/.agents/skills/milens-security-review/SKILL.md +224 -0
- package/.agents/skills/milens-tdd/SKILL.md +156 -0
- package/.agents/skills/parser/SKILL.md +60 -0
- package/.agents/skills/root/SKILL.md +64 -0
- package/.agents/skills/scripts/SKILL.md +27 -0
- package/.agents/skills/security/SKILL.md +44 -0
- package/.agents/skills/server/SKILL.md +46 -0
- package/.agents/skills/store/SKILL.md +53 -0
- package/.agents/skills/test/SKILL.md +73 -0
- package/LICENSE +75 -75
- package/README.md +508 -432
- package/adapters/README.md +107 -0
- package/adapters/claude-code/.claude/mcp.json +9 -0
- package/adapters/claude-code/CLAUDE.md +58 -0
- package/adapters/codex/.codex/codex.md +52 -0
- package/adapters/copilot/.github/copilot-instructions.md +62 -0
- package/adapters/cursor/.cursorrules +9 -0
- package/adapters/gemini/.gemini/context.md +58 -0
- package/adapters/opencode/.opencode/config.json +9 -0
- package/adapters/opencode/AGENTS.md +58 -0
- package/adapters/zed/.zed/settings.json +8 -0
- package/dist/agents-md.d.ts +3 -0
- package/dist/agents-md.d.ts.map +1 -0
- package/dist/agents-md.js +112 -0
- package/dist/agents-md.js.map +1 -0
- package/dist/analyzer/engine.js +1 -1
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/cli.js +1190 -401
- package/dist/cli.js.map +1 -1
- package/dist/metrics.d.ts +51 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +64 -0
- package/dist/metrics.js.map +1 -0
- package/dist/parser/lang-go.js +47 -47
- package/dist/parser/lang-java.js +29 -29
- package/dist/parser/lang-js.js +105 -105
- package/dist/parser/lang-php.js +38 -38
- package/dist/parser/lang-py.js +34 -34
- package/dist/parser/lang-ruby.js +14 -14
- package/dist/parser/lang-rust.js +30 -30
- package/dist/parser/lang-ts.js +191 -191
- package/dist/security/deps.d.ts +38 -0
- package/dist/security/deps.d.ts.map +1 -0
- package/dist/security/deps.js +685 -0
- package/dist/security/deps.js.map +1 -0
- package/dist/security/rules.d.ts +42 -0
- package/dist/security/rules.d.ts.map +1 -0
- package/dist/security/rules.js +940 -0
- package/dist/security/rules.js.map +1 -0
- package/dist/server/hooks.d.ts +26 -0
- package/dist/server/hooks.d.ts.map +1 -0
- package/dist/server/hooks.js +253 -0
- package/dist/server/hooks.js.map +1 -0
- package/dist/server/mcp-prompts.d.ts +277 -0
- package/dist/server/mcp-prompts.d.ts.map +1 -0
- package/dist/server/mcp-prompts.js +627 -0
- package/dist/server/mcp-prompts.js.map +1 -0
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +618 -643
- package/dist/server/mcp.js.map +1 -1
- package/dist/server/test-plan.d.ts +20 -0
- package/dist/server/test-plan.d.ts.map +1 -0
- package/dist/server/test-plan.js +100 -0
- package/dist/server/test-plan.js.map +1 -0
- package/dist/skills.js +152 -152
- package/dist/store/annotations.d.ts +41 -0
- package/dist/store/annotations.d.ts.map +1 -0
- package/dist/store/annotations.js +192 -0
- package/dist/store/annotations.js.map +1 -0
- package/dist/store/confidence.d.ts +18 -0
- package/dist/store/confidence.d.ts.map +1 -0
- package/dist/store/confidence.js +82 -0
- package/dist/store/confidence.js.map +1 -0
- package/dist/store/db.d.ts +37 -14
- package/dist/store/db.d.ts.map +1 -1
- package/dist/store/db.js +332 -239
- package/dist/store/db.js.map +1 -1
- package/dist/store/schema.sql +128 -116
- package/dist/store/vectors.js +2 -2
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +24 -0
- package/package.json +80 -66
- package/dist/gateway/analyzer.d.ts +0 -6
- package/dist/gateway/analyzer.d.ts.map +0 -1
- package/dist/gateway/analyzer.js +0 -218
- package/dist/gateway/analyzer.js.map +0 -1
- package/dist/gateway/cache.d.ts +0 -35
- package/dist/gateway/cache.d.ts.map +0 -1
- package/dist/gateway/cache.js +0 -175
- package/dist/gateway/cache.js.map +0 -1
- package/dist/gateway/config.d.ts +0 -10
- package/dist/gateway/config.d.ts.map +0 -1
- package/dist/gateway/config.js +0 -167
- package/dist/gateway/config.js.map +0 -1
- package/dist/gateway/context-memory.d.ts +0 -68
- package/dist/gateway/context-memory.d.ts.map +0 -1
- package/dist/gateway/context-memory.js +0 -157
- package/dist/gateway/context-memory.js.map +0 -1
- package/dist/gateway/observability.d.ts +0 -83
- package/dist/gateway/observability.d.ts.map +0 -1
- package/dist/gateway/observability.js +0 -152
- package/dist/gateway/observability.js.map +0 -1
- package/dist/gateway/privacy.d.ts +0 -27
- package/dist/gateway/privacy.d.ts.map +0 -1
- package/dist/gateway/privacy.js +0 -139
- package/dist/gateway/privacy.js.map +0 -1
- package/dist/gateway/providers.d.ts +0 -66
- package/dist/gateway/providers.d.ts.map +0 -1
- package/dist/gateway/providers.js +0 -377
- package/dist/gateway/providers.js.map +0 -1
- package/dist/gateway/router.d.ts +0 -18
- package/dist/gateway/router.d.ts.map +0 -1
- package/dist/gateway/router.js +0 -102
- package/dist/gateway/router.js.map +0 -1
- package/dist/gateway/server.d.ts +0 -20
- package/dist/gateway/server.d.ts.map +0 -1
- package/dist/gateway/server.js +0 -387
- package/dist/gateway/server.js.map +0 -1
- package/dist/gateway/translator.d.ts +0 -19
- package/dist/gateway/translator.d.ts.map +0 -1
- package/dist/gateway/translator.js +0 -340
- package/dist/gateway/translator.js.map +0 -1
- package/dist/gateway/types.d.ts +0 -215
- package/dist/gateway/types.d.ts.map +0 -1
- package/dist/gateway/types.js +0 -3
- package/dist/gateway/types.js.map +0 -1
- package/dist/store/gateway-schema.sql +0 -53
package/dist/server/mcp.js
CHANGED
|
@@ -6,19 +6,18 @@ 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, mkdirSync } from 'node:fs';
|
|
10
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
|
|
11
10
|
import { homedir } from 'node:os';
|
|
12
11
|
import ignore from 'ignore';
|
|
13
12
|
import { Database } from '../store/db.js';
|
|
14
13
|
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';
|
|
19
14
|
import { getParser, loadLanguage } from '../parser/loader.js';
|
|
20
15
|
import { ALL_LANGS } from '../parser/languages.js';
|
|
21
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { generateTestPlan } from './test-plan.js';
|
|
18
|
+
import { AnnotationStore } from '../store/annotations.js';
|
|
19
|
+
import { registerAllPrompts } from './mcp-prompts.js';
|
|
20
|
+
import { loadRules } from '../security/rules.js';
|
|
22
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
22
|
const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
24
23
|
// ── Lazy DB connection with idle eviction ──
|
|
@@ -31,8 +30,6 @@ class LazyDb {
|
|
|
31
30
|
statsCache = null;
|
|
32
31
|
domainCache = null;
|
|
33
32
|
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;
|
|
36
33
|
constructor(dbPath) {
|
|
37
34
|
this.dbPath = dbPath;
|
|
38
35
|
}
|
|
@@ -69,19 +66,6 @@ class LazyDb {
|
|
|
69
66
|
invalidateCache() {
|
|
70
67
|
this.statsCache = null;
|
|
71
68
|
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;
|
|
85
69
|
}
|
|
86
70
|
resetTimer() {
|
|
87
71
|
if (this.timer)
|
|
@@ -94,7 +78,6 @@ class LazyDb {
|
|
|
94
78
|
this.timer = null;
|
|
95
79
|
this.statsCache = null;
|
|
96
80
|
this.domainCache = null;
|
|
97
|
-
this.tfidfCache = null;
|
|
98
81
|
}
|
|
99
82
|
shutdown() {
|
|
100
83
|
if (this.timer)
|
|
@@ -118,21 +101,6 @@ const TOKEN_SAVINGS_MULTIPLIER = {
|
|
|
118
101
|
domains: 3, // vs exploring file structure
|
|
119
102
|
status: 2, // vs checking multiple stats
|
|
120
103
|
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
|
|
136
104
|
explain_relationship: 4, // vs manual path finding
|
|
137
105
|
find_dead_code: 3, // vs manual export usage search
|
|
138
106
|
get_file_symbols: 2, // vs reading entire file
|
|
@@ -194,6 +162,14 @@ function fmtImpact(items, detail = 'L1') {
|
|
|
194
162
|
}
|
|
195
163
|
return lines.join('\n');
|
|
196
164
|
}
|
|
165
|
+
/** Check if a file path looks like a test/spec file */
|
|
166
|
+
function isTestFilePath(filePath) {
|
|
167
|
+
return /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
|
|
168
|
+
/^tests?[/\\]/.test(filePath) ||
|
|
169
|
+
/__tests__[/\\]/.test(filePath) ||
|
|
170
|
+
/_test\.(go|py|rb|rs|java|php)$/.test(filePath) ||
|
|
171
|
+
/^test_.*\.py$/.test(filePath.split('/').pop() ?? '');
|
|
172
|
+
}
|
|
197
173
|
// ── Text grep across project files ──
|
|
198
174
|
const GREP_SKIP_DIRS = new Set([
|
|
199
175
|
'node_modules', '.git', 'dist', 'build', 'out',
|
|
@@ -212,7 +188,7 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
212
188
|
'.wasm', '.node', '.so', '.dll', '.dylib',
|
|
213
189
|
'.lock',
|
|
214
190
|
]);
|
|
215
|
-
|
|
191
|
+
function grepFiles(rootPath, pattern, options) {
|
|
216
192
|
const { isRegex = false, caseSensitive = false, maxResults = 50, includePattern } = options;
|
|
217
193
|
const flags = caseSensitive ? '' : 'i';
|
|
218
194
|
let regex;
|
|
@@ -225,12 +201,12 @@ async function grepFiles(rootPath, pattern, options) {
|
|
|
225
201
|
const ig = loadGrepIgnoreRules(rootPath);
|
|
226
202
|
const includeRe = includePattern ? globToRegex(includePattern) : null;
|
|
227
203
|
const results = [];
|
|
228
|
-
|
|
204
|
+
function walk(dir) {
|
|
229
205
|
if (results.length >= maxResults)
|
|
230
206
|
return;
|
|
231
207
|
let entries;
|
|
232
208
|
try {
|
|
233
|
-
entries =
|
|
209
|
+
entries = readdirSync(dir);
|
|
234
210
|
}
|
|
235
211
|
catch {
|
|
236
212
|
return;
|
|
@@ -246,26 +222,26 @@ async function grepFiles(rootPath, pattern, options) {
|
|
|
246
222
|
continue;
|
|
247
223
|
if (ig.ignores(rel))
|
|
248
224
|
continue;
|
|
249
|
-
let
|
|
225
|
+
let stat;
|
|
250
226
|
try {
|
|
251
|
-
|
|
227
|
+
stat = statSync(abs);
|
|
252
228
|
}
|
|
253
229
|
catch {
|
|
254
230
|
continue;
|
|
255
231
|
}
|
|
256
|
-
if (
|
|
257
|
-
|
|
232
|
+
if (stat.isDirectory()) {
|
|
233
|
+
walk(abs);
|
|
258
234
|
}
|
|
259
|
-
else if (
|
|
235
|
+
else if (stat.isFile()) {
|
|
260
236
|
const ext = '.' + entry.split('.').pop()?.toLowerCase();
|
|
261
237
|
if (BINARY_EXTENSIONS.has(ext))
|
|
262
238
|
continue;
|
|
263
|
-
if (
|
|
239
|
+
if (stat.size > 512 * 1024)
|
|
264
240
|
continue; // skip files > 512KB
|
|
265
241
|
if (includeRe && !includeRe.test(rel))
|
|
266
242
|
continue;
|
|
267
243
|
try {
|
|
268
|
-
const content =
|
|
244
|
+
const content = readFileSync(abs, 'utf-8');
|
|
269
245
|
const lines = content.split('\n');
|
|
270
246
|
for (let i = 0; i < lines.length && results.length < maxResults; i++) {
|
|
271
247
|
if (regex.test(lines[i])) {
|
|
@@ -277,7 +253,7 @@ async function grepFiles(rootPath, pattern, options) {
|
|
|
277
253
|
}
|
|
278
254
|
}
|
|
279
255
|
}
|
|
280
|
-
|
|
256
|
+
walk(rootPath);
|
|
281
257
|
return results;
|
|
282
258
|
}
|
|
283
259
|
function escapeRegExp(s) {
|
|
@@ -287,9 +263,7 @@ function escapeRegExp(s) {
|
|
|
287
263
|
function matchesScope(lineText, scope) {
|
|
288
264
|
const trimmed = lineText.trimStart();
|
|
289
265
|
if (scope === 'imports') {
|
|
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"
|
|
266
|
+
return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed);
|
|
293
267
|
}
|
|
294
268
|
// definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
|
|
295
269
|
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);
|
|
@@ -298,12 +272,8 @@ function matchesScope(lineText, scope) {
|
|
|
298
272
|
function safeRegex(pattern, flags) {
|
|
299
273
|
if (pattern.length > 200)
|
|
300
274
|
throw new Error('Pattern too long');
|
|
301
|
-
// Reject nested quantifiers
|
|
302
|
-
|
|
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))
|
|
275
|
+
// Reject nested quantifiers like (a+)+, (a*)*, (a{1,})+
|
|
276
|
+
if (/([+*}])\)?[+*{]/.test(pattern))
|
|
307
277
|
throw new Error('Unsafe regex pattern');
|
|
308
278
|
// Reject overlapping alternation inside quantified groups: (a|a)*, (ab|a)+
|
|
309
279
|
if (/\((?:[^)]*\|[^)]*)\)[+*{]/.test(pattern))
|
|
@@ -326,26 +296,12 @@ function safeRegex(pattern, flags) {
|
|
|
326
296
|
return new RegExp(pattern, flags);
|
|
327
297
|
}
|
|
328
298
|
function globToRegex(glob) {
|
|
329
|
-
|
|
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, '\\$&')
|
|
299
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
339
300
|
.replace(/\*\*/g, '§STARSTAR§')
|
|
340
301
|
.replace(/\*/g, '[^/]*')
|
|
341
302
|
.replace(/§STARSTAR§/g, '.*')
|
|
342
303
|
.replace(/\?/g, '.');
|
|
343
|
-
|
|
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');
|
|
304
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
349
305
|
}
|
|
350
306
|
function loadGrepIgnoreRules(rootPath) {
|
|
351
307
|
const ig = ignore();
|
|
@@ -358,57 +314,42 @@ function loadGrepIgnoreRules(rootPath) {
|
|
|
358
314
|
return ig;
|
|
359
315
|
}
|
|
360
316
|
// ── Server instructions (sent to client via MCP protocol on initialize) ──
|
|
361
|
-
const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codebases into symbol graphs.
|
|
362
|
-
|
|
363
|
-
## Tool selection
|
|
364
|
-
- \`query\` — find symbol definitions (code identifiers only)
|
|
365
|
-
- \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
|
|
366
|
-
- \`context\` — 360° view: incoming + outgoing for a symbol
|
|
367
|
-
- \`impact\` — blast radius: what breaks if symbol changes
|
|
368
|
-
- \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
|
|
369
|
-
- \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
|
|
370
|
-
- \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
|
|
371
|
-
- \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask,
|
|
372
|
-
- \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
|
|
373
|
-
- \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
|
|
374
|
-
- \`repos\` — list all indexed repositories with summary stats (multi-repo support)
|
|
375
|
-
- \`detect_changes\` — git diff → affected symbols
|
|
376
|
-
- \`
|
|
377
|
-
- \`
|
|
378
|
-
- \`
|
|
379
|
-
- \`
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
-
|
|
383
|
-
- \`
|
|
384
|
-
- \`
|
|
385
|
-
- \`
|
|
386
|
-
- \`
|
|
387
|
-
-
|
|
388
|
-
-
|
|
389
|
-
-
|
|
390
|
-
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
-
|
|
396
|
-
-
|
|
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
|
|
400
|
-
- \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
|
|
401
|
-
- Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
|
|
402
|
-
- impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
|
|
403
|
-
- ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
|
|
404
|
-
- ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
|
|
405
|
-
- ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
|
|
406
|
-
|
|
407
|
-
## Resources (MCP Resources protocol)
|
|
408
|
-
- \`milens://overview\` — index overview (stats, domains, coverage, staleness)
|
|
409
|
-
- \`milens://symbol/{name}\` — symbol context by name
|
|
410
|
-
- \`milens://file/{path}\` — all symbols in a file
|
|
411
|
-
- \`milens://domain/{name}\` — domain cluster details
|
|
317
|
+
const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codebases into symbol graphs.
|
|
318
|
+
|
|
319
|
+
## Tool selection
|
|
320
|
+
- \`query\` — find symbol definitions (code identifiers only)
|
|
321
|
+
- \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
|
|
322
|
+
- \`context\` — 360° view: incoming + outgoing for a symbol
|
|
323
|
+
- \`impact\` — blast radius: what breaks if symbol changes
|
|
324
|
+
- \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
|
|
325
|
+
- \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
|
|
326
|
+
- \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
|
|
327
|
+
- \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
|
|
328
|
+
- \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
|
|
329
|
+
- \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
|
|
330
|
+
- \`repos\` — list all indexed repositories with summary stats (multi-repo support)
|
|
331
|
+
- \`detect_changes\` — git diff → affected symbols
|
|
332
|
+
- \`explain_relationship\` — shortest path between two symbols
|
|
333
|
+
- \`find_dead_code\` — unused exports
|
|
334
|
+
- \`get_file_symbols\` — all symbols in a file
|
|
335
|
+
- \`get_type_hierarchy\` — inheritance tree
|
|
336
|
+
|
|
337
|
+
## Rules
|
|
338
|
+
- Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
|
|
339
|
+
- For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
|
|
340
|
+
- For writing tests: run \`smart_context\` with intent=test — shows deps to mock + callers to cover
|
|
341
|
+
- \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
|
|
342
|
+
- Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
|
|
343
|
+
- impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
|
|
344
|
+
- ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
|
|
345
|
+
- ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
|
|
346
|
+
- ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
|
|
347
|
+
|
|
348
|
+
## Resources (MCP Resources protocol)
|
|
349
|
+
- \`milens://overview\` — index overview (stats, domains, coverage, staleness)
|
|
350
|
+
- \`milens://symbol/{name}\` — symbol context by name
|
|
351
|
+
- \`milens://file/{path}\` — all symbols in a file
|
|
352
|
+
- \`milens://domain/{name}\` — domain cluster details
|
|
412
353
|
`;
|
|
413
354
|
// ── Server setup ──
|
|
414
355
|
export function createMcpServer(rootPath) {
|
|
@@ -473,6 +414,29 @@ export function createMcpServer(rootPath) {
|
|
|
473
414
|
}
|
|
474
415
|
return origTool(...args);
|
|
475
416
|
});
|
|
417
|
+
// ── Selective tool profiles (W4) ──
|
|
418
|
+
const profile = process.env.MILENS_PROFILE || undefined;
|
|
419
|
+
if (profile && profile !== 'full') {
|
|
420
|
+
const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
|
|
421
|
+
const standard = new Set([...minimal, 'domains', 'repos', 'explain_relationship', 'find_dead_code', 'get_type_hierarchy', 'trace', 'routes', 'smart_context', 'review_pr', 'review_symbol', 'test_coverage_gaps', 'test_plan', 'test_impact', 'session_start', 'recall']);
|
|
422
|
+
const allowed = profile === 'minimal' ? minimal : standard;
|
|
423
|
+
// Wrap server.tool again to gate by profile
|
|
424
|
+
const profileWrappedTool = server.tool.bind(server);
|
|
425
|
+
server.tool = ((...args) => {
|
|
426
|
+
const toolName = args[0];
|
|
427
|
+
if (!allowed.has(toolName)) {
|
|
428
|
+
// Return a no-op tool that explains it's disabled
|
|
429
|
+
const origLength = args.length;
|
|
430
|
+
const handler = args[origLength - 1];
|
|
431
|
+
if (typeof handler === 'function') {
|
|
432
|
+
args[origLength - 1] = async () => ({
|
|
433
|
+
content: [{ type: 'text', text: `Tool "${toolName}" disabled by profile "${profile}". Use --profile full to enable.` }],
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return profileWrappedTool(...args);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
476
440
|
// ── Tool: query ──
|
|
477
441
|
server.tool('query', 'Search indexed symbol definitions by name/kind. For text in templates/configs/docs, use `grep`.', {
|
|
478
442
|
query: z.string().describe('Symbol name, kind, or keyword to search'),
|
|
@@ -502,7 +466,7 @@ export function createMcpServer(rootPath) {
|
|
|
502
466
|
const effectiveInclude = scope === 'code' && !include
|
|
503
467
|
? '**/*.{ts,tsx,js,jsx,mjs,cjs,vue,py,go,rs,java,php,rb}'
|
|
504
468
|
: include;
|
|
505
|
-
const matches =
|
|
469
|
+
const matches = grepFiles(root, pattern, {
|
|
506
470
|
isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
|
|
507
471
|
});
|
|
508
472
|
// Apply scope-specific line filtering
|
|
@@ -717,7 +681,7 @@ export function createMcpServer(rootPath) {
|
|
|
717
681
|
}
|
|
718
682
|
}
|
|
719
683
|
// Section 4: Grep (text references across all files)
|
|
720
|
-
const grepMatches =
|
|
684
|
+
const grepMatches = grepFiles(root, name, { maxResults: 20 });
|
|
721
685
|
if (grepMatches.length > 0) {
|
|
722
686
|
const grouped = new Map();
|
|
723
687
|
for (const m of grepMatches) {
|
|
@@ -755,24 +719,19 @@ export function createMcpServer(rootPath) {
|
|
|
755
719
|
try {
|
|
756
720
|
const dbPath = registry.findDbPath(entry.rootPath);
|
|
757
721
|
if (dbPath) {
|
|
758
|
-
const
|
|
759
|
-
const tempDb = fromPool
|
|
722
|
+
const tempDb = pools.has(entry.rootPath)
|
|
760
723
|
? pools.get(entry.rootPath).get()
|
|
761
724
|
: new Database(dbPath);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
lines.push(` domains: ${summary.domains.join(', ')}`);
|
|
767
|
-
}
|
|
768
|
-
if (summary.staleCount > 0) {
|
|
769
|
-
lines.push(` ⏳ ${summary.staleCount} stale files`);
|
|
770
|
-
}
|
|
725
|
+
const summary = tempDb.getRepoSummary();
|
|
726
|
+
lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
|
|
727
|
+
if (summary.domains.length > 0) {
|
|
728
|
+
lines.push(` domains: ${summary.domains.join(', ')}`);
|
|
771
729
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
tempDb.close();
|
|
730
|
+
if (summary.staleCount > 0) {
|
|
731
|
+
lines.push(` ⏳ ${summary.staleCount} stale files`);
|
|
775
732
|
}
|
|
733
|
+
if (!pools.has(entry.rootPath))
|
|
734
|
+
tempDb.close();
|
|
776
735
|
}
|
|
777
736
|
}
|
|
778
737
|
catch {
|
|
@@ -794,9 +753,8 @@ export function createMcpServer(rootPath) {
|
|
|
794
753
|
}
|
|
795
754
|
let changedFiles;
|
|
796
755
|
try {
|
|
797
|
-
// Show both staged and unstaged changes against the ref
|
|
798
756
|
const output = execFileSync('git', ['diff', '--name-only', ref], { cwd: root, encoding: 'utf-8' });
|
|
799
|
-
const staged = execFileSync('git', ['diff', '--cached', '--name-only'
|
|
757
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: root, encoding: 'utf-8' });
|
|
800
758
|
changedFiles = [...new Set([...output.trim().split('\n'), ...staged.trim().split('\n')])].filter(Boolean);
|
|
801
759
|
}
|
|
802
760
|
catch {
|
|
@@ -825,343 +783,6 @@ export function createMcpServer(rootPath) {
|
|
|
825
783
|
lines.push(`\nTotal direct dependents affected: ${totalAffected}`);
|
|
826
784
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
827
785
|
});
|
|
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
|
-
});
|
|
1165
786
|
// ── Tool: explain_relationship ──
|
|
1166
787
|
server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
|
|
1167
788
|
from: z.string().describe('Source symbol name'),
|
|
@@ -1212,13 +833,10 @@ export function createMcpServer(rootPath) {
|
|
|
1212
833
|
const sorted = detail === 'L2'
|
|
1213
834
|
? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
|
|
1214
835
|
: 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));
|
|
1219
836
|
const lines = [`${file}: ${symbols.length} symbols\n`];
|
|
1220
837
|
for (const sym of sorted) {
|
|
1221
|
-
const
|
|
838
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
839
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1222
840
|
const exp = sym.exported ? ' (exported)' : '';
|
|
1223
841
|
if (detail === 'L0') {
|
|
1224
842
|
lines.push(`${sym.name} [${sym.kind}]${exp}`);
|
|
@@ -1230,10 +848,10 @@ export function createMcpServer(rootPath) {
|
|
|
1230
848
|
if (sym.heat != null && sym.heat > 0)
|
|
1231
849
|
meta.push(`heat:${sym.heat}`);
|
|
1232
850
|
const metaStr = meta.length > 0 ? ` {${meta.join(',')}}` : '';
|
|
1233
|
-
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${
|
|
851
|
+
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
1234
852
|
}
|
|
1235
853
|
else {
|
|
1236
|
-
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${
|
|
854
|
+
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
1237
855
|
}
|
|
1238
856
|
}
|
|
1239
857
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
@@ -1299,10 +917,8 @@ export function createMcpServer(rootPath) {
|
|
|
1299
917
|
sections.push(`callers: none`);
|
|
1300
918
|
}
|
|
1301
919
|
// 3. Export chain — is this re-exported from barrel files?
|
|
1302
|
-
const grepMatches =
|
|
1303
|
-
const reExportMatches = grepMatches.filter(m =>
|
|
1304
|
-
/from\s+\./.test(m.text) // Python re-export: from .module import X
|
|
1305
|
-
);
|
|
920
|
+
const grepMatches = grepFiles(root, name, { maxResults: 10, includePattern: '**/index.{ts,js,mjs}' });
|
|
921
|
+
const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
|
|
1306
922
|
if (reExportMatches.length > 0) {
|
|
1307
923
|
sections.push(`re-exported via:`);
|
|
1308
924
|
for (const m of reExportMatches) {
|
|
@@ -1317,27 +933,30 @@ export function createMcpServer(rootPath) {
|
|
|
1317
933
|
sections.push(` ${fmtSymbol(d)}`);
|
|
1318
934
|
}
|
|
1319
935
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
936
|
+
}
|
|
937
|
+
// 5. Unresolved warning (only for internal)
|
|
938
|
+
const unresolved = db.getUnresolvedStats();
|
|
939
|
+
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
940
|
+
sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
|
|
941
|
+
}
|
|
942
|
+
// 6. Test coverage for this symbol
|
|
943
|
+
for (const sym of symbols) {
|
|
944
|
+
const incoming = db.getIncomingLinks(sym.id);
|
|
945
|
+
const testRefs = incoming.filter(l => {
|
|
1324
946
|
const from = db.findSymbolById(l.fromId);
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
947
|
+
return from && isTestFilePath(from.filePath);
|
|
948
|
+
});
|
|
949
|
+
if (testRefs.length > 0) {
|
|
950
|
+
const testFiles = [...new Set(testRefs.map(l => {
|
|
951
|
+
const from = db.findSymbolById(l.fromId);
|
|
952
|
+
return from?.filePath;
|
|
953
|
+
}).filter(Boolean))];
|
|
954
|
+
sections.push(`✓ tested from: ${testFiles.join(', ')}`);
|
|
1331
955
|
}
|
|
1332
956
|
else if (sym.exported) {
|
|
1333
957
|
sections.push(`⚠ no test coverage for this exported symbol`);
|
|
1334
958
|
}
|
|
1335
959
|
}
|
|
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
|
-
}
|
|
1341
960
|
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
1342
961
|
});
|
|
1343
962
|
// ── Tool: trace ──
|
|
@@ -1399,9 +1018,9 @@ export function createMcpServer(rootPath) {
|
|
|
1399
1018
|
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
1400
1019
|
});
|
|
1401
1020
|
// ── Tool: routes ──
|
|
1402
|
-
server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask,
|
|
1021
|
+
server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
|
|
1403
1022
|
repo: z.string().optional(),
|
|
1404
|
-
framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask,
|
|
1023
|
+
framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
|
|
1405
1024
|
limit: z.number().optional().default(50),
|
|
1406
1025
|
}, async ({ repo, framework, limit }) => {
|
|
1407
1026
|
const root = resolveRoot(repo);
|
|
@@ -1411,24 +1030,20 @@ export function createMcpServer(rootPath) {
|
|
|
1411
1030
|
{ name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
|
|
1412
1031
|
{ name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
1413
1032
|
{ 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' },
|
|
1415
1033
|
{ name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
|
|
1416
1034
|
{ 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' },
|
|
1418
1035
|
{ name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
|
|
1419
1036
|
{ 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' },
|
|
1422
1037
|
];
|
|
1423
1038
|
const activePatterns = framework
|
|
1424
1039
|
? routePatterns.filter(p => p.name === framework.toLowerCase())
|
|
1425
1040
|
: routePatterns;
|
|
1426
1041
|
if (activePatterns.length === 0) {
|
|
1427
|
-
return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask,
|
|
1042
|
+
return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
|
|
1428
1043
|
}
|
|
1429
1044
|
const routes = [];
|
|
1430
1045
|
for (const rp of activePatterns) {
|
|
1431
|
-
const matches =
|
|
1046
|
+
const matches = grepFiles(root, rp.pattern.source, {
|
|
1432
1047
|
isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
|
|
1433
1048
|
});
|
|
1434
1049
|
for (const m of matches) {
|
|
@@ -1541,15 +1156,15 @@ export function createMcpServer(rootPath) {
|
|
|
1541
1156
|
}
|
|
1542
1157
|
}
|
|
1543
1158
|
// Re-export detection
|
|
1544
|
-
const reExportMatches =
|
|
1545
|
-
.filter(m =>
|
|
1159
|
+
const reExportMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' })
|
|
1160
|
+
.filter(m => /export\s*\{/.test(m.text) && m.text.includes('from'));
|
|
1546
1161
|
if (reExportMatches.length > 0) {
|
|
1547
1162
|
sections.push(`re-exported via: ${reExportMatches.map(m => `${m.file}:${m.line}`).join(', ')}`);
|
|
1548
1163
|
}
|
|
1549
1164
|
// Test coverage
|
|
1550
1165
|
const testRefs = incoming.filter(l => {
|
|
1551
1166
|
const from = db.findSymbolById(l.fromId);
|
|
1552
|
-
return from &&
|
|
1167
|
+
return from && isTestFilePath(from.filePath);
|
|
1553
1168
|
});
|
|
1554
1169
|
if (testRefs.length > 0) {
|
|
1555
1170
|
sections.push(`✓ has test coverage`);
|
|
@@ -1571,18 +1186,17 @@ export function createMcpServer(rootPath) {
|
|
|
1571
1186
|
else {
|
|
1572
1187
|
sections.push(`no call chains found (may be entrypoint or unreachable)`);
|
|
1573
1188
|
}
|
|
1574
|
-
//
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
for (const l of callLinks) {
|
|
1189
|
+
// What does this call? (downstream immediate)
|
|
1190
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type === 'calls');
|
|
1191
|
+
if (outgoing.length > 0) {
|
|
1192
|
+
sections.push(`calls (${outgoing.length}):`);
|
|
1193
|
+
for (const l of outgoing) {
|
|
1580
1194
|
const to = db.findSymbolById(l.toId);
|
|
1581
1195
|
sections.push(` ${to ? fmtSymbol(to) : l.toId}`);
|
|
1582
1196
|
}
|
|
1583
1197
|
}
|
|
1584
|
-
// Data types used
|
|
1585
|
-
const dataTypes =
|
|
1198
|
+
// Data types used
|
|
1199
|
+
const dataTypes = db.getOutgoingLinks(sym.id)
|
|
1586
1200
|
.filter(l => l.type === 'imports')
|
|
1587
1201
|
.map(l => db.findSymbolById(l.toId))
|
|
1588
1202
|
.filter(s => s && (s.kind === 'interface' || s.kind === 'type' || s.kind === 'class'))
|
|
@@ -1592,33 +1206,47 @@ export function createMcpServer(rootPath) {
|
|
|
1592
1206
|
}
|
|
1593
1207
|
}
|
|
1594
1208
|
else if (intent === 'test') {
|
|
1595
|
-
// Test coverage + what to mock
|
|
1209
|
+
// Test coverage + what to mock
|
|
1596
1210
|
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1211
|
+
const testRefs = incoming.filter(l => {
|
|
1212
|
+
const from = db.findSymbolById(l.fromId);
|
|
1213
|
+
return from && isTestFilePath(from.filePath);
|
|
1214
|
+
});
|
|
1599
1215
|
if (testRefs.length > 0) {
|
|
1600
|
-
const testFiles = [...new Set(testRefs.map(
|
|
1216
|
+
const testFiles = [...new Set(testRefs.map(l => {
|
|
1217
|
+
const from = db.findSymbolById(l.fromId);
|
|
1218
|
+
return from?.filePath;
|
|
1219
|
+
}).filter(Boolean))];
|
|
1601
1220
|
sections.push(`✓ tested from: ${testFiles.join(', ')}`);
|
|
1602
1221
|
}
|
|
1603
1222
|
else {
|
|
1604
1223
|
sections.push(`⚠ no existing tests`);
|
|
1605
1224
|
}
|
|
1606
|
-
// Dependencies to mock
|
|
1225
|
+
// Dependencies to mock
|
|
1607
1226
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1608
|
-
const
|
|
1609
|
-
|
|
1227
|
+
const externalDeps = outgoing.filter(l => {
|
|
1228
|
+
const to = db.findSymbolById(l.toId);
|
|
1229
|
+
return to && to.filePath !== sym.filePath;
|
|
1230
|
+
});
|
|
1610
1231
|
if (externalDeps.length > 0) {
|
|
1611
1232
|
sections.push(`dependencies to mock (${externalDeps.length}):`);
|
|
1612
|
-
for (const
|
|
1613
|
-
|
|
1233
|
+
for (const l of externalDeps) {
|
|
1234
|
+
const to = db.findSymbolById(l.toId);
|
|
1235
|
+
if (to)
|
|
1236
|
+
sections.push(` ${l.type}: ${fmtSymbol(to)}`);
|
|
1614
1237
|
}
|
|
1615
1238
|
}
|
|
1616
1239
|
// Inputs — what calls this? (test should cover these call patterns)
|
|
1617
|
-
const nonTestCallers =
|
|
1240
|
+
const nonTestCallers = incoming.filter(l => {
|
|
1241
|
+
const from = db.findSymbolById(l.fromId);
|
|
1242
|
+
return from && !isTestFilePath(from.filePath);
|
|
1243
|
+
});
|
|
1618
1244
|
if (nonTestCallers.length > 0) {
|
|
1619
1245
|
sections.push(`callers to cover (${nonTestCallers.length}):`);
|
|
1620
|
-
for (const
|
|
1621
|
-
|
|
1246
|
+
for (const l of nonTestCallers.slice(0, 5)) {
|
|
1247
|
+
const from = db.findSymbolById(l.fromId);
|
|
1248
|
+
if (from)
|
|
1249
|
+
sections.push(` ${fmtSymbol(from)}`);
|
|
1622
1250
|
}
|
|
1623
1251
|
}
|
|
1624
1252
|
}
|
|
@@ -1707,115 +1335,283 @@ export function createMcpServer(rootPath) {
|
|
|
1707
1335
|
return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
|
|
1708
1336
|
}
|
|
1709
1337
|
});
|
|
1710
|
-
//
|
|
1711
|
-
server.tool('
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1338
|
+
// ═══ codebase_summary ═══
|
|
1339
|
+
server.tool('codebase_summary', 'Compact ~500 token codebase overview: domains, top hubs, test coverage, annotations count. Use at the start of every session.', { repo: z.string().optional() }, async ({ repo }) => {
|
|
1340
|
+
const { db } = getDb(repo);
|
|
1341
|
+
const summary = db.getCodebaseSummary();
|
|
1342
|
+
const lines = [
|
|
1343
|
+
'Milens Codebase Summary:',
|
|
1344
|
+
` Symbols: ${summary.symbols} | Links: ${summary.links} | Files: ${summary.files}`,
|
|
1345
|
+
];
|
|
1346
|
+
if (summary.exportedSymbols > 0) {
|
|
1347
|
+
lines.push(` Test coverage: ${summary.coveragePct}% (${summary.testedSymbols}/${summary.exportedSymbols} exported symbols tested)`);
|
|
1348
|
+
}
|
|
1349
|
+
if (summary.domains.length > 0) {
|
|
1350
|
+
const domainStr = summary.domains.map(d => `${d.domain}(${d.symbols}s)`).join(', ');
|
|
1351
|
+
lines.push(` Domains: ${domainStr}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (summary.topHubs.length > 0) {
|
|
1354
|
+
const hubStr = summary.topHubs.map(h => `${h.name}(${h.kind},heat:${h.heat})`).join(', ');
|
|
1355
|
+
lines.push(` Top hubs: ${hubStr}`);
|
|
1356
|
+
}
|
|
1723
1357
|
try {
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
}
|
|
1727
|
-
catch {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1358
|
+
const annCount = db.getAnnotationCount();
|
|
1359
|
+
lines.push(` Total annotations: ${annCount}`);
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
lines.push(' Total annotations: 0');
|
|
1363
|
+
}
|
|
1364
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1365
|
+
});
|
|
1366
|
+
// ═══ review_pr ═══
|
|
1367
|
+
server.tool('review_pr', 'PR risk assessment: git diff -> affected symbols with risk scores (LOW/MEDIUM/HIGH/CRITICAL).', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
|
|
1368
|
+
const { db, root } = getDb(repo);
|
|
1369
|
+
let changedFiles = [];
|
|
1370
|
+
try {
|
|
1371
|
+
const { execSync } = await import('node:child_process');
|
|
1372
|
+
const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
|
|
1373
|
+
changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
|
|
1374
|
+
}
|
|
1375
|
+
catch { }
|
|
1376
|
+
if (changedFiles.length === 0) {
|
|
1377
|
+
return { content: [{ type: 'text', text: 'No changed files detected.' }] };
|
|
1378
|
+
}
|
|
1379
|
+
const allAffected = [];
|
|
1380
|
+
for (const file of changedFiles) {
|
|
1381
|
+
const syms = db.getSymbolsByFile(file);
|
|
1382
|
+
if (syms.length === 0)
|
|
1383
|
+
continue;
|
|
1384
|
+
for (const sym of syms) {
|
|
1385
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1386
|
+
const depsCount = incoming.length;
|
|
1387
|
+
const heat = sym.heat ?? 0;
|
|
1388
|
+
const hasTest = db.getSymbolTestCoverage(sym.id);
|
|
1389
|
+
const score = Math.round((heat / 100) * 40 + Math.min(depsCount / 10, 1) * 35 + (hasTest ? 0 : 25));
|
|
1390
|
+
let level = 'LOW';
|
|
1391
|
+
if (score > 75)
|
|
1392
|
+
level = 'CRITICAL';
|
|
1393
|
+
else if (score > 50)
|
|
1394
|
+
level = 'HIGH';
|
|
1395
|
+
else if (score > 25)
|
|
1396
|
+
level = 'MEDIUM';
|
|
1397
|
+
allAffected.push({ symbol: sym.name, kind: sym.kind, file: sym.filePath, heat, dependents: depsCount, hasTest, riskScore: score, riskLevel: level });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
allAffected.sort((a, b) => b.riskScore - a.riskScore);
|
|
1401
|
+
const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
1402
|
+
for (const a of allAffected)
|
|
1403
|
+
summary[a.riskLevel]++;
|
|
1404
|
+
const lines = [`PR Risk Assessment (vs ${ref}):\n`];
|
|
1405
|
+
lines.push(`${changedFiles.length} changed files, ${allAffected.length} affected symbols\n`);
|
|
1406
|
+
for (const a of allAffected.slice(0, 30)) {
|
|
1407
|
+
lines.push(` ${a.symbol} [${a.kind}] ${a.file} — heat:${a.heat} deps:${a.dependents} test:${a.hasTest ? 'yes' : 'no'} → ${a.riskLevel}(${a.riskScore})`);
|
|
1408
|
+
}
|
|
1409
|
+
lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
|
|
1410
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1411
|
+
});
|
|
1412
|
+
// ═══ review_symbol ═══
|
|
1413
|
+
server.tool('review_symbol', 'Deep-dive single symbol risk: role, heat, dependents, test status, risk level.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
|
|
1414
|
+
const { db, root } = getDb(repo);
|
|
1415
|
+
const syms = db.findSymbolByName(name);
|
|
1416
|
+
if (syms.length === 0)
|
|
1417
|
+
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1418
|
+
const lines = [];
|
|
1419
|
+
for (const sym of syms) {
|
|
1420
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1421
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1422
|
+
const depsCount = incoming.length;
|
|
1423
|
+
const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
|
|
1424
|
+
const outCount = outgoing.length;
|
|
1425
|
+
const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
|
|
1426
|
+
const hasTest = sym.exported ? db.getSymbolTestCoverage(sym.id) : false;
|
|
1427
|
+
const heat = sym.heat ?? 0;
|
|
1428
|
+
const score = Math.round((heat / 100) * 40 + Math.min(depsCount / 10, 1) * 35 + (hasTest ? 0 : 25));
|
|
1429
|
+
let risk = 'LOW';
|
|
1430
|
+
if (score > 75)
|
|
1431
|
+
risk = 'CRITICAL';
|
|
1432
|
+
else if (score > 50)
|
|
1433
|
+
risk = 'HIGH';
|
|
1434
|
+
else if (score > 25)
|
|
1435
|
+
risk = 'MEDIUM';
|
|
1436
|
+
lines.push(`${sym.name} [${sym.kind}] ${sym.filePath}:${sym.startLine}`);
|
|
1437
|
+
lines.push(` role: ${sym.role ?? 'unknown'} | heat: ${heat} | exported: ${sym.exported}`);
|
|
1438
|
+
lines.push(` dependents: ${depsCount} ${depsTop.length ? '(' + depsTop.join(', ') + ')' : ''}`);
|
|
1439
|
+
lines.push(` dependencies: ${outCount} ${outTop.length ? '(' + outTop.join(', ') + ')' : ''}`);
|
|
1440
|
+
lines.push(` test coverage: ${hasTest ? 'yes' : 'no'}`);
|
|
1441
|
+
lines.push(` risk: ${risk} (score: ${score})`);
|
|
1442
|
+
if (risk === 'CRITICAL')
|
|
1443
|
+
lines.push(` recommendation: High risk — has ${depsCount} dependents with no test coverage. Write tests before modifying.`);
|
|
1444
|
+
else if (risk === 'HIGH')
|
|
1445
|
+
lines.push(` recommendation: Review dependents carefully before modifying.`);
|
|
1446
|
+
lines.push('');
|
|
1447
|
+
}
|
|
1448
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1449
|
+
});
|
|
1450
|
+
// ═══ test_coverage_gaps ═══
|
|
1451
|
+
server.tool('test_coverage_gaps', 'Untested exported symbols sorted by risk. Prioritize writing tests for these.', { limit: z.number().optional().default(20), repo: z.string().optional() }, async ({ limit, repo }) => {
|
|
1452
|
+
const { db } = getDb(repo);
|
|
1453
|
+
const coverage = db.getTestCoverage();
|
|
1454
|
+
const gaps = db.getTestCoverageGaps(limit);
|
|
1455
|
+
const lines = [`Test Coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${coverage.exportedProductionSymbols > 0 ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0}%) from ${coverage.testFiles} test files\n`];
|
|
1456
|
+
if (gaps.length === 0) {
|
|
1457
|
+
lines.push('All exported symbols have test coverage!');
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
lines.push(`Top ${gaps.length} untested symbols:\n`);
|
|
1461
|
+
for (const g of gaps) {
|
|
1462
|
+
const incoming = db.getIncomingLinks(g.id).filter(l => l.type !== 'contains');
|
|
1463
|
+
const risk = (g.heat ?? 0) > 80 ? 'CRITICAL' : (g.heat ?? 0) > 50 ? 'HIGH' : (g.heat ?? 0) > 30 ? 'MEDIUM' : 'LOW';
|
|
1464
|
+
lines.push(` ${g.name} [${g.kind}] ${g.filePath}:${g.startLine} — heat:${g.heat ?? 0} deps:${incoming.length} risk:${risk}`);
|
|
1755
1465
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1466
|
+
}
|
|
1467
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1468
|
+
});
|
|
1469
|
+
// ═══ test_impact ═══
|
|
1470
|
+
server.tool('test_impact', 'Map changed code -> which test files to run. Use after making changes.', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
|
|
1471
|
+
const { db, root } = getDb(repo);
|
|
1472
|
+
let changedFiles = [];
|
|
1473
|
+
try {
|
|
1474
|
+
const { execSync } = await import('node:child_process');
|
|
1475
|
+
const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
|
|
1476
|
+
changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
|
|
1477
|
+
}
|
|
1478
|
+
catch { }
|
|
1479
|
+
if (changedFiles.length === 0)
|
|
1480
|
+
return { content: [{ type: 'text', text: 'No changed files.' }] };
|
|
1481
|
+
const changedIds = [];
|
|
1482
|
+
const changedNames = [];
|
|
1483
|
+
for (const file of changedFiles) {
|
|
1484
|
+
for (const sym of db.getSymbolsByFile(file)) {
|
|
1485
|
+
changedIds.push(sym.id);
|
|
1486
|
+
changedNames.push(sym.name);
|
|
1760
1487
|
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
const lines =
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1488
|
+
}
|
|
1489
|
+
if (changedIds.length === 0)
|
|
1490
|
+
return { content: [{ type: 'text', text: 'No symbols in changed files.' }] };
|
|
1491
|
+
const impact = db.getTestImpact(changedIds);
|
|
1492
|
+
const lines = [`Changed symbols (${changedNames.length}): ${changedNames.join(', ')}`];
|
|
1493
|
+
lines.push(`\nAffected test files (${impact.testFiles.length}):`);
|
|
1494
|
+
for (const f of impact.testFiles)
|
|
1495
|
+
lines.push(` ${f}`);
|
|
1496
|
+
if (impact.testFiles.length > 0) {
|
|
1497
|
+
lines.push(`\nSuggested command: npx vitest run ${impact.testFiles.join(' ')}`);
|
|
1498
|
+
}
|
|
1499
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1771
1500
|
});
|
|
1772
|
-
//
|
|
1773
|
-
server.tool('
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1501
|
+
// ═══ test_plan ═══
|
|
1502
|
+
server.tool('test_plan', 'Generate a test strategy for a symbol: mock plan + >=3 test scenarios.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
|
|
1503
|
+
const { db } = getDb(repo);
|
|
1504
|
+
const plan = generateTestPlan(db, name);
|
|
1505
|
+
if (!plan)
|
|
1506
|
+
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1507
|
+
return { content: [{ type: 'text', text: plan.planText }] };
|
|
1508
|
+
});
|
|
1509
|
+
// ═══ annotate ═══
|
|
1510
|
+
server.tool('annotate', 'Record a note about a symbol for future sessions. Use after discovering bugs, patterns, or important caveats.', {
|
|
1511
|
+
symbol: z.string(),
|
|
1512
|
+
key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']),
|
|
1513
|
+
value: z.string(),
|
|
1514
|
+
agent: z.string().optional(),
|
|
1515
|
+
session_id: z.string().optional(),
|
|
1516
|
+
confidence: z.number().optional().default(0.5),
|
|
1517
|
+
}, async ({ symbol, key, value, agent, session_id, confidence }) => {
|
|
1518
|
+
const { db } = getDb();
|
|
1519
|
+
const store = new AnnotationStore(db.connection);
|
|
1520
|
+
const ann = store.annotate(symbol, key, value, { agent, sessionId: session_id });
|
|
1521
|
+
return { content: [{ type: 'text', text: `Annotation saved: ${ann.id}\n symbol: ${ann.symbol}\n key: ${ann.key}\n confidence: ${ann.confidence}` }] };
|
|
1522
|
+
});
|
|
1523
|
+
// ═══ recall ═══
|
|
1524
|
+
server.tool('recall', 'Retrieve annotations saved in previous sessions. Filter by symbol, key, or agent.', {
|
|
1525
|
+
symbol: z.string().optional(), key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']).optional(),
|
|
1526
|
+
agent: z.string().optional(), limit: z.number().optional().default(50),
|
|
1527
|
+
}, async ({ symbol, key, agent, limit }) => {
|
|
1528
|
+
const { db } = getDb();
|
|
1529
|
+
const store = new AnnotationStore(db.connection);
|
|
1530
|
+
const results = store.recall({ symbol, key, agent, limit });
|
|
1531
|
+
if (results.length === 0)
|
|
1532
|
+
return { content: [{ type: 'text', text: 'No annotations found.' }] };
|
|
1533
|
+
const lines = [`${results.length} annotation(s):\n`];
|
|
1534
|
+
for (const a of results) {
|
|
1535
|
+
lines.push(`[${a.key}] ${a.symbol} — ${a.value.slice(0, 120)}`);
|
|
1536
|
+
lines.push(` confidence: ${a.confidence.toFixed(1)} | agent: ${a.agent ?? '?'} | ${a.updatedAt}\n`);
|
|
1537
|
+
}
|
|
1538
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1539
|
+
});
|
|
1540
|
+
// ═══ session_start ═══
|
|
1541
|
+
server.tool('session_start', 'Start a new session. Returns a session ID to use with annotate, session_end, and handoff.', { agent: z.string().describe('Agent name (e.g. vibe-coder, reviewer)') }, async ({ agent }) => {
|
|
1542
|
+
const { db } = getDb();
|
|
1543
|
+
const store = new AnnotationStore(db.connection);
|
|
1544
|
+
const sessionId = store.sessionStart(agent);
|
|
1545
|
+
return { content: [{ type: 'text', text: `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().` }] };
|
|
1546
|
+
});
|
|
1547
|
+
// ═══ session_context ═══
|
|
1548
|
+
server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
|
|
1549
|
+
const { db } = getDb();
|
|
1550
|
+
const store = new AnnotationStore(db.connection);
|
|
1551
|
+
const ctx = store.sessionContext(session_id);
|
|
1552
|
+
if (!ctx.session)
|
|
1553
|
+
return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
|
|
1554
|
+
const s = ctx.session;
|
|
1555
|
+
const lines = [
|
|
1556
|
+
`Session: ${s.id}`,
|
|
1557
|
+
`Agent: ${s.agent} | Status: ${s.status}`,
|
|
1558
|
+
`Started: ${s.startedAt} | Ended: ${s.endedAt ?? 'in progress'}`,
|
|
1559
|
+
`Tool calls: ${s.toolCallsCount} | Annotations: ${s.annotationsCount}`,
|
|
1560
|
+
];
|
|
1561
|
+
if (s.context)
|
|
1562
|
+
lines.push(`Context: ${s.context}`);
|
|
1563
|
+
if (ctx.annotations.length > 0) {
|
|
1564
|
+
lines.push(`\nAnnotations (${ctx.annotations.length}):`);
|
|
1565
|
+
for (const a of ctx.annotations) {
|
|
1566
|
+
lines.push(` [${a.key}] ${a.symbol}: ${a.value.slice(0, 80)}`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1570
|
+
});
|
|
1571
|
+
// ═══ session_end ═══
|
|
1572
|
+
server.tool('session_end', 'End a session and record its stats. Use at the end of every session.', { session_id: z.string(), status: z.enum(['completed', 'failed']).optional().default('completed') }, async ({ session_id, status }) => {
|
|
1573
|
+
const { db } = getDb();
|
|
1574
|
+
const store = new AnnotationStore(db.connection);
|
|
1575
|
+
const summary = store.sessionEnd(session_id, status);
|
|
1576
|
+
return { content: [{ type: 'text', text: `Session ended: ${session_id}\nStatus: ${status}\nAnnotations: ${summary.annotationCount}` }] };
|
|
1577
|
+
});
|
|
1578
|
+
// ═══ handoff ═══
|
|
1579
|
+
server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one for the target agent.', {
|
|
1580
|
+
from_session: z.string(), to_agent: z.string(),
|
|
1581
|
+
context: z.string().describe('Summary of what was done, key decisions, and caveats for the next agent'),
|
|
1582
|
+
}, async ({ from_session, to_agent, context }) => {
|
|
1583
|
+
const { db } = getDb();
|
|
1584
|
+
const store = new AnnotationStore(db.connection);
|
|
1585
|
+
const result = store.handoff(from_session, to_agent, context);
|
|
1586
|
+
return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
|
|
1587
|
+
});
|
|
1588
|
+
// ═══ semantic_search ═══
|
|
1589
|
+
server.tool('semantic_search', 'Search symbols by semantic meaning (falls back to FTS5 keyword search when embeddings unavailable).', { query: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ query, limit, repo }) => {
|
|
1590
|
+
const { db } = getDb(repo);
|
|
1591
|
+
if (db.searchSymbols(query, limit).length > 0) {
|
|
1592
|
+
const results = db.searchSymbols(query, limit);
|
|
1593
|
+
const lines = [`Semantic search (FTS5 fallback — embeddings not available):\n`];
|
|
1594
|
+
for (const s of results) {
|
|
1595
|
+
lines.push(`${s.name} [${s.kind}] ${s.filePath}:${s.startLine}${s.exported ? ' (exported)' : ''}`);
|
|
1596
|
+
}
|
|
1597
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1598
|
+
}
|
|
1599
|
+
return { content: [{ type: 'text', text: `No results for "${query}". Embeddings not available. Run \`milens analyze --embeddings\` for semantic search.` }] };
|
|
1600
|
+
});
|
|
1601
|
+
// ═══ find_similar ═══
|
|
1602
|
+
server.tool('find_similar', 'Find symbols topologically similar to a given symbol (shared callers/callees). Useful for finding patterns to copy or refactor together.', { name: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ name, limit, repo }) => {
|
|
1603
|
+
const { db } = getDb(repo);
|
|
1781
1604
|
const syms = db.findSymbolByName(name);
|
|
1782
|
-
if (syms.length === 0)
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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 }] };
|
|
1605
|
+
if (syms.length === 0)
|
|
1606
|
+
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1607
|
+
const results = db.findTopologicallySimilar(syms[0].id, limit);
|
|
1608
|
+
if (results.length === 0)
|
|
1609
|
+
return { content: [{ type: 'text', text: `No similar symbols found for "${name}".` }] };
|
|
1610
|
+
const lines = [`Symbols similar to "${name}":\n`];
|
|
1611
|
+
for (const r of results) {
|
|
1612
|
+
lines.push(` ${r.symbol.name} [${r.symbol.kind}] ${r.symbol.filePath}:${r.symbol.startLine} — similarity: ${r.similarity.toFixed(2)}`);
|
|
1613
|
+
}
|
|
1614
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1819
1615
|
});
|
|
1820
1616
|
// ══════════════════════════════════════════════
|
|
1821
1617
|
// ── MCP Resources ──
|
|
@@ -1859,11 +1655,11 @@ export function createMcpServer(rootPath) {
|
|
|
1859
1655
|
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
|
|
1860
1656
|
}
|
|
1861
1657
|
const lines = [`${filePath}: ${symbols.length} symbols\n`];
|
|
1862
|
-
const linkCounts = db.getLinkCountsForSymbols(symbols.map(s => s.id));
|
|
1863
1658
|
for (const sym of symbols) {
|
|
1864
|
-
const
|
|
1659
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1660
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1865
1661
|
const exp = sym.exported ? ' (exported)' : '';
|
|
1866
|
-
lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${
|
|
1662
|
+
lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
1867
1663
|
}
|
|
1868
1664
|
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1869
1665
|
});
|
|
@@ -1971,6 +1767,185 @@ export function createMcpServer(rootPath) {
|
|
|
1971
1767
|
},
|
|
1972
1768
|
}],
|
|
1973
1769
|
}));
|
|
1770
|
+
// ── Prompt: vibe-code-planner ──
|
|
1771
|
+
server.prompt('vibe-code-planner', 'ECC-style Planner Agent workflow: analyze codebase, create implementation plan with blast radius awareness', { feature: z.string().describe('Feature or task name to plan') }, ({ feature }) => ({
|
|
1772
|
+
messages: [{
|
|
1773
|
+
role: 'user',
|
|
1774
|
+
content: {
|
|
1775
|
+
type: 'text',
|
|
1776
|
+
text: `I am the Planner Agent. I need to create an implementation plan for "${feature}".\n\n` +
|
|
1777
|
+
`Follow this ECC Planner workflow:\n\n` +
|
|
1778
|
+
`PHASE 1 — CODEBASE INTELLIGENCE:\n` +
|
|
1779
|
+
`1. Run \`codebase_summary()\` to understand the project structure\n` +
|
|
1780
|
+
`2. Run \`domains()\` to see module clusters\n` +
|
|
1781
|
+
`3. Run \`routes()\` to find relevant API endpoints\n\n` +
|
|
1782
|
+
`PHASE 2 — TARGET ANALYSIS:\n` +
|
|
1783
|
+
`4. Run \`smart_context({name: "keySymbol", intent: "edit"})\` for each affected symbol\n` +
|
|
1784
|
+
`5. Run \`edit_check({name: "keySymbol"})\` for safety\n` +
|
|
1785
|
+
`6. Run \`trace({to: "keySymbol"})\` to understand execution flow\n\n` +
|
|
1786
|
+
`PHASE 3 — IMPACT PREDICTION:\n` +
|
|
1787
|
+
`7. Run \`impact({target: "keySymbol", depth: 3})\` to see blast radius\n` +
|
|
1788
|
+
`8. Run \`explain_relationship({from: "A", to: "B"})\` for distant dependencies\n\n` +
|
|
1789
|
+
`PHASE 4 — TEST STRATEGY:\n` +
|
|
1790
|
+
`9. Run \`test_plan({name: "keySymbol"})\` for mock strategy\n` +
|
|
1791
|
+
`10. Run \`test_coverage_gaps()\` to check existing coverage\n\n` +
|
|
1792
|
+
`PHASE 5 — FINAL PLAN:\n` +
|
|
1793
|
+
`Output a plan.md with: Overview, Architecture Changes, Implementation Steps (file+action+why+deps+risk), Testing Strategy, Risks & Mitigations, Success Criteria.\n\n` +
|
|
1794
|
+
`Use the ECC plan format with specific file paths, dependencies, and risk levels (LOW/MEDIUM/HIGH).`,
|
|
1795
|
+
},
|
|
1796
|
+
}],
|
|
1797
|
+
}));
|
|
1798
|
+
// ── Prompt: vibe-code-reviewer ──
|
|
1799
|
+
server.prompt('vibe-code-reviewer', 'ECC-style Reviewer Agent workflow: PR risk assessment, dead code detection, security scan', { session_id: z.string().optional().describe('Optional session ID for annotation context') }, ({ session_id }) => ({
|
|
1800
|
+
messages: [{
|
|
1801
|
+
role: 'user',
|
|
1802
|
+
content: {
|
|
1803
|
+
type: 'text',
|
|
1804
|
+
text: `I am the Reviewer Agent. Review the current changes thoroughly.${session_id ? ` Session: ${session_id}` : ''}\n\n` +
|
|
1805
|
+
`Follow this ECC Reviewer workflow:\n\n` +
|
|
1806
|
+
`1. Run \`review_pr()\` to get risk scores for all changed symbols\n` +
|
|
1807
|
+
`2. For each CRITICAL/HIGH symbol:\n` +
|
|
1808
|
+
` a. Run \`review_symbol({name})\` for deep dive\n` +
|
|
1809
|
+
` b. Run \`context({name})\` to see relationships\n` +
|
|
1810
|
+
` c. Run \`grep({pattern: "symbolName"})\` for text references\n` +
|
|
1811
|
+
`3. Run \`find_dead_code()\` to detect orphaned symbols\n` +
|
|
1812
|
+
`4. Run \`grep({pattern: "password|secret|api_key|token", scope: "code"})\` for secrets\n` +
|
|
1813
|
+
`5. Run \`grep({pattern: "TODO|FIXME|HACK|console\\\\.log", scope: "code"})\` for tech debt\n` +
|
|
1814
|
+
`6. Run \`detect_changes()\` to verify expected files only\n` +
|
|
1815
|
+
`7. Create a review report: symbols OK to merge vs symbols needing fixes\n` +
|
|
1816
|
+
`8. Run \`annotate({symbol, key: "bug"|"security", value})\` for any critical findings`,
|
|
1817
|
+
},
|
|
1818
|
+
}],
|
|
1819
|
+
}));
|
|
1820
|
+
// ── Prompt: closed-loop-session ──
|
|
1821
|
+
server.prompt('closed-loop-session', 'Complete 6-phase closed-loop session: Analyze → Plan → Code → Verify → Learn → Improve', { task: z.string().describe('Task description'), agent: z.string().optional().default('vibe-coder') }, ({ task, agent }) => ({
|
|
1822
|
+
messages: [{
|
|
1823
|
+
role: 'user',
|
|
1824
|
+
content: {
|
|
1825
|
+
type: 'text',
|
|
1826
|
+
text: `Run a complete closed-loop development session for: "${task}"\n\n` +
|
|
1827
|
+
`PHASE 1 — ANALYZE (bootstrap):\n` +
|
|
1828
|
+
` session_start({agent: "${agent}"}) → codebase_summary() → domains() → recall()\n\n` +
|
|
1829
|
+
`PHASE 2 — PLAN:\n` +
|
|
1830
|
+
` smart_context({intent: "edit"}) → edit_check() → impact({depth: 3}) → test_plan()\n\n` +
|
|
1831
|
+
`PHASE 3 — CODE:\n` +
|
|
1832
|
+
` Implement changes with guard: edit_check() before each edit, impact() mid-edit, context() for reference\n\n` +
|
|
1833
|
+
`PHASE 4 — VERIFY:\n` +
|
|
1834
|
+
` detect_changes() → test_impact() → review_pr() → test_coverage_gaps() → grep(secrets)\n\n` +
|
|
1835
|
+
`PHASE 5 — LEARN:\n` +
|
|
1836
|
+
` annotate() key observations → session_context() → handoff() if needed\n\n` +
|
|
1837
|
+
`PHASE 6 — IMPROVE:\n` +
|
|
1838
|
+
` milens evolve (if patterns ready) → milens metrics (check health)\n\n` +
|
|
1839
|
+
`At the end: session_end({session_id}) to record stats.`,
|
|
1840
|
+
},
|
|
1841
|
+
}],
|
|
1842
|
+
}));
|
|
1843
|
+
// ── Register MCP Prompts (W1) ──
|
|
1844
|
+
registerAllPrompts(server);
|
|
1845
|
+
// ── Tool: security_scan (S2) ──
|
|
1846
|
+
server.tool('security_scan', 'Scan codebase for security vulnerabilities using 50+ built-in rules. Replaces multiple manual grep() calls. Categories: secrets, injection, unicode, dangerous, config, data-leak, crypto, auth, file-access.', {
|
|
1847
|
+
scope: z.enum(['all', 'secrets', 'injection', 'unicode', 'dangerous', 'config', 'data-leak', 'crypto', 'auth', 'file-access']).optional().default('all').describe('Scan scope'),
|
|
1848
|
+
repo: z.string().optional().describe('Repository root path'),
|
|
1849
|
+
severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional().describe('Minimum severity filter'),
|
|
1850
|
+
limit: z.number().optional().default(50).describe('Max findings'),
|
|
1851
|
+
}, async ({ scope, repo, severity, limit }) => {
|
|
1852
|
+
const { db, root } = getDb(repo);
|
|
1853
|
+
const rules = loadRules();
|
|
1854
|
+
// Filter rules by scope and severity
|
|
1855
|
+
const filtered = rules.filter(r => {
|
|
1856
|
+
if (scope !== 'all' && r.category !== scope)
|
|
1857
|
+
return false;
|
|
1858
|
+
if (severity) {
|
|
1859
|
+
const sevOrder = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
1860
|
+
if ((sevOrder[r.severity] || 0) < (sevOrder[severity] || 0))
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
return r.enabled;
|
|
1864
|
+
});
|
|
1865
|
+
// Get all source files from the DB
|
|
1866
|
+
const symbols = db.getAllSymbols();
|
|
1867
|
+
const fileSet = new Set();
|
|
1868
|
+
for (const s of symbols) {
|
|
1869
|
+
if (s.filePath && !s.filePath.includes('node_modules') && !s.filePath.includes('.git')) {
|
|
1870
|
+
fileSet.add(s.filePath);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
const files = [...fileSet].slice(0, 1000); // cap at 1000 files
|
|
1874
|
+
const { readFileSync: rfs, existsSync: es } = await import('node:fs');
|
|
1875
|
+
const { resolve: resolvePath } = await import('node:path');
|
|
1876
|
+
const findings = [];
|
|
1877
|
+
const byCategory = {};
|
|
1878
|
+
const bySeverity = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
1879
|
+
for (const file of files) {
|
|
1880
|
+
const fullPath = resolvePath(root, file);
|
|
1881
|
+
if (!es(fullPath))
|
|
1882
|
+
continue;
|
|
1883
|
+
// Skip files that don't match rule fileGlobs (simple check)
|
|
1884
|
+
const applicableRules = filtered.filter(r => {
|
|
1885
|
+
if (!r.fileGlob)
|
|
1886
|
+
return true;
|
|
1887
|
+
// Simple glob: just check extension
|
|
1888
|
+
const ext = r.fileGlob.replace('**/*.', '').replace('**/*', '');
|
|
1889
|
+
return file.endsWith(ext) || r.fileGlob === '**/*';
|
|
1890
|
+
});
|
|
1891
|
+
if (applicableRules.length === 0)
|
|
1892
|
+
continue;
|
|
1893
|
+
try {
|
|
1894
|
+
const content = rfs(fullPath, 'utf-8');
|
|
1895
|
+
const lines = content.split('\n');
|
|
1896
|
+
for (const rule of applicableRules) {
|
|
1897
|
+
for (const pattern of rule.patterns) {
|
|
1898
|
+
let match;
|
|
1899
|
+
// Reset regex lastIndex for global patterns
|
|
1900
|
+
pattern.lastIndex = 0;
|
|
1901
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1902
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
1903
|
+
const ctxStart = Math.max(0, lineNum - 3);
|
|
1904
|
+
const ctxEnd = Math.min(lines.length, lineNum + 2);
|
|
1905
|
+
const context = lines.slice(ctxStart, ctxEnd).join('\n');
|
|
1906
|
+
findings.push({
|
|
1907
|
+
ruleId: rule.id,
|
|
1908
|
+
category: rule.category,
|
|
1909
|
+
severity: rule.severity,
|
|
1910
|
+
owasp: rule.owasp,
|
|
1911
|
+
file,
|
|
1912
|
+
line: lineNum,
|
|
1913
|
+
match: match[0].length > 100 ? match[0].slice(0, 97) + '...' : match[0],
|
|
1914
|
+
context,
|
|
1915
|
+
fix: rule.fix,
|
|
1916
|
+
});
|
|
1917
|
+
byCategory[rule.category] = (byCategory[rule.category] || 0) + 1;
|
|
1918
|
+
bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
catch {
|
|
1924
|
+
// Skip unreadable files
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
// Calculate security score (100 - deductions)
|
|
1928
|
+
const deduction = findings.filter((f) => f.severity === 'CRITICAL').length * 5 +
|
|
1929
|
+
findings.filter((f) => f.severity === 'HIGH').length * 2 +
|
|
1930
|
+
findings.filter((f) => f.severity === 'MEDIUM').length * 0.5;
|
|
1931
|
+
const score = Math.max(0, Math.round(100 - deduction));
|
|
1932
|
+
const limited = findings.slice(0, limit);
|
|
1933
|
+
return {
|
|
1934
|
+
content: [{
|
|
1935
|
+
type: 'text',
|
|
1936
|
+
text: JSON.stringify({
|
|
1937
|
+
summary: {
|
|
1938
|
+
totalScanned: files.length,
|
|
1939
|
+
findings: findings.length,
|
|
1940
|
+
byCategory,
|
|
1941
|
+
bySeverity,
|
|
1942
|
+
score,
|
|
1943
|
+
},
|
|
1944
|
+
findings: limited,
|
|
1945
|
+
}, null, 2),
|
|
1946
|
+
}],
|
|
1947
|
+
};
|
|
1948
|
+
});
|
|
1974
1949
|
return server;
|
|
1975
1950
|
}
|
|
1976
1951
|
// ── Transport: stdio ──
|