@ctxo/cli 0.13.0 → 0.14.0

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 (42) hide show
  1. package/README.md +20 -4
  2. package/dist/chunk-2Q76LH2H.js +186 -0
  3. package/dist/chunk-2Q76LH2H.js.map +1 -0
  4. package/dist/chunk-72CEQEQD.js +53 -0
  5. package/dist/chunk-72CEQEQD.js.map +1 -0
  6. package/dist/chunk-7HSA5JVP.js +1049 -0
  7. package/dist/chunk-7HSA5JVP.js.map +1 -0
  8. package/dist/chunk-G4BMKCKG.js +76 -0
  9. package/dist/chunk-G4BMKCKG.js.map +1 -0
  10. package/dist/chunk-L2CAUER6.js +508 -0
  11. package/dist/chunk-L2CAUER6.js.map +1 -0
  12. package/dist/chunk-LAX6VVBU.js +98 -0
  13. package/dist/chunk-LAX6VVBU.js.map +1 -0
  14. package/dist/chunk-SRQZDGFZ.js +128 -0
  15. package/dist/chunk-SRQZDGFZ.js.map +1 -0
  16. package/dist/{chunk-DT3ZH4AU.js → chunk-X4TVFJ4C.js} +6 -1
  17. package/dist/chunk-X4TVFJ4C.js.map +1 -0
  18. package/dist/{chunk-BYX3UFBJ.js → chunk-ZL36YS2V.js} +785 -1624
  19. package/dist/chunk-ZL36YS2V.js.map +1 -0
  20. package/dist/{cli-router-RR4CM62C.js → cli-router-AZCDNX5R.js} +982 -506
  21. package/dist/cli-router-AZCDNX5R.js.map +1 -0
  22. package/dist/index.js +13 -2874
  23. package/dist/index.js.map +1 -1
  24. package/dist/internal.d.ts +747 -0
  25. package/dist/internal.js +13 -0
  26. package/dist/{json-index-reader-OXTRTGE6.js → json-index-reader-DBW32TCN.js} +6 -4
  27. package/dist/json-index-reader-DBW32TCN.js.map +1 -0
  28. package/dist/mcp-server-6YG2QE6E.js +2604 -0
  29. package/dist/mcp-server-6YG2QE6E.js.map +1 -0
  30. package/dist/module-host-KNMKS62R.js +13 -0
  31. package/dist/module-host-KNMKS62R.js.map +1 -0
  32. package/dist/module-loader-B3LYF3W7.js +14 -0
  33. package/dist/module-loader-B3LYF3W7.js.map +1 -0
  34. package/dist/plugin-discovery-XRXI3IJF.js +13 -0
  35. package/dist/plugin-discovery-XRXI3IJF.js.map +1 -0
  36. package/llms-full.txt +105 -1
  37. package/llms.txt +21 -1
  38. package/package.json +15 -4
  39. package/dist/chunk-BYX3UFBJ.js.map +0 -1
  40. package/dist/chunk-DT3ZH4AU.js.map +0 -1
  41. package/dist/cli-router-RR4CM62C.js.map +0 -1
  42. /package/dist/{json-index-reader-OXTRTGE6.js.map → internal.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,114 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- ArchitecturalOverlay,
4
- BlastRadiusCalculator,
5
- BoundaryViolationDetector,
6
- CoChangeReader,
7
- ContextsViewReader,
8
- DeadCodeDetector,
9
- DriftDetector,
10
- PageRankCalculator,
11
- RevertDetector,
12
- SessionRecorderAdapter,
13
- SimpleGitAdapter,
14
- SqliteStorageAdapter,
15
- SymbolGraph,
16
- SymbolTokenizer,
17
- compareCodepoint,
18
- detectWorkspace,
19
- findCoChangedFilesForBlastRadius,
20
- loadConfig,
21
- makeGlobMatcher,
22
- maskingClusterLabelPatterns,
23
- statsEnabled
24
- } from "./chunk-BYX3UFBJ.js";
25
- import "./chunk-RFEXMBTG.js";
26
- import {
27
- DetailLevelSchema,
28
- EdgeKindSchema,
29
- JsonIndexReader,
30
- SYMBOL_KINDS,
31
- SymbolKindSchema
32
- } from "./chunk-DT3ZH4AU.js";
3
+ FAST_ROUTE_COMMANDS,
4
+ runFastRoute
5
+ } from "./chunk-SRQZDGFZ.js";
6
+ import "./chunk-72CEQEQD.js";
33
7
  import {
34
8
  createLogger
35
9
  } from "./chunk-5DSKZJNN.js";
36
10
 
37
- // src/index.ts
38
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
39
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
40
-
41
- // src/adapters/transport/http-server-transport.ts
42
- import { createServer } from "http";
43
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
44
- import { randomUUID } from "crypto";
45
- var log = createLogger("ctxo:http");
46
- async function startHttpTransport(serverFactory, port) {
47
- const sessions = /* @__PURE__ */ new Map();
48
- const httpServer = createServer(async (req, res) => {
49
- res.setHeader("Access-Control-Allow-Origin", "*");
50
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
51
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id");
52
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
53
- if (req.method === "OPTIONS") {
54
- res.writeHead(204);
55
- res.end();
56
- return;
57
- }
58
- const sessionId = req.headers["mcp-session-id"];
59
- if (sessionId && sessions.has(sessionId)) {
60
- try {
61
- await sessions.get(sessionId).transport.handleRequest(req, res);
62
- } catch (err) {
63
- log.error("Request error: %s", err.message);
64
- if (!res.headersSent) {
65
- res.writeHead(500);
66
- res.end();
67
- }
68
- }
69
- return;
70
- }
71
- if (sessionId && !sessions.has(sessionId)) {
72
- res.writeHead(404, { "Content-Type": "application/json" });
73
- res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found" }, id: null }));
74
- return;
75
- }
76
- try {
77
- const server = await serverFactory();
78
- const transport = new StreamableHTTPServerTransport({
79
- sessionIdGenerator: () => randomUUID()
80
- });
81
- transport.onclose = () => {
82
- if (transport.sessionId) {
83
- sessions.delete(transport.sessionId);
84
- log.info("Session closed: %s", transport.sessionId);
85
- }
86
- };
87
- await server.connect(transport);
88
- await transport.handleRequest(req, res);
89
- if (transport.sessionId) {
90
- sessions.set(transport.sessionId, { transport, server });
91
- log.info("New session: %s (total: %d)", transport.sessionId, sessions.size);
92
- }
93
- } catch (err) {
94
- log.error("Session creation error: %s", err.message);
95
- if (!res.headersSent) {
96
- res.writeHead(500);
97
- res.end();
98
- }
99
- }
100
- });
101
- return new Promise((resolve, reject) => {
102
- httpServer.on("error", reject);
103
- httpServer.listen(port, () => {
104
- log.info("MCP HTTP server listening on http://localhost:%d", port);
105
- process.stderr.write(`[ctxo] MCP HTTP server running at http://localhost:${port}
106
- `);
107
- resolve({ httpServer });
108
- });
109
- });
110
- }
111
-
112
11
  // src/cli/http-port.ts
113
12
  var DEFAULT_HTTP_PORT = "3001";
