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