@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,241 +0,0 @@
1
- import { runClaude } from "./claude-runner.js";
2
- const SEPARATOR = "/";
3
- function normalizeIdentifier(id) {
4
- let normalized = id.toLowerCase();
5
- normalized = normalized.replace(/[.\-_]+/g, SEPARATOR);
6
- normalized = normalized.replace(/^\/+|\/+$/g, "");
7
- normalized = normalized.replace(/^(?:api\/)?v\d+\//i, "");
8
- normalized = normalized.replace(/:[a-zA-Z_]+/g, ":param");
9
- normalized = normalized.replace(/\{[a-zA-Z_]+\}/g, ":param");
10
- normalized = normalized.replace(/<[a-zA-Z_]+>/g, ":param");
11
- return normalized;
12
- }
13
- function identifierSimilarity(a, b) {
14
- const na = normalizeIdentifier(a);
15
- const nb = normalizeIdentifier(b);
16
- if (na === nb) return 1;
17
- if (na.includes(nb) || nb.includes(na)) return 0.8;
18
- const segsA = new Set(na.split(SEPARATOR).filter(Boolean));
19
- const segsB = new Set(nb.split(SEPARATOR).filter(Boolean));
20
- if (segsA.size === 0 || segsB.size === 0) return 0;
21
- let intersection = 0;
22
- for (const seg of segsA) {
23
- if (segsB.has(seg)) intersection++;
24
- }
25
- const union = (/* @__PURE__ */ new Set([...segsA, ...segsB])).size;
26
- return Math.min(0.7, intersection / union);
27
- }
28
- const MATCH_RULES = [
29
- // Kafka: producer -> consumer on same topic
30
- { producerType: "kafka-producer", consumerType: "kafka-consumer", edgeType: "kafka", baseConfidence: 0.95, threshold: 0.8 },
31
- // HTTP: exposed path -> consumed path
32
- { producerType: "http", consumerType: "http", edgeType: "http", baseConfidence: 0.85, threshold: 0.6 },
33
- // gRPC: exposed service -> consumed service
34
- { producerType: "grpc", consumerType: "grpc", edgeType: "grpc", baseConfidence: 0.95, threshold: 0.8 },
35
- // Database: shared table references
36
- { producerType: "database", consumerType: "database", edgeType: "database", baseConfidence: 0.8, threshold: 0.8 },
37
- // Redis: shared key patterns
38
- { producerType: "redis", consumerType: "redis", edgeType: "redis", baseConfidence: 0.75, threshold: 0.7 },
39
- // S3: shared bucket identifiers
40
- { producerType: "s3", consumerType: "s3", edgeType: "s3", baseConfidence: 0.8, threshold: 0.8 }
41
- ];
42
- function matchEndpoints(profiles) {
43
- const edges = [];
44
- const seen = /* @__PURE__ */ new Set();
45
- for (const rule of MATCH_RULES) {
46
- const isBidirectional = ["database", "redis", "s3"].includes(rule.producerType);
47
- for (let i = 0; i < profiles.length; i++) {
48
- for (let j = 0; j < profiles.length; j++) {
49
- if (i === j) continue;
50
- const repoA = profiles[i];
51
- const repoB = profiles[j];
52
- if (isBidirectional && i > j) continue;
53
- const exposedA = repoA.exposes.filter((e) => e.type === rule.producerType);
54
- const consumedB = isBidirectional ? [...repoB.consumes.filter((e) => e.type === rule.consumerType), ...repoB.exposes.filter((e) => e.type === rule.producerType)] : repoB.consumes.filter((e) => e.type === rule.consumerType);
55
- for (const exposed of exposedA) {
56
- for (const consumed of consumedB) {
57
- const similarity = identifierSimilarity(exposed.identifier, consumed.identifier);
58
- if (similarity < rule.threshold) continue;
59
- const confidence = rule.baseConfidence * similarity;
60
- const edgeKey = `${repoA.name}:${repoB.name}:${rule.edgeType}:${normalizeIdentifier(exposed.identifier)}`;
61
- if (seen.has(edgeKey)) continue;
62
- seen.add(edgeKey);
63
- edges.push({
64
- sourceRepo: repoA.name,
65
- sourceNode: exposed.identifier,
66
- targetRepo: repoB.name,
67
- targetNode: consumed.identifier,
68
- edgeType: rule.edgeType,
69
- evidence: `${repoA.name} exposes ${exposed.type}:${exposed.identifier} matched with ${repoB.name} ${isBidirectional ? "exposes" : "consumes"} ${consumed.type}:${consumed.identifier} (similarity: ${similarity.toFixed(2)})`,
70
- confidence: Math.round(confidence * 100) / 100
71
- });
72
- }
73
- }
74
- }
75
- }
76
- }
77
- return edges;
78
- }
79
- const SYSTEM_PROMPT = `You are a software architect analyzing an organization's service architecture.
80
- You will be given profiles of repositories that have NO detected connections to other repos.
81
- Based on their roles, technologies, domains, and descriptions, identify likely connections.
82
-
83
- Consider:
84
- - Shared databases (same DB tech + similar domain = likely shared DB)
85
- - Config-driven routing (gateway -> backend services)
86
- - Cron/scheduler -> worker relationships
87
- - Shared infrastructure services (logging, auth, notifications)
88
- - Domain proximity (repos in same domain likely interact)
89
-
90
- Respond with ONLY valid JSON array:
91
- [
92
- {
93
- "source": "repo-name-a",
94
- "target": "repo-name-b",
95
- "type": "http|kafka|grpc|database|redis|s3|other",
96
- "evidence": "Both repos are in email-delivery domain, A is gateway, B is worker",
97
- "confidence": 0.7
98
- }
99
- ]
100
-
101
- Rules:
102
- - Only suggest connections with confidence >= 0.6
103
- - Be conservative \u2014 better to miss a connection than hallucinate one
104
- - Use the repo descriptions to understand actual purpose, not just names`;
105
- function parseJsonArray(raw) {
106
- let cleaned = raw.trim();
107
- cleaned = cleaned.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?\s*```$/i, "");
108
- cleaned = cleaned.trim();
109
- try {
110
- const parsed = JSON.parse(cleaned);
111
- if (Array.isArray(parsed)) return parsed;
112
- return [];
113
- } catch {
114
- const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
115
- if (arrayMatch) {
116
- try {
117
- return JSON.parse(arrayMatch[0]);
118
- } catch {
119
- return [];
120
- }
121
- }
122
- return [];
123
- }
124
- }
125
- function toEdgeType(raw) {
126
- const valid = [
127
- "http",
128
- "kafka",
129
- "grpc",
130
- "database",
131
- "redis",
132
- "s3"
133
- ];
134
- const lower = raw.toLowerCase();
135
- const match = valid.find((v) => v === lower);
136
- return match ?? "llm-inferred";
137
- }
138
- async function inferOrphanConnections(profiles, existingEdges, opts) {
139
- const connectedRepos = /* @__PURE__ */ new Set();
140
- for (const edge of existingEdges) {
141
- connectedRepos.add(edge.sourceRepo);
142
- connectedRepos.add(edge.targetRepo);
143
- }
144
- const orphans = profiles.filter((p) => !connectedRepos.has(p.name));
145
- if (orphans.length === 0) return [];
146
- const orphanDomains = new Set(orphans.map((p) => p.domain));
147
- const contextProfiles = profiles.filter(
148
- (p) => orphanDomains.has(p.domain) || !connectedRepos.has(p.name)
149
- );
150
- const slimProfiles = contextProfiles.map((p) => ({
151
- name: p.name,
152
- role: p.role,
153
- domain: p.domain,
154
- description: p.description,
155
- technologies: p.technologies
156
- }));
157
- const orphanNames = orphans.map((p) => p.name);
158
- const userPrompt = `The following repos have NO detected connections to any other repo:
159
- ${JSON.stringify(orphanNames, null, 2)}
160
-
161
- Here are profiles of those repos and their domain neighbors:
162
- ${JSON.stringify(slimProfiles, null, 2)}
163
-
164
- Identify likely connections between these orphan repos and any other repo listed above.`;
165
- const result = await runClaude(
166
- userPrompt,
167
- SYSTEM_PROMPT,
168
- { model: opts?.model, timeoutMs: 6e5 }
169
- );
170
- const inferred = parseJsonArray(result.result);
171
- const profileNames = new Set(profiles.map((p) => p.name));
172
- return inferred.filter(
173
- (e) => e.confidence >= 0.6 && profileNames.has(e.source) && profileNames.has(e.target) && e.source !== e.target
174
- ).map((e) => ({
175
- sourceRepo: e.source,
176
- sourceNode: "inferred",
177
- targetRepo: e.target,
178
- targetNode: "inferred",
179
- edgeType: toEdgeType(e.type),
180
- evidence: `[LLM-inferred] ${e.evidence}`,
181
- confidence: Math.round(e.confidence * 100) / 100
182
- }));
183
- }
184
- async function inferServiceMesh(profiles, opts) {
185
- const progress = opts?.onProgress ?? (() => {
186
- });
187
- progress(`Matching endpoints across ${profiles.length} repos...`);
188
- const deterministicEdges = matchEndpoints(profiles);
189
- progress(`Phase A: Found ${deterministicEdges.length} deterministic edges.`);
190
- const connectedRepos = /* @__PURE__ */ new Set();
191
- for (const edge of deterministicEdges) {
192
- connectedRepos.add(edge.sourceRepo);
193
- connectedRepos.add(edge.targetRepo);
194
- }
195
- const orphanCount = profiles.filter((p) => !connectedRepos.has(p.name)).length;
196
- let llmEdges = [];
197
- if (orphanCount > 0 && opts?.skipLlm !== true) {
198
- progress(`Phase B: ${orphanCount} orphan repo(s) detected. Inferring connections via LLM...`);
199
- try {
200
- llmEdges = await inferOrphanConnections(profiles, deterministicEdges, {
201
- model: opts?.model
202
- });
203
- progress(`Phase B: Inferred ${llmEdges.length} additional edges.`);
204
- } catch (err) {
205
- const message = err instanceof Error ? err.message : String(err);
206
- progress(`Phase B: LLM inference failed \u2014 ${message}. Continuing with deterministic edges only.`);
207
- }
208
- } else if (orphanCount === 0) {
209
- progress("Phase B: No orphan repos \u2014 skipping LLM inference.");
210
- } else {
211
- progress("Phase B: Skipped (skipLlm=true).");
212
- }
213
- const allEdges = [...deterministicEdges, ...llmEdges];
214
- const deduped = deduplicateEdges(allEdges);
215
- progress(`Service mesh: ${deduped.length} total edges (${deterministicEdges.length} deterministic, ${llmEdges.length} LLM-inferred).`);
216
- return deduped;
217
- }
218
- function deduplicateEdges(edges) {
219
- const map = /* @__PURE__ */ new Map();
220
- for (const edge of edges) {
221
- const key = [
222
- edge.sourceRepo,
223
- edge.targetRepo,
224
- edge.edgeType,
225
- normalizeIdentifier(edge.sourceNode),
226
- normalizeIdentifier(edge.targetNode)
227
- ].join("::");
228
- const existing = map.get(key);
229
- if (!existing || edge.confidence > existing.confidence) {
230
- map.set(key, edge);
231
- }
232
- }
233
- return Array.from(map.values());
234
- }
235
- export {
236
- identifierSimilarity,
237
- inferOrphanConnections,
238
- inferServiceMesh,
239
- matchEndpoints,
240
- normalizeIdentifier
241
- };
@@ -1,316 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- const STRING_LITERAL_RE = /(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g;
3
- const LINE_COMMENT_RE = /\/\/.*$/gm;
4
- const HASH_COMMENT_RE = /#.*$/gm;
5
- const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
6
- const PYTHON_DOCSTRING_RE = /"""[\s\S]*?"""|'''[\s\S]*?'''/g;
7
- const HASH_COMMENT_LANGS = /* @__PURE__ */ new Set(["python", "ruby", "bash", "shell", "yaml"]);
8
- const SLASH_COMMENT_LANGS = /* @__PURE__ */ new Set([
9
- "typescript",
10
- "javascript",
11
- "tsx",
12
- "jsx",
13
- "go",
14
- "rust",
15
- "java",
16
- "c",
17
- "cpp",
18
- "csharp",
19
- "swift",
20
- "kotlin",
21
- "scala",
22
- "dart",
23
- "php"
24
- ]);
25
- function stripComments(content, language) {
26
- const strings = [];
27
- let protected_ = content.replace(STRING_LITERAL_RE, (match) => {
28
- strings.push(match);
29
- return `__STR_${strings.length - 1}__`;
30
- });
31
- if (language === "python") {
32
- protected_ = protected_.replace(PYTHON_DOCSTRING_RE, "");
33
- }
34
- if (SLASH_COMMENT_LANGS.has(language) || language === "css") {
35
- protected_ = protected_.replace(BLOCK_COMMENT_RE, "");
36
- }
37
- if (SLASH_COMMENT_LANGS.has(language)) {
38
- protected_ = protected_.replace(LINE_COMMENT_RE, "");
39
- }
40
- if (HASH_COMMENT_LANGS.has(language)) {
41
- protected_ = protected_.replace(HASH_COMMENT_RE, "");
42
- }
43
- protected_ = protected_.replace(/__STR_(\d+)__/g, (_, idx) => strings[Number(idx)]);
44
- return protected_;
45
- }
46
- function normalizeWhitespace(content) {
47
- return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line) => line.replace(/,\s*$/, "")).map((line) => line.replace(/\s+/g, " ")).join("\n");
48
- }
49
- const VAR_DECL_PATTERNS = {
50
- typescript: [
51
- /\b(const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=/g
52
- ],
53
- javascript: [
54
- /\b(const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=/g
55
- ],
56
- tsx: [
57
- /\b(const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=/g
58
- ],
59
- jsx: [
60
- /\b(const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=/g
61
- ],
62
- go: [
63
- /\b([a-zA-Z_][\w]*)\s*:=/g,
64
- /\bvar\s+([a-zA-Z_][\w]*)\b/g
65
- ],
66
- python: [
67
- // Simple assignment (not augmented, not type-annotated attribute)
68
- /^(\s*)([a-zA-Z_][\w]*)\s*=/gm
69
- ],
70
- rust: [
71
- /\blet\s+(?:mut\s+)?([a-zA-Z_][\w]*)\s*(?::|=)/g
72
- ]
73
- };
74
- function normalizeLocalVariables(content, language) {
75
- const langKey = language.toLowerCase();
76
- const patterns = VAR_DECL_PATTERNS[langKey];
77
- if (!patterns) return content;
78
- const localNames = [];
79
- for (const pattern of patterns) {
80
- const re = new RegExp(pattern.source, pattern.flags);
81
- let match;
82
- while ((match = re.exec(content)) !== null) {
83
- const name = match[2] ?? match[1];
84
- if (name && !localNames.includes(name) && !isReservedIdentifier(name)) {
85
- localNames.push(name);
86
- }
87
- }
88
- }
89
- let result = content;
90
- for (let i = 0; i < localNames.length; i++) {
91
- const name = localNames[i];
92
- const nameRe = new RegExp(`\\b${escapeRegex(name)}\\b`, "g");
93
- result = result.replace(nameRe, `v${i}`);
94
- }
95
- return result;
96
- }
97
- function isReservedIdentifier(name) {
98
- const reserved = /* @__PURE__ */ new Set([
99
- // JS/TS
100
- "true",
101
- "false",
102
- "null",
103
- "undefined",
104
- "this",
105
- "super",
106
- "return",
107
- "if",
108
- "else",
109
- "for",
110
- "while",
111
- "do",
112
- "switch",
113
- "case",
114
- "break",
115
- "continue",
116
- "throw",
117
- "try",
118
- "catch",
119
- "finally",
120
- "new",
121
- "delete",
122
- "typeof",
123
- "instanceof",
124
- "void",
125
- "in",
126
- "of",
127
- "async",
128
- "await",
129
- "yield",
130
- "import",
131
- "export",
132
- "default",
133
- "from",
134
- "as",
135
- "class",
136
- "extends",
137
- "implements",
138
- "interface",
139
- "type",
140
- "enum",
141
- "function",
142
- // Go
143
- "func",
144
- "package",
145
- "defer",
146
- "go",
147
- "select",
148
- "chan",
149
- "map",
150
- "range",
151
- "struct",
152
- "nil",
153
- "err",
154
- // Python
155
- "def",
156
- "lambda",
157
- "self",
158
- "cls",
159
- "None",
160
- "True",
161
- "False",
162
- "with",
163
- "pass",
164
- "raise",
165
- "except",
166
- "global",
167
- "nonlocal",
168
- // Rust
169
- "fn",
170
- "pub",
171
- "crate",
172
- "mod",
173
- "use",
174
- "impl",
175
- "trait",
176
- "where",
177
- "Some",
178
- "None",
179
- "Ok",
180
- "Err",
181
- "Self"
182
- ]);
183
- return reserved.has(name);
184
- }
185
- function escapeRegex(str) {
186
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
- }
188
- function sortOrderIndependentBlocks(content, language) {
189
- let result = content;
190
- result = result.replace(
191
- /import\s*\{([^}]+)\}\s*from/g,
192
- (_match, specifiers) => {
193
- const sorted = specifiers.split(",").map((s) => s.trim()).filter((s) => s.length > 0).sort().join(", ");
194
- return `import { ${sorted} } from`;
195
- }
196
- );
197
- if (["typescript", "javascript", "tsx", "jsx", "go", "rust"].includes(language)) {
198
- result = sortBracedBlocks(result, /\b(?:interface|type\s+\w+\s*=|enum)\s+\w+[^{]*\{/g);
199
- }
200
- return result;
201
- }
202
- function sortBracedBlocks(content, blockStartRe) {
203
- const re = new RegExp(blockStartRe.source, blockStartRe.flags);
204
- let result = content;
205
- let match;
206
- const replacements = [];
207
- while ((match = re.exec(result)) !== null) {
208
- const openBrace = result.indexOf("{", match.index + match[0].length - 1);
209
- if (openBrace === -1) continue;
210
- let depth = 1;
211
- let pos = openBrace + 1;
212
- while (pos < result.length && depth > 0) {
213
- if (result[pos] === "{") depth++;
214
- else if (result[pos] === "}") depth--;
215
- pos++;
216
- }
217
- if (depth !== 0) continue;
218
- const closeBrace = pos - 1;
219
- const interior = result.slice(openBrace + 1, closeBrace);
220
- if (interior.includes("{")) continue;
221
- const lines = interior.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
222
- if (lines.length <= 1) continue;
223
- const sorted = lines.sort().join("\n");
224
- replacements.push({
225
- start: openBrace + 1,
226
- end: closeBrace,
227
- replacement: "\n" + sorted + "\n"
228
- });
229
- }
230
- for (const r of replacements.sort((a, b) => b.start - a.start)) {
231
- result = result.slice(0, r.start) + r.replacement + result.slice(r.end);
232
- }
233
- return result;
234
- }
235
- function canonicalize(content, language) {
236
- let result = content;
237
- result = stripComments(result, language);
238
- result = normalizeWhitespace(result);
239
- result = normalizeLocalVariables(result, language);
240
- result = sortOrderIndependentBlocks(result, language);
241
- result = normalizeWhitespace(result);
242
- return result;
243
- }
244
- function computeStructuralHash(content, language) {
245
- const canonical = canonicalize(content, language);
246
- const hash = createHash("sha256").update(canonical).digest("hex");
247
- return { hash, canonicalSize: canonical.length };
248
- }
249
- function computeStructuralHashes(chunks) {
250
- const hashes = /* @__PURE__ */ new Map();
251
- const dedupGroups = /* @__PURE__ */ new Map();
252
- for (const chunk of chunks) {
253
- const { hash } = computeStructuralHash(chunk.content, chunk.language);
254
- hashes.set(chunk.id, hash);
255
- const group = dedupGroups.get(hash);
256
- if (group) {
257
- group.push(chunk.id);
258
- } else {
259
- dedupGroups.set(hash, [chunk.id]);
260
- }
261
- }
262
- let duplicateCount = 0;
263
- for (const group of Array.from(dedupGroups.values())) {
264
- if (group.length > 1) {
265
- duplicateCount += group.length - 1;
266
- }
267
- }
268
- return {
269
- hashes,
270
- dedupGroups,
271
- duplicateCount,
272
- uniqueCount: chunks.length - duplicateCount
273
- };
274
- }
275
- const CHARS_PER_TOKEN = 4;
276
- function deduplicateByStructure(chunks) {
277
- const hashMap = /* @__PURE__ */ new Map();
278
- for (const chunk of chunks) {
279
- const hash = chunk.structuralHash ?? computeStructuralHash(chunk.content, chunk.language).hash;
280
- hashMap.set(chunk.id, hash);
281
- }
282
- const groups = /* @__PURE__ */ new Map();
283
- for (const chunk of chunks) {
284
- const hash = hashMap.get(chunk.id);
285
- const group = groups.get(hash);
286
- if (group) {
287
- group.push(chunk);
288
- } else {
289
- groups.set(hash, [chunk]);
290
- }
291
- }
292
- const unique = [];
293
- const duplicates = [];
294
- let savedChars = 0;
295
- for (const group of Array.from(groups.values())) {
296
- const sorted = [...group].sort((a, b) => a.id.localeCompare(b.id));
297
- unique.push(sorted[0]);
298
- for (let i = 1; i < sorted.length; i++) {
299
- duplicates.push(sorted[i]);
300
- savedChars += sorted[i].content.length;
301
- }
302
- }
303
- return {
304
- unique,
305
- duplicates,
306
- savings: {
307
- chunks: duplicates.length,
308
- estimatedTokens: Math.round(savedChars / CHARS_PER_TOKEN)
309
- }
310
- };
311
- }
312
- export {
313
- computeStructuralHash,
314
- computeStructuralHashes,
315
- deduplicateByStructure
316
- };