ctxman 3.1.0 → 3.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
@@ -19,11 +19,29 @@ import { PromptProvider } from './prompts.js';
19
19
  import { buildPrContext } from '../../pr/pr-context.js';
20
20
  import { resolveRootDir, resolveSandboxPath } from './sandbox.js';
21
21
  import { searchProject, DEFAULT_LIMIT, DEFAULT_MAX_TOKENS } from '../../search/bm25.js';
22
+ import { hybridSearch } from '../../search/hybrid.js';
22
23
  import { getSymbol } from '../../search/symbols.js';
23
24
  import { countForModel } from '../../count/index.js';
24
25
  import { explainHistory } from '../../why/index.js';
25
26
  import { packContext, describePack, TOOL_DEFAULT_BUDGET } from '../../pack/index.js';
26
- import { loadPolicy, guardExport } from '../../policy/index.js';
27
+ import { focusContext, describeFocus, DEFAULT_FOCUS_DEPTH } from '../../focus/index.js';
28
+ import { mapWorkspace, searchWorkspace, packWorkspace } from '../../workspace/index.js';
29
+ import {
30
+ loadPolicy,
31
+ guardExport,
32
+ PolicyConfigError,
33
+ auditLogFiles,
34
+ readAuditLog,
35
+ filterAuditEvents,
36
+ parseSince,
37
+ checkWorkspacePolicies,
38
+ readWorkspaceAuditLogs,
39
+ } from '../../policy/index.js';
40
+ import { askCodebase, DEFAULT_ASK_LIMIT } from '../../rag/ask.js';
41
+ import { buildTaskContext, DEFAULT_TASK_BUDGET } from '../../task/index.js';
42
+ import { diagnoseFailure, DEFAULT_DIAGNOSE_BUDGET } from '../../diagnose/index.js';
43
+ import { findExperts, DEFAULT_WHO_LIMIT } from '../../who/index.js';
44
+ import { IncrementalAnalyzer } from '../../watch/IncrementalAnalyzer.js';
27
45
  import fs from 'fs';
28
46
  import path from 'path';
29
47
  import { createRequire } from 'module';
@@ -33,6 +51,11 @@ const { version } = createRequire(import.meta.url)('../../../package.json');
33
51
  // Tools whose `path` may also name a single file
34
52
  const FILE_PATH_TOOLS = new Set(['list_methods']);
35
53
 
