@esankhan3/code-search-mcp 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +343 -248
  2. package/dist/__tests__/config-resolver.test.js +153 -0
  3. package/dist/__tests__/daemon-rpc.test.js +100 -0
  4. package/dist/__tests__/observability.test.js +69 -0
  5. package/dist/__tests__/projects.test.js +141 -0
  6. package/dist/backends/daemon-client.js +87 -0
  7. package/dist/backends/in-process.js +104 -0
  8. package/dist/backends/index.js +29 -0
  9. package/dist/cli/index.js +231 -0
  10. package/dist/core/config.js +362 -49
  11. package/dist/core/env-config.js +45 -43
  12. package/dist/daemon/index.js +119 -0
  13. package/dist/daemon/rpc-server.js +99 -0
  14. package/dist/daemon/watcher.js +105 -0
  15. package/dist/index.js +7 -0
  16. package/dist/observability/logger.js +66 -0
  17. package/dist/observability/metrics.js +138 -0
  18. package/dist/projects/registry.js +146 -0
  19. package/dist/resources/resources.js +3 -3
  20. package/dist/server.js +6 -2
  21. package/dist/tools/graph.js +1 -1
  22. package/dist/tools/index-tools.js +1 -1
  23. package/dist/tools/profile.js +2 -2
  24. package/dist/tools/search.js +1 -1
  25. package/dist/transports/http-transport.js +38 -0
  26. package/package.json +31 -12
  27. package/scripts/smoke/run-smoke.sh +167 -0
  28. package/dist/core/ast-graph-builder.js +0 -800
  29. package/dist/core/chunker.js +0 -368
  30. package/dist/core/claude-runner.js +0 -173
  31. package/dist/core/cross-repo-detector.js +0 -887
  32. package/dist/core/embedder.js +0 -334
  33. package/dist/core/file-walker.js +0 -186
  34. package/dist/core/git-diff.js +0 -160
  35. package/dist/core/graph-metrics.js +0 -233
  36. package/dist/core/indexer.js +0 -552
  37. package/dist/core/project-graph-builder.js +0 -202
  38. package/dist/core/query-classifier.js +0 -144
  39. package/dist/core/query-router.js +0 -210
  40. package/dist/core/rag-evaluator.js +0 -213
  41. package/dist/core/repo-profiler.js +0 -383
  42. package/dist/core/reranker.js +0 -218
  43. package/dist/core/retriever.js +0 -242
  44. package/dist/core/semantic-edge-detector.js +0 -147
  45. package/dist/core/service-mesh-inferrer.js +0 -241
  46. package/dist/core/structural-hasher.js +0 -316
  47. package/dist/core/tree-sitter-parser.js +0 -539
  48. package/dist/core/vector-store.js +0 -259
  49. package/dist/core/workspace-detector.js +0 -481
  50. /package/dist/{core → backends}/types.js +0 -0
