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