114
13
  function resolveHttpPort(args, env) {
@@ -120,2785 +19,25 @@ function resolveHttpPort(args, env) {
120
19
  }
121
20
 
122
21
  // src/index.ts
123
- import { z as z15 } from "zod";
124
- import { existsSync, readFileSync } from "fs";
125
- import { join, dirname } from "path";
126
- import { fileURLToPath } from "url";
127
-
128
- // src/core/masking/masking-pipeline.ts
129
- var DEFAULT_PATTERNS = [
130
- // AWS Access Key IDs (AKIA...)
131
- { regex: /AKIA[0-9A-Z]{16}/g, label: "AWS_KEY" },
132
- // AWS Secret Access Keys (40 char base64 containing / or +). The `[A-Za-z0-9=]*[/+]`
133
- // lookahead is scoped to the token itself (not `.*`, which would reach a later `/` in
134
- // the surrounding text, e.g. a file path in JSON, and wrongly redact hex-only git SHAs).
135
- { regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?=[A-Za-z0-9=]*[/+])[A-Za-z0-9/+=]{40}/g, label: "AWS_SECRET" },
136
- // JWT tokens (eyJ...)
137
- { regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, label: "JWT" },
138
- // Private IPv4 (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
139
- { regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g, label: "PRIVATE_IP" },
140
- // Private IPv6 (fc00::/7)
141
- { regex: /\b[fF][cCdD][0-9a-fA-F]{0,2}(?::[0-9a-fA-F]{1,4}){1,7}\b/g, label: "PRIVATE_IPV6" },
142
- // Environment variable assignments for secrets
143
- { regex: /(?:_SECRET|_KEY|_TOKEN|_PASSWORD)\s*=\s*['"]?[^\s'"]{8,}['"]?/g, label: "ENV_SECRET" },
144
- // GCP service account keys
145
- { regex: /"private_key"\s*:\s*"-----BEGIN[^"]+"/g, label: "GCP_KEY" },
146
- // Azure connection strings
147
- { regex: /AccountKey=[A-Za-z0-9+/=]{20,}/g, label: "AZURE_KEY" }
148
- ];
149
- var MaskingPipeline = class _MaskingPipeline {
150
- patterns;
151
- constructor(additionalPatterns = []) {
152
- this.patterns = [...DEFAULT_PATTERNS, ...additionalPatterns].map(({ regex, label }) => ({
153
- regex: new RegExp(regex.source, regex.flags),
154
- label
155
- }));
156
- }
157
- static fromConfig(configPatterns) {
158
- const additional = [];
159
- for (const { pattern, flags, label } of configPatterns) {
160
- try {
161
- additional.push({ regex: new RegExp(pattern, flags ?? "g"), label });
162
- } catch (err) {
163
- console.error(`[ctxo:masking] Invalid regex pattern "${pattern}": ${err.message}`);
164
- }
165
- }
166
- return new _MaskingPipeline(additional);
167
- }
168
- mask(text) {
169
- if (text.length === 0) return text;
170
- let result = text;
171
- for (const { regex, label } of this.patterns) {
172
- result = result.replace(regex, `[REDACTED:${label}]`);
173
- }
174
- return result;
175
- }
176
- };
177
-
178
- // src/adapters/mcp/get-logic-slice.ts
179
- import { z } from "zod";
180
-
181
- // src/core/logic-slice/logic-slice-query.ts
182
- var LogicSliceQuery = class {
183
- getLogicSlice(graph, symbolId, maxDepth = Infinity) {
184
- const root = graph.getNode(symbolId);
185
- if (!root) return void 0;
186
- const visited = /* @__PURE__ */ new Set([root.symbolId]);
187
- const dependencies = [];
188
- const collectedEdges = [];
189
- const queue = [{ id: root.symbolId, depth: 0 }];
190
- while (queue.length > 0) {
191
- const current = queue.shift();
192
- if (current.depth >= maxDepth) continue;
193
- const edges = graph.getForwardEdges(current.id);
194
- for (const edge of edges) {
195
- const depNode = graph.getNode(edge.to);
196
- if (!depNode) continue;
197
- collectedEdges.push(edge);
198
- if (visited.has(edge.to)) continue;
199
- visited.add(edge.to);
200
- dependencies.push(depNode);
201
- queue.push({ id: edge.to, depth: current.depth + 1 });
202
- }
203
- }
204
- return { root, dependencies, edges: collectedEdges };
205
- }
206
- };
207
-
208
- // src/core/detail-levels/detail-formatter.ts
209
- var L1_MAX_LINES = 150;
210
- var L4_TOKEN_BUDGET = 8e3;
211
- function withoutAnnotations(node) {
212
- if (node.annotations === void 0) return node;
213
- const { annotations: _annotations, ...rest } = node;
214
- return rest;
215
- }
216
- var DetailFormatter = class {
217
- format(slice, level) {
218
- switch (level) {
219
- case 1:
220
- return this.formatL1(slice);
221
- case 2:
222
- return this.formatL2(slice);
223
- case 3:
224
- return this.formatL3(slice);
225
- case 4:
226
- return this.formatL4(slice);
227
- }
228
- }
229
- formatL1(slice) {
230
- const root = slice.root;
231
- const lineCount = root.endLine - root.startLine + 1;
232
- const clampedRoot = lineCount > L1_MAX_LINES ? { ...root, endLine: root.startLine + L1_MAX_LINES - 1 } : root;
233
- return {
234
- root: withoutAnnotations(clampedRoot),
235
- dependencies: [],
236
- edges: [],
237
- level: 1,
238
- levelDescription: "L1: Root symbol signature only (no dependencies)",
239
- ...lineCount > L1_MAX_LINES ? { truncation: { truncated: true, reason: "token_budget_exceeded" } } : {}
240
- };
241
- }
242
- formatL2(slice) {
243
- const directEdges = slice.edges.filter((e) => e.from === slice.root.symbolId);
244
- const directDepIds = new Set(directEdges.map((e) => e.to));
245
- const directDeps = slice.dependencies.filter((d) => directDepIds.has(d.symbolId));
246
- return {
247
- root: withoutAnnotations(slice.root),
248
- dependencies: directDeps.map(withoutAnnotations),
249
- edges: directEdges,
250
- level: 2,
251
- levelDescription: "L2: Root + direct dependencies (depth 1)"
252
- };
253
- }
254
- formatL3(slice) {
255
- return {
256
- root: withoutAnnotations(slice.root),
257
- dependencies: slice.dependencies.map(withoutAnnotations),
258
- edges: slice.edges,
259
- level: 3,
260
- levelDescription: "L3: Full transitive dependency closure"
261
- };
262
- }
263
- formatL4(slice) {
264
- const estimatedTokens = this.estimateTokens(slice);
265
- if (estimatedTokens > L4_TOKEN_BUDGET) {
266
- const truncated = this.truncateToTokenBudget(slice, L4_TOKEN_BUDGET);
267
- return {
268
- ...truncated,
269
- level: 4,
270
- levelDescription: "L4: Full closure with 8K token budget (truncated)",
271
- truncation: {
272
- truncated: true,
273
- reason: "token_budget_exceeded"
274
- }
275
- };
276
- }
277
- return {
278
- root: slice.root,
279
- dependencies: slice.dependencies,
280
- edges: slice.edges,
281
- level: 4,
282
- levelDescription: "L4: Full closure with 8K token budget"
283
- };
284
- }
285
- estimateTokens(slice) {
286
- let chars = this.symbolCharEstimate(slice.root);
287
- for (const dep of slice.dependencies) {
288
- chars += this.symbolCharEstimate(dep);
289
- }
290
- for (const edge of slice.edges) {
291
- chars += edge.from.length + edge.to.length + edge.kind.length + 10;
292
- }
293
- return Math.ceil(chars / 4);
294
- }
295
- symbolCharEstimate(node) {
296
- if (node.startOffset !== void 0 && node.endOffset !== void 0) {
297
- return node.endOffset - node.startOffset;
298
- }
299
- return (node.endLine - node.startLine + 1) * 40;
300
- }
301
- truncateToTokenBudget(slice, budget) {
302
- let currentTokens = this.symbolCharEstimate(slice.root) / 4;
303
- const deps = [];
304
- const includedIds = /* @__PURE__ */ new Set([slice.root.symbolId]);
305
- for (const dep of slice.dependencies) {
306
- const depTokens = this.symbolCharEstimate(dep) / 4;
307
- if (currentTokens + depTokens > budget) break;
308
- currentTokens += depTokens;
309
- deps.push(dep);
310
- includedIds.add(dep.symbolId);
311
- }
312
- const edges = slice.edges.filter(
313
- (e) => includedIds.has(e.from) && includedIds.has(e.to)
314
- );
315
- return { root: slice.root, dependencies: deps, edges };
316
- }
317
- };
318
-
319
- // src/core/response-envelope.ts
320
- var DEFAULT_THRESHOLD = 8192;
321
- var workspaceMeta;
322
- function setWorkspaceMeta(workspace) {
323
- workspaceMeta = workspace;
324
- }
325
- var TRUNCATABLE_FIELDS = {
326
- impactedSymbols: "Use search_symbols to find specific impacted symbols, or get_blast_radius with a confidence filter.",
327
- importers: "Use find_importers with edgeKinds filter or maxDepth to narrow results.",
328
- deadSymbols: "Use search_symbols with kind filter to find specific dead symbols.",
329
- unusedExports: "Use find_importers to check specific symbols.",
330
- results: "Use search_symbols or get_ranked_context with a narrower query.",
331
- rankings: "Use get_symbol_importance with kind or filePattern filter to narrow results.",
332
- context: "Use get_context_for_task with a smaller tokenBudget.",
333
- hierarchies: "Use get_class_hierarchy with a specific symbolId.",
334
- scaffolding: "Review the scaffolding markers directly in the listed files.",
335
- deadFiles: "Use find_dead_code with includeTests to adjust scope.",
336
- files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles.",
337
- clusters: "Use get_architectural_overlay with mode=communities and layer filter for a focused view.",
338
- candidates: "Use get_architectural_overlay with mode=contexts and candidate: <id> for one candidate in full, or ctxo contexts --json for the full contexts view.",
339
- clustersAffected: "Use get_blast_radius on individual changed symbols for per-cluster impact."
340
- };
341
- function getThreshold() {
342
- const env = process.env["CTXO_RESPONSE_LIMIT"];
343
- if (env) {
344
- const n = parseInt(env, 10);
345
- if (!isNaN(n) && n > 0) return n;
346
- }
347
- return DEFAULT_THRESHOLD;
348
- }
349
- function findTruncatableArray(data) {
350
- let best = null;
351
- for (const [key, value] of Object.entries(data)) {
352
- if (Array.isArray(value) && key in TRUNCATABLE_FIELDS) {
353
- if (!best || value.length > best.arr.length) {
354
- best = { key, arr: value };
355
- }
356
- }
357
- }
358
- return best;
359
- }
360
- function buildMeta(base, extras) {
361
- const meta = workspaceMeta ? { ...base, workspace: workspaceMeta } : base;
362
- if (extras?.snapshotStaleness) {
363
- return { ...meta, snapshotStaleness: extras.snapshotStaleness };
364
- }
365
- return meta;
366
- }
367
- function wrapResponse(data, extras) {
368
- const threshold = getThreshold();
369
- const fullJson = JSON.stringify(data);
370
- const totalBytes = Buffer.byteLength(fullJson, "utf-8");
371
- const truncatable = findTruncatableArray(data);
372
- const totalItems = truncatable ? truncatable.arr.length : 0;
373
- if (totalBytes <= threshold) {
374
- return {
375
- ...data,
376
- _meta: buildMeta(
377
- {
378
- totalItems,
379
- returnedItems: totalItems,
380
- truncated: false,
381
- totalBytes
382
- },
383
- extras
384
- )
385
- };
386
- }
387
- if (!truncatable || truncatable.arr.length <= 1) {
388
- return {
389
- ...data,
390
- _meta: buildMeta(
391
- {
392
- totalItems,
393
- returnedItems: totalItems,
394
- truncated: false,
395
- totalBytes
396
- },
397
- extras
398
- )
399
- };
400
- }
401
- let lo = 1;
402
- let hi = truncatable.arr.length;
403
- while (lo < hi) {
404
- const mid = Math.ceil((lo + hi) / 2);
405
- const trial = {
406
- ...data,
407
- [truncatable.key]: truncatable.arr.slice(0, mid),
408
- _meta: buildMeta(
409
- {
410
- totalItems,
411
- returnedItems: mid,
412
- truncated: true,
413
- totalBytes,
414
- hint: TRUNCATABLE_FIELDS[truncatable.key]
415
- },
416
- extras
417
- )
418
- };
419
- const size = Buffer.byteLength(JSON.stringify(trial), "utf-8");
420
- if (size <= threshold) {
421
- lo = mid;
422
- } else {
423
- hi = mid - 1;
424
- }
425
- }
426
- const hint = TRUNCATABLE_FIELDS[truncatable.key];
427
- return {
428
- ...data,
429
- [truncatable.key]: truncatable.arr.slice(0, lo),
430
- _meta: buildMeta(
431
- {
432
- totalItems,
433
- returnedItems: lo,
434
- truncated: true,
435
- totalBytes,
436
- hint
437
- },
438
- extras
439
- )
440
- };
441
- }
442
-
443
- // src/core/intent-filter.ts
444
- function extractKeywords(intent) {
445
- return intent.toLowerCase().split(/\s+/).filter((w) => w.length >= 2);
446
- }
447
- function matchesAny(keywords, values) {
448
- const lower = values.map((v) => v.toLowerCase());
449
- return keywords.some((kw) => lower.some((v) => v.includes(kw)));
450
- }
451
- function collectMatchableStrings(entry) {
452
- const strings = [];
453
- for (const key of ["symbolId", "file", "name", "kind", "edgeKind", "reason", "confidence"]) {
454
- const v = entry[key];
455
- if (typeof v === "string") strings.push(v);
456
- }
457
- const edgeKinds = entry["edgeKinds"];
458
- if (Array.isArray(edgeKinds)) {
459
- for (const ek of edgeKinds) {
460
- if (typeof ek === "string") strings.push(ek);
461
- }
462
- }
463
- return strings;
464
- }
465
- function filterByIntent(items, intent) {
466
- if (!intent || intent.trim().length === 0) return items;
467
- const keywords = extractKeywords(intent);
468
- if (keywords.length === 0) return items;
469
- return items.filter((item) => {
470
- const strings = collectMatchableStrings(item);
471
- return matchesAny(keywords, strings);
472
- });
473
- }
474
-
475
- // src/adapters/mcp/get-logic-slice.ts
476
- var InputSchema = z.object({
477
- symbolId: z.string().min(1).optional(),
478
- symbolIds: z.array(z.string().min(1)).optional(),
479
- level: DetailLevelSchema.optional().default(3),
480
- intent: z.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter", "storage")')
481
- }).refine(
482
- (data) => data.symbolId || data.symbolIds && data.symbolIds.length > 0,
483
- { message: "Either symbolId or symbolIds must be provided" }
484
- );
485
- function buildGraphFromStorage(storage) {
486
- const graph = new SymbolGraph();
487
- for (const sym of storage.getAllSymbols()) {
488
- graph.addNode(sym);
489
- }
490
- for (const edge of storage.getAllEdges()) {
491
- graph.addEdge(edge);
492
- }
493
- return graph;
494
- }
495
- function buildGraphAndFilesFromJsonIndex(ctxoRoot) {
496
- const reader = new JsonIndexReader(ctxoRoot);
497
- const indices = reader.readAll();
498
- return { graph: buildGraphFromIndices(indices), indexedFiles: indices.map((fileIndex) => fileIndex.file) };
499
- }
500
- function buildGraphFromIndices(indices) {
501
- const graph = new SymbolGraph();
502
- for (const fileIndex of indices) {
503
- for (const sym of fileIndex.symbols) {
504
- graph.addNode(sym);
505
- }
506
- }
507
- for (const fileIndex of indices) {
508
- for (const edge of fileIndex.edges) {
509
- graph.addEdge(edge);
510
- }
511
- }
512
- return graph;
513
- }
514
- function getGraphAndFiles(ctxoRoot, storage) {
515
- const json = buildGraphAndFilesFromJsonIndex(ctxoRoot);
516
- if (json.graph.nodeCount > 0) return json;
517
- return { graph: buildGraphFromStorage(storage), indexedFiles: storage.listIndexedFiles() };
518
- }
519
- function handleGetLogicSlice(storage, masking, staleness, ctxoRoot = ".ctxo") {
520
- const query = new LogicSliceQuery();
521
- const formatter = new DetailFormatter();
522
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
523
- const handler = (args) => {
524
- try {
525
- const parsed = InputSchema.safeParse(args);
526
- if (!parsed.success) {
527
- return {
528
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
529
- };
530
- }
531
- const { symbolId, symbolIds, level, intent } = parsed.data;
532
- const ids = symbolIds ?? (symbolId ? [symbolId] : []);
533
- const { graph, indexedFiles } = getGraph();
534
- const results = [];
535
- for (const id of ids) {
536
- const slice = query.getLogicSlice(graph, id);
537
- if (slice) {
538
- const formatted = formatter.format(slice, level);
539
- if (intent && Array.isArray(formatted["dependencies"])) {
540
- formatted["dependencies"] = filterByIntent(
541
- formatted["dependencies"],
542
- intent
543
- );
544
- }
545
- results.push(formatted);
546
- } else {
547
- results.push({ found: false, symbolId: id, hint: 'Symbol not found. Run "ctxo index".' });
548
- }
549
- }
550
- const responseData = ids.length === 1 ? results[0] : { batch: true, results };
551
- const payload = masking.mask(JSON.stringify(wrapResponse(responseData)));
552
- const content = [];
553
- if (staleness) {
554
- const warning = staleness.check(indexedFiles);
555
- if (warning) {
556
- content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
557
- }
558
- }
559
- content.push({ type: "text", text: payload });
560
- return { content };
561
- } catch (err) {
562
- return {
563
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
564
- };
565
- }
566
- };
567
- return handler;
568
- }
569
-
570
- // src/adapters/mcp/get-why-context.ts
571
- import { z as z2 } from "zod";
572
-
573
- // src/core/overlay/snapshot-staleness.ts
574
- async function buildSnapshotStaleness(storage, git) {
575
- const snapshot = storage.readCommunities();
576
- if (!snapshot || !snapshot.commitSha || snapshot.commitSha === "nocommit") return void 0;
577
- const getHead = git.getHeadSha?.bind(git);
578
- const countBetween = git.countCommitsBetween?.bind(git);
579
- const head = getHead ? await getHead() : void 0;
580
- if (!head) {
581
- return { snapshotCommit: snapshot.commitSha };
582
- }
583
- if (head === snapshot.commitSha) {
584
- return {
585
- snapshotCommit: snapshot.commitSha,
586
- currentHeadCommit: head,
587
- commitsBehind: 0
588
- };
589
- }
590
- const behind = countBetween ? await countBetween(snapshot.commitSha) : void 0;
591
- const result = {
592
- snapshotCommit: snapshot.commitSha,
593
- currentHeadCommit: head,
594
- ...typeof behind === "number" ? { commitsBehind: behind } : {}
595
- };
596
- if (typeof behind === "number" && behind > 0) {
597
- return {
598
- ...result,
599
- hint: `Community snapshot is ${behind} commit${behind === 1 ? "" : "s"} behind HEAD. Run \`ctxo index\` to refresh cluster / drift / boundary data.`
600
- };
601
- }
602
- if (behind === void 0) {
603
- return {
604
- ...result,
605
- hint: "Snapshot commit is not reachable from HEAD \u2014 it may have been rebased away. Run `ctxo index` to re-anchor."
606
- };
607
- }
608
- return result;
609
- }
610
-
611
- // src/adapters/mcp/get-why-context.ts
612
- function graphOf(indices, storage) {
613
- const graph = buildGraphFromIndices(indices);
614
- return graph.nodeCount > 0 ? graph : buildGraphFromStorage(storage);
615
- }
616
- var InputSchema2 = z2.object({
617
- symbolId: z2.string().min(1),
618
- maxCommits: z2.number().int().min(1).optional()
619
- });
620
- function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker) {
621
- const revertDetector = new RevertDetector();
622
- const indexReader = new JsonIndexReader(ctxoRoot);
623
- const driftDetector = new DriftDetector();
624
- return async (args) => {
625
- try {
626
- const parsed = InputSchema2.safeParse(args);
627
- if (!parsed.success) {
628
- return {
629
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
630
- };
631
- }
632
- const { symbolId: inputId, maxCommits } = parsed.data;
633
- const indices = indexReader.readAll();
634
- const symbolId = storage.getSymbolById(inputId) ? inputId : graphOf(indices, storage).getNode(inputId)?.symbolId;
635
- if (!symbolId) {
636
- return {
637
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
638
- };
639
- }
640
- const filePath = symbolId.split("::")[0];
641
- const fileIndex = indices.find((i) => i.file === filePath);
642
- let commitHistory;
643
- let antiPatterns;
644
- if (fileIndex && fileIndex.intent.length > 0) {
645
- commitHistory = fileIndex.intent;
646
- antiPatterns = fileIndex.antiPatterns;
647
- } else {
648
- const commits = await git.getCommitHistory(filePath);
649
- commitHistory = commits.map((c) => ({
650
- hash: c.hash,
651
- message: c.message,
652
- date: c.date,
653
- kind: "commit"
654
- }));
655
- antiPatterns = revertDetector.detect(commits);
656
- }
657
- const limitedHistory = maxCommits ? commitHistory.slice(0, maxCommits) : commitHistory;
658
- const responsePayload = {
659
- commitHistory: limitedHistory,
660
- antiPatternWarnings: antiPatterns
661
- };
662
- if (antiPatterns.length > 0) {
663
- responsePayload.warningBadge = "\u26A0 Anti-pattern detected";
664
- }
665
- const rawSnapshot = storage.readCommunities();
666
- const currentSnapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
667
- if (currentSnapshot) {
668
- const history = storage.listCommunityHistory();
669
- const drift = driftDetector.detect(currentSnapshot, history);
670
- const relevant = drift.events.filter((event) => event.symbolId === symbolId);
671
- if (relevant.length > 0 || drift.hint) {
672
- responsePayload.driftSignals = {
673
- events: relevant,
674
- confidence: drift.confidence,
675
- snapshotsAvailable: drift.snapshotsAvailable,
676
- distinctCommits: drift.distinctCommits,
677
- stabilitySummary: drift.stability,
678
- ...drift.hint ? { hint: drift.hint } : {}
679
- };
680
- }
681
- }
682
- const stalenessMeta = await buildSnapshotStaleness(storage, git);
683
- if (stalenessMeta) {
684
- responsePayload._meta = { snapshotStaleness: stalenessMeta };
685
- }
686
- const payload = masking.mask(JSON.stringify(responsePayload));
687
- const content = [];
688
- if (staleness) {
689
- const warning = staleness.check(storage.listIndexedFiles());
690
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
691
- }
692
- content.push({ type: "text", text: payload });
693
- return { content };
694
- } catch (err) {
695
- return {
696
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
697
- };
698
- }
699
- };
700
- }
701
-
702
- // src/adapters/mcp/get-change-intelligence.ts
703
- import { z as z3 } from "zod";
704
-
705
- // src/core/change-intelligence/churn-analyzer.ts
706
- var ChurnAnalyzer = class {
707
- normalize(commitCount, maxCommitCount) {
708
- if (maxCommitCount <= 0) return 0;
709
- if (commitCount < 0) {
710
- throw new Error(`Invalid commit count: ${commitCount}`);
711
- }
712
- return Math.min(commitCount / maxCommitCount, 1);
713
- }
714
- };
715
-
716
- // src/core/change-intelligence/health-scorer.ts
717
- var HealthScorer = class {
718
- score(symbolId, complexity, churn) {
719
- const composite = complexity * churn;
720
- const band = this.toBand(composite);
721
- return { symbolId, complexity, churn, composite, band };
722
- }
723
- toBand(composite) {
724
- if (composite < 0.3) return "low";
725
- if (composite < 0.7) return "medium";
726
- return "high";
727
- }
728
- };
729
-
730
- // src/adapters/mcp/get-change-intelligence.ts
731
- var InputSchema3 = z3.object({
732
- symbolId: z3.string().min(1)
733
- });
734
- function handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
735
- const churnAnalyzer = new ChurnAnalyzer();
736
- const healthScorer = new HealthScorer();
737
- const indexReader = new JsonIndexReader(ctxoRoot);
738
- return async (args) => {
739
- try {
740
- const parsed = InputSchema3.safeParse(args);
741
- if (!parsed.success) {
742
- return {
743
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
744
- };
745
- }
746
- const { symbolId } = parsed.data;
747
- const symbol = storage.getSymbolById(symbolId);
748
- if (!symbol) {
749
- return {
750
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
751
- };
752
- }
753
- const filePath = symbolId.split("::")[0];
754
- const normalizePath = (p) => p.replace(/\\/g, "/");
755
- const allFiles = storage.listIndexedFiles();
756
- const churnResults = await Promise.all(allFiles.map((f) => git.getFileChurn(f)));
757
- const maxChurn = Math.max(1, ...churnResults.map((c) => c.commitCount));
758
- const targetChurn = churnResults.find((c) => normalizePath(c.filePath) === normalizePath(filePath));
759
- const commitCount = targetChurn?.commitCount ?? 0;
760
- const normalizedChurn = churnAnalyzer.normalize(commitCount, maxChurn);
761
- const indices = indexReader.readAll();
762
- const fileIndex = indices.find((i) => i.file === filePath);
763
- const complexityEntries = fileIndex?.complexity ?? [];
764
- const indexMissingWarning = !fileIndex ? `JSON index for ${filePath} not found on disk \u2014 complexity falls back to 0. Run "ctxo index" to resync.` : void 0;
765
- let cyclomatic = complexityEntries.find((c) => c.symbolId === symbolId)?.cyclomatic;
766
- if (cyclomatic === void 0) {
767
- const symbolName = symbolId.split("::")[1] ?? "";
768
- const relatedEntries = complexityEntries.filter(
769
- (c) => c.symbolId.startsWith(`${filePath}::${symbolName}.`)
770
- );
771
- cyclomatic = relatedEntries.length > 0 ? Math.max(...relatedEntries.map((c) => c.cyclomatic)) : 1;
772
- }
773
- const normalizedComplexity = Math.min((cyclomatic - 1) / 9, 1);
774
- const score = healthScorer.score(symbolId, normalizedComplexity, normalizedChurn);
775
- const payload = masking.mask(JSON.stringify(score));
776
- const content = [];
777
- if (staleness) {
778
- const warning = staleness.check(storage.listIndexedFiles());
779
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
780
- }
781
- if (indexMissingWarning) {
782
- content.push({ type: "text", text: `\u26A0\uFE0F ${indexMissingWarning}` });
783
- }
784
- content.push({ type: "text", text: payload });
785
- return { content };
786
- } catch (err) {
787
- return {
788
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
789
- };
790
- }
791
- };
792
- }
793
-
794
- // src/adapters/mcp/get-blast-radius.ts
795
- import { z as z4 } from "zod";
796
- function computeClusterBreakdown(snapshot, rootSymbolId, impactedSymbolIds) {
797
- if (impactedSymbolIds.length === 0) return void 0;
798
- const assignment = /* @__PURE__ */ new Map();
799
- for (const entry of snapshot.communities) {
800
- assignment.set(entry.symbolId, { id: entry.communityId, label: entry.communityLabel });
801
- }
802
- const rootCluster = assignment.get(rootSymbolId);
803
- const byCluster = {};
804
- let crossClusterCount = 0;
805
- for (const symbolId of impactedSymbolIds) {
806
- const cluster = assignment.get(symbolId);
807
- if (!cluster) continue;
808
- const label = cluster.label || `community-${cluster.id}`;
809
- byCluster[label] = (byCluster[label] ?? 0) + 1;
810
- if (rootCluster && cluster.id !== rootCluster.id) crossClusterCount++;
811
- }
812
- const clusterCount = Object.keys(byCluster).length;
813
- const multiClusterHint = clusterCount >= 3 ? `Change impacts ${clusterCount} clusters \u2014 multi-team review recommended.` : void 0;
814
- return { byCluster, crossClusterCount, multiClusterHint };
815
- }
816
- var InputSchema4 = z4.object({
817
- symbolId: z4.string().min(1),
818
- confidence: z4.enum(["confirmed", "likely", "potential"]).optional(),
819
- intent: z4.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "security")')
820
- });
821
- function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", git, clusterLabelMasker, coChange) {
822
- const calculator = new BlastRadiusCalculator();
823
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
824
- return async (args) => {
825
- try {
826
- const parsed = InputSchema4.safeParse(args);
827
- if (!parsed.success) {
828
- return {
829
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
830
- };
831
- }
832
- const { symbolId } = parsed.data;
833
- const { graph, indexedFiles } = getGraph();
834
- const root = graph.getNode(symbolId);
835
- if (!root) {
836
- return {
837
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
838
- };
839
- }
840
- const rootId = root.symbolId;
841
- const coChangeMap = coChange?.readCoChangeMap();
842
- const result = calculator.calculate(graph, rootId, coChangeMap);
843
- let symbols = result.impactedSymbols;
844
- if (parsed.data.confidence) {
845
- symbols = symbols.filter((s) => s.confidence === parsed.data.confidence);
846
- }
847
- symbols = filterByIntent(symbols, parsed.data.intent);
848
- const confirmedCount = symbols.filter((s) => s.confidence === "confirmed").length;
849
- const likelyCount = symbols.filter((s) => s.confidence === "likely").length;
850
- const potentialCount = symbols.filter((s) => s.confidence === "potential").length;
851
- const rawSnapshot = storage.readCommunities();
852
- const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
853
- const clusterBreakdown = snapshot ? computeClusterBreakdown(snapshot, rootId, symbols.map((s) => s.symbolId)) : void 0;
854
- const body = {
855
- symbolId,
856
- impactScore: symbols.length,
857
- directDependentsCount: result.directDependentsCount,
858
- confirmedCount,
859
- likelyCount,
860
- potentialCount,
861
- overallRiskScore: result.overallRiskScore,
862
- impactedSymbols: symbols
863
- };
864
- if (clusterBreakdown) {
865
- body.byCluster = clusterBreakdown.byCluster;
866
- body.crossClusterEdges = clusterBreakdown.crossClusterCount;
867
- if (clusterBreakdown.multiClusterHint) {
868
- body.multiClusterHint = clusterBreakdown.multiClusterHint;
869
- }
870
- }
871
- const coChangedFiles = findCoChangedFilesForBlastRadius(coChangeMap, rootId, result.impactedSymbols);
872
- if (coChangedFiles.length > 0) body.coChangedFiles = coChangedFiles;
873
- const stalenessMeta = git ? await buildSnapshotStaleness(storage, git) : void 0;
874
- const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
875
- const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
876
- const content = [];
877
- if (staleness) {
878
- const warning = staleness.check(indexedFiles);
879
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
880
- }
881
- content.push({ type: "text", text: payload });
882
- return { content };
883
- } catch (err) {
884
- return {
885
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
886
- };
887
- }
888
- };
889
- }
890
-
891
- // src/adapters/mcp/get-architectural-overlay.ts
892
- import { z as z5 } from "zod";
893
- var OVERLAY_MODES = ["regex", "communities", "both", "contexts"];
894
- var InputSchema5 = z5.object({
895
- layer: z5.string().optional(),
896
- mode: z5.enum(OVERLAY_MODES).optional(),
897
- candidate: z5.number().int().positive().optional()
898
- });
899
- function buildCommunityOverlay(snapshot, options = {}) {
900
- const previewLimit = options.memberPreviewLimit ?? 15;
901
- const byCluster = /* @__PURE__ */ new Map();
902
- for (const entry of snapshot.communities) {
903
- const list = byCluster.get(entry.communityId) ?? [];
904
- list.push(entry.symbolId);
905
- byCluster.set(entry.communityId, list);
906
- }
907
- const labelFor = /* @__PURE__ */ new Map();
908
- for (const entry of snapshot.communities) {
909
- if (!labelFor.has(entry.communityId)) {
910
- labelFor.set(entry.communityId, entry.communityLabel);
911
- }
912
- }
913
- const godNodesByCommunity = /* @__PURE__ */ new Map();
914
- for (const god of snapshot.godNodes) {
915
- for (const communityId of god.bridgedCommunities) {
916
- const set = godNodesByCommunity.get(communityId) ?? /* @__PURE__ */ new Set();
917
- set.add(god.symbolId);
918
- godNodesByCommunity.set(communityId, set);
919
- }
920
- }
921
- const clusters = [];
922
- for (const [id, members] of byCluster) {
923
- members.sort();
924
- clusters.push({
925
- id,
926
- label: labelFor.get(id) ?? `community-${id}`,
927
- memberCount: members.length,
928
- members: members.slice(0, previewLimit),
929
- godNodes: [...godNodesByCommunity.get(id) ?? []].sort()
930
- });
931
- }
932
- clusters.sort((a, b) => b.memberCount - a.memberCount || a.id - b.id);
933
- return {
934
- clusters,
935
- modularity: snapshot.modularity,
936
- edgeQuality: snapshot.edgeQuality,
937
- crossClusterEdges: snapshot.crossClusterEdges,
938
- computedAt: snapshot.computedAt,
939
- commitSha: snapshot.commitSha
940
- };
941
- }
942
- var HUB_COLLISION_LIMIT = 50;
943
- var SUMMARY_TERM_LIMIT = 5;
944
- var DETAIL_FILE_LIMIT = 15;
945
- var DETAIL_COLLISION_LIMIT = 10;
946
- var DETAIL_OCCURRENCE_LIMIT = 5;
947
- var DETAIL_DATA_LIMIT = 15;
948
- var DETAIL_ACCESS_LIMIT = 10;
949
- var NO_VIEW_HINT = "No contexts view available. Run ctxo index to generate one.";
950
- var SUMMARY_HINT = "Pass candidate: <id> for one candidate in full; run ctxo contexts --json for the whole view.";
951
- var DETAIL_HINT = "Run ctxo contexts --json for full member sets.";
952
- function labelMaskerFor(view, labelMasker) {
953
- const masker = labelMasker?.isEnabled() ? labelMasker : void 0;
954
- if (!masker) return (label) => label;
955
- for (const label of [...new Set(view.candidates.map((c) => c.label))].sort(compareCodepoint)) masker.mask(label);
956
- return (label) => masker.mask(label);
957
- }
958
- function collisionSummary(collision) {
959
- return {
960
- name: collision.name,
961
- minJaccard: collision.minJaccard,
962
- candidates: [...new Set(collision.occurrences.map((occurrence) => occurrence.candidate))].sort((a, b) => a - b),
963
- occurrenceCount: collision.occurrences.length
964
- };
965
- }
966
- function contextsSummary(view, maskLabel) {
967
- return {
968
- commitSha: view.commitSha,
969
- coverage: view.coverage,
970
- candidates: view.candidates.map((candidate) => ({
971
- id: candidate.id,
972
- label: maskLabel(candidate.label),
973
- fingerprint: candidate.fingerprint,
974
- fileCount: candidate.fileCount,
975
- cohesion: candidate.cohesion,
976
- topTerms: candidate.topTerms.slice(0, SUMMARY_TERM_LIMIT),
977
- ...candidate.sharedTables.length > 0 ? { sharedTableCount: candidate.sharedTables.length } : {}
978
- })),
979
- hubs: view.hubs.slice(0, HUB_COLLISION_LIMIT).map((hub) => ({
980
- file: hub.file,
981
- reasons: hub.reasons,
982
- ...hub.candidate !== void 0 ? { candidate: hub.candidate } : {}
983
- })),
984
- hubsTotal: view.hubs.length,
985
- collisions: view.collisions.slice(0, HUB_COLLISION_LIMIT).map(collisionSummary),
986
- collisionsTotal: view.collisions.length,
987
- unclustered: { count: view.unclustered.count },
988
- dataResourcesTotal: view.dataResources.length,
989
- sharedResourcesTotal: view.dataResources.filter((resource) => resource.accesses.length >= 2).length,
990
- externalResourcesTotal: view.dataResources.filter((resource) => resource.external).length,
991
- hint: SUMMARY_HINT
992
- };
993
- }
994
- function sharedTableDetail(table, id) {
995
- const own = table.accesses.filter((access) => access.candidate === id);
996
- const others = table.accesses.filter((access) => access.candidate !== id).slice(0, DETAIL_ACCESS_LIMIT - own.length);
997
- return {
998
- ...table,
999
- accesses: [...own, ...others].sort((a, b) => a.candidate - b.candidate),
1000
- accessCount: table.accesses.length
1001
- };
1002
- }
1003
- function ownDataResources(view, id) {
1004
- const own = [];
1005
- for (const resource of view.dataResources) {
1006
- const access = resource.accesses.find((entry) => entry.candidate === id);
1007
- if (!access) continue;
1008
- own.push({
1009
- name: resource.name,
1010
- ...resource.kind !== void 0 ? { kind: resource.kind } : {},
1011
- external: resource.external,
1012
- modes: access.modes,
1013
- ...resource.owner !== void 0 ? { owner: resource.owner } : {},
1014
- writerCount: resource.writers.length
1015
- });
1016
- }
1017
- return own;
1018
- }
1019
- function contextsDetail(view, id, maskLabel, extras) {
1020
- const candidate = view.candidates.find((c) => c.id === id);
1021
- if (!candidate) {
1022
- return { found: false, hint: `No candidate ${id} in the contexts view. Call without candidate for the list.` };
1023
- }
1024
- const hubs = view.hubs.filter((hub) => hub.candidate === id);
1025
- const collisions = view.collisions.filter(
1026
- (collision) => collision.occurrences.some((occurrence) => occurrence.candidate === id)
1027
- );
1028
- const dataResources = ownDataResources(view, id);
1029
- const shownTables = candidate.sharedTables.slice(0, DETAIL_DATA_LIMIT).map((table) => sharedTableDetail(table, id));
1030
- const shownResources = dataResources.slice(0, DETAIL_DATA_LIMIT);
1031
- const body = {
1032
- commitSha: view.commitSha,
1033
- candidate: {
1034
- ...candidate,
1035
- label: maskLabel(candidate.label),
1036
- files: candidate.files.slice(0, DETAIL_FILE_LIMIT),
1037
- sharedTables: shownTables,
1038
- sharedTableCount: candidate.sharedTables.length
1039
- },
1040
- hubs: hubs.slice(0, HUB_COLLISION_LIMIT).map((hub) => ({ file: hub.file, reasons: hub.reasons, dependents: hub.dependents, damping: hub.damping })),
1041
- hubsTotal: hubs.length,
1042
- collisions: collisions.slice(0, DETAIL_COLLISION_LIMIT).map((collision) => ({
1043
- ...collisionSummary(collision),
1044
- occurrences: collision.occurrences.filter((occurrence) => occurrence.candidate === id).slice(0, DETAIL_OCCURRENCE_LIMIT).map((occurrence) => ({
1045
- symbolId: occurrence.symbolId,
1046
- members: occurrence.members.slice(0, DETAIL_OCCURRENCE_LIMIT),
1047
- memberCount: occurrence.members.length
1048
- }))
1049
- })),
1050
- collisionsTotal: collisions.length,
1051
- dataResources: shownResources,
1052
- dataResourcesTotal: dataResources.length,
1053
- hint: DETAIL_HINT
1054
- };
1055
- fitDataToLimit(body, shownTables, shownResources, extras);
1056
- return body;
1057
- }
1058
- function fitDataToLimit(body, tables, resources, extras) {
1059
- const limit = getThreshold();
1060
- const size = () => Buffer.byteLength(JSON.stringify(wrapResponse(body, extras)), "utf-8");
1061
- while ((tables.length > 0 || resources.length > 0) && size() > limit) {
1062
- if (resources.length >= tables.length) resources.pop();
1063
- else tables.pop();
1064
- }
1065
- }
1066
- function buildContextsOverlay(view, options = {}) {
1067
- if (!view) return { found: false, hint: NO_VIEW_HINT };
1068
- const maskLabel = labelMaskerFor(view, options.labelMasker);
1069
- return options.candidate === void 0 ? contextsSummary(view, maskLabel) : contextsDetail(view, options.candidate, maskLabel, options.extras);
1070
- }
1071
- function handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker, contextsView) {
1072
- const overlay = new ArchitecturalOverlay();
1073
- return async (args) => {
1074
- try {
1075
- const parsed = InputSchema5.safeParse(args);
1076
- if (!parsed.success) {
1077
- return {
1078
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
1079
- };
1080
- }
1081
- const files = storage.listIndexedFiles();
1082
- const result = overlay.classify(files);
1083
- const mode = parsed.data.mode ?? "both";
1084
- const buildContent = (payloadStr) => {
1085
- const content = [];
1086
- if (staleness) {
1087
- const warning = staleness.check(files);
1088
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1089
- }
1090
- content.push({ type: "text", text: payloadStr });
1091
- return content;
1092
- };
1093
- const stalenessMeta = git ? await buildSnapshotStaleness(storage, git) : void 0;
1094
- const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
1095
- if (parsed.data.layer) {
1096
- const filtered = result.layers[parsed.data.layer];
1097
- const payload2 = masking.mask(
1098
- JSON.stringify(wrapResponse({ layer: parsed.data.layer, files: filtered ?? [] }, extras))
1099
- );
1100
- return { content: buildContent(payload2) };
1101
- }
1102
- if (mode === "contexts") {
1103
- const body2 = buildContextsOverlay(contextsView?.readContextsView(), {
1104
- candidate: parsed.data.candidate,
1105
- labelMasker: clusterLabelMasker,
1106
- extras
1107
- });
1108
- const payload2 = masking.mask(JSON.stringify(wrapResponse(body2, extras)));
1109
- return { content: buildContent(payload2) };
1110
- }
1111
- const rawSnapshot = mode === "regex" ? void 0 : storage.readCommunities();
1112
- const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
1113
- const community = snapshot ? buildCommunityOverlay(snapshot) : void 0;
1114
- const body = {};
1115
- if (mode !== "communities") body.layers = result.layers;
1116
- if (community) body.communities = community;
1117
- if (mode === "communities" && !community) {
1118
- body.hint = "No community snapshot available. Run `ctxo index` to generate one.";
1119
- }
1120
- const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
1121
- return { content: buildContent(payload) };
1122
- } catch (err) {
1123
- return {
1124
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
1125
- };
1126
- }
1127
- };
1128
- }
1129
-
1130
- // src/adapters/mcp/get-dead-code.ts
1131
- import { z as z6 } from "zod";
1132
- var InputSchema6 = z6.object({
1133
- includeTests: z6.boolean().optional().default(false),
1134
- intent: z6.string().optional().describe('Filter dead code results by intent keywords (e.g., "adapter", "core", "function")')
1135
- });
1136
- function handleFindDeadCode(storage, masking, staleness, ctxoRoot = ".ctxo") {
1137
- const detector = new DeadCodeDetector();
1138
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
1139
- return (args) => {
1140
- try {
1141
- const parsed = InputSchema6.safeParse(args);
1142
- if (!parsed.success) {
1143
- return {
1144
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
1145
- };
1146
- }
1147
- const { graph, indexedFiles } = getGraph();
1148
- const result = detector.detect(graph, { includeTests: parsed.data.includeTests });
1149
- const filtered = {
1150
- ...result,
1151
- deadSymbols: filterByIntent(result.deadSymbols, parsed.data.intent)
1152
- };
1153
- const payload = masking.mask(JSON.stringify(wrapResponse(filtered)));
1154
- const content = [];
1155
- if (staleness) {
1156
- const warning = staleness.check(indexedFiles);
1157
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1158
- }
1159
- content.push({ type: "text", text: payload });
1160
- return { content };
1161
- } catch (err) {
1162
- return {
1163
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
1164
- };
1165
- }
1166
- };
1167
- }
1168
-
1169
- // src/adapters/mcp/get-context-for-task.ts
1170
- import { z as z7 } from "zod";
1171
-
1172
- // src/core/context-assembly/task-context-strategy.ts
1173
- var TASK_WEIGHTS = {
1174
- fix: {
1175
- directDependency: 0.3,
1176
- interfaceTypeDef: 0.1,
1177
- blastRadiusDependent: 0.1,
1178
- highComplexity: 0.2,
1179
- antiPatternHistory: 0.3
1180
- },
1181
- extend: {
1182
- directDependency: 0.4,
1183
- interfaceTypeDef: 0.5,
1184
- blastRadiusDependent: 0.1,
1185
- highComplexity: 0,
1186
- antiPatternHistory: 0
1187
- },
1188
- refactor: {
1189
- directDependency: 0.2,
1190
- interfaceTypeDef: 0.1,
1191
- blastRadiusDependent: 0.5,
1192
- highComplexity: 0.1,
1193
- antiPatternHistory: 0.1
1194
- },
1195
- understand: {
1196
- directDependency: 0.5,
1197
- interfaceTypeDef: 0.3,
1198
- blastRadiusDependent: 0.1,
1199
- highComplexity: 0.1,
1200
- antiPatternHistory: 0
1201
- }
1202
- };
1203
- function getWeightsForTask(taskType) {
1204
- return TASK_WEIGHTS[taskType];
1205
- }
1206
-
1207
- // src/core/context-assembly/context-assembler.ts
1208
- var DEFAULT_TOKEN_BUDGET = 4e3;
1209
- var ContextAssembler = class {
1210
- logicSlice = new LogicSliceQuery();
1211
- blastRadius = new BlastRadiusCalculator();
1212
- /**
1213
- * `coChangeMap` is the same lookup `get_blast_radius` uses: with it, a potential dependent whose file
1214
- * co-changes with the target's becomes likely. It changes the tier shown on an entry, not which entries
1215
- * make the context or how they rank.
1216
- */
1217
- assembleForTask(graph, symbolId, taskType, indices, tokenBudget = DEFAULT_TOKEN_BUDGET, coChangeMap) {
1218
- const target = graph.getNode(symbolId);
1219
- if (!target) return void 0;
1220
- const targetId = target.symbolId;
1221
- const weights = getWeightsForTask(taskType);
1222
- const slice = this.logicSlice.getLogicSlice(graph, targetId);
1223
- const blastResult = this.blastRadius.calculate(graph, targetId, coChangeMap);
1224
- const candidateMap = /* @__PURE__ */ new Map();
1225
- if (slice) {
1226
- for (const dep of slice.dependencies) {
1227
- candidateMap.set(dep.symbolId, {
1228
- node: dep,
1229
- signals: { isDirectDep: true, isInterfaceOrType: this.isInterfaceOrType(dep.kind), isDependent: false, complexity: 0, hasAntiPattern: false }
1230
- });
1231
- }
1232
- }
1233
- for (const entry of blastResult.impactedSymbols) {
1234
- const node = graph.getNode(entry.symbolId) ?? synthesizeNodeFromId(entry.symbolId);
1235
- if (!node) continue;
1236
- const existing = candidateMap.get(entry.symbolId);
1237
- if (existing) {
1238
- existing.signals.isDependent = true;
1239
- existing.signals.dependentConfidence = entry.confidence;
1240
- } else {
1241
- candidateMap.set(entry.symbolId, {
1242
- node,
1243
- signals: { isDirectDep: false, isInterfaceOrType: this.isInterfaceOrType(node.kind), isDependent: true, dependentConfidence: entry.confidence, complexity: 0, hasAntiPattern: false }
1244
- });
1245
- }
1246
- }
1247
- const fileIndexMap = new Map(indices.map((i) => [i.file, i]));
1248
- for (const [sid, candidate] of candidateMap) {
1249
- const file = sid.split("::")[0] ?? "";
1250
- const fileIndex = fileIndexMap.get(file);
1251
- if (fileIndex) {
1252
- const complexity = fileIndex.complexity?.find((c) => c.symbolId === sid);
1253
- candidate.signals.complexity = complexity?.cyclomatic ?? 0;
1254
- candidate.signals.hasAntiPattern = fileIndex.antiPatterns.length > 0;
1255
- }
1256
- }
1257
- const scored = [...candidateMap.entries()].map(([, { node, signals }]) => ({
1258
- entry: this.buildContextEntry(node, signals, weights),
1259
- score: this.computeScore(signals, weights)
1260
- }));
1261
- scored.sort((a, b) => b.score - a.score);
1262
- const context = [];
1263
- let totalTokens = 0;
1264
- for (const { entry } of scored) {
1265
- if (totalTokens + entry.tokens > tokenBudget) continue;
1266
- context.push(entry);
1267
- totalTokens += entry.tokens;
1268
- }
1269
- const warnings = [];
1270
- const targetFile = targetId.split("::")[0] ?? "";
1271
- const targetIndex = fileIndexMap.get(targetFile);
1272
- if (targetIndex && targetIndex.antiPatterns.length > 0) {
1273
- warnings.push(`\u26A0 Target symbol has ${targetIndex.antiPatterns.length} anti-pattern(s) in file history`);
1274
- }
1275
- return { target: withoutAnnotations(target), taskType, context, totalTokens, tokenBudget, warnings };
1276
- }
1277
- assembleRanked(graph, query, strategy = "combined", tokenBudget = DEFAULT_TOKEN_BUDGET) {
1278
- const allNodes = graph.allNodes();
1279
- const queryLower = query.toLowerCase();
1280
- let maxReverseEdges = 1;
1281
- for (const node of allNodes) {
1282
- const count = graph.getReverseEdges(node.symbolId).length;
1283
- if (count > maxReverseEdges) maxReverseEdges = count;
1284
- }
1285
- const scored = allNodes.map((node) => {
1286
- const relevanceScore = this.computeTextRelevance(node.name, queryLower);
1287
- const importanceScore = graph.getReverseEdges(node.symbolId).length / maxReverseEdges;
1288
- let combinedScore;
1289
- switch (strategy) {
1290
- case "dependency":
1291
- combinedScore = relevanceScore;
1292
- break;
1293
- case "importance":
1294
- combinedScore = importanceScore;
1295
- break;
1296
- default:
1297
- combinedScore = relevanceScore * 0.6 + importanceScore * 0.4;
1298
- }
1299
- return {
1300
- symbolId: node.symbolId,
1301
- name: node.name,
1302
- kind: node.kind,
1303
- file: node.symbolId.split("::")[0] ?? "",
1304
- relevanceScore: Math.round(relevanceScore * 1e3) / 1e3,
1305
- importanceScore: Math.round(importanceScore * 1e3) / 1e3,
1306
- combinedScore: Math.round(combinedScore * 1e3) / 1e3,
1307
- tokens: this.estimateTokens(node)
1308
- };
1309
- });
1310
- const filtered = strategy === "importance" ? scored : scored.filter((s) => s.relevanceScore > 0 || s.importanceScore > 0.1);
1311
- filtered.sort((a, b) => b.combinedScore - a.combinedScore);
1312
- const results = [];
1313
- let totalTokens = 0;
1314
- for (const item of filtered) {
1315
- if (totalTokens + item.tokens > tokenBudget) continue;
1316
- results.push(item);
1317
- totalTokens += item.tokens;
1318
- }
1319
- return { query, strategy, results, totalTokens, tokenBudget };
1320
- }
1321
- computeTextRelevance(name, queryLower) {
1322
- const nameLower = name.toLowerCase();
1323
- if (nameLower === queryLower) return 1;
1324
- if (nameLower.includes(queryLower)) return 0.7;
1325
- if (queryLower.split(/\s+/).some((word) => nameLower.includes(word))) return 0.4;
1326
- return 0;
1327
- }
1328
- computeScore(signals, weights) {
1329
- let score = 0;
1330
- if (signals.isDirectDep) score += weights.directDependency;
1331
- if (signals.isInterfaceOrType) score += weights.interfaceTypeDef;
1332
- if (signals.isDependent) score += weights.blastRadiusDependent;
1333
- if (signals.complexity > 5) score += weights.highComplexity;
1334
- if (signals.hasAntiPattern) score += weights.antiPatternHistory;
1335
- return Math.round(score * 1e3) / 1e3;
1336
- }
1337
- buildContextEntry(node, signals, weights) {
1338
- const reasons = [];
1339
- if (signals.isDirectDep) reasons.push("direct dependency");
1340
- if (signals.isInterfaceOrType) reasons.push("type/interface definition");
1341
- if (signals.isDependent) reasons.push("blast radius dependent");
1342
- if (signals.complexity > 5) reasons.push(`high complexity (CC=${signals.complexity})`);
1343
- if (signals.hasAntiPattern) reasons.push("anti-pattern history");
1344
- return {
1345
- symbolId: node.symbolId,
1346
- name: node.name,
1347
- kind: node.kind,
1348
- file: node.symbolId.split("::")[0] ?? "",
1349
- relevanceScore: this.computeScore(signals, weights),
1350
- reason: reasons.join(", ") || "related symbol",
1351
- lines: node.endLine - node.startLine + 1,
1352
- tokens: this.estimateTokens(node),
1353
- ...signals.dependentConfidence ? { confidence: signals.dependentConfidence } : {}
1354
- };
1355
- }
1356
- isInterfaceOrType(kind) {
1357
- return kind === "interface" || kind === "type";
1358
- }
1359
- estimateTokensPublic(node) {
1360
- return this.estimateTokens(node);
1361
- }
1362
- estimateTokens(node) {
1363
- if (node.startOffset !== void 0 && node.endOffset !== void 0) {
1364
- return Math.ceil((node.endOffset - node.startOffset) / 4);
1365
- }
1366
- return (node.endLine - node.startLine + 1) * 10;
1367
- }
1368
- };
1369
- var VALID_SYMBOL_KINDS = new Set(SYMBOL_KINDS);
1370
- function synthesizeNodeFromId(symbolId) {
1371
- const parts = symbolId.split("::");
1372
- if (parts.length !== 3) return void 0;
1373
- const [file, name, kindRaw] = parts;
1374
- if (!file || !name || !kindRaw) return void 0;
1375
- const kind = VALID_SYMBOL_KINDS.has(kindRaw) ? kindRaw : "variable";
1376
- return { symbolId, name, kind, startLine: 0, endLine: 0 };
1377
- }
1378
-
1379
- // src/adapters/mcp/get-context-for-task.ts
1380
- var TaskTypeSchema = z7.enum(["fix", "extend", "refactor", "understand"]);
1381
- var InputSchema7 = z7.object({
1382
- symbolId: z7.string().min(1),
1383
- taskType: TaskTypeSchema,
1384
- tokenBudget: z7.number().min(100).optional().default(4e3)
1385
- });
1386
- function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo", coChange) {
1387
- const assembler = new ContextAssembler();
1388
- const indexReader = new JsonIndexReader(ctxoRoot);
1389
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
1390
- return (args) => {
1391
- try {
1392
- const parsed = InputSchema7.safeParse(args);
1393
- if (!parsed.success) {
1394
- return {
1395
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
1396
- };
1397
- }
1398
- const { symbolId, taskType, tokenBudget } = parsed.data;
1399
- const { graph, indexedFiles } = getGraph();
1400
- const indices = indexReader.readAll();
1401
- const result = assembler.assembleForTask(graph, symbolId, taskType, indices, tokenBudget, coChange?.readCoChangeMap());
1402
- if (!result) {
1403
- return {
1404
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index".' }) }]
1405
- };
1406
- }
1407
- const payload = masking.mask(JSON.stringify(wrapResponse(result)));
1408
- const content = [];
1409
- if (staleness) {
1410
- const warning = staleness.check(indexedFiles);
1411
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1412
- }
1413
- content.push({ type: "text", text: payload });
1414
- return { content };
1415
- } catch (err) {
1416
- return {
1417
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
1418
- };
1419
- }
1420
- };
1421
- }
1422
-
1423
- // src/adapters/mcp/get-ranked-context.ts
1424
- import { z as z8 } from "zod";
1425
-
1426
- // src/core/search/fuzzy-corrector.ts
1427
- var FuzzyCorrector = class {
1428
- /** term → frequency (for tie-breaking) */
1429
- vocabulary = /* @__PURE__ */ new Map();
1430
- buildVocabulary(termFrequencies) {
1431
- this.vocabulary = new Map(termFrequencies);
1432
- }
1433
- /**
1434
- * Attempt to correct query tokens.
1435
- * Returns null if no correction found or no improvement possible.
1436
- */
1437
- correct(queryTokens) {
1438
- if (this.vocabulary.size === 0) return null;
1439
- const corrections = [];
1440
- const correctedTokens = [];
1441
- let anyChanged = false;
1442
- for (const token of queryTokens) {
1443
- if (token.length <= 2) {
1444
- correctedTokens.push(token);
1445
- continue;
1446
- }
1447
- if (this.vocabulary.has(token)) {
1448
- correctedTokens.push(token);
1449
- continue;
1450
- }
1451
- const maxDist = token.length <= 5 ? 1 : 2;
1452
- const best = this.findClosest(token, maxDist);
1453
- if (best) {
1454
- corrections.push({
1455
- original: token,
1456
- corrected: best.term,
1457
- distance: best.distance
1458
- });
1459
- correctedTokens.push(best.term);
1460
- anyChanged = true;
1461
- } else {
1462
- correctedTokens.push(token);
1463
- }
1464
- }
1465
- if (!anyChanged) return null;
1466
- return {
1467
- originalQuery: queryTokens.join(" "),
1468
- correctedQuery: correctedTokens.join(" "),
1469
- corrections
1470
- };
1471
- }
1472
- /**
1473
- * Find the closest vocabulary term within maxDistance.
1474
- * Tie-breaks by frequency (higher = preferred).
1475
- */
1476
- findClosest(token, maxDistance) {
1477
- let bestTerm = null;
1478
- let bestDist = maxDistance + 1;
1479
- let bestFreq = -1;
1480
- for (const [vocabTerm, freq] of this.vocabulary) {
1481
- if (Math.abs(vocabTerm.length - token.length) > maxDistance) continue;
1482
- const dist = damerauLevenshtein(token, vocabTerm);
1483
- if (dist <= maxDistance) {
1484
- if (dist < bestDist || dist === bestDist && freq > bestFreq) {
1485
- bestTerm = vocabTerm;
1486
- bestDist = dist;
1487
- bestFreq = freq;
1488
- }
1489
- }
1490
- }
1491
- return bestTerm !== null ? { term: bestTerm, distance: bestDist } : null;
1492
- }
1493
- };
1494
- function damerauLevenshtein(a, b) {
1495
- const lenA = a.length;
1496
- const lenB = b.length;
1497
- if (lenA === 0) return lenB;
1498
- if (lenB === 0) return lenA;
1499
- const d = Array.from({ length: lenA + 1 }, () => new Array(lenB + 1).fill(0));
1500
- for (let i = 0; i <= lenA; i++) d[i][0] = i;
1501
- for (let j = 0; j <= lenB; j++) d[0][j] = j;
1502
- for (let i = 1; i <= lenA; i++) {
1503
- for (let j = 1; j <= lenB; j++) {
1504
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1505
- d[i][j] = Math.min(
1506
- d[i - 1][j] + 1,
1507
- // deletion
1508
- d[i][j - 1] + 1,
1509
- // insertion
1510
- d[i - 1][j - 1] + cost
1511
- // substitution
1512
- );
1513
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
1514
- d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost);
1515
- }
1516
- }
1517
- }
1518
- return d[lenA][lenB];
1519
- }
1520
-
1521
- // src/core/search/search-engine.ts
1522
- var log2 = createLogger("ctxo:search");
1523
- var DEFAULT_CONFIG = {
1524
- bm25: { k1: 1.2, b: 0.25 },
1525
- pageRankWeight: 0.5,
1526
- trigramPenalty: 0.8,
1527
- phase2Threshold: 3,
1528
- fuzzyThreshold: 3,
1529
- maxPerPhase: 50
1530
- };
1531
- var SearchEngine = class {
1532
- config;
1533
- tokenizer;
1534
- fuzzy;
1535
- // Primary index (exact + tokenized)
1536
- documents = [];
1537
- primaryIndex = /* @__PURE__ */ new Map();
1538
- avgDocLength = 0;
1539
- // Trigram index (character 3-grams)
1540
- trigramIndex = /* @__PURE__ */ new Map();
1541
- // PageRank scores
1542
- pageRankScores = /* @__PURE__ */ new Map();
1543
- constructor(config) {
1544
- this.config = { ...DEFAULT_CONFIG, ...config };
1545
- this.tokenizer = new SymbolTokenizer({ includeOriginal: true, includeFilePath: true });
1546
- this.fuzzy = new FuzzyCorrector();
1547
- }
1548
- getTier() {
1549
- return "in-memory";
1550
- }
1551
- buildIndex(symbols, pageRankScores) {
1552
- const start = performance.now();
1553
- this.documents = [];
1554
- this.primaryIndex = /* @__PURE__ */ new Map();
1555
- this.trigramIndex = /* @__PURE__ */ new Map();
1556
- this.pageRankScores = pageRankScores ?? /* @__PURE__ */ new Map();
1557
- let totalLength = 0;
1558
- const vocabFreq = /* @__PURE__ */ new Map();
1559
- for (const sym of symbols) {
1560
- const filePath = sym.symbolId.split("::")[0] ?? "";
1561
- const tokens = this.tokenizer.tokenize(sym.name, filePath);
1562
- const doc = {
1563
- symbolId: sym.symbolId,
1564
- name: sym.name,
1565
- kind: sym.kind,
1566
- filePath,
1567
- tokens,
1568
- length: tokens.length
1569
- };
1570
- const docIndex = this.documents.length;
1571
- this.documents.push(doc);
1572
- totalLength += tokens.length;
1573
- const termFreqs = /* @__PURE__ */ new Map();
1574
- for (const token of tokens) {
1575
- termFreqs.set(token, (termFreqs.get(token) ?? 0) + 1);
1576
- vocabFreq.set(token, (vocabFreq.get(token) ?? 0) + 1);
1577
- }
1578
- for (const [term, tf] of termFreqs) {
1579
- let postings = this.primaryIndex.get(term);
1580
- if (!postings) {
1581
- postings = [];
1582
- this.primaryIndex.set(term, postings);
1583
- }
1584
- postings.push({ docIndex, termFrequency: tf });
1585
- }
1586
- const nameLower = sym.name.toLowerCase();
1587
- const trigrams = this.extractTrigrams(nameLower);
1588
- const trigramFreqs = /* @__PURE__ */ new Map();
1589
- for (const tri of trigrams) {
1590
- trigramFreqs.set(tri, (trigramFreqs.get(tri) ?? 0) + 1);
1591
- }
1592
- for (const [tri, tf] of trigramFreqs) {
1593
- let postings = this.trigramIndex.get(tri);
1594
- if (!postings) {
1595
- postings = [];
1596
- this.trigramIndex.set(tri, postings);
1597
- }
1598
- postings.push({ docIndex, termFrequency: tf });
1599
- }
1600
- }
1601
- this.avgDocLength = this.documents.length > 0 ? totalLength / this.documents.length : 1;
1602
- this.fuzzy.buildVocabulary(vocabFreq);
1603
- const elapsed = performance.now() - start;
1604
- log2.info(`Index built: ${symbols.length} symbols, ${this.primaryIndex.size} terms, ${this.trigramIndex.size} trigrams (${elapsed.toFixed(0)}ms)`);
1605
- }
1606
- search(query, limit = 50) {
1607
- const start = performance.now();
1608
- const queryTokens = this.tokenizer.tokenizeQuery(query);
1609
- if (queryTokens.length === 0) {
1610
- return this.emptyResponse(query, start);
1611
- }
1612
- const metrics = {
1613
- porterHits: 0,
1614
- trigramHits: 0,
1615
- phase2Activated: false,
1616
- fuzzyApplied: false,
1617
- latencyMs: 0
1618
- };
1619
- const phase1Scores = this.bm25Search(queryTokens, this.primaryIndex);
1620
- metrics.porterHits = phase1Scores.size;
1621
- let allScores = phase1Scores;
1622
- if (phase1Scores.size < this.config.phase2Threshold) {
1623
- metrics.phase2Activated = true;
1624
- const queryLower = query.toLowerCase();
1625
- if (queryLower.length >= 3) {
1626
- const queryTrigrams = this.extractTrigrams(queryLower);
1627
- const phase2Scores = this.bm25Search(queryTrigrams, this.trigramIndex);
1628
- metrics.trigramHits = phase2Scores.size;
1629
- allScores = this.mergeScores(phase1Scores, phase2Scores, this.config.trigramPenalty);
1630
- }
1631
- }
1632
- let fuzzyCorrection;
1633
- if (allScores.size < this.config.fuzzyThreshold) {
1634
- const correction = this.fuzzy.correct(queryTokens);
1635
- if (correction) {
1636
- metrics.fuzzyApplied = true;
1637
- fuzzyCorrection = correction;
1638
- const correctedTokens = this.tokenizer.tokenizeQuery(correction.correctedQuery);
1639
- const fuzzyScores = this.bm25Search(correctedTokens, this.primaryIndex);
1640
- allScores = this.mergeScores(allScores, fuzzyScores, 0.9);
1641
- if (allScores.size < this.config.fuzzyThreshold && correction.correctedQuery.length >= 3) {
1642
- const correctedTrigrams = this.extractTrigrams(correction.correctedQuery.toLowerCase());
1643
- const fuzzyTrigramScores = this.bm25Search(correctedTrigrams, this.trigramIndex);
1644
- allScores = this.mergeScores(allScores, fuzzyTrigramScores, this.config.trigramPenalty * 0.9);
1645
- }
1646
- }
1647
- }
1648
- const rawRelevance = new Map(allScores);
1649
- const effectiveQueryTerms = fuzzyCorrection ? this.tokenizer.tokenizeQuery(fuzzyCorrection.correctedQuery) : queryTokens;
1650
- if (effectiveQueryTerms.length >= 2) {
1651
- for (const [docIdx, score] of allScores) {
1652
- const doc = this.documents[docIdx];
1653
- const boost = this.bigramBoost(effectiveQueryTerms, doc.tokens);
1654
- allScores.set(docIdx, score * boost);
1655
- }
1656
- }
1657
- for (const [docIdx, score] of allScores) {
1658
- const doc = this.documents[docIdx];
1659
- const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
1660
- allScores.set(docIdx, score * (1 + this.config.pageRankWeight * pr));
1661
- }
1662
- metrics.latencyMs = performance.now() - start;
1663
- const results = this.buildResults(allScores, rawRelevance, limit);
1664
- const phaseInfo = metrics.phase2Activated ? "phase2=yes" : "phase2=no";
1665
- if (fuzzyCorrection) {
1666
- log2.info(`search "${query}" \u2192 fuzzy "${fuzzyCorrection.correctedQuery}": ${results.length} results (${metrics.latencyMs.toFixed(0)}ms)`);
1667
- } else {
1668
- log2.info(`search "${query}": ${results.length} results, porter=${metrics.porterHits} trigram=${metrics.trigramHits} ${phaseInfo} (${metrics.latencyMs.toFixed(0)}ms)`);
1669
- }
1670
- return { query, results, metrics, ...fuzzyCorrection ? { fuzzyCorrection } : {} };
1671
- }
1672
- updateFile(filePath, symbols) {
1673
- const oldIndices = /* @__PURE__ */ new Set();
1674
- for (let i = 0; i < this.documents.length; i++) {
1675
- if (this.documents[i].filePath === filePath) {
1676
- oldIndices.add(i);
1677
- }
1678
- }
1679
- if (oldIndices.size === 0 && symbols.length === 0) return;
1680
- const allSymbols = [];
1681
- for (let i = 0; i < this.documents.length; i++) {
1682
- if (!oldIndices.has(i)) {
1683
- const doc = this.documents[i];
1684
- allSymbols.push({
1685
- symbolId: doc.symbolId,
1686
- name: doc.name,
1687
- kind: doc.kind,
1688
- startLine: 0,
1689
- endLine: 0
1690
- });
1691
- }
1692
- }
1693
- allSymbols.push(...symbols);
1694
- this.buildIndex(allSymbols, this.pageRankScores);
1695
- }
1696
- /**
1697
- * BM25 scoring over an inverted index.
1698
- * Returns Map<docIndex, score>
1699
- */
1700
- bm25Search(queryTerms, index) {
1701
- const scores = /* @__PURE__ */ new Map();
1702
- const N = this.documents.length;
1703
- const { k1, b } = this.config.bm25;
1704
- for (const term of queryTerms) {
1705
- const postings = index.get(term);
1706
- if (!postings) continue;
1707
- const df = postings.length;
1708
- const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
1709
- for (const { docIndex, termFrequency } of postings) {
1710
- const doc = this.documents[docIndex];
1711
- const tf = termFrequency;
1712
- const docLen = doc.length;
1713
- const numerator = tf * (k1 + 1);
1714
- const denominator = tf + k1 * (1 - b + b * (docLen / this.avgDocLength));
1715
- const termScore = idf * (numerator / denominator);
1716
- scores.set(docIndex, (scores.get(docIndex) ?? 0) + termScore);
1717
- }
1718
- }
1719
- const queryJoined = queryTerms.join("");
1720
- for (const [docIdx, score] of scores) {
1721
- const doc = this.documents[docIdx];
1722
- const nameLower = doc.name.toLowerCase();
1723
- if (nameLower === queryJoined || nameLower === queryTerms.join(" ")) {
1724
- scores.set(docIdx, score * 5);
1725
- }
1726
- }
1727
- return scores;
1728
- }
1729
- /**
1730
- * Merge two score maps. Phase 2 scores are penalized.
1731
- */
1732
- mergeScores(primary, secondary, penalty) {
1733
- const merged = new Map(primary);
1734
- for (const [docIdx, score] of secondary) {
1735
- const existing = merged.get(docIdx);
1736
- const penalized = score * penalty;
1737
- if (existing !== void 0) {
1738
- merged.set(docIdx, Math.max(existing, penalized));
1739
- } else {
1740
- merged.set(docIdx, penalized);
1741
- }
1742
- }
1743
- return merged;
1744
- }
1745
- /**
1746
- * Bigram boost for multi-word queries.
1747
- * Each adjacent query term pair found adjacent in symbol tokens → 2x boost.
1748
- */
1749
- bigramBoost(queryTerms, symbolTokens) {
1750
- if (queryTerms.length < 2) return 1;
1751
- let adjacentPairs = 0;
1752
- for (let i = 0; i < queryTerms.length - 1; i++) {
1753
- const idxA = symbolTokens.findIndex((t) => t.includes(queryTerms[i]));
1754
- const idxB = symbolTokens.findIndex((t) => t.includes(queryTerms[i + 1]));
1755
- if (idxA >= 0 && idxB >= 0 && Math.abs(idxA - idxB) === 1) {
1756
- adjacentPairs++;
1757
- }
1758
- }
1759
- return 1 + adjacentPairs * 2;
1760
- }
1761
- /**
1762
- * Extract character trigrams from a string.
1763
- * "sqlite" → ["sql", "qli", "lit", "ite"]
1764
- */
1765
- extractTrigrams(text) {
1766
- const trigrams = [];
1767
- for (let i = 0; i <= text.length - 3; i++) {
1768
- trigrams.push(text.substring(i, i + 3));
1769
- }
1770
- return trigrams;
1771
- }
1772
- buildResults(combinedScores, rawRelevance, limit) {
1773
- const entries = [...combinedScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
1774
- return entries.map(([docIdx, combined]) => {
1775
- const doc = this.documents[docIdx];
1776
- const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
1777
- return {
1778
- symbolId: doc.symbolId,
1779
- name: doc.name,
1780
- kind: doc.kind,
1781
- filePath: doc.filePath,
1782
- relevanceScore: rawRelevance.get(docIdx) ?? 0,
1783
- importanceScore: pr,
1784
- combinedScore: combined
1785
- };
1786
- });
1787
- }
1788
- emptyResponse(query, start) {
1789
- return {
1790
- query,
1791
- results: [],
1792
- metrics: {
1793
- porterHits: 0,
1794
- trigramHits: 0,
1795
- phase2Activated: false,
1796
- fuzzyApplied: false,
1797
- latencyMs: performance.now() - start
1798
- }
1799
- };
1800
- }
1801
- };
1802
-
1803
- // src/adapters/mcp/get-ranked-context.ts
1804
- var log3 = createLogger("ctxo:search");
1805
- var InputSchema8 = z8.object({
1806
- query: z8.string().min(1),
1807
- tokenBudget: z8.number().min(100).optional().default(4e3),
1808
- strategy: z8.enum(["combined", "dependency", "importance"]).optional().default("combined"),
1809
- searchMode: z8.enum(["fts", "legacy"]).optional().default("fts")
1810
- });
1811
- function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo", searchEngine) {
1812
- const assembler = new ContextAssembler();
1813
- const engine = searchEngine ?? new SearchEngine();
1814
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
1815
- let lastNodeCount = -1;
1816
- return (args) => {
1817
- try {
1818
- const parsed = InputSchema8.safeParse(args);
1819
- if (!parsed.success) {
1820
- return {
1821
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
1822
- };
1823
- }
1824
- const { query, tokenBudget, strategy, searchMode } = parsed.data;
1825
- const { graph, indexedFiles } = getGraph();
1826
- if (searchMode === "fts" && strategy !== "importance") {
1827
- const currentNodeCount = graph.nodeCount;
1828
- if (currentNodeCount !== lastNodeCount) {
1829
- const allNodes = graph.allNodes();
1830
- const pageRankScores = /* @__PURE__ */ new Map();
1831
- let maxReverseEdges = 1;
1832
- for (const node of allNodes) {
1833
- const count = graph.getReverseEdges(node.symbolId).length;
1834
- if (count > maxReverseEdges) maxReverseEdges = count;
1835
- }
1836
- for (const node of allNodes) {
1837
- const count = graph.getReverseEdges(node.symbolId).length;
1838
- pageRankScores.set(node.symbolId, count / maxReverseEdges);
1839
- }
1840
- engine.buildIndex(allNodes, pageRankScores);
1841
- lastNodeCount = currentNodeCount;
1842
- log3.info(`Search engine: ${engine.getTier()} (${allNodes.length} symbols)`);
1843
- }
1844
- const searchResult = engine.search(query, 100);
1845
- const results = [];
1846
- let totalTokens = 0;
1847
- for (const sr of searchResult.results) {
1848
- const node = graph.getNode(sr.symbolId);
1849
- const tokens = node ? assembler.estimateTokensPublic(node) : 90;
1850
- if (totalTokens + tokens > tokenBudget) continue;
1851
- results.push({
1852
- symbolId: sr.symbolId,
1853
- name: sr.name,
1854
- kind: sr.kind,
1855
- file: sr.filePath,
1856
- relevanceScore: Math.round(sr.relevanceScore * 1e3) / 1e3,
1857
- importanceScore: Math.round(sr.importanceScore * 1e3) / 1e3,
1858
- combinedScore: Math.round(sr.combinedScore * 1e3) / 1e3,
1859
- tokens
1860
- });
1861
- totalTokens += tokens;
1862
- }
1863
- const payload2 = masking.mask(JSON.stringify(wrapResponse({
1864
- query,
1865
- strategy,
1866
- results,
1867
- totalTokens,
1868
- tokenBudget,
1869
- searchMetrics: searchResult.metrics,
1870
- ...searchResult.fuzzyCorrection ? { fuzzyCorrection: searchResult.fuzzyCorrection } : {}
1871
- })));
1872
- const content2 = [];
1873
- if (staleness) {
1874
- const warning = staleness.check(indexedFiles);
1875
- if (warning) content2.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1876
- }
1877
- content2.push({ type: "text", text: payload2 });
1878
- return { content: content2 };
1879
- }
1880
- const result = assembler.assembleRanked(graph, query, strategy, tokenBudget);
1881
- const payload = masking.mask(JSON.stringify(wrapResponse(result)));
1882
- const content = [];
1883
- if (staleness) {
1884
- const warning = staleness.check(indexedFiles);
1885
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1886
- }
1887
- content.push({ type: "text", text: payload });
1888
- return { content };
1889
- } catch (err) {
1890
- return {
1891
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
1892
- };
1893
- }
1894
- };
1895
- }
1896
-
1897
- // src/adapters/mcp/search-symbols.ts
1898
- import { z as z9 } from "zod";
1899
- var InputSchema9 = z9.object({
1900
- pattern: z9.string().min(1),
1901
- kind: SymbolKindSchema.optional(),
1902
- filePattern: z9.string().optional(),
1903
- limit: z9.number().int().min(1).max(100).optional().default(25),
1904
- mode: z9.enum(["regex", "fts"]).optional().default("regex")
1905
- });
1906
- function handleSearchSymbols(storage, masking, staleness, ctxoRoot = ".ctxo", searchEngine) {
1907
- const engine = searchEngine ?? new SearchEngine();
1908
- let lastFtsNodeCount = -1;
1909
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
1910
- return (args) => {
1911
- try {
1912
- const parsed = InputSchema9.safeParse(args);
1913
- if (!parsed.success) {
1914
- return {
1915
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
1916
- };
1917
- }
1918
- const { pattern, kind, filePattern, limit, mode } = parsed.data;
1919
- const { graph, indexedFiles } = getGraph();
1920
- const allNodes = graph.allNodes();
1921
- if (mode === "fts") {
1922
- if (allNodes.length !== lastFtsNodeCount) {
1923
- engine.buildIndex(allNodes);
1924
- lastFtsNodeCount = allNodes.length;
1925
- }
1926
- const searchResult = engine.search(pattern, limit);
1927
- let ftsResults = searchResult.results;
1928
- if (kind) {
1929
- ftsResults = ftsResults.filter((r) => r.kind === kind);
1930
- }
1931
- if (filePattern) {
1932
- const fp = filePattern.toLowerCase();
1933
- ftsResults = ftsResults.filter((r) => r.filePath.toLowerCase().includes(fp));
1934
- }
1935
- const results2 = ftsResults.slice(0, limit).map((r) => {
1936
- const node = graph.getNode(r.symbolId);
1937
- return {
1938
- symbolId: r.symbolId,
1939
- name: r.name,
1940
- kind: r.kind,
1941
- file: r.filePath,
1942
- startLine: node?.startLine ?? 0,
1943
- endLine: node?.endLine ?? 0,
1944
- relevanceScore: Math.round(r.relevanceScore * 1e3) / 1e3
1945
- };
1946
- });
1947
- const payload2 = masking.mask(JSON.stringify(wrapResponse({
1948
- totalMatches: ftsResults.length,
1949
- results: results2,
1950
- searchMetrics: searchResult.metrics,
1951
- ...searchResult.fuzzyCorrection ? { fuzzyCorrection: searchResult.fuzzyCorrection } : {}
1952
- })));
1953
- const content2 = [];
1954
- if (staleness) {
1955
- const warning = staleness.check(indexedFiles);
1956
- if (warning) content2.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1957
- }
1958
- content2.push({ type: "text", text: payload2 });
1959
- return { content: content2 };
1960
- }
1961
- let matcher;
1962
- try {
1963
- const regex = new RegExp(pattern, "i");
1964
- matcher = (name) => regex.test(name);
1965
- } catch {
1966
- const lowerPattern = pattern.toLowerCase();
1967
- matcher = (name) => name.toLowerCase().includes(lowerPattern);
1968
- }
1969
- const matches = allNodes.filter((node) => {
1970
- if (!matcher(node.name)) return false;
1971
- if (kind && node.kind !== kind) return false;
1972
- if (filePattern) {
1973
- const file = node.symbolId.split("::")[0] ?? "";
1974
- if (!file.toLowerCase().includes(filePattern.toLowerCase())) return false;
1975
- }
1976
- return true;
1977
- });
1978
- const results = matches.slice(0, limit).map((node) => ({
1979
- symbolId: node.symbolId,
1980
- name: node.name,
1981
- kind: node.kind,
1982
- file: node.symbolId.split("::")[0],
1983
- startLine: node.startLine,
1984
- endLine: node.endLine
1985
- }));
1986
- const payload = masking.mask(JSON.stringify(wrapResponse({
1987
- totalMatches: matches.length,
1988
- results
1989
- })));
1990
- const content = [];
1991
- if (staleness) {
1992
- const warning = staleness.check(indexedFiles);
1993
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1994
- }
1995
- content.push({ type: "text", text: payload });
1996
- return { content };
1997
- } catch (err) {
1998
- return {
1999
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2000
- };
2001
- }
2002
- };
2003
- }
2004
-
2005
- // src/adapters/mcp/get-changed-symbols.ts
2006
- import { z as z10 } from "zod";
2007
- var InputSchema10 = z10.object({
2008
- since: z10.string().optional().default("HEAD~1"),
2009
- maxFiles: z10.number().int().min(1).optional().default(50)
2010
- });
2011
- function handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
2012
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2013
- return async (args) => {
2014
- try {
2015
- const parsed = InputSchema10.safeParse(args);
2016
- if (!parsed.success) {
2017
- return {
2018
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
2019
- };
2020
- }
2021
- const { since, maxFiles } = parsed.data;
2022
- const changedPaths = await git.getChangedFiles(since);
2023
- const { graph, indexedFiles } = getGraph();
2024
- const nodesByFile = /* @__PURE__ */ new Map();
2025
- for (const node of graph.allNodes()) {
2026
- const file = node.symbolId.split("::")[0] ?? "";
2027
- const existing = nodesByFile.get(file) ?? [];
2028
- existing.push({ symbolId: node.symbolId, name: node.name, kind: node.kind, startLine: node.startLine, endLine: node.endLine });
2029
- nodesByFile.set(file, existing);
2030
- }
2031
- const files = [];
2032
- let totalSymbols = 0;
2033
- for (const filePath of changedPaths.slice(0, maxFiles)) {
2034
- const symbols = nodesByFile.get(filePath);
2035
- if (symbols && symbols.length > 0) {
2036
- files.push({ file: filePath, symbols });
2037
- totalSymbols += symbols.length;
2038
- }
2039
- }
2040
- const payload = masking.mask(JSON.stringify(wrapResponse({
2041
- since,
2042
- changedFiles: files.length,
2043
- changedSymbols: totalSymbols,
2044
- files
2045
- })));
2046
- const content = [];
2047
- if (staleness) {
2048
- const warning = staleness.check(indexedFiles);
2049
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2050
- }
2051
- content.push({ type: "text", text: payload });
2052
- return { content };
2053
- } catch (err) {
2054
- return {
2055
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2056
- };
2057
- }
2058
- };
2059
- }
2060
-
2061
- // src/adapters/mcp/find-importers.ts
2062
- import { z as z11 } from "zod";
2063
- var InputSchema11 = z11.object({
2064
- symbolId: z11.string().min(1),
2065
- edgeKinds: z11.array(EdgeKindSchema).optional(),
2066
- transitive: z11.boolean().optional().default(false),
2067
- maxDepth: z11.number().int().min(1).max(10).optional().default(5),
2068
- intent: z11.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "core")')
2069
- });
2070
- function handleFindImporters(storage, masking, staleness, ctxoRoot = ".ctxo") {
2071
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2072
- return (args) => {
2073
- try {
2074
- const parsed = InputSchema11.safeParse(args);
2075
- if (!parsed.success) {
2076
- return {
2077
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
2078
- };
2079
- }
2080
- const { symbolId, edgeKinds, transitive, maxDepth } = parsed.data;
2081
- const { graph, indexedFiles } = getGraph();
2082
- const root = graph.getNode(symbolId);
2083
- if (!root) {
2084
- return {
2085
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
2086
- };
2087
- }
2088
- const rootId = root.symbolId;
2089
- const importers = [];
2090
- if (!transitive) {
2091
- const reverseEdges = graph.getDependentEdges(rootId);
2092
- const seen = /* @__PURE__ */ new Map();
2093
- for (const edge of reverseEdges) {
2094
- if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
2095
- const node = graph.getNode(edge.from);
2096
- if (node) {
2097
- const existing = seen.get(node.symbolId);
2098
- if (existing) {
2099
- if (!existing.edgeKinds.includes(edge.kind)) {
2100
- existing.edgeKinds.push(edge.kind);
2101
- }
2102
- } else {
2103
- const entry = {
2104
- symbolId: node.symbolId,
2105
- name: node.name,
2106
- kind: node.kind,
2107
- file: node.symbolId.split("::")[0] ?? "",
2108
- edgeKind: edge.kind,
2109
- edgeKinds: [edge.kind],
2110
- depth: 1
2111
- };
2112
- seen.set(node.symbolId, entry);
2113
- importers.push(entry);
2114
- }
2115
- }
2116
- }
2117
- } else {
2118
- const definitions = graph.dataResourceIdentity(rootId)?.definitions.map((definition) => definition.symbolId) ?? [];
2119
- const visited = /* @__PURE__ */ new Set([rootId, ...definitions]);
2120
- const queue = [{ id: rootId, depth: 0 }];
2121
- while (queue.length > 0) {
2122
- const current = queue.shift();
2123
- if (current.depth >= maxDepth) continue;
2124
- const reverseEdges = graph.getDependentEdges(current.id);
2125
- for (const edge of reverseEdges) {
2126
- if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
2127
- if (visited.has(edge.from)) continue;
2128
- visited.add(edge.from);
2129
- const node = graph.getNode(edge.from);
2130
- if (node) {
2131
- const depth = current.depth + 1;
2132
- importers.push({
2133
- symbolId: node.symbolId,
2134
- name: node.name,
2135
- kind: node.kind,
2136
- file: node.symbolId.split("::")[0] ?? "",
2137
- edgeKind: edge.kind,
2138
- depth
2139
- });
2140
- queue.push({ id: edge.from, depth });
2141
- }
2142
- }
2143
- }
2144
- }
2145
- importers.sort((a, b) => a.depth - b.depth);
2146
- const filtered = filterByIntent(importers, parsed.data.intent);
2147
- const payload = masking.mask(JSON.stringify(wrapResponse({
2148
- symbolId,
2149
- importerCount: filtered.length,
2150
- importers: filtered
2151
- })));
2152
- const content = [];
2153
- if (staleness) {
2154
- const warning = staleness.check(indexedFiles);
2155
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2156
- }
2157
- content.push({ type: "text", text: payload });
2158
- return { content };
2159
- } catch (err) {
2160
- return {
2161
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2162
- };
2163
- }
2164
- };
2165
- }
2166
-
2167
- // src/adapters/mcp/get-class-hierarchy.ts
2168
- import { z as z12 } from "zod";
2169
- var HIERARCHY_EDGE_KINDS = /* @__PURE__ */ new Set(["extends", "implements"]);
2170
- var InputSchema12 = z12.object({
2171
- symbolId: z12.string().min(1).optional(),
2172
- direction: z12.enum(["ancestors", "descendants", "both"]).optional().default("both")
2173
- });
2174
- function handleGetClassHierarchy(storage, masking, staleness, ctxoRoot = ".ctxo") {
2175
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2176
- return (args) => {
2177
- try {
2178
- const parsed = InputSchema12.safeParse(args);
2179
- if (!parsed.success) {
2180
- return {
2181
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
2182
- };
2183
- }
2184
- const { symbolId, direction } = parsed.data;
2185
- const { graph, indexedFiles } = getGraph();
2186
- if (symbolId) {
2187
- return handleRooted(graph, symbolId, direction, masking, staleness, indexedFiles);
2188
- }
2189
- return handleFull(graph, masking, staleness, indexedFiles);
2190
- } catch (err) {
2191
- return {
2192
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2193
- };
2194
- }
2195
- };
2196
- function handleRooted(graph, symbolId, direction, maskingPort, stalenessCheck, indexedFiles) {
2197
- if (!graph.hasNode(symbolId)) {
2198
- return {
2199
- content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
2200
- };
2201
- }
2202
- const ancestors = [];
2203
- const descendants = [];
2204
- const isHierarchyEdge = (kind) => HIERARCHY_EDGE_KINDS.has(kind);
2205
- if (direction === "ancestors" || direction === "both") {
2206
- const visited = /* @__PURE__ */ new Set([symbolId]);
2207
- const queue = [{ id: symbolId, depth: 0 }];
2208
- while (queue.length > 0) {
2209
- const current = queue.shift();
2210
- for (const edge of graph.getForwardEdges(current.id)) {
2211
- if (!isHierarchyEdge(edge.kind)) continue;
2212
- if (visited.has(edge.to)) continue;
2213
- visited.add(edge.to);
2214
- const node = graph.getNode(edge.to);
2215
- if (node) {
2216
- const depth = current.depth + 1;
2217
- ancestors.push({
2218
- symbolId: node.symbolId,
2219
- name: node.name,
2220
- kind: node.kind,
2221
- file: node.symbolId.split("::")[0] ?? "",
2222
- edgeKind: edge.kind,
2223
- depth
2224
- });
2225
- queue.push({ id: edge.to, depth });
2226
- }
2227
- }
2228
- }
2229
- }
2230
- if (direction === "descendants" || direction === "both") {
2231
- const visited = /* @__PURE__ */ new Set([symbolId]);
2232
- const queue = [{ id: symbolId, depth: 0 }];
2233
- while (queue.length > 0) {
2234
- const current = queue.shift();
2235
- for (const edge of graph.getReverseEdges(current.id)) {
2236
- if (!isHierarchyEdge(edge.kind)) continue;
2237
- if (visited.has(edge.from)) continue;
2238
- visited.add(edge.from);
2239
- const node = graph.getNode(edge.from);
2240
- if (node) {
2241
- const depth = current.depth + 1;
2242
- descendants.push({
2243
- symbolId: node.symbolId,
2244
- name: node.name,
2245
- kind: node.kind,
2246
- file: node.symbolId.split("::")[0] ?? "",
2247
- edgeKind: edge.kind,
2248
- depth
2249
- });
2250
- queue.push({ id: edge.from, depth });
2251
- }
2252
- }
2253
- }
2254
- }
2255
- const result = { symbolId };
2256
- if (direction === "ancestors" || direction === "both") result.ancestors = ancestors;
2257
- if (direction === "descendants" || direction === "both") result.descendants = descendants;
2258
- const payload = maskingPort.mask(JSON.stringify(wrapResponse(result)));
2259
- const content = [];
2260
- if (stalenessCheck) {
2261
- const warning = stalenessCheck.check(indexedFiles);
2262
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2263
- }
2264
- content.push({ type: "text", text: payload });
2265
- return { content };
2266
- }
2267
- function handleFull(graph, maskingPort, stalenessCheck, indexedFiles) {
2268
- const isHierarchyEdge = (kind) => HIERARCHY_EDGE_KINDS.has(kind);
2269
- const hierarchyEdges = graph.allEdges().filter((e) => isHierarchyEdge(e.kind));
2270
- const involved = /* @__PURE__ */ new Set();
2271
- for (const edge of hierarchyEdges) {
2272
- involved.add(edge.from);
2273
- involved.add(edge.to);
2274
- }
2275
- const sources = new Set(hierarchyEdges.map((e) => e.from));
2276
- const roots = [...involved].filter((id) => !sources.has(id));
2277
- const rootSet = roots.length > 0 ? roots : [...involved];
2278
- function buildTree(rootId, visited2) {
2279
- const node = graph.getNode(rootId);
2280
- if (!node) return void 0;
2281
- visited2.add(rootId);
2282
- const children = [];
2283
- for (const edge of graph.getReverseEdges(rootId)) {
2284
- if (!isHierarchyEdge(edge.kind)) continue;
2285
- if (visited2.has(edge.from)) continue;
2286
- const child = buildTree(edge.from, visited2);
2287
- if (child) {
2288
- child.edgeKind = edge.kind;
2289
- children.push(child);
2290
- }
2291
- }
2292
- return {
2293
- symbolId: node.symbolId,
2294
- name: node.name,
2295
- kind: node.kind,
2296
- file: node.symbolId.split("::")[0] ?? "",
2297
- children
2298
- };
2299
- }
2300
- const visited = /* @__PURE__ */ new Set();
2301
- const hierarchies = [];
2302
- for (const rootId of rootSet) {
2303
- if (visited.has(rootId)) continue;
2304
- const tree = buildTree(rootId, visited);
2305
- if (tree) hierarchies.push(tree);
2306
- }
2307
- const payload = maskingPort.mask(JSON.stringify(wrapResponse({
2308
- hierarchies,
2309
- totalClasses: involved.size,
2310
- totalEdges: hierarchyEdges.length
2311
- })));
2312
- const content = [];
2313
- if (stalenessCheck) {
2314
- const warning = stalenessCheck.check(indexedFiles);
2315
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2316
- }
2317
- content.push({ type: "text", text: payload });
2318
- return { content };
2319
- }
2320
- }
2321
-
2322
- // src/adapters/mcp/get-symbol-importance.ts
2323
- import { z as z13 } from "zod";
2324
- var InputSchema13 = z13.object({
2325
- limit: z13.number().int().min(1).max(200).optional().default(25),
2326
- kind: SymbolKindSchema.optional(),
2327
- filePattern: z13.string().optional(),
2328
- damping: z13.number().min(0).max(1).optional().default(0.85)
2329
- });
2330
- function handleGetSymbolImportance(storage, masking, staleness, ctxoRoot = ".ctxo") {
2331
- const calculator = new PageRankCalculator();
2332
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2333
- return (args) => {
2334
- try {
2335
- const parsed = InputSchema13.safeParse(args);
2336
- if (!parsed.success) {
2337
- return {
2338
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
2339
- };
2340
- }
2341
- const { limit, kind, filePattern, damping } = parsed.data;
2342
- const { graph, indexedFiles } = getGraph();
2343
- const result = calculator.calculate(graph, { damping, limit: graph.nodeCount, maxIterations: 100 });
2344
- let filtered = result.rankings;
2345
- if (kind) {
2346
- filtered = filtered.filter((e) => e.kind === kind);
2347
- }
2348
- if (filePattern) {
2349
- const lowerPattern = filePattern.toLowerCase();
2350
- filtered = filtered.filter((e) => e.file.toLowerCase().includes(lowerPattern));
2351
- }
2352
- filtered = filtered.slice(0, limit);
2353
- const payload = masking.mask(JSON.stringify(wrapResponse({
2354
- rankings: filtered,
2355
- totalSymbols: result.totalSymbols,
2356
- iterations: result.iterations,
2357
- converged: result.converged,
2358
- damping
2359
- })));
2360
- const content = [];
2361
- if (staleness) {
2362
- const warning = staleness.check(indexedFiles);
2363
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2364
- }
2365
- content.push({ type: "text", text: payload });
2366
- return { content };
2367
- } catch (err) {
2368
- return {
2369
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2370
- };
2371
- }
2372
- };
2373
- }
2374
-
2375
- // src/adapters/mcp/get-pr-impact.ts
2376
- import { z as z14 } from "zod";
2377
- var InputSchema14 = z14.object({
2378
- since: z14.string().optional().default("HEAD~1"),
2379
- maxFiles: z14.number().int().min(1).optional().default(50),
2380
- confidence: z14.enum(["confirmed", "likely", "potential"]).optional()
2381
- });
2382
- function computeClustersAffected(snapshot, changedSymbolIds) {
2383
- const totals = /* @__PURE__ */ new Map();
2384
- for (const entry of snapshot.communities) {
2385
- if (!changedSymbolIds.has(entry.symbolId)) continue;
2386
- const existing = totals.get(entry.communityId);
2387
- if (existing) {
2388
- existing.count += 1;
2389
- } else {
2390
- totals.set(entry.communityId, { label: entry.communityLabel, count: 1 });
2391
- }
2392
- }
2393
- return [...totals.entries()].map(([id, { label, count }]) => ({ id, label, symbolCount: count })).sort((a, b) => b.symbolCount - a.symbolCount || a.id - b.id);
2394
- }
2395
- function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker, coChange) {
2396
- const calculator = new BlastRadiusCalculator();
2397
- const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2398
- return async (args) => {
2399
- try {
2400
- const parsed = InputSchema14.safeParse(args);
2401
- if (!parsed.success) {
2402
- return {
2403
- content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
2404
- };
2405
- }
2406
- const { since, maxFiles, confidence: confFilter } = parsed.data;
2407
- const changedPaths = await git.getChangedFiles(since);
2408
- if (changedPaths.length === 0) {
2409
- return {
2410
- content: [{ type: "text", text: JSON.stringify({ since, changedFiles: 0, changedSymbols: 0, totalImpact: 0, riskLevel: "low", files: [], summary: { confirmedTotal: 0, likelyTotal: 0, potentialTotal: 0, highRiskSymbols: [] } }) }]
2411
- };
2412
- }
2413
- const { graph, indexedFiles } = getGraph();
2414
- const coChangeMap = coChange?.readCoChangeMap();
2415
- const allNodes = graph.allNodes();
2416
- const nodesByFile = /* @__PURE__ */ new Map();
2417
- for (const node of allNodes) {
2418
- const file = node.symbolId.split("::")[0];
2419
- let list = nodesByFile.get(file);
2420
- if (!list) {
2421
- list = [];
2422
- nodesByFile.set(file, list);
2423
- }
2424
- list.push(node);
2425
- }
2426
- const limitedPaths = changedPaths.slice(0, maxFiles);
2427
- let totalImpact = 0;
2428
- let confirmedTotal = 0;
2429
- let likelyTotal = 0;
2430
- let potentialTotal = 0;
2431
- const highRiskSymbols = [];
2432
- const files = [];
2433
- for (const filePath of limitedPaths) {
2434
- const symbols = nodesByFile.get(filePath);
2435
- if (!symbols || symbols.length === 0) continue;
2436
- const fileSymbols = [];
2437
- for (const sym of symbols) {
2438
- const result = calculator.calculate(graph, sym.symbolId, coChangeMap);
2439
- let impacted = result.impactedSymbols;
2440
- if (confFilter) {
2441
- impacted = impacted.filter((s) => s.confidence === confFilter);
2442
- }
2443
- const impactScore = impacted.length;
2444
- const confirmed = impacted.filter((s) => s.confidence === "confirmed").length;
2445
- const likely = impacted.filter((s) => s.confidence === "likely").length;
2446
- const potential = impacted.filter((s) => s.confidence === "potential").length;
2447
- totalImpact += impactScore;
2448
- confirmedTotal += confirmed;
2449
- likelyTotal += likely;
2450
- potentialTotal += potential;
2451
- if (result.overallRiskScore > 0.7) {
2452
- highRiskSymbols.push(sym.symbolId);
2453
- }
2454
- const topImpacted = impacted.sort((a, b) => b.riskScore - a.riskScore).slice(0, 10);
2455
- fileSymbols.push({
2456
- symbolId: sym.symbolId,
2457
- name: sym.name,
2458
- kind: sym.kind,
2459
- blast: {
2460
- impactScore,
2461
- confirmedCount: confirmed,
2462
- likelyCount: likely,
2463
- potentialCount: potential,
2464
- riskScore: result.overallRiskScore,
2465
- topImpacted
2466
- }
2467
- });
2468
- }
2469
- let coChangedWith;
2470
- if (coChangeMap) {
2471
- const entries = coChangeMap.get(filePath);
2472
- if (entries && entries.length > 0) {
2473
- coChangedWith = [...entries].sort((a, b) => b.frequency - a.frequency).slice(0, 5).map((e) => e.file1 === filePath ? e.file2 : e.file1);
2474
- }
2475
- }
2476
- files.push({
2477
- file: filePath,
2478
- symbols: fileSymbols,
2479
- ...coChangedWith ? { coChangedWith } : {}
2480
- });
2481
- }
2482
- const maxRisk = files.reduce((max, f) => {
2483
- const fileMax = f.symbols.reduce((m, s) => Math.max(m, s.blast.riskScore), 0);
2484
- return Math.max(max, fileMax);
2485
- }, 0);
2486
- const riskLevel = maxRisk > 0.7 ? "high" : maxRisk > 0.3 ? "medium" : "low";
2487
- const changedSymbols = files.reduce((sum, f) => sum + f.symbols.length, 0);
2488
- const rawSnapshot = storage.readCommunities();
2489
- const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
2490
- const history = snapshot ? storage.listCommunityHistory() : [];
2491
- let boundarySection;
2492
- let clustersAffected;
2493
- if (snapshot) {
2494
- const detector = new BoundaryViolationDetector();
2495
- const violationResult = detector.detect(graph, snapshot, history);
2496
- const changedSymbolIds = /* @__PURE__ */ new Set();
2497
- for (const file of files) {
2498
- for (const sym of file.symbols) changedSymbolIds.add(sym.symbolId);
2499
- }
2500
- const relevant = violationResult.violations.filter(
2501
- (v) => changedSymbolIds.has(v.from.symbolId) || changedSymbolIds.has(v.to.symbolId)
2502
- );
2503
- boundarySection = {
2504
- violations: relevant,
2505
- confidence: violationResult.confidence,
2506
- snapshotsAvailable: violationResult.snapshotsAvailable,
2507
- distinctCommits: violationResult.distinctCommits,
2508
- ...violationResult.hint ? { hint: violationResult.hint } : {}
2509
- };
2510
- clustersAffected = computeClustersAffected(snapshot, changedSymbolIds);
2511
- }
2512
- const body = {
2513
- since,
2514
- changedFiles: files.length,
2515
- changedSymbols,
2516
- totalImpact,
2517
- riskLevel,
2518
- files,
2519
- summary: {
2520
- confirmedTotal,
2521
- likelyTotal,
2522
- potentialTotal,
2523
- highRiskSymbols: highRiskSymbols.slice(0, 10)
2524
- }
2525
- };
2526
- if (boundarySection) body.boundaryViolations = boundarySection;
2527
- if (clustersAffected && clustersAffected.length > 0) body.clustersAffected = clustersAffected;
2528
- const stalenessMeta = await buildSnapshotStaleness(storage, git);
2529
- const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
2530
- const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
2531
- const content = [];
2532
- if (staleness) {
2533
- const warning = staleness.check(indexedFiles);
2534
- if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
2535
- }
2536
- content.push({ type: "text", text: payload });
2537
- return { content };
2538
- } catch (err) {
2539
- return {
2540
- content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
2541
- };
2542
- }
2543
- };
2544
- }
2545
-
2546
- // src/adapters/stats/with-recording.ts
2547
- function withRecording(toolName, handler, recorder) {
2548
- if (!recorder) return handler;
2549
- return (args) => {
2550
- const start = performance.now();
2551
- const resultOrPromise = handler(args);
2552
- if (resultOrPromise instanceof Promise) {
2553
- return resultOrPromise.then((result) => {
2554
- recordEvent(toolName, args, result, performance.now() - start, recorder);
2555
- return result;
2556
- });
2557
- }
2558
- const latencyMs = performance.now() - start;
2559
- recordEvent(toolName, args, resultOrPromise, latencyMs, recorder);
2560
- return resultOrPromise;
2561
- };
2562
- }
2563
- function recordEvent(toolName, args, result, latencyMs, recorder) {
2564
- try {
2565
- const responseText = result.content.filter((c) => c.type === "text").map((c) => c.text).join("");
2566
- let truncated = false;
2567
- try {
2568
- const parsed = JSON.parse(responseText);
2569
- truncated = parsed?._meta?.truncated ?? false;
2570
- } catch {
2571
- }
2572
- const responseBytes = Buffer.byteLength(responseText, "utf-8");
2573
- const rawLevel = args["level"];
2574
- let detailLevel = null;
2575
- if (typeof rawLevel === "number" && rawLevel >= 1 && rawLevel <= 4) {
2576
- detailLevel = `L${rawLevel}`;
2577
- }
2578
- const event = {
2579
- tool: toolName,
2580
- symbolId: typeof args["symbolId"] === "string" ? args["symbolId"] : null,
2581
- detailLevel,
2582
- responseTokens: Math.ceil(responseBytes / 4),
2583
- responseBytes,
2584
- latencyMs,
2585
- truncated
2586
- };
2587
- recorder.record(event);
2588
- } catch {
2589
- }
2590
- }
2591
-
2592
- // src/core/overlay/cluster-label-masker.ts
2593
- var ClusterLabelMasker = class {
2594
- matcher;
2595
- enabled;
2596
- labelCache = /* @__PURE__ */ new Map();
2597
- constructor(patterns) {
2598
- this.enabled = patterns.length > 0;
2599
- this.matcher = this.enabled ? makeGlobMatcher([...patterns]) : () => false;
2600
- }
2601
- isEnabled() {
2602
- return this.enabled;
2603
- }
2604
- mask(label) {
2605
- if (!this.enabled) return label;
2606
- if (!this.matcher(label)) return label;
2607
- const cached = this.labelCache.get(label);
2608
- if (cached) return cached;
2609
- const masked = `[masked-cluster-${this.labelCache.size + 1}]`;
2610
- this.labelCache.set(label, masked);
2611
- return masked;
2612
- }
2613
- maskSnapshot(snapshot) {
2614
- if (!this.enabled) return snapshot;
2615
- const uniqueLabels = [...new Set(snapshot.communities.map((c) => c.communityLabel))].sort();
2616
- for (const label of uniqueLabels) this.mask(label);
2617
- const maskedCommunities = snapshot.communities.map((entry) => ({
2618
- ...entry,
2619
- communityLabel: this.mask(entry.communityLabel)
2620
- }));
2621
- const maskedGodNodes = snapshot.godNodes.map((g) => ({ ...g }));
2622
- return { ...snapshot, communities: maskedCommunities, godNodes: maskedGodNodes };
2623
- }
2624
- };
2625
-
2626
- // src/index.ts
2627
- var log4 = createLogger("ctxo:mcp");
2628
- function loadMaskingConfig(ctxoRoot) {
2629
- const jsonConfigPath = join(ctxoRoot, "masking.json");
2630
- if (existsSync(jsonConfigPath)) {
2631
- try {
2632
- const raw = readFileSync(jsonConfigPath, "utf-8");
2633
- const patterns = JSON.parse(raw);
2634
- log4.info(`Loaded ${patterns.length} custom masking pattern(s)`);
2635
- return MaskingPipeline.fromConfig(patterns);
2636
- } catch (err) {
2637
- log4.error(`Failed to load masking config: ${err.message}`);
2638
- }
2639
- }
2640
- return new MaskingPipeline();
2641
- }
2642
- function readStatsEnabled(ctxoRoot) {
2643
- return statsEnabled(loadConfig(ctxoRoot).config);
2644
- }
2645
- var PKG_VERSION = (() => {
2646
- try {
2647
- const __filename = fileURLToPath(import.meta.url);
2648
- const __dirname = dirname(__filename);
2649
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
2650
- return pkg.version ?? "0.0.0";
2651
- } catch {
2652
- return "0.0.0";
2653
- }
2654
- })();
22
+ var log = createLogger("ctxo:mcp");
2655
23
  async function main() {
2656
24
  const args = process.argv.slice(2);
2657
25
  const httpPortStr = resolveHttpPort(args, process.env);
2658
26
  if (!httpPortStr && args.length > 0) {
2659
- const { CliRouter } = await import("./cli-router-RR4CM62C.js");
27
+ if (FAST_ROUTE_COMMANDS.has(args[0])) {
28
+ process.exitCode = await runFastRoute(args, process.cwd());
29
+ return;
30
+ }
31
+ const { CliRouter } = await import("./cli-router-AZCDNX5R.js");
2660
32
  const router = new CliRouter(process.cwd());
2661
33
  await router.route(args);
2662
34
  return;
2663
35
  }
2664
- const ctxoRoot = ".ctxo";
2665
- const workspace = detectWorkspace(process.cwd());
2666
- setWorkspaceMeta({ root: workspace.root });
2667
- const storage = new SqliteStorageAdapter(ctxoRoot, { allowProductionPath: true });
2668
- await storage.init();
2669
- const masking = loadMaskingConfig(ctxoRoot);
2670
- const clusterLabelMasker = new ClusterLabelMasker(
2671
- maskingClusterLabelPatterns(loadConfig(ctxoRoot).config)
2672
- );
2673
- const git = new SimpleGitAdapter(process.cwd());
2674
- const coChange = new CoChangeReader(ctxoRoot);
2675
- const recorder = readStatsEnabled(ctxoRoot) ? new SessionRecorderAdapter(storage.getDb(), () => storage.persist()) : null;
2676
- const server = new McpServer({ name: "ctxo", version: PKG_VERSION });
2677
- const { StalenessDetector } = await import("./staleness-detector-4HJYBQ7Z.js");
2678
- const staleness = new StalenessDetector(process.cwd(), ctxoRoot);
2679
- registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker, coChange);
2680
- if (httpPortStr) {
2681
- await startHttpTransport(async () => {
2682
- const s = new McpServer({ name: "ctxo", version: PKG_VERSION });
2683
- registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker, coChange);
2684
- return s;
2685
- }, parseInt(httpPortStr, 10));
2686
- } else {
2687
- const transport = new StdioServerTransport();
2688
- await server.connect(transport);
2689
- }
2690
- }
2691
- function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo", clusterLabelMasker, coChange) {
2692
- const toolAnnotations = {
2693
- readOnlyHint: true,
2694
- destructiveHint: false,
2695
- idempotentHint: true,
2696
- openWorldHint: false
2697
- };
2698
- const SYMBOL_ID_FORMAT = "format: file::name::kind; a SQL table, view or procedure also as name::kind, for example dbo.loans::table";
2699
- const logicSliceHandler = withRecording("get_logic_slice", handleGetLogicSlice(storage, masking, staleness, ctxoRoot), recorder);
2700
- const whyContextHandler = withRecording("get_why_context", handleGetWhyContext(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker), recorder);
2701
- const changeIntelligenceHandler = withRecording("get_change_intelligence", handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot), recorder);
2702
- server.registerTool(
2703
- "get_logic_slice",
2704
- {
2705
- description: 'Retrieve a symbol and all its transitive dependencies as a Logic-Slice. Use this when you need to UNDERSTAND what a symbol depends on (downstream view). L1=signature only, L2=direct deps, L3=full closure, L4=with token budget and symbol annotations (attributes, columns; only L4 includes them). Use `intent` to filter dependencies by keyword (e.g., "core", "adapter"). All responses include `_meta` with item counts and truncation info. For impact analysis (what BREAKS if this changes), use get_blast_radius instead. For task-specific context, use get_context_for_task.',
2706
- inputSchema: {
2707
- symbolId: z15.string().optional().describe(`Single symbol ID (${SYMBOL_ID_FORMAT})`),
2708
- symbolIds: z15.array(z15.string()).optional().describe("Batch: array of symbol IDs"),
2709
- level: z15.number().min(1).max(4).optional().default(3).describe("Detail level (L1=signature, L2=direct deps, L3=full closure, L4=with token budget and annotations)"),
2710
- intent: z15.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter")')
2711
- },
2712
- annotations: toolAnnotations
2713
- },
2714
- (args) => logicSliceHandler(args)
2715
- );
2716
- server.registerTool(
2717
- "get_why_context",
2718
- {
2719
- description: "Retrieve git commit history intent and anti-pattern warnings (reverts, rollbacks) for a symbol. Use this when you need to understand WHY code was written this way or whether it has a history of problems. Pair with get_change_intelligence for complexity/churn scores.",
2720
- inputSchema: {
2721
- symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT})`),
2722
- maxCommits: z15.number().int().min(1).optional().describe("Limit commit history to N most recent commits")
2723
- },
2724
- annotations: toolAnnotations
2725
- },
2726
- (args) => whyContextHandler(args)
2727
- );
2728
- server.registerTool(
2729
- "get_change_intelligence",
2730
- {
2731
- description: "Retrieve complexity x churn composite score for a symbol \u2014 identifies hotspots that are both complex and frequently changed. Use this to prioritize refactoring targets or assess risk before modifying code. For git history details, use get_why_context. For impact scope, use get_blast_radius.",
2732
- inputSchema: {
2733
- symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)")
2734
- },
2735
- annotations: toolAnnotations
2736
- },
2737
- (args) => changeIntelligenceHandler(args)
2738
- );
2739
- const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot, git, clusterLabelMasker, coChange), recorder);
2740
- server.registerTool(
2741
- "get_blast_radius",
2742
- {
2743
- description: "BEFORE modifying any symbol (a function, class, method or type, or a SQL table, view or procedure), call this to understand impact. Returns every symbol that depends on the target, directly or transitively, with risk scores that fall with depth. Each dependent is in the tier of its strongest edge: confirmed (`calls`, `extends`, `implements` and the data edges `reads`, `writes`, `executes`), likely (co-changed or `uses`), and potential (`imports`). When co-change history exists, `coChangedFiles` lists up to 10 files that often change together with the target's file but have no dependency path to it (informational: not counted in the tiers or risk scores). Use `confidence` to filter by tier, `intent` to filter by keyword. Large results are auto-truncated with `_meta.hint` for drill-in. For what a symbol DEPENDS ON (downstream), use get_logic_slice instead. For full PR-level analysis, use get_pr_impact.",
2744
- inputSchema: {
2745
- symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT}). Every definition of a SQL object, and its name::kind id, has the same dependents`),
2746
- confidence: z15.enum(["confirmed", "likely", "potential"]).optional().describe("Filter by confidence tier"),
2747
- intent: z15.string().optional().describe('Filter impacted symbols by intent keywords (e.g., "test", "adapter")')
2748
- },
2749
- annotations: toolAnnotations
2750
- },
2751
- (args) => blastRadiusHandler(args)
2752
- );
2753
- const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker, new ContextsViewReader(ctxoRoot)), recorder);
2754
- server.registerTool(
2755
- "get_architectural_overlay",
2756
- {
2757
- description: 'Get the project architecture map. mode "both" (default) returns the regex layer map (Domain, Infrastructure, Adapter, Test, Composition, Configuration) plus data-driven Louvain communities; "regex" or "communities" returns one of them. mode "contexts" returns candidate bounded contexts computed by `ctxo index`: file-level clusters of dependencies and co-change with hub files damped. The summary lists every candidate with label, size, cohesion and top terms, plus hubs and name collisions (type names whose shapes differ between candidates). Use this when onboarding to a new codebase, validating layer boundaries, or preparing a context map. For symbol-level analysis, use get_logic_slice or get_blast_radius. Pass candidate: <id> for one candidate in full: its files, cross-candidate edges, co-change pairs, hubs and collisions. The summary also gives each candidate\'s sharedTableCount and the totals dataResourcesTotal, sharedResourcesTotal and externalResourcesTotal. With candidate: <id>, the detail adds sharedTables (SQL tables and views that files of this candidate and at least one other read or write, with access modes per candidate; up to the first 15 by name, each with up to 10 accesses) and the candidate\'s own dataResources (up to the first 15 by name, each with its access modes, writer count, the owning candidate when only one writes it, and an external flag for resources that no .sql file defines). Both lists hold fewer entries when the response limit requires; sharedTableCount and dataResourcesTotal keep the full counts, and ctxo contexts --json has the full lists.',
2758
- inputSchema: {
2759
- layer: z15.string().optional().describe("Filter by specific layer name"),
2760
- mode: z15.enum(OVERLAY_MODES).optional().describe('View to return: "both" (default) layers plus communities, "regex" layer map, "communities" Louvain clusters, "contexts" candidate contexts (file level)'),
2761
- candidate: z15.number().int().positive().optional().describe('With mode "contexts": the id of one candidate to return in full (files, terms, cross edges, co-change pairs, its hubs and collisions); omit for the summary of all candidates')
2762
- },
2763
- annotations: toolAnnotations
2764
- },
2765
- (args) => overlayHandler(args)
2766
- );
2767
- const deadCodeHandler = withRecording("find_dead_code", handleFindDeadCode(storage, masking, staleness, ctxoRoot), recorder);
2768
- server.registerTool(
2769
- "find_dead_code",
2770
- {
2771
- description: 'Find unreachable symbols and files that are never imported or called anywhere. Use this during cleanup, refactoring, or before deleting code to confirm it is truly unused. Symbols in .sql files are never reported. Use `intent` to filter results by keyword (e.g., "adapter", "function"). Large results are auto-truncated with `_meta`. For reverse dependency lookup of a specific symbol, use find_importers instead.',
2772
- inputSchema: {
2773
- includeTests: z15.boolean().optional().default(false).describe("Include test files in analysis (default: exclude)"),
2774
- intent: z15.string().optional().describe('Filter dead code results by intent keywords (e.g., "adapter", "function")')
2775
- },
2776
- annotations: toolAnnotations
2777
- },
2778
- (args) => deadCodeHandler(args)
2779
- );
2780
- const contextForTaskHandler = withRecording("get_context_for_task", handleGetContextForTask(storage, masking, staleness, ctxoRoot, coChange), recorder);
2781
- server.registerTool(
2782
- "get_context_for_task",
2783
- {
2784
- description: 'Get task-optimized context for a symbol based on what you are about to do. Specify taskType: "fix" (bug investigation - includes history + anti-patterns), "extend" (add feature - includes deps + blast radius), "refactor" (restructure - includes importers + complexity), or "understand" (learn - includes full slice + architecture). Context entries that depend on the symbol (its blast radius) carry an optional `confidence` tier: confirmed, likely or potential, where co-change history can raise potential to likely. This is the BEST starting point when you know both the symbol and your intent.',
2785
- inputSchema: {
2786
- symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT})`),
2787
- taskType: z15.enum(["fix", "extend", "refactor", "understand"]).describe("Task type determines which context is most relevant"),
2788
- tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for context (default 4000)")
2789
- },
2790
- annotations: toolAnnotations
2791
- },
2792
- (args) => contextForTaskHandler(args)
2793
- );
2794
- const rankedContextHandler = withRecording("get_ranked_context", handleGetRankedContext(storage, masking, staleness, ctxoRoot), recorder);
2795
- server.registerTool(
2796
- "get_ranked_context",
2797
- {
2798
- description: "Search and rank symbols by relevance to a natural language query, packed within a token budget. Uses BM25 text matching + PageRank importance scoring. Results include `_meta` with truncation info. Use this when you have a question or topic but do not know which specific symbol to look at. For exact symbol name search, use search_symbols instead.",
2799
- inputSchema: {
2800
- query: z15.string().min(1).describe("Search query (matches symbol names)"),
2801
- tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for results (default 4000)"),
2802
- strategy: z15.enum(["combined", "dependency", "importance"]).optional().default("combined").describe("Ranking strategy"),
2803
- searchMode: z15.enum(["fts", "legacy"]).optional().default("fts").describe('"fts" (default): BM25 ranking with camelCase splitting, trigram fallback and typo correction; "legacy": substring matching; strategy "importance" always uses legacy matching')
2804
- },
2805
- annotations: toolAnnotations
2806
- },
2807
- (args) => rankedContextHandler(args)
2808
- );
2809
- const searchSymbolsHandler = withRecording("search_symbols", handleSearchSymbols(storage, masking, staleness, ctxoRoot), recorder);
2810
- server.registerTool(
2811
- "search_symbols",
2812
- {
2813
- description: "Search symbols by exact name or regex pattern across the codebase index. Use this when you know (part of) the symbol name and need to find its ID for use with other tools. For semantic/relevance-based search, use get_ranked_context instead.",
2814
- inputSchema: {
2815
- pattern: z15.string().min(1).describe("Search pattern (substring or regex)"),
2816
- kind: SymbolKindSchema.optional().describe("Filter by symbol kind (table, view and procedure come from SQL plugins)"),
2817
- filePattern: z15.string().optional().describe("Filter by file path substring"),
2818
- limit: z15.number().int().min(1).max(100).optional().default(25).describe("Max results (default 25)"),
2819
- mode: z15.enum(["regex", "fts"]).optional().default("regex").describe('"regex" (default): case-insensitive regex or substring match on names; "fts": BM25 relevance search')
2820
- },
2821
- annotations: toolAnnotations
2822
- },
2823
- (args) => searchSymbolsHandler(args)
2824
- );
2825
- const changedSymbolsHandler = withRecording("get_changed_symbols", handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot), recorder);
2826
- server.registerTool(
2827
- "get_changed_symbols",
2828
- {
2829
- description: "Get symbols in recently changed files based on git diff. Use this to see what was modified in recent commits. For full PR risk assessment (changed symbols + blast radius + co-changes), use get_pr_impact instead.",
2830
- inputSchema: {
2831
- since: z15.string().optional().default("HEAD~1").describe("Git ref to diff against (default HEAD~1)"),
2832
- maxFiles: z15.number().int().min(1).optional().default(50).describe("Max changed files to process (default 50)")
2833
- },
2834
- annotations: toolAnnotations
2835
- },
2836
- (args) => changedSymbolsHandler(args)
2837
- );
2838
- const findImportersHandler = withRecording("find_importers", handleFindImporters(storage, masking, staleness, ctxoRoot), recorder);
2839
- server.registerTool(
2840
- "find_importers",
2841
- {
2842
- description: 'Find all symbols that import or depend on a given symbol (reverse dependency / "who uses this?"). Use this to check if a symbol is safe to modify or delete. Supports transitive traversal for deep impact chains. Use `intent` to filter by keyword (e.g., "test", "core"). For forward dependencies (what this symbol uses), use get_logic_slice. For aggregated impact with risk scores, use get_blast_radius.',
2843
- inputSchema: {
2844
- symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT}). Every definition of a SQL object, and its name::kind id, has the same importers`),
2845
- edgeKinds: z15.array(EdgeKindSchema).optional().describe("Filter by edge kinds (reads, writes and executes are data access)"),
2846
- transitive: z15.boolean().optional().default(false).describe("Follow transitive reverse edges (default false)"),
2847
- maxDepth: z15.number().int().min(1).max(10).optional().default(5).describe("Max BFS depth for transitive mode (default 5)"),
2848
- intent: z15.string().optional().describe('Filter importers by intent keywords (e.g., "test", "core", "adapter")')
2849
- },
2850
- annotations: toolAnnotations
2851
- },
2852
- (args) => findImportersHandler(args)
2853
- );
2854
- const classHierarchyHandler = withRecording("get_class_hierarchy", handleGetClassHierarchy(storage, masking, staleness, ctxoRoot), recorder);
2855
- server.registerTool(
2856
- "get_class_hierarchy",
2857
- {
2858
- description: "Get class inheritance hierarchy \u2014 ancestors (extends/implements chain) and descendants (subclasses). Use this when working with OOP code to understand type relationships before modifying a base class or interface. For dependency-based relationships (imports/calls), use get_logic_slice or find_importers.",
2859
- inputSchema: {
2860
- symbolId: z15.string().min(1).optional().describe("Root symbol ID (omit for full project hierarchy)"),
2861
- direction: z15.enum(["ancestors", "descendants", "both"]).optional().default("both").describe("Traversal direction (default both)")
2862
- },
2863
- annotations: toolAnnotations
2864
- },
2865
- (args) => classHierarchyHandler(args)
2866
- );
2867
- const symbolImportanceHandler = withRecording("get_symbol_importance", handleGetSymbolImportance(storage, masking, staleness, ctxoRoot), recorder);
2868
- server.registerTool(
2869
- "get_symbol_importance",
2870
- {
2871
- description: "Rank symbols by structural importance using PageRank centrality on the dependency graph. Use this to identify the most critical symbols in the codebase \u2014 high-PageRank symbols are heavily depended upon and risky to change. For finding unused/unimportant code, use find_dead_code instead.",
2872
- inputSchema: {
2873
- limit: z15.number().int().min(1).max(200).optional().default(25).describe("Max results (default 25)"),
2874
- kind: SymbolKindSchema.optional().describe("Filter by symbol kind (table, view and procedure come from SQL plugins)"),
2875
- filePattern: z15.string().optional().describe("Filter by file path substring"),
2876
- damping: z15.number().min(0).max(1).optional().default(0.85).describe("PageRank damping factor (default 0.85)")
2877
- },
2878
- annotations: toolAnnotations
2879
- },
2880
- (args) => symbolImportanceHandler(args)
2881
- );
2882
- const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker, coChange), recorder);
2883
- server.registerTool(
2884
- "get_pr_impact",
2885
- {
2886
- description: "Analyze the full impact of a PR or recent changes in a SINGLE call \u2014 combines changed symbols + blast radius + co-change history into one risk assessment. Results include `_meta` with truncation info. Use this FIRST when reviewing a PR or evaluating recent commits. For single-symbol analysis, use get_blast_radius instead.",
2887
- inputSchema: {
2888
- since: z15.string().optional().default("HEAD~1").describe("Git ref to diff against (default HEAD~1)"),
2889
- maxFiles: z15.number().int().min(1).optional().default(50).describe("Max changed files to analyze"),
2890
- confidence: z15.enum(["confirmed", "likely", "potential"]).optional().describe("Filter impacted symbols by confidence tier")
2891
- },
2892
- annotations: toolAnnotations
2893
- },
2894
- (args) => prImpactHandler(args)
2895
- );
2896
- server.resource("ctxo-status", "ctxo://status", async (uri) => ({
2897
- contents: [{ uri: uri.href, text: "Ctxo MCP server is running." }]
2898
- }));
36
+ const { startMcpServer } = await import("./mcp-server-6YG2QE6E.js");
37
+ await startMcpServer(httpPortStr ? { httpPort: parseInt(httpPortStr, 10) } : {});
2899
38
  }
2900
39
  main().catch((err) => {
2901
- log4.error("Fatal: %s", err.message);
40
+ log.error("Fatal: %s", err.message);
2902
41
  process.exit(1);
2903
42
  });
2904
43
  //# sourceMappingURL=index.js.map