@@ -1,218 +0,0 @@
1
- async function parallelMap(items, fn, concurrency) {
2
- const results = new Array(items.length);
3
- let nextIndex = 0;
4
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
5
- while (nextIndex < items.length) {
6
- const i = nextIndex++;
7
- results[i] = await fn(items[i], i);
8
- }
9
- });
10
- await Promise.all(workers);
11
- return results;
12
- }
13
- class OllamaReranker {
14
- baseUrl;
15
- model;
16
- timeoutMs;
17
- constructor(opts) {
18
- this.baseUrl = opts?.baseUrl || process.env.OLLAMA_HOST || "http://localhost:11434";
19
- this.model = opts?.model || process.env.RERANKER_MODEL || "qwen3:0.6b";
20
- this.timeoutMs = opts?.timeoutMs ?? 3e4;
21
- }
22
- async scoreOne(query, doc, index) {
23
- try {
24
- const controller = new AbortController();
25
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
26
- const response = await fetch(`${this.baseUrl}/api/chat`, {
27
- method: "POST",
28
- headers: { "Content-Type": "application/json" },
29
- body: JSON.stringify({
30
- model: this.model,
31
- messages: [
32
- {
33
- role: "user",
34
- content: `Given a code search query, evaluate whether the following code document is relevant. Answer ONLY "yes" or "no", nothing else.
35
-
36
- Query: ${query}
37
-
38
- Document:
39
- ${doc.slice(0, 1500)}
40
-
41
- Relevant? /no_think`
42
- }
43
- ],
44
- stream: false,
45
- options: { temperature: 0, num_predict: 10 }
46
- }),
47
- signal: controller.signal
48
- });
49
- clearTimeout(timer);
50
- if (!response.ok) return { index, score: 0.5 };
51
- const json = await response.json();
52
- const raw = (json.message?.content ?? "").toLowerCase().trim();
53
- const answer = raw.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
54
- let score = 0.5;
55
- if (answer.startsWith("yes")) score = 1;
56
- else if (answer.startsWith("no")) score = 0;
57
- else if (raw.includes("yes")) score = 0.8;
58
- else if (raw.includes("no")) score = 0.2;
59
- return { index, score };
60
- } catch {
61
- return { index, score: 0.5 };
62
- }
63
- }
64
- async rerank(query, documents, topN) {
65
- if (documents.length === 0) return [];
66
- const scores = [];
67
- for (let i = 0; i < documents.length; i++) {
68
- const result = await this.scoreOne(query, documents[i], i);
69
- scores.push(result);
70
- }
71
- scores.sort((a, b) => b.score - a.score);
72
- return topN ? scores.slice(0, topN) : scores;
73
- }
74
- }
75
- class CohereReranker {
76
- async rerank(query, documents, topN) {
77
- const apiKey = process.env.COHERE_API_KEY;
78
- if (!apiKey) {
79
- throw new Error("COHERE_API_KEY environment variable is not set");
80
- }
81
- const response = await fetch("https://api.cohere.com/v2/rerank", {
82
- method: "POST",
83
- headers: {
84
- "Content-Type": "application/json",
85
- Authorization: `Bearer ${apiKey}`
86
- },
87
- body: JSON.stringify({
88
- model: "rerank-v3.5",
89
- query,
90
- documents,
91
- top_n: topN ?? documents.length
92
- })
93
- });
94
- if (!response.ok) {
95
- const body = await response.text();
96
- throw new Error(`Cohere rerank request failed (${response.status}): ${body}`);
97
- }
98
- const json = await response.json();
99
- return json.results.map((r) => ({ index: r.index, score: r.relevance_score }));
100
- }
101
- }
102
- class VoyageReranker {
103
- async rerank(query, documents, topN) {
104
- const apiKey = process.env.VOYAGE_API_KEY;
105
- if (!apiKey) {
106
- throw new Error("VOYAGE_API_KEY environment variable is not set");
107
- }
108
- const response = await fetch("https://api.voyageai.com/v1/rerank", {
109
- method: "POST",
110
- headers: {
111
- "Content-Type": "application/json",
112
- Authorization: `Bearer ${apiKey}`
113
- },
114
- body: JSON.stringify({
115
- model: "rerank-2",
116
- query,
117
- documents,
118
- top_k: topN ?? documents.length
119
- })
120
- });
121
- if (!response.ok) {
122
- const body = await response.text();
123
- throw new Error(`Voyage rerank request failed (${response.status}): ${body}`);
124
- }
125
- const json = await response.json();
126
- return json.data.map((r) => ({ index: r.index, score: r.relevance_score }));
127
- }
128
- }
129
- class OpenAICompatibleReranker {
130
- baseUrl;
131
- model;
132
- apiKey;
133
- timeoutMs;
134
- constructor(opts) {
135
- this.baseUrl = opts?.baseUrl || process.env.CODE_SEARCH_RERANKER_BASE_URL || "";
136
- this.model = opts?.model || process.env.CODE_SEARCH_RERANKER_MODEL || "";
137
- this.apiKey = opts?.apiKey || process.env.CODE_SEARCH_RERANKER_API_KEY;
138
- this.timeoutMs = opts?.timeoutMs ?? 3e4;
139
- if (!this.baseUrl) throw new Error("Reranker base URL required. Set CODE_SEARCH_RERANKER_BASE_URL");
140
- if (!this.model) throw new Error("Reranker model required. Set CODE_SEARCH_RERANKER_MODEL");
141
- }
142
- async scoreOne(query, doc, index) {
143
- try {
144
- const controller = new AbortController();
145
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
146
- const headers = { "Content-Type": "application/json" };
147
- if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
148
- const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}/v1/chat/completions`, {
149
- method: "POST",
150
- headers,
151
- body: JSON.stringify({
152
- model: this.model,
153
- messages: [
154
- {
155
- role: "user",
156
- content: `Given a code search query, evaluate whether the following code document is relevant. Answer ONLY "yes" or "no", nothing else.
157
-
158
- Query: ${query}
159
-
160
- Document:
161
- ${doc.slice(0, 1500)}
162
-
163
- Relevant?`
164
- }
165
- ],
166
- temperature: 0,
167
- max_tokens: 5
168
- }),
169
- signal: controller.signal
170
- });
171
- clearTimeout(timer);
172
- if (!response.ok) return { index, score: 0.5 };
173
- const json = await response.json();
174
- const answer = (json.choices?.[0]?.message?.content ?? "").toLowerCase().trim();
175
- let score = 0.5;
176
- if (answer.startsWith("yes")) score = 1;
177
- else if (answer.startsWith("no")) score = 0;
178
- else if (answer.includes("yes")) score = 0.8;
179
- else if (answer.includes("no")) score = 0.2;
180
- return { index, score };
181
- } catch {
182
- return { index, score: 0.5 };
183
- }
184
- }
185
- async rerank(query, documents, topN) {
186
- if (documents.length === 0) return [];
187
- const scores = await parallelMap(
188
- documents,
189
- (doc, i) => this.scoreOne(query, doc, i),
190
- 5
191
- );
192
- scores.sort((a, b) => b.score - a.score);
193
- return topN ? scores.slice(0, topN) : scores;
194
- }
195
- }
196
- function createReranker(provider) {
197
- switch (provider) {
198
- case "ollama":
199
- return new OllamaReranker();
200
- case "cohere":
201
- return new CohereReranker();
202
- case "voyage":
203
- return new VoyageReranker();
204
- case "openai-compatible":
205
- case "custom":
206
- return new OpenAICompatibleReranker();
207
- case "none":
208
- return null;
209
- default:
210
- if (process.env.CODE_SEARCH_RERANKER_BASE_URL) {
211
- return new OpenAICompatibleReranker();
212
- }
213
- return new OllamaReranker();
214
- }
215
- }
216
- export {
217
- createReranker
218
- };
@@ -1,242 +0,0 @@
1
- import { classifyQuery } from "./query-classifier.js";
2
- const queryEmbeddingCache = /* @__PURE__ */ new Map();
3
- const CACHE_MAX_SIZE = 128;
4
- const CACHE_TTL_MS = 10 * 60 * 1e3;
5
- function getCachedEmbedding(query) {
6
- const entry = queryEmbeddingCache.get(query);
7
- if (!entry) return null;
8
- if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
9
- queryEmbeddingCache.delete(query);
10
- return null;
11
- }
12
- return entry.embedding;
13
- }
14
- function cacheEmbedding(query, embedding) {
15
- if (queryEmbeddingCache.size >= CACHE_MAX_SIZE) {
16
- const oldest = queryEmbeddingCache.keys().next().value;
17
- if (oldest !== void 0) queryEmbeddingCache.delete(oldest);
18
- }
19
- queryEmbeddingCache.set(query, { embedding, timestamp: Date.now() });
20
- }
21
- class HybridRetriever {
22
- constructor(vectorStore, embedder, graph, config, reranker = null, queryRouter = null) {
23
- this.config = config;
24
- this.reranker = reranker;
25
- this.queryRouter = queryRouter;
26
- this.vectorStore = vectorStore;
27
- this.embedder = embedder;
28
- this.graph = graph;
29
- }
30
- // Expose for testing/debugging
31
- vectorStore;
32
- embedder;
33
- graph;
34
- async retrieve(query, opts) {
35
- const maxChunks = opts?.maxChunks ?? this.config.maxChunks;
36
- const maxTokens = opts?.maxTokens ?? this.config.maxTokens;
37
- const mode = opts?.mode ?? "vector+bm25+graph";
38
- const useVector = mode !== "bm25";
39
- const useBm25 = mode === "bm25" || mode === "vector+bm25" || mode === "vector+bm25+graph";
40
- const useGraph = mode === "vector+graph" || mode === "vector+bm25+graph";
41
- const classification = classifyQuery(query);
42
- const adaptiveWeights = classification.weights;
43
- let filterRepos = opts?.repoFilter ?? opts?.repos;
44
- if (!filterRepos && this.queryRouter) {
45
- try {
46
- const routeResult = await this.queryRouter.route(query);
47
- if (routeResult.strategy === "filtered") {
48
- filterRepos = routeResult.repos;
49
- }
50
- } catch {
51
- }
52
- }
53
- const filter = filterRepos ? `repoName IN (${filterRepos.map((r) => `'${r}'`).join(",")})` : void 0;
54
- let queryEmbedding = null;
55
- if (useVector) {
56
- queryEmbedding = getCachedEmbedding(query);
57
- if (!queryEmbedding) {
58
- queryEmbedding = await this.embedder.embedSingle(query);
59
- cacheEmbedding(query, queryEmbedding);
60
- }
61
- }
62
- const [vectorResults, bm25Results] = await Promise.all([
63
- useVector && queryEmbedding ? this.vectorStore.vectorSearch(queryEmbedding, { limit: 50, filter }) : Promise.resolve([]),
64
- useBm25 ? this.vectorStore.fullTextSearch(query, 50) : Promise.resolve([])
65
- ]);
66
- if (mode === "vector") {
67
- const selected2 = packWithinBudget(vectorResults, maxTokens, maxChunks);
68
- return { chunks: selected2, graphContext: "", totalTokens: sumTokens(selected2), query };
69
- }
70
- if (mode === "bm25") {
71
- const selected2 = packWithinBudget(bm25Results, maxTokens, maxChunks);
72
- return { chunks: selected2, graphContext: "", totalTokens: sumTokens(selected2), query };
73
- }
74
- const wV = adaptiveWeights.vector ?? this.config.hybridWeights.vector;
75
- const wB = adaptiveWeights.bm25 ?? this.config.hybridWeights.bm25;
76
- const retrievalSets = [];
77
- const weights = [];
78
- if (vectorResults.length > 0) {
79
- retrievalSets.push(vectorResults);
80
- weights.push(wV);
81
- }
82
- if (bm25Results.length > 0) {
83
- retrievalSets.push(bm25Results);
84
- weights.push(wB);
85
- }
86
- const fused = retrievalSets.length > 1 ? reciprocalRankFusion(retrievalSets, weights) : retrievalSets[0] ?? [];
87
- let astChunks = [];
88
- if (useGraph && this.graph) {
89
- const seedNodeIds = this.resolveFusedSeeds(fused, 5);
90
- if (seedNodeIds.length > 0) {
91
- const expandedNodeIds = this.tripartiteExpand(seedNodeIds);
92
- if (expandedNodeIds.length > 0) {
93
- const lookups = expandedNodeIds.map((nodeId) => {
94
- const parts = nodeId.split("::");
95
- if (parts.length >= 3) {
96
- return { repoName: parts[0], filePath: parts[1], entityName: parts.slice(2).join("::") };
97
- }
98
- return { repoName: parts[0], filePath: parts.slice(1).join("::") };
99
- });
100
- astChunks = await this.vectorStore.getChunksByEntity(lookups);
101
- }
102
- }
103
- }
104
- const candidatePool = deduplicateChunks([...fused.slice(0, 15), ...astChunks]);
105
- let finalChunks;
106
- if (this.reranker && candidatePool.length > 1) {
107
- try {
108
- const documents = candidatePool.map((sc) => sc.chunk.contextualizedContent || sc.chunk.content);
109
- const ranked = await this.reranker.rerank(query, documents, maxChunks);
110
- finalChunks = ranked.map((r) => ({
111
- ...candidatePool[r.index],
112
- score: r.score,
113
- source: "fused"
114
- }));
115
- } catch {
116
- finalChunks = candidatePool;
117
- }
118
- } else {
119
- finalChunks = candidatePool;
120
- }
121
- const selected = packWithinBudget(finalChunks, maxTokens, maxChunks);
122
- const graphContext = useGraph && this.graph ? this.graph.exportForPrompt(2e3) ?? "" : "";
123
- return {
124
- chunks: selected,
125
- graphContext,
126
- totalTokens: sumTokens(selected),
127
- query
128
- };
129
- }
130
- // ---------------------------------------------------------------
131
- // Seed resolution: map chunk entities to graph node IDs
132
- // ---------------------------------------------------------------
133
- resolveFusedSeeds(fused, maxSeeds) {
134
- if (!this.graph) return [];
135
- const g = this.graph.getGraph();
136
- const seenFiles = /* @__PURE__ */ new Set();
137
- const nodeIds = [];
138
- for (const sc of fused) {
139
- if (nodeIds.length >= maxSeeds) break;
140
- const { repoName, entityName, filePath } = sc.chunk;
141
- if (!entityName) continue;
142
- if (seenFiles.has(filePath)) continue;
143
- seenFiles.add(filePath);
144
- const baseName = entityName.replace(/\$\d+$/, "");
145
- const exactId = `${repoName}::${filePath}::${baseName}`;
146
- if (g.hasNode(exactId)) {
147
- nodeIds.push(exactId);
148
- continue;
149
- }
150
- const exactIdOrig = `${repoName}::${filePath}::${entityName}`;
151
- if (g.hasNode(exactIdOrig)) {
152
- nodeIds.push(exactIdOrig);
153
- continue;
154
- }
155
- let found = false;
156
- g.forEachNode((nodeId, attrs) => {
157
- if (!found && attrs.repo === repoName && attrs.label === baseName) {
158
- nodeIds.push(nodeId);
159
- found = true;
160
- }
161
- });
162
- }
163
- return nodeIds;
164
- }
165
- // ---------------------------------------------------------------
166
- // Tripartite expansion: dependencies + dependents + definitions
167
- // ---------------------------------------------------------------
168
- tripartiteExpand(seedNodeIds) {
169
- if (!this.graph) return [];
170
- const g = this.graph.getGraph();
171
- const expanded = /* @__PURE__ */ new Set();
172
- const seedSet = new Set(seedNodeIds);
173
- for (const seed of seedNodeIds) {
174
- if (!g.hasNode(seed)) continue;
175
- g.forEachOutEdge(seed, (_edge, attrs, _src, tgt) => {
176
- const conf = attrs.confidence ?? 0.8;
177
- if (conf >= 0.7 && attrs.type !== "contains" && !seedSet.has(tgt)) {
178
- expanded.add(tgt);
179
- }
180
- });
181
- g.forEachInEdge(seed, (_edge, attrs, src) => {
182
- const conf = attrs.confidence ?? 0.8;
183
- if (conf >= 0.7 && attrs.type !== "contains" && !seedSet.has(src)) {
184
- expanded.add(src);
185
- }
186
- });
187
- }
188
- return [...expanded].filter((nodeId) => {
189
- const type = g.getNodeAttributes(nodeId)?.type;
190
- return type !== "module" && type !== "package";
191
- });
192
- }
193
- }
194
- function reciprocalRankFusion(resultSets, weights, k = 60) {
195
- const scoreMap = /* @__PURE__ */ new Map();
196
- for (let setIdx = 0; setIdx < resultSets.length; setIdx++) {
197
- const results = resultSets[setIdx];
198
- const weight = weights[setIdx] ?? 1;
199
- for (let rank = 0; rank < results.length; rank++) {
200
- const { chunk } = results[rank];
201
- const rrfScore = weight * (1 / (k + rank + 1));
202
- const existing = scoreMap.get(chunk.id);
203
- if (existing) {
204
- existing.score += rrfScore;
205
- } else {
206
- scoreMap.set(chunk.id, { chunk, score: rrfScore });
207
- }
208
- }
209
- }
210
- return Array.from(scoreMap.values()).sort((a, b) => b.score - a.score).map(({ chunk, score }) => ({
211
- chunk,
212
- score,
213
- source: "fused"
214
- }));
215
- }
216
- function deduplicateChunks(chunks) {
217
- const seen = /* @__PURE__ */ new Set();
218
- const result = [];
219
- for (const sc of chunks) {
220
- if (seen.has(sc.chunk.id)) continue;
221
- seen.add(sc.chunk.id);
222
- result.push(sc);
223
- }
224
- return result;
225
- }
226
- function packWithinBudget(chunks, maxTokens, maxChunks) {
227
- const selected = [];
228
- let totalTokens = 0;
229
- for (const sc of chunks) {
230
- if (maxChunks && selected.length >= maxChunks) break;
231
- if (totalTokens + sc.chunk.tokens > maxTokens) break;
232
- selected.push(sc);
233
- totalTokens += sc.chunk.tokens;
234
- }
235
- return selected;
236
- }
237
- function sumTokens(chunks) {
238
- return chunks.reduce((sum, sc) => sum + sc.chunk.tokens, 0);
239
- }
240
- export {
241
- HybridRetriever
242
- };
@@ -1,147 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- const DI_EVENT_PATTERNS = [
4
- // DI patterns
5
- /\@Injectable|\@Inject|\@Autowired/,
6
- /container\.(?:register|bind|singleton|resolve)/,
7
- /\$this->app->(?:bind|singleton|make)/,
8
- /providers:\s*\[/,
9
- // Event patterns
10
- /\.on\s*\(|\.emit\s*\(|\.addEventListener/,
11
- /EventEmitter|@Subscribe|@Listener|@EventHandler/,
12
- /event\s*\(\s*['"`]/,
13
- // Routing patterns
14
- /\@Controller|\@Get|\@Post|\@Put|\@Delete|\@RequestMapping/,
15
- /app\.(?:get|post|put|delete|use)\s*\(/,
16
- /router\.(?:get|post|put|delete)\s*\(/,
17
- // Factory/Registry patterns
18
- /\.register\s*\(|\.factory\s*\(/,
19
- /switch\s*\([^)]*type[^)]*\)/i
20
- ];
21
- function fileMatchesDIPatterns(content) {
22
- return DI_EVENT_PATTERNS.some((p) => p.test(content));
23
- }
24
- function findOrphanEntityFiles(graph) {
25
- const outgoing = /* @__PURE__ */ new Map();
26
- for (const edge of graph.links) {
27
- if (edge.type === "contains") continue;
28
- outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1);
29
- }
30
- const orphanFiles = /* @__PURE__ */ new Set();
31
- for (const node of graph.nodes) {
32
- if (node.type === "module" || node.type === "package") continue;
33
- if ((outgoing.get(node.id) ?? 0) === 0 && node.file) {
34
- orphanFiles.add(node.file);
35
- }
36
- }
37
- return orphanFiles;
38
- }
39
- const SEMANTIC_EDGE_PROMPT = `You are analyzing source code to find structural relationships that static AST analysis missed.
40
-
41
- Given code files and the list of known entities, identify relationships of these types ONLY:
42
- 1. **di-inject**: A class receives another class via constructor injection, decorator, or container registration
43
- 2. **event-wire**: An entity emits/publishes an event that another entity subscribes to/handles
44
- 3. **config-route**: A config file or decorator maps a path/key to a handler function
45
- 4. **dynamic-dispatch**: A factory/registry pattern where a string key maps to a class/function
46
-
47
- Respond with ONLY valid JSON (no markdown, no explanation):
48
- {
49
- "edges": [
50
- {
51
- "source": "file/path.ext::EntityName",
52
- "target": "file/path.ext::EntityName",
53
- "type": "di-inject",
54
- "evidence": "brief explanation",
55
- "confidence": 0.8
56
- }
57
- ]
58
- }
59
-
60
- Rules:
61
- - ONLY report edges between entities in the Known Entities list below
62
- - ONLY report edges with confidence >= 0.7
63
- - Do NOT report edges detectable by import analysis or direct function calls
64
- - If no relationships found, return {"edges": []}
65
-
66
- ## Known Entities
67
- {ENTITY_LIST}
68
-
69
- ## Code Files
70
- {FILE_CONTENTS}`;
71
- async function detectSemanticEdges(repoPath, graph, callLLM, opts) {
72
- const maxFiles = opts?.maxFiles ?? 30;
73
- const allEdges = [];
74
- let totalTokens = 0;
75
- const orphanFiles = findOrphanEntityFiles(graph);
76
- const patternFiles = /* @__PURE__ */ new Set();
77
- for (const node of graph.nodes) {
78
- if (node.type !== "module" || !node.file) continue;
79
- try {
80
- const content = readFileSync(join(repoPath, node.file), "utf-8");
81
- if (fileMatchesDIPatterns(content)) {
82
- patternFiles.add(node.file);
83
- }
84
- } catch {
85
- }
86
- }
87
- const candidateFiles = [.../* @__PURE__ */ new Set([...orphanFiles, ...patternFiles])].slice(0, maxFiles);
88
- if (candidateFiles.length === 0) return { edges: [], filesAnalyzed: 0, tokensUsed: 0 };
89
- const nodeSet = new Set(graph.nodes.map((n) => n.id));
90
- const entityList = graph.nodes.filter((n) => n.type !== "module" && n.type !== "package").map((n) => `${n.id} (${n.type})`).join("\n");
91
- const batches = [];
92
- let currentBatch = [];
93
- let currentSize = 0;
94
- for (const relPath of candidateFiles) {
95
- try {
96
- const content = readFileSync(join(repoPath, relPath), "utf-8");
97
- const trimmed = content.slice(0, 3e3);
98
- if (currentSize + trimmed.length > 12e3) {
99
- if (currentBatch.length > 0) batches.push(currentBatch);
100
- currentBatch = [];
101
- currentSize = 0;
102
- }
103
- currentBatch.push({ relPath, content: trimmed });
104
- currentSize += trimmed.length;
105
- } catch {
106
- }
107
- }
108
- if (currentBatch.length > 0) batches.push(currentBatch);
109
- for (const batch of batches) {
110
- const fileContents = batch.map((f) => `### ${f.relPath}
111
- \`\`\`
112
- ${f.content}
113
- \`\`\``).join("\n\n");
114
- const prompt = SEMANTIC_EDGE_PROMPT.replace("{ENTITY_LIST}", entityList.slice(0, 5e3)).replace("{FILE_CONTENTS}", fileContents);
115
- try {
116
- const result = await callLLM(prompt);
117
- totalTokens += result.inputTokens + result.outputTokens;
118
- let jsonStr = result.content.trim();
119
- const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
120
- if (jsonMatch) jsonStr = jsonMatch[1].trim();
121
- const parsed = JSON.parse(jsonStr);
122
- if (!Array.isArray(parsed?.edges)) continue;
123
- for (const edge of parsed.edges) {
124
- if (nodeSet.has(edge.source) && nodeSet.has(edge.target) && edge.source !== edge.target) {
125
- const confidence = typeof edge.confidence === "number" ? Math.min(edge.confidence, 0.95) : 0.75;
126
- if (confidence >= 0.7) {
127
- allEdges.push({
128
- source: edge.source,
129
- target: edge.target,
130
- type: edge.type || "semantic",
131
- confidence
132
- });
133
- }
134
- }
135
- }
136
- } catch {
137
- }
138
- }
139
- return {
140
- edges: allEdges,
141
- filesAnalyzed: candidateFiles.length,
142
- tokensUsed: totalTokens
143
- };
144
- }
145
- export {
146
- detectSemanticEdges
147
- };