54
+ const DEFAULT_AUDIT_LIMIT = 50;
55
+ // Characters of each ask_codebase snippet (the index stores whole files)
56
+ const ASK_SNIPPET_CHARS = 2000;
57
+ const DEFAULT_GRAPH_LIMIT = 50;
58
+
36
59
  class MCPServer {
37
60
  /**
38
61
  * @param {Object} [options]
@@ -56,14 +79,39 @@ class MCPServer {
56
79
  }
57
80
  );
58
81
 
59
- this.analyzer = new Analyzer();
82
+ this.analyzer = new Analyzer({ cache: true });
60
83
  this.contextBuilder = new ContextBuilder();
84
+ this.incrementalAnalyzers = new Map();
61
85
  this.resourceProvider = new ResourceProvider(this.root ?? undefined);
62
86
  this.promptProvider = new PromptProvider(this.analyzer, this.contextBuilder);
63
87
  this.setupHandlers();
64
88
  this.setupErrorHandling();
65
89
  }
66
90
 
91
+ /**
92
+ * Get or create a cached IncrementalAnalyzer instance bound to the project's SQLite knowledge graph.
93
+ * @param {string} projectPath
94
+ * @returns {IncrementalAnalyzer}
95
+ */
96
+ getIncrementalAnalyzer(projectPath, repos = []) {
97
+ const resolved = path.resolve(projectPath);
98
+ const extraRepos = Array.isArray(repos)
99
+ ? repos.filter(Boolean).map((r) => path.resolve(r))
100
+ : [];
101
+ const cacheKey = [resolved, ...extraRepos].join('::');
102
+ let inc = this.incrementalAnalyzers.get(cacheKey);
103
+ if (!inc) {
104
+ inc = new IncrementalAnalyzer({
105
+ projectRoot: resolved,
106
+ repos: extraRepos,
107
+ sync: true,
108
+ hydrateGraphOnStart: false,
109
+ });
110
+ this.incrementalAnalyzers.set(cacheKey, inc);
111
+ }
112
+ return inc;
113
+ }
114
+
67
115
  setupHandlers() {
68
116
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
69
117
  return {
@@ -123,13 +171,17 @@ class MCPServer {
123
171
  {
124
172
  name: 'search_code',
125
173
  description:
126
- 'Search source lines by regular expression (default) or plain substring ("semantic" is currently a substring match). "ranked" returns the methods/functions (or 40-line windows) that best match the query words by BM25, with their source.',
174
+ 'Search source lines by regular expression (default) or plain substring. "ranked" returns BM25-ranked methods/chunks; "semantic" runs vector embedding search projected onto chunks (with BM25 fallback); "hybrid" fuses BM25 and semantic embeddings via Reciprocal Rank Fusion (RRF).',
127
175
  inputSchema: {
128
176
  type: 'object',
129
177
  properties: {
130
178
  path: { type: 'string', description: 'Project root' },
131
179
  query: { type: 'string', description: 'Search query' },
132
- type: { type: 'string', enum: ['regex', 'semantic', 'ranked'], default: 'regex' },
180
+ type: {
181
+ type: 'string',
182
+ enum: ['regex', 'substring', 'semantic', 'ranked', 'hybrid'],
183
+ default: 'regex',
184
+ },
133
185
  limit: {
134
186
  type: 'number',
135
187
  description: `ranked: maximum number of chunks (default ${DEFAULT_LIMIT})`,
@@ -142,6 +194,12 @@ class MCPServer {
142
194
  type: 'boolean',
143
195
  description: 'ranked: redact secrets in the returned source (default true)',
144
196
  },
197
+ repos: {
198
+ type: 'array',
199
+ items: { type: 'string' },
200
+ description:
201
+ 'ranked/hybrid: additional repository roots to search across under one shared token budget',
202
+ },
145
203
  },
146
204
  required: ['path', 'query'],
147
205
  },
@@ -182,6 +240,12 @@ class MCPServer {
182
240
  description:
183
241
  'Project-relative files or folders to rank higher, with the files next to them in the import graph',
184
242
  },
243
+ repos: {
244
+ type: 'array',
245
+ items: { type: 'string' },
246
+ description:
247
+ 'Additional repository roots to include in a multi-repository workspace map under one shared token budget',
248
+ },
185
249
  },
186
250
  required: ['path'],
187
251
  },
@@ -189,7 +253,7 @@ class MCPServer {
189
253
  {
190
254
  name: 'pr_context',
191
255
  description:
192
- 'Review context for a branch: summary, commit log, changed files, full source of changed methods, unified diff and related tests (secrets redacted)',
256
+ 'Review context for a branch (single-repo or multi-repo workspace): summary, commit log, changed files, full source of changed methods, unified diff and related tests (secrets redacted)',
193
257
  inputSchema: {
194
258
  type: 'object',
195
259
  properties: {
@@ -202,6 +266,12 @@ class MCPServer {
202
266
  head: { type: 'string', description: 'Head branch or commit (default: HEAD)' },
203
267
  budget: { type: 'number', description: 'Maximum tokens of the result' },
204
268
  style: { type: 'string', enum: ['markdown', 'xml', 'json'], default: 'markdown' },
269
+ repos: {
270
+ type: 'array',
271
+ items: { type: 'string' },
272
+ description:
273
+ 'Additional repository roots to include in multi-repository PR review context under one shared token budget',
274
+ },
205
275
  },
206
276
  required: ['path'],
207
277
  },
@@ -209,7 +279,7 @@ class MCPServer {
209
279
  {
210
280
  name: 'get_symbol',
211
281
  description:
212
- 'Full source of a method, function or class by name, with file and line range. Exact matches first, then case-insensitive; similar names are suggested when nothing matches. "Class.method" narrows to methods of a class.',
282
+ 'Full source of a method, function or class by name, with file and line range (single-repo or multi-repo workspace). Exact matches first, then case-insensitive; similar names are suggested when nothing matches. "Class.method" narrows to methods of a class.',
213
283
  inputSchema: {
214
284
  type: 'object',
215
285
  properties: {
@@ -217,12 +287,19 @@ class MCPServer {
217
287
  name: { type: 'string', description: 'Symbol name, or Class.method' },
218
288
  file: {
219
289
  type: 'string',
220
- description: 'Optional: only look in this project-relative file',
290
+ description:
291
+ 'Optional: only look in this project-relative (or <alias>/<path>) file',
221
292
  },
222
293
  redact: {
223
294
  type: 'boolean',
224
295
  description: 'Redact secrets in the source (default true)',
225
296
  },
297
+ repos: {
298
+ type: 'array',
299
+ items: { type: 'string' },
300
+ description:
301
+ 'Additional repository roots to search for symbols across under one shared token budget',
302
+ },
226
303
  },
227
304
  required: ['path', 'name'],
228
305
  },
@@ -253,7 +330,7 @@ class MCPServer {
253
330
  {
254
331
  name: 'explain_history',
255
332
  description:
256
- 'Explain why code looks the way it does, from the local git history (offline): commits that changed a file, symbol or line range with their messages, referenced issues/PRs/tickets, blame ownership, files that change together with it, related docs/ADRs/comments, TODO notes and tests. Returns Markdown.',
333
+ 'Explain why code looks the way it does, from the local git history (offline, single-repo or multi-repo workspace): commits that changed a file, symbol or line range with their messages, referenced issues/PRs/tickets, institutional memory citations (.plansmith/analyzer.sqlite), blame ownership, files that change together with it, related docs/ADRs/comments, TODO notes and tests. Returns Markdown.',
257
334
  inputSchema: {
258
335
  type: 'object',
259
336
  properties: {
@@ -264,7 +341,7 @@ class MCPServer {
264
341
  target: {
265
342
  type: 'string',
266
343
  description:
267
- 'File relative to path, optionally with a symbol or line range: "src/cart.js", "src/cart.js:total", "src/cart.js:Cart.total", "src/cart.js#L10-L40"',
344
+ 'File relative to path (or <alias>/<path> in multi-repo mode), optionally with a symbol or line range: "src/cart.js", "src/cart.js:total", "src/cart.js:Cart.total", "src/cart.js#L10-L40"',
268
345
  },
269
346
  limit: {
270
347
  type: 'number',
@@ -283,6 +360,32 @@ class MCPServer {
283
360
  type: 'boolean',
284
361
  description: "Include each commit's diff of the target (default false)",
285
362
  },
363
+ discussions: {
364
+ type: 'boolean',
365
+ description:
366
+ 'Fetch referenced GitHub/GitLab issue, PR and MR discussions over HTTPS (default false; uses GITHUB_TOKEN/GH_TOKEN or GITLAB_TOKEN when set)',
367
+ },
368
+ maxDiscussions: {
369
+ type: 'number',
370
+ description:
371
+ 'Maximum remote discussions to fetch when discussions is true (default 10)',
372
+ },
373
+ analyzer: {
374
+ type: 'boolean',
375
+ description:
376
+ 'Query local @plansmithai/analyzer SQLite database (.plansmith/analyzer.sqlite) for institutional memory citations',
377
+ },
378
+ analyzerDb: {
379
+ type: 'string',
380
+ description:
381
+ 'Explicit path to @plansmithai/analyzer SQLite database file or directory',
382
+ },
383
+ repos: {
384
+ type: 'array',
385
+ items: { type: 'string' },
386
+ description:
387
+ 'Additional repository roots to include in multi-repository history explanation and cross-repo ADR/doc/test discovery',
388
+ },
286
389
  },
287
390
  required: ['path', 'target'],
288
391
  },
@@ -336,6 +439,288 @@ class MCPServer {
336
439
  type: 'string',
337
440
  description: "Count with this model's tokenizer encoding and report the fit",
338
441
  },
442
+ repos: {
443
+ type: 'array',
444
+ items: { type: 'string' },
445
+ description:
446
+ 'Additional repository roots to pack into one workspace document under one shared token budget',
447
+ },
448
+ },
449
+ required: ['path'],
450
+ },
451
+ },
452
+ {
453
+ name: 'focus_context',
454
+ description:
455
+ 'Focused neighborhood context around a target file (or file:symbol): packs the target file together with its outgoing imports/callees and incoming importers/callers up to `depth` hops in the repository import and AST symbol call graph (directional traversal avoids shared-hub explosion), honouring .gitignore/.contextignore and export policy, with secrets redacted and trimmed to a token budget (target file kept first).',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ path: { type: 'string', description: 'Project root' },
460
+ file: {
461
+ type: 'string',
462
+ description:
463
+ 'Project-relative (or <alias>/<path>) file or file:symbol to focus on (e.g. "src/auth/login.ts" or "src/auth/login.ts:authenticate")',
464
+ },
465
+ symbol: {
466
+ type: 'string',
467
+ description:
468
+ 'Optional function or method name inside `file` to traverse the cross-file AST symbol call graph around',
469
+ },
470
+ depth: {
471
+ type: 'number',
472
+ description: `Import/call graph hops (default ${DEFAULT_FOCUS_DEPTH}; 0 = target file only)`,
473
+ },
474
+ direction: {
475
+ type: 'string',
476
+ enum: ['both', 'imports', 'importers', 'calls', 'callers'],
477
+ default: 'both',
478
+ },
479
+ callGraph: {
480
+ type: 'boolean',
481
+ description:
482
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
483
+ },
484
+ includeTests: {
485
+ type: 'boolean',
486
+ description: 'Include test files in the neighborhood (default true)',
487
+ },
488
+ style: {
489
+ type: 'string',
490
+ enum: ['xml', 'markdown', 'plain', 'json'],
491
+ default: 'xml',
492
+ },
493
+ compress: {
494
+ type: 'boolean',
495
+ description: 'Keep signatures, replace function bodies with a placeholder',
496
+ },
497
+ lineNumbers: { type: 'boolean', description: 'Number every line' },
498
+ tree: {
499
+ type: 'boolean',
500
+ description: 'Directory tree at the top (default true)',
501
+ },
502
+ instructions: {
503
+ type: 'string',
504
+ description: 'Text placed at the end, after the files',
505
+ },
506
+ budget: {
507
+ type: 'number',
508
+ description: `Maximum tokens of the document (default ${TOOL_DEFAULT_BUDGET})`,
509
+ },
510
+ targetModel: {
511
+ type: 'string',
512
+ description: "Count with this model's tokenizer encoding and report the fit",
513
+ },
514
+ repos: {
515
+ type: 'array',
516
+ items: { type: 'string' },
517
+ description:
518
+ 'Additional repository roots to include in the workspace import/call graph neighborhood under one shared token budget',
519
+ },
520
+ },
521
+ required: ['path', 'file'],
522
+ },
523
+ },
524
+ {
525
+ name: 'check_policy',
526
+ description:
527
+ 'The effective export policy of a project or multi-repository workspace (.ctxman/policy.json plus the organisation policy named by CTXMAN_POLICY): denied paths, redaction and token limits, allowed target models, AI-tool ignore files and audit logging. Reports whether the policy files are valid and, if not, every error.',
528
+ inputSchema: {
529
+ type: 'object',
530
+ properties: {
531
+ path: { type: 'string', description: 'Project root' },
532
+ repos: {
533
+ type: 'array',
534
+ items: { type: 'string' },
535
+ description:
536
+ 'Additional repository roots to validate and summarize together in a multi-repository workspace',
537
+ },
538
+ },
539
+ required: ['path'],
540
+ },
541
+ },
542
+ {
543
+ name: 'audit_log',
544
+ description:
545
+ "Exports recorded in the project's (or multi-repository workspace's) audit log while the export policy enables audit: when, by whom, which command, where to, which files, token count and a SHA-256 of the exported text. Newest last.",
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ path: { type: 'string', description: 'Project root' },
550
+ since: {
551
+ type: 'string',
552
+ description:
553
+ 'Only exports at or after this date (2026-10-01), ISO time or age (30m, 12h, 7d, 2w)',
554
+ },
555
+ limit: {
556
+ type: 'number',
557
+ description: `The last N events (default ${DEFAULT_AUDIT_LIMIT}; 0 = all)`,
558
+ },
559
+ repos: {
560
+ type: 'array',
561
+ items: { type: 'string' },
562
+ description:
563
+ 'Additional repository roots whose export audit logs should be merged chronologically with repo attribution',
564
+ },
565
+ },
566
+ required: ['path'],
567
+ },
568
+ },
569
+ {
570
+ name: 'ask_codebase',
571
+ description:
572
+ 'Semantic search: the files whose content is closest in meaning to a question, from a local SQLite embedding index (.ctxman/self-knowledge/index.sqlite via built-in node:sqlite, single-repo or multi-repo workspace). Uses @xenova/transformers when installed and automatically falls back to deterministic offline LocalHashEmbeddingProvider otherwise. For exact names or keywords, search_code is faster.',
573
+ inputSchema: {
574
+ type: 'object',
575
+ properties: {
576
+ path: { type: 'string', description: 'Project root' },
577
+ query: { type: 'string', description: 'The question, in natural language' },
578
+ limit: {
579
+ type: 'number',
580
+ description: `How many files (default ${DEFAULT_ASK_LIMIT})`,
581
+ },
582
+ repos: {
583
+ type: 'array',
584
+ items: { type: 'string' },
585
+ description:
586
+ 'Additional repository roots to include in multi-repository semantic search',
587
+ },
588
+ },
589
+ required: ['path', 'query'],
590
+ },
591
+ },
592
+ {
593
+ name: 'task_context',
594
+ description:
595
+ 'Task-driven automatic context builder: fuses hybrid search, PageRank import/call neighborhood, and architectural map for a task description within a token budget (single-repo or multi-repo workspace).',
596
+ inputSchema: {
597
+ type: 'object',
598
+ properties: {
599
+ path: { type: 'string', description: 'Project root' },
600
+ task: { type: 'string', description: 'Natural-language task or issue description' },
601
+ budget: {
602
+ type: 'number',
603
+ description: `Maximum tokens (default ${DEFAULT_TASK_BUDGET})`,
604
+ },
605
+ format: { type: 'string', enum: ['xml', 'markdown', 'json'], default: 'xml' },
606
+ model: { type: 'string', description: 'Optional target LLM model ID' },
607
+ callGraph: {
608
+ type: 'boolean',
609
+ description:
610
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
611
+ },
612
+ repos: {
613
+ type: 'array',
614
+ items: { type: 'string' },
615
+ description:
616
+ 'Additional repository roots to include in a multi-repository task context bundle under one shared token budget',
617
+ },
618
+ },
619
+ required: ['path', 'task'],
620
+ },
621
+ },
622
+ {
623
+ name: 'diagnose_failure',
624
+ description:
625
+ 'Diagnose a stack trace, test failure, or error log: parses implicated repo files/lines (single-repo or multi-repo workspace), packs enclosing symbols and 1-hop graph neighbors, and surfaces recent commits that touched them.',
626
+ inputSchema: {
627
+ type: 'object',
628
+ properties: {
629
+ path: { type: 'string', description: 'Project root' },
630
+ trace: { type: 'string', description: 'Error stack trace or failure log text' },
631
+ budget: {
632
+ type: 'number',
633
+ description: `Maximum tokens (default ${DEFAULT_DIAGNOSE_BUDGET})`,
634
+ },
635
+ format: { type: 'string', enum: ['xml', 'markdown', 'json'], default: 'xml' },
636
+ callGraph: {
637
+ type: 'boolean',
638
+ description:
639
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
640
+ },
641
+ repos: {
642
+ type: 'array',
643
+ items: { type: 'string' },
644
+ description:
645
+ 'Additional repository roots to resolve stack frames and 1-hop neighbors across under one shared token budget',
646
+ },
647
+ },
648
+ required: ['path', 'trace'],
649
+ },
650
+ },
651
+ {
652
+ name: 'find_experts',
653
+ description:
654
+ 'Expert Finder ("Who knows about this?"): ranks code and domain experts for a file, symbol, directory, or natural-language topic (single-repo or multi-repo workspace) by fusing surviving git blame ownership, recency-decayed commit/churn history, and CODEOWNERS stewardship.',
655
+ inputSchema: {
656
+ type: 'object',
657
+ properties: {
658
+ path: { type: 'string', description: 'Project root inside a git repository' },
659
+ target: {
660
+ type: 'string',
661
+ description:
662
+ 'File path (<alias>/<path>), file:symbol, file#L10-L40, directory, or natural-language topic query',
663
+ },
664
+ limit: {
665
+ type: 'number',
666
+ description: `Maximum ranked experts to return (default ${DEFAULT_WHO_LIMIT})`,
667
+ },
668
+ halfLifeDays: {
669
+ type: 'number',
670
+ description: 'Recency exponential decay half-life in days (default 90)',
671
+ },
672
+ style: {
673
+ type: 'string',
674
+ enum: ['markdown', 'xml', 'json'],
675
+ default: 'markdown',
676
+ },
677
+ repos: {
678
+ type: 'array',
679
+ items: { type: 'string' },
680
+ description:
681
+ 'Additional repository roots inside git repositories to include in multi-repository expert ranking',
682
+ },
683
+ },
684
+ required: ['path', 'target'],
685
+ },
686
+ },
687
+ {
688
+ name: 'query_knowledge_graph',
689
+ description:
690
+ 'Query the persistent SQLite knowledge graph (.ctxman/self-knowledge/index.sqlite) or IncrementalAnalyzer cache for file nodes, method declarations, and import/dependency edges.',
691
+ inputSchema: {
692
+ type: 'object',
693
+ properties: {
694
+ path: { type: 'string', description: 'Project root' },
695
+ query: {
696
+ type: 'string',
697
+ description:
698
+ 'Optional substring or symbol query to filter graph nodes and method declarations',
699
+ },
700
+ file: {
701
+ type: 'string',
702
+ description:
703
+ 'Optional project-relative file path to inspect along with its dependency neighborhood',
704
+ },
705
+ depth: {
706
+ type: 'number',
707
+ description: 'Neighborhood hop depth when file is provided (default: 1, max: 4)',
708
+ },
709
+ limit: {
710
+ type: 'number',
711
+ description: `Maximum number of graph nodes to return (default ${DEFAULT_GRAPH_LIMIT})`,
712
+ },
713
+ refresh: {
714
+ type: 'boolean',
715
+ description:
716
+ 'Re-synchronize the IncrementalAnalyzer cache and SQLite knowledge graph before querying',
717
+ },
718
+ repos: {
719
+ type: 'array',
720
+ items: { type: 'string' },
721
+ description:
722
+ 'Optional additional repository roots to include in a multi-repository knowledge graph under <alias>/<path>',
723
+ },
339
724
  },
340
725
  required: ['path'],
341
726
  },
@@ -405,6 +790,21 @@ class MCPServer {
405
790
  }),
406
791
  };
407
792
  }
793
+ if (args.repos !== undefined) {
794
+ if (!Array.isArray(args.repos)) {
795
+ throw new Error('repos must be an array of directory paths');
796
+ }
797
+ args = {
798
+ ...args,
799
+ repos: args.repos.map((repoPath) =>
800
+ resolveSandboxPath(repoPath, {
801
+ root: this.root,
802
+ expect: 'directory',
803
+ name: 'repos',
804
+ })
805
+ ),
806
+ };
807
+ }
408
808
 
409
809
  switch (name) {
410
810
  case 'analyze_codebase': {
@@ -502,6 +902,17 @@ class MCPServer {
502
902
  }
503
903
  case 'search_code': {
504
904
  const { path: projectPath, query, type = 'regex' } = args;
905
+ if (
906
+ args.repos?.length > 0 &&
907
+ (type === 'ranked' || type === 'hybrid' || type === 'semantic')
908
+ ) {
909
+ const result = searchWorkspace([projectPath, ...args.repos], query, {
910
+ limit: args.limit ?? DEFAULT_LIMIT,
911
+ maxTokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
912
+ redact: args.redact !== false,
913
+ });
914
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
915
+ }
505
916
  if (type === 'ranked') {
506
917
  const result = searchProject(projectPath, query, {
507
918
  limit: args.limit ?? DEFAULT_LIMIT,
@@ -510,6 +921,16 @@ class MCPServer {
510
921
  });
511
922
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
512
923
  }
924
+ if (type === 'hybrid' || type === 'semantic') {
925
+ const result = await hybridSearch(projectPath, query, {
926
+ mode: type,
927
+ fallbackToBm25: true,
928
+ limit: args.limit ?? DEFAULT_LIMIT,
929
+ maxTokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
930
+ redact: args.redact !== false,
931
+ });
932
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
933
+ }
513
934
  let pattern = null;
514
935
  if (type === 'regex') {
515
936
  try {
@@ -572,7 +993,7 @@ class MCPServer {
572
993
  }
573
994
 
574
995
  // Use Analyzer with methodLevel: true
575
- const methodAnalyzer = new Analyzer({ methodLevel: true });
996
+ const methodAnalyzer = new Analyzer({ methodLevel: true, cache: true });
576
997
  const analysis = await methodAnalyzer.analyze(files);
577
998
 
578
999
  const methods = [];
@@ -592,10 +1013,18 @@ class MCPServer {
592
1013
  };
593
1014
  }
594
1015
  case 'repo_map': {
595
- const map = renderRepoMap(args.path, {
596
- tokens: args.tokens,
597
- focus: args.focus,
598
- });
1016
+ const map =
1017
+ args.repos?.length > 0
1018
+ ? mapWorkspace([args.path, ...args.repos], {
1019
+ tokens: args.tokens,
1020
+ focus: args.focus,
1021
+ callGraph: args.callGraph,
1022
+ })
1023
+ : renderRepoMap(args.path, {
1024
+ tokens: args.tokens,
1025
+ focus: args.focus,
1026
+ callGraph: args.callGraph,
1027
+ });
599
1028
  const notes = map.warnings.map((warning) => `Note: ${warning}`).join('\n');
600
1029
  return {
601
1030
  content: [
@@ -608,6 +1037,7 @@ class MCPServer {
608
1037
  }
609
1038
  case 'pr_context': {
610
1039
  const result = await buildPrContext(args.path, {
1040
+ repos: args.repos,
611
1041
  base: args.base,
612
1042
  head: args.head,
613
1043
  budget: args.budget == null ? undefined : Math.floor(args.budget),
@@ -619,6 +1049,7 @@ class MCPServer {
619
1049
  const result = getSymbol(args.path, args.name, {
620
1050
  file: args.file,
621
1051
  redact: args.redact !== false,
1052
+ repos: args.repos,
622
1053
  });
623
1054
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
624
1055
  }
@@ -636,15 +1067,47 @@ class MCPServer {
636
1067
  }
637
1068
  case 'explain_history': {
638
1069
  const whole = (value) => (typeof value === 'number' ? Math.floor(value) : value);
1070
+ const analyzerDb =
1071
+ args.analyzerDb !== undefined
1072
+ ? resolveSandboxPath(args.analyzerDb, {
1073
+ root: this.root,
1074
+ expect: 'any',
1075
+ name: 'analyzerDb',
1076
+ })
1077
+ : undefined;
639
1078
  const result = await explainHistory(args.path, args.target, {
1079
+ repos: args.repos,
640
1080
  limit: whole(args.limit),
641
1081
  budget: whole(args.budget ?? 20000),
642
1082
  since: args.since,
643
1083
  patches: args.patches === true,
1084
+ discussions: args.discussions === true,
1085
+ maxDiscussions: whole(args.maxDiscussions),
1086
+ analyzer: args.analyzer === true,
1087
+ analyzerDb,
644
1088
  });
645
1089
  return { content: [{ type: 'text', text: result.text }] };
646
1090
  }
647
1091
  case 'pack_context': {
1092
+ if (args.repos?.length > 0) {
1093
+ const result = await packWorkspace([args.path, ...args.repos], {
1094
+ include: args.include,
1095
+ exclude: args.exclude,
1096
+ style: args.style,
1097
+ compress: args.compress,
1098
+ lineNumbers: args.lineNumbers,
1099
+ tree: args.tree,
1100
+ instructions: args.instructions,
1101
+ budget: args.budget == null ? TOOL_DEFAULT_BUDGET : Math.floor(args.budget),
1102
+ targetModel: args.targetModel,
1103
+ });
1104
+ return {
1105
+ content: [
1106
+ { type: 'text', text: result.text },
1107
+ { type: 'text', text: describePack(result) },
1108
+ ],
1109
+ };
1110
+ }
648
1111
  const result = await packContext(args.path, {
649
1112
  include: args.include,
650
1113
  exclude: args.exclude,
@@ -666,6 +1129,242 @@ class MCPServer {
666
1129
  ],
667
1130
  };
668
1131
  }
1132
+ case 'focus_context': {
1133
+ const result = await focusContext(args.path, args.file ?? args.target, {
1134
+ repos: args.repos,
1135
+ symbol: args.symbol,
1136
+ depth: args.depth == null ? undefined : Math.floor(args.depth),
1137
+ direction: args.direction,
1138
+ callGraph: args.callGraph,
1139
+ includeTests: args.includeTests,
1140
+ cwd: args.path,
1141
+ style: args.style,
1142
+ compress: args.compress,
1143
+ lineNumbers: args.lineNumbers,
1144
+ tree: args.tree,
1145
+ instructions: args.instructions,
1146
+ budget: args.budget == null ? TOOL_DEFAULT_BUDGET : Math.floor(args.budget),
1147
+ targetModel: args.targetModel,
1148
+ });
1149
+ return {
1150
+ content: [
1151
+ { type: 'text', text: result.text },
1152
+ { type: 'text', text: describeFocus(result) },
1153
+ ],
1154
+ };
1155
+ }
1156
+ case 'check_policy': {
1157
+ if (args.repos?.length > 0) {
1158
+ const result = checkWorkspacePolicies(args.path, args.repos);
1159
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1160
+ }
1161
+ let result;
1162
+ try {
1163
+ result = { valid: true, ...loadPolicy(args.path).toJSON() };
1164
+ } catch (error) {
1165
+ if (!(error instanceof PolicyConfigError)) throw error;
1166
+ result = { valid: false, file: error.file, errors: error.errors };
1167
+ }
1168
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1169
+ }
1170
+ case 'audit_log': {
1171
+ const since = args.since === undefined ? null : parseSince(String(args.since));
1172
+ const limit =
1173
+ Number.isInteger(args.limit) && args.limit >= 0 ? args.limit : DEFAULT_AUDIT_LIMIT;
1174
+ if (args.repos?.length > 0) {
1175
+ const ws = readWorkspaceAuditLogs(args.path, args.repos, { since, limit });
1176
+ const result = {
1177
+ repos: ws.repos,
1178
+ files: ws.files,
1179
+ total: ws.total,
1180
+ events: ws.events,
1181
+ skippedLines: ws.skippedLines,
1182
+ };
1183
+ if (ws.policyErrors.length > 0) result.policyErrors = ws.policyErrors;
1184
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1185
+ }
1186
+ // Only the policy's own logs: a tool argument never names a file to read
1187
+ const { files, error } = auditLogFiles(args.path);
1188
+ const { events, skipped, missing } = readAuditLog(files);
1189
+ const matching = filterAuditEvents(events, { since });
1190
+ const result = {
1191
+ files: files.filter((file) => !missing.includes(file)),
1192
+ total: matching.length,
1193
+ events: filterAuditEvents(matching, { limit }),
1194
+ skippedLines: skipped,
1195
+ };
1196
+ if (error) result.policyError = error.message;
1197
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1198
+ }
1199
+ case 'ask_codebase': {
1200
+ const result = await askCodebase(args.path, args.query, {
1201
+ repos: args.repos,
1202
+ limit: Number.isInteger(args.limit) ? args.limit : undefined,
1203
+ maxChars: ASK_SNIPPET_CHARS,
1204
+ });
1205
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1206
+ }
1207
+ case 'task_context': {
1208
+ const result = await buildTaskContext(
1209
+ args.path ?? args.dir ?? process.cwd(),
1210
+ args.task ?? args.query ?? '',
1211
+ {
1212
+ repos: args.repos,
1213
+ budget: args.budget == null ? DEFAULT_TASK_BUDGET : Math.floor(args.budget),
1214
+ format: args.format,
1215
+ model: args.model,
1216
+ callGraph: args.callGraph,
1217
+ }
1218
+ );
1219
+ return {
1220
+ content: [
1221
+ { type: 'text', text: result.text },
1222
+ {
1223
+ type: 'text',
1224
+ text: `Task Context: ${result.files.length} file(s), ${result.totalTokens}/${result.budget} tokens (${result.format}).`,
1225
+ },
1226
+ ],
1227
+ };
1228
+ }
1229
+ case 'diagnose_failure': {
1230
+ const result = await diagnoseFailure(
1231
+ args.path ?? args.dir ?? process.cwd(),
1232
+ args.trace ?? args.log ?? '',
1233
+ {
1234
+ repos: args.repos,
1235
+ budget: args.budget == null ? DEFAULT_DIAGNOSE_BUDGET : Math.floor(args.budget),
1236
+ format: args.format,
1237
+ callGraph: args.callGraph,
1238
+ }
1239
+ );
1240
+ return {
1241
+ content: [
1242
+ { type: 'text', text: result.text },
1243
+ {
1244
+ type: 'text',
1245
+ text: `Diagnosed: ${result.implicatedFiles.length} implicated file(s), ${result.totalTokens}/${result.budget} tokens.`,
1246
+ },
1247
+ ],
1248
+ };
1249
+ }
1250
+ case 'find_experts': {
1251
+ const result = await findExperts(
1252
+ args.path ?? process.cwd(),
1253
+ args.target ?? args.query ?? '',
1254
+ {
1255
+ repos: args.repos,
1256
+ limit: Number.isInteger(args.limit) ? args.limit : undefined,
1257
+ halfLifeDays: typeof args.halfLifeDays === 'number' ? args.halfLifeDays : undefined,
1258
+ style: args.style || 'markdown',
1259
+ }
1260
+ );
1261
+ return {
1262
+ content: [{ type: 'text', text: result.text }],
1263
+ };
1264
+ }
1265
+ case 'query_knowledge_graph': {
1266
+ const projectPath = args.path ?? process.cwd();
1267
+ const extraRepos = Array.isArray(args.repos) ? args.repos : [];
1268
+ const scanner = new Scanner(projectPath);
1269
+ scanner.getPolicy();
1270
+ for (const repoPath of extraRepos) {
1271
+ new Scanner(repoPath).getPolicy();
1272
+ }
1273
+
1274
+ const inc = this.getIncrementalAnalyzer(projectPath, extraRepos);
1275
+ await inc.ensureSyncReady(projectPath);
1276
+ const store = inc.vectorStore;
1277
+ const initialCounts = store.graphCounts();
1278
+
1279
+ let bootstrapped = false;
1280
+ if (args.refresh === true || initialCounts.nodes === 0) {
1281
+ await inc.bootstrapSync(projectPath, { repos: extraRepos });
1282
+ bootstrapped = true;
1283
+ }
1284
+
1285
+ const rawGraph = await inc.getGraph();
1286
+ let nodes = rawGraph.nodes.filter((n) => !inc.isPathDenied(n.path));
1287
+ let edges = rawGraph.edges.filter(
1288
+ (e) => !inc.isPathDenied(e.from) && !inc.isPathDenied(e.to)
1289
+ );
1290
+
1291
+ if (typeof args.file === 'string' && args.file.trim()) {
1292
+ const targetFile = args.file.trim().replace(/\\/g, '/').replace(/^\.\//, '');
1293
+ const maxDepth = Math.max(
1294
+ 1,
1295
+ Math.min(4, Number.isFinite(args.depth) ? Math.floor(args.depth) : 1)
1296
+ );
1297
+ const visited = new Set([targetFile]);
1298
+ let frontier = new Set([targetFile]);
1299
+
1300
+ for (let hop = 0; hop < maxDepth && frontier.size > 0; hop++) {
1301
+ const nextFrontier = new Set();
1302
+ for (const edge of edges) {
1303
+ if (frontier.has(edge.from) && !visited.has(edge.to)) {
1304
+ visited.add(edge.to);
1305
+ nextFrontier.add(edge.to);
1306
+ }
1307
+ if (frontier.has(edge.to) && !visited.has(edge.from)) {
1308
+ visited.add(edge.from);
1309
+ nextFrontier.add(edge.from);
1310
+ }
1311
+ }
1312
+ frontier = nextFrontier;
1313
+ }
1314
+
1315
+ nodes = nodes.filter((n) => visited.has(n.path));
1316
+ edges = edges.filter((e) => visited.has(e.from) && visited.has(e.to));
1317
+ }
1318
+
1319
+ if (typeof args.query === 'string' && args.query.trim()) {
1320
+ const needle = args.query.trim().toLowerCase();
1321
+ nodes = nodes.filter((n) => {
1322
+ if (n.path.toLowerCase().includes(needle)) return true;
1323
+ if (Array.isArray(n.methods)) {
1324
+ return n.methods.some((m) => {
1325
+ const name = typeof m === 'string' ? m : m?.name || '';
1326
+ const sig = typeof m === 'object' ? m?.signature || '' : '';
1327
+ return (
1328
+ name.toLowerCase().includes(needle) || sig.toLowerCase().includes(needle)
1329
+ );
1330
+ });
1331
+ }
1332
+ return false;
1333
+ });
1334
+ const matchedPaths = new Set(nodes.map((n) => n.path));
1335
+ edges = edges.filter((e) => matchedPaths.has(e.from) || matchedPaths.has(e.to));
1336
+ }
1337
+
1338
+ const limit = Math.max(
1339
+ 1,
1340
+ Math.min(
1341
+ 500,
1342
+ Number.isFinite(args.limit) ? Math.floor(args.limit) : DEFAULT_GRAPH_LIMIT
1343
+ )
1344
+ );
1345
+ const limitedNodes = nodes.slice(0, limit);
1346
+ const keptPaths = new Set(limitedNodes.map((n) => n.path));
1347
+ const matchingEdges = edges.filter((e) => keptPaths.has(e.from) || keptPaths.has(e.to));
1348
+
1349
+ const counts = store.graphCounts();
1350
+ const payload = {
1351
+ path: projectPath,
1352
+ bootstrapped,
1353
+ cached: inc.fileCache.size > 0 || inc.graphNodes.size > 0,
1354
+ stats: {
1355
+ totalNodes: counts.nodes,
1356
+ totalEdges: counts.edges,
1357
+ vectorDocuments: store.count(),
1358
+ cachedFiles: inc.fileCache.size,
1359
+ },
1360
+ nodes: limitedNodes,
1361
+ edges: matchingEdges,
1362
+ };
1363
+
1364
+ return {
1365
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1366
+ };
1367
+ }
669
1368
  default:
670
1369
  throw new Error(`Unknown tool: ${name}`);
671
1370
  }