ctxman 3.1.1 → 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 (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  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/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. package/package.json +1 -1
@@ -19,10 +19,13 @@ 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';
27
+ import { focusContext, describeFocus, DEFAULT_FOCUS_DEPTH } from '../../focus/index.js';
28
+ import { mapWorkspace, searchWorkspace, packWorkspace } from '../../workspace/index.js';
26
29
  import {
27
30
  loadPolicy,
28
31
  guardExport,
@@ -31,8 +34,14 @@ import {
31
34
  readAuditLog,
32
35
  filterAuditEvents,
33
36
  parseSince,
37
+ checkWorkspacePolicies,
38
+ readWorkspaceAuditLogs,
34
39
  } from '../../policy/index.js';
35
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';
36
45
  import fs from 'fs';
37
46
  import path from 'path';
38
47
  import { createRequire } from 'module';
@@ -45,6 +54,7 @@ const FILE_PATH_TOOLS = new Set(['list_methods']);
45
54
  const DEFAULT_AUDIT_LIMIT = 50;
46
55
  // Characters of each ask_codebase snippet (the index stores whole files)
47
56
  const ASK_SNIPPET_CHARS = 2000;
57
+ const DEFAULT_GRAPH_LIMIT = 50;
48
58
 
49
59
  class MCPServer {
50
60
  /**
@@ -69,14 +79,39 @@ class MCPServer {
69
79
  }
70
80
  );
71
81
 
72
- this.analyzer = new Analyzer();
82
+ this.analyzer = new Analyzer({ cache: true });
73
83
  this.contextBuilder = new ContextBuilder();
84
+ this.incrementalAnalyzers = new Map();
74
85
  this.resourceProvider = new ResourceProvider(this.root ?? undefined);
75
86
  this.promptProvider = new PromptProvider(this.analyzer, this.contextBuilder);
76
87
  this.setupHandlers();
77
88
  this.setupErrorHandling();
78
89
  }
79
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
+
80
115
  setupHandlers() {
81
116
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
82
117
  return {
@@ -136,13 +171,17 @@ class MCPServer {
136
171
  {
137
172
  name: 'search_code',
138
173
  description:
139
- '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).',
140
175
  inputSchema: {
141
176
  type: 'object',
142
177
  properties: {
143
178
  path: { type: 'string', description: 'Project root' },
144
179
  query: { type: 'string', description: 'Search query' },
145
- 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
+ },
146
185
  limit: {
147
186
  type: 'number',
148
187
  description: `ranked: maximum number of chunks (default ${DEFAULT_LIMIT})`,
@@ -155,6 +194,12 @@ class MCPServer {
155
194
  type: 'boolean',
156
195
  description: 'ranked: redact secrets in the returned source (default true)',
157
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
+ },
158
203
  },
159
204
  required: ['path', 'query'],
160
205
  },
@@ -195,6 +240,12 @@ class MCPServer {
195
240
  description:
196
241
  'Project-relative files or folders to rank higher, with the files next to them in the import graph',
197
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
+ },
198
249
  },
199
250
  required: ['path'],
200
251
  },
@@ -202,7 +253,7 @@ class MCPServer {
202
253
  {
203
254
  name: 'pr_context',
204
255
  description:
205
- '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)',
206
257
  inputSchema: {
207
258
  type: 'object',
208
259
  properties: {
@@ -215,6 +266,12 @@ class MCPServer {
215
266
  head: { type: 'string', description: 'Head branch or commit (default: HEAD)' },
216
267
  budget: { type: 'number', description: 'Maximum tokens of the result' },
217
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
+ },
218
275
  },
219
276
  required: ['path'],
220
277
  },
@@ -222,7 +279,7 @@ class MCPServer {
222
279
  {
223
280
  name: 'get_symbol',
224
281
  description:
225
- '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.',
226
283
  inputSchema: {
227
284
  type: 'object',
228
285
  properties: {
@@ -230,12 +287,19 @@ class MCPServer {
230
287
  name: { type: 'string', description: 'Symbol name, or Class.method' },
231
288
  file: {
232
289
  type: 'string',
233
- description: 'Optional: only look in this project-relative file',
290
+ description:
291
+ 'Optional: only look in this project-relative (or <alias>/<path>) file',
234
292
  },
235
293
  redact: {
236
294
  type: 'boolean',
237
295
  description: 'Redact secrets in the source (default true)',
238
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
+ },
239
303
  },
240
304
  required: ['path', 'name'],
241
305
  },
@@ -266,7 +330,7 @@ class MCPServer {
266
330
  {
267
331
  name: 'explain_history',
268
332
  description:
269
- '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.',
270
334
  inputSchema: {
271
335
  type: 'object',
272
336
  properties: {
@@ -277,7 +341,7 @@ class MCPServer {
277
341
  target: {
278
342
  type: 'string',
279
343
  description:
280
- '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"',
281
345
  },
282
346
  limit: {
283
347
  type: 'number',
@@ -296,6 +360,32 @@ class MCPServer {
296
360
  type: 'boolean',
297
361
  description: "Include each commit's diff of the target (default false)",
298
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
+ },
299
389
  },
300
390
  required: ['path', 'target'],
301
391
  },
@@ -349,18 +439,102 @@ class MCPServer {
349
439
  type: 'string',
350
440
  description: "Count with this model's tokenizer encoding and report the fit",
351
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
+ },
352
448
  },
353
449
  required: ['path'],
354
450
  },
355
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
+ },
356
524
  {
357
525
  name: 'check_policy',
358
526
  description:
359
- 'The effective export policy of a project (.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.',
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.',
360
528
  inputSchema: {
361
529
  type: 'object',
362
530
  properties: {
363
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
+ },
364
538
  },
365
539
  required: ['path'],
366
540
  },
@@ -368,7 +542,7 @@ class MCPServer {
368
542
  {
369
543
  name: 'audit_log',
370
544
  description:
371
- "Exports recorded in the project'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.",
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.",
372
546
  inputSchema: {
373
547
  type: 'object',
374
548
  properties: {
@@ -382,6 +556,12 @@ class MCPServer {
382
556
  type: 'number',
383
557
  description: `The last N events (default ${DEFAULT_AUDIT_LIMIT}; 0 = all)`,
384
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
+ },
385
565
  },
386
566
  required: ['path'],
387
567
  },
@@ -389,7 +569,7 @@ class MCPServer {
389
569
  {
390
570
  name: 'ask_codebase',
391
571
  description:
392
- 'Semantic search: the files whose content is closest in meaning to a question, from a local embedding index (.ctxman/self-knowledge, built on first use; the first call downloads the embedding model and can take minutes). Needs the optional @xenova/transformers and @lancedb/lancedb packages. For exact names or keywords, search_code is faster.',
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.',
393
573
  inputSchema: {
394
574
  type: 'object',
395
575
  properties: {
@@ -399,10 +579,152 @@ class MCPServer {
399
579
  type: 'number',
400
580
  description: `How many files (default ${DEFAULT_ASK_LIMIT})`,
401
581
  },
582
+ repos: {
583
+ type: 'array',
584
+ items: { type: 'string' },
585
+ description:
586
+ 'Additional repository roots to include in multi-repository semantic search',
587
+ },
402
588
  },
403
589
  required: ['path', 'query'],
404
590
  },
405
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
+ },
724
+ },
725
+ required: ['path'],
726
+ },
727
+ },
406
728
  ],
407
729
  };
408
730
  });
@@ -468,6 +790,21 @@ class MCPServer {
468
790
  }),
469
791
  };
470
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
+ }
471
808
 
472
809
  switch (name) {
473
810
  case 'analyze_codebase': {
@@ -565,6 +902,17 @@ class MCPServer {
565
902
  }
566
903
  case 'search_code': {
567
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
+ }
568
916
  if (type === 'ranked') {
569
917
  const result = searchProject(projectPath, query, {
570
918
  limit: args.limit ?? DEFAULT_LIMIT,
@@ -573,6 +921,16 @@ class MCPServer {
573
921
  });
574
922
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
575
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
+ }
576
934
  let pattern = null;
577
935
  if (type === 'regex') {
578
936
  try {
@@ -635,7 +993,7 @@ class MCPServer {
635
993
  }
636
994
 
637
995
  // Use Analyzer with methodLevel: true
638
- const methodAnalyzer = new Analyzer({ methodLevel: true });
996
+ const methodAnalyzer = new Analyzer({ methodLevel: true, cache: true });
639
997
  const analysis = await methodAnalyzer.analyze(files);
640
998
 
641
999
  const methods = [];
@@ -655,10 +1013,18 @@ class MCPServer {
655
1013
  };
656
1014
  }
657
1015
  case 'repo_map': {
658
- const map = renderRepoMap(args.path, {
659
- tokens: args.tokens,
660
- focus: args.focus,
661
- });
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
+ });
662
1028
  const notes = map.warnings.map((warning) => `Note: ${warning}`).join('\n');
663
1029
  return {
664
1030
  content: [
@@ -671,6 +1037,7 @@ class MCPServer {
671
1037
  }
672
1038
  case 'pr_context': {
673
1039
  const result = await buildPrContext(args.path, {
1040
+ repos: args.repos,
674
1041
  base: args.base,
675
1042
  head: args.head,
676
1043
  budget: args.budget == null ? undefined : Math.floor(args.budget),
@@ -682,6 +1049,7 @@ class MCPServer {
682
1049
  const result = getSymbol(args.path, args.name, {
683
1050
  file: args.file,
684
1051
  redact: args.redact !== false,
1052
+ repos: args.repos,
685
1053
  });
686
1054
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
687
1055
  }
@@ -699,15 +1067,47 @@ class MCPServer {
699
1067
  }
700
1068
  case 'explain_history': {
701
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;
702
1078
  const result = await explainHistory(args.path, args.target, {
1079
+ repos: args.repos,
703
1080
  limit: whole(args.limit),
704
1081
  budget: whole(args.budget ?? 20000),
705
1082
  since: args.since,
706
1083
  patches: args.patches === true,
1084
+ discussions: args.discussions === true,
1085
+ maxDiscussions: whole(args.maxDiscussions),
1086
+ analyzer: args.analyzer === true,
1087
+ analyzerDb,
707
1088
  });
708
1089
  return { content: [{ type: 'text', text: result.text }] };
709
1090
  }
710
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
+ }
711
1111
  const result = await packContext(args.path, {
712
1112
  include: args.include,
713
1113
  exclude: args.exclude,
@@ -729,7 +1129,35 @@ class MCPServer {
729
1129
  ],
730
1130
  };
731
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
+ }
732
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
+ }
733
1161
  let result;
734
1162
  try {
735
1163
  result = { valid: true, ...loadPolicy(args.path).toJSON() };
@@ -743,6 +1171,18 @@ class MCPServer {
743
1171
  const since = args.since === undefined ? null : parseSince(String(args.since));
744
1172
  const limit =
745
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
+ }
746
1186
  // Only the policy's own logs: a tool argument never names a file to read
747
1187
  const { files, error } = auditLogFiles(args.path);
748
1188
  const { events, skipped, missing } = readAuditLog(files);
@@ -758,11 +1198,173 @@ class MCPServer {
758
1198
  }
759
1199
  case 'ask_codebase': {
760
1200
  const result = await askCodebase(args.path, args.query, {
1201
+ repos: args.repos,
761
1202
  limit: Number.isInteger(args.limit) ? args.limit : undefined,
762
1203
  maxChars: ASK_SNIPPET_CHARS,
763
1204
  });
764
1205
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
765
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
+ }
766
1368
  default:
767
1369
  throw new Error(`Unknown tool: ${name}`);
768
1370
  }