@takara-ai/miru-code 0.1.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 +176 -0
- package/bin/miru.cjs +41 -0
- package/package.json +72 -0
- package/src/agents/claude.md +41 -0
- package/src/agents/copilot.md +41 -0
- package/src/agents/cursor.md +40 -0
- package/src/agents/gemini.md +43 -0
- package/src/agents/kiro.md +43 -0
- package/src/agents/opencode.md +44 -0
- package/src/agents.ts +30 -0
- package/src/cache.ts +141 -0
- package/src/chunking/chunking.ts +36 -0
- package/src/chunking/lines.ts +79 -0
- package/src/chunking/structural.ts +244 -0
- package/src/cli.ts +245 -0
- package/src/concurrency.ts +58 -0
- package/src/embeddings/openai.ts +442 -0
- package/src/env-files.ts +44 -0
- package/src/env.ts +50 -0
- package/src/git.ts +52 -0
- package/src/index/bm25-types.ts +18 -0
- package/src/index/bm25-worker.ts +48 -0
- package/src/index/bm25.ts +238 -0
- package/src/index/create.ts +195 -0
- package/src/index/dense.ts +105 -0
- package/src/index/file-walker.ts +113 -0
- package/src/index/files.ts +126 -0
- package/src/index/persistence.ts +100 -0
- package/src/index/quantize.ts +168 -0
- package/src/index/semantic-index.ts +11 -0
- package/src/index/sparse.ts +25 -0
- package/src/index/topk.ts +52 -0
- package/src/index/vector-storage.ts +27 -0
- package/src/index.ts +3 -0
- package/src/mcp/index-cache.ts +95 -0
- package/src/mcp/serve.ts +24 -0
- package/src/mcp/server.ts +100 -0
- package/src/miru-index.ts +269 -0
- package/src/ranking/boosting.ts +329 -0
- package/src/ranking/penalties.ts +110 -0
- package/src/ranking/weighting.ts +11 -0
- package/src/search.ts +180 -0
- package/src/tokens.ts +27 -0
- package/src/types.ts +47 -0
- package/src/utils.ts +64 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import type { ContentType } from "../types.ts";
|
|
4
|
+
import { formatResults, resolveChunk } from "../utils.ts";
|
|
5
|
+
import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
|
|
6
|
+
|
|
7
|
+
const REPO_DESCRIPTION =
|
|
8
|
+
"https:// or http:// git URL (e.g. https://github.com/org/repo) or local directory path to index and search. " +
|
|
9
|
+
"Required when no default index was configured at startup. " +
|
|
10
|
+
"The index is cached after the first call, so repeat queries are fast.";
|
|
11
|
+
|
|
12
|
+
const SERVER_INSTRUCTIONS =
|
|
13
|
+
"Instant code search for any local or remote git repository. " +
|
|
14
|
+
"Call `search` to find relevant code; call `find_related` on a result to discover similar code elsewhere. " +
|
|
15
|
+
"When working in a local project, pass the project root as `repo`. " +
|
|
16
|
+
"For remote repos, pass an explicit https:// URL. Never guess or infer URLs. " +
|
|
17
|
+
"Prefer these tools over Grep, Glob, or Read for any question about how code works.";
|
|
18
|
+
|
|
19
|
+
export function createMcpServer(cache: IndexCache, defaultSource: string | null = null): McpServer {
|
|
20
|
+
const server = new McpServer(
|
|
21
|
+
{
|
|
22
|
+
name: "miru",
|
|
23
|
+
version: "0.1.0",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
server.registerTool(
|
|
31
|
+
"search",
|
|
32
|
+
{
|
|
33
|
+
description:
|
|
34
|
+
"Search a codebase with a natural-language or code query. " +
|
|
35
|
+
"Pass a git URL or local path as `repo` to index it on demand; indexes are cached for the session.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
query: z.string().describe("Natural language or code query."),
|
|
38
|
+
repo: z.string().nullable().optional().describe(REPO_DESCRIPTION),
|
|
39
|
+
top_k: z.number().int().min(1).optional().describe("Number of results to return."),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async ({ query, repo, top_k: topK }) => {
|
|
43
|
+
try {
|
|
44
|
+
const index = await getIndexForRepo(repo ?? null, defaultSource, cache);
|
|
45
|
+
const results = await index.search({ query, topK: topK ?? 5 });
|
|
46
|
+
if (results.length === 0) {
|
|
47
|
+
return toolText(JSON.stringify({ error: "No results found." }));
|
|
48
|
+
}
|
|
49
|
+
return toolText(JSON.stringify(formatResults(query, results)));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return toolText(err instanceof Error ? err.message : String(err));
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
server.registerTool(
|
|
57
|
+
"find_related",
|
|
58
|
+
{
|
|
59
|
+
description:
|
|
60
|
+
"Find code chunks semantically similar to a specific location in a file. " +
|
|
61
|
+
"Use after `search` to explore related implementations or callers.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
file_path: z
|
|
64
|
+
.string()
|
|
65
|
+
.describe(
|
|
66
|
+
"Path to the file as stored in the index (use file_path from a search result).",
|
|
67
|
+
),
|
|
68
|
+
line: z.number().int().describe("Line number (1-indexed)."),
|
|
69
|
+
repo: z.string().nullable().optional().describe(REPO_DESCRIPTION),
|
|
70
|
+
top_k: z.number().int().min(1).optional().describe("Number of similar chunks to return."),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
async ({ file_path: filePath, line, repo, top_k: topK }) => {
|
|
74
|
+
try {
|
|
75
|
+
const index = await getIndexForRepo(repo ?? null, defaultSource, cache);
|
|
76
|
+
const chunk = resolveChunk(index.chunks, filePath, line);
|
|
77
|
+
if (!chunk) {
|
|
78
|
+
return toolText(
|
|
79
|
+
`No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const results = await index.findRelated(chunk, topK ?? 5);
|
|
83
|
+
if (results.length === 0) {
|
|
84
|
+
return toolText(
|
|
85
|
+
JSON.stringify({ error: `No related chunks found for ${filePath}:${line}.` }),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return toolText(
|
|
89
|
+
JSON.stringify(formatResults(`Chunks related to ${filePath}:${line}`, results)),
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return toolText(err instanceof Error ? err.message : String(err));
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return server;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type { ContentType };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { findIndexCachePath, getValidatedCache, loadCachedIndex } from "./cache.ts";
|
|
4
|
+
import {
|
|
5
|
+
type EmbeddingBackend,
|
|
6
|
+
getEmbeddingBackend,
|
|
7
|
+
resolveEmbeddingDimensions,
|
|
8
|
+
resolveEmbeddingModel,
|
|
9
|
+
} from "./embeddings/openai.ts";
|
|
10
|
+
import { cloneGitRepository } from "./git.ts";
|
|
11
|
+
import type { BM25Index } from "./index/bm25.ts";
|
|
12
|
+
import { createIndexFromPath } from "./index/create.ts";
|
|
13
|
+
import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
|
|
14
|
+
import type { SemanticIndex } from "./index/semantic-index.ts";
|
|
15
|
+
import { hybridSearch, searchSemanticOnly } from "./search.ts";
|
|
16
|
+
import type { Chunk, ContentType, SearchResult } from "./types.ts";
|
|
17
|
+
import { chunkKey, chunkToDict } from "./types.ts";
|
|
18
|
+
import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
|
|
19
|
+
|
|
20
|
+
export class MiruIndex {
|
|
21
|
+
readonly embeddings: EmbeddingBackend;
|
|
22
|
+
readonly chunks: Chunk[];
|
|
23
|
+
readonly loadedFromDisk: boolean;
|
|
24
|
+
|
|
25
|
+
private readonly bm25Index: BM25Index;
|
|
26
|
+
private readonly semanticIndex: SemanticIndex;
|
|
27
|
+
private readonly embeddingModel: string;
|
|
28
|
+
private readonly root: string | null;
|
|
29
|
+
private readonly content: ContentType[];
|
|
30
|
+
private readonly fileMapping: Map<string, number[]>;
|
|
31
|
+
private readonly languageMapping: Map<string, number[]>;
|
|
32
|
+
|
|
33
|
+
constructor(options: {
|
|
34
|
+
embeddings: EmbeddingBackend;
|
|
35
|
+
bm25Index: BM25Index;
|
|
36
|
+
semanticIndex: SemanticIndex;
|
|
37
|
+
chunks: Chunk[];
|
|
38
|
+
embeddingModel: string;
|
|
39
|
+
root?: string | null;
|
|
40
|
+
content?: ContentType[];
|
|
41
|
+
loadedFromDisk?: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
this.embeddings = options.embeddings;
|
|
44
|
+
this.bm25Index = options.bm25Index;
|
|
45
|
+
this.semanticIndex = options.semanticIndex;
|
|
46
|
+
this.chunks = options.chunks;
|
|
47
|
+
this.embeddingModel = options.embeddingModel;
|
|
48
|
+
this.root = options.root ?? null;
|
|
49
|
+
this.content = options.content ?? ["code"];
|
|
50
|
+
this.loadedFromDisk = options.loadedFromDisk ?? false;
|
|
51
|
+
|
|
52
|
+
this.fileMapping = new Map();
|
|
53
|
+
this.languageMapping = new Map();
|
|
54
|
+
for (let i = 0; i < this.chunks.length; i++) {
|
|
55
|
+
const chunk = this.chunks[i];
|
|
56
|
+
if (!chunk) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const fp = chunk.file_path;
|
|
60
|
+
if (!this.fileMapping.has(fp)) {
|
|
61
|
+
this.fileMapping.set(fp, []);
|
|
62
|
+
}
|
|
63
|
+
this.fileMapping.get(fp)?.push(i);
|
|
64
|
+
if (chunk.language) {
|
|
65
|
+
if (!this.languageMapping.has(chunk.language)) {
|
|
66
|
+
this.languageMapping.set(chunk.language, []);
|
|
67
|
+
}
|
|
68
|
+
this.languageMapping.get(chunk.language)?.push(i);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static async fromGit(
|
|
74
|
+
url: string,
|
|
75
|
+
content: ContentType[] = ["code"],
|
|
76
|
+
embeddingModel?: string,
|
|
77
|
+
ref?: string | null,
|
|
78
|
+
): Promise<MiruIndex> {
|
|
79
|
+
const cacheKey = computeSourceCacheKey(url, ref);
|
|
80
|
+
const model = embeddingModel ?? resolveEmbeddingModel();
|
|
81
|
+
const cached = await getValidatedCache(cacheKey, model, content);
|
|
82
|
+
if (cached) {
|
|
83
|
+
return MiruIndex.loadFromDisk(cached, model);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cloneDir = await cloneGitRepository(url, ref);
|
|
87
|
+
try {
|
|
88
|
+
const embeddings = getEmbeddingBackend(model);
|
|
89
|
+
const { bm25, semantic, chunks } = await createIndexFromPath(
|
|
90
|
+
cloneDir,
|
|
91
|
+
embeddings,
|
|
92
|
+
content,
|
|
93
|
+
cloneDir,
|
|
94
|
+
);
|
|
95
|
+
const index = new MiruIndex({
|
|
96
|
+
embeddings,
|
|
97
|
+
bm25Index: bm25,
|
|
98
|
+
semanticIndex: semantic,
|
|
99
|
+
chunks,
|
|
100
|
+
embeddingModel: model,
|
|
101
|
+
root: null,
|
|
102
|
+
content,
|
|
103
|
+
});
|
|
104
|
+
await index.save(findIndexCachePath(cacheKey));
|
|
105
|
+
return index;
|
|
106
|
+
} finally {
|
|
107
|
+
await rm(cloneDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static async fromSource(
|
|
112
|
+
source: string,
|
|
113
|
+
content: ContentType[] = ["code"],
|
|
114
|
+
embeddingModel?: string,
|
|
115
|
+
ref?: string | null,
|
|
116
|
+
): Promise<MiruIndex> {
|
|
117
|
+
if (isGitUrl(source)) {
|
|
118
|
+
return MiruIndex.fromGit(source, content, embeddingModel, ref);
|
|
119
|
+
}
|
|
120
|
+
return MiruIndex.fromPath(source, content, embeddingModel);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static async fromPath(
|
|
124
|
+
path: string,
|
|
125
|
+
content: ContentType[] = ["code"],
|
|
126
|
+
embeddingModel?: string,
|
|
127
|
+
): Promise<MiruIndex> {
|
|
128
|
+
const resolved = resolve(path);
|
|
129
|
+
try {
|
|
130
|
+
const st = await Bun.file(resolved).stat();
|
|
131
|
+
if (!st.isDirectory()) {
|
|
132
|
+
throw new Error(`Path is not a directory: ${path}`);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof Error && err.message.startsWith("Path is not a directory")) {
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const model = embeddingModel ?? resolveEmbeddingModel();
|
|
142
|
+
const cached = await getValidatedCache(resolved, model, content);
|
|
143
|
+
if (cached) {
|
|
144
|
+
return MiruIndex.loadFromDisk(cached, model);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const embeddings = getEmbeddingBackend(model);
|
|
148
|
+
const { bm25, semantic, chunks } = await createIndexFromPath(
|
|
149
|
+
resolved,
|
|
150
|
+
embeddings,
|
|
151
|
+
content,
|
|
152
|
+
resolved,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return new MiruIndex({
|
|
156
|
+
embeddings,
|
|
157
|
+
bm25Index: bm25,
|
|
158
|
+
semanticIndex: semantic,
|
|
159
|
+
chunks,
|
|
160
|
+
embeddingModel: model,
|
|
161
|
+
root: resolved,
|
|
162
|
+
content,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
|
|
167
|
+
const model = embeddingModel ?? resolveEmbeddingModel();
|
|
168
|
+
const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
|
|
169
|
+
const content = (metadata.content_type as ContentType[]) ?? ["code"];
|
|
170
|
+
const root = metadata.root_path ? String(metadata.root_path) : null;
|
|
171
|
+
|
|
172
|
+
return new MiruIndex({
|
|
173
|
+
embeddings: getEmbeddingBackend(model),
|
|
174
|
+
bm25Index: bm25,
|
|
175
|
+
semanticIndex: semantic,
|
|
176
|
+
chunks,
|
|
177
|
+
embeddingModel: model,
|
|
178
|
+
root,
|
|
179
|
+
content,
|
|
180
|
+
loadedFromDisk: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async save(path: string): Promise<void> {
|
|
185
|
+
const paths = persistencePaths(path);
|
|
186
|
+
await mkdir(paths.root, { recursive: true });
|
|
187
|
+
await saveIndexBundle({
|
|
188
|
+
paths,
|
|
189
|
+
bm25: this.bm25Index,
|
|
190
|
+
semantic: this.semanticIndex,
|
|
191
|
+
chunks: this.chunks.map(chunkToDict),
|
|
192
|
+
metadata: {
|
|
193
|
+
root_path: this.root,
|
|
194
|
+
time: Date.now() / 1000,
|
|
195
|
+
embedding_model: this.embeddingModel,
|
|
196
|
+
embedding_dimensions:
|
|
197
|
+
this.embeddings.dimensions || resolveEmbeddingDimensions(this.embeddingModel),
|
|
198
|
+
embedding_provider: "openai",
|
|
199
|
+
content_type: this.content,
|
|
200
|
+
file_paths: [...this.fileMapping.keys()].sort(),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async saveToDefaultCache(sourcePath: string): Promise<void> {
|
|
206
|
+
if (!this.loadedFromDisk) {
|
|
207
|
+
await this.save(findIndexCachePath(sourcePath));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private getSelector(filterLanguages?: string[], filterPaths?: string[]): number[] | undefined {
|
|
212
|
+
const selector: number[] = [];
|
|
213
|
+
for (const lang of filterLanguages ?? []) {
|
|
214
|
+
selector.push(...(this.languageMapping.get(lang) ?? []));
|
|
215
|
+
}
|
|
216
|
+
for (const fp of filterPaths ?? []) {
|
|
217
|
+
selector.push(...(this.fileMapping.get(fp) ?? []));
|
|
218
|
+
}
|
|
219
|
+
if (selector.length === 0) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
return [...new Set(selector)];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async search(options: {
|
|
226
|
+
query: string;
|
|
227
|
+
topK?: number;
|
|
228
|
+
alpha?: number | null;
|
|
229
|
+
filterLanguages?: string[];
|
|
230
|
+
filterPaths?: string[];
|
|
231
|
+
rerank?: boolean;
|
|
232
|
+
}): Promise<SearchResult[]> {
|
|
233
|
+
const { query, topK = 10, alpha, filterLanguages, filterPaths, rerank } = options;
|
|
234
|
+
|
|
235
|
+
if (!this.chunks.length || !query.trim()) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const resolvedRerank = rerank ?? this.content.includes("code");
|
|
240
|
+
|
|
241
|
+
return hybridSearch({
|
|
242
|
+
query,
|
|
243
|
+
embeddings: this.embeddings,
|
|
244
|
+
semanticIndex: this.semanticIndex,
|
|
245
|
+
bm25Index: this.bm25Index,
|
|
246
|
+
chunks: this.chunks,
|
|
247
|
+
topK,
|
|
248
|
+
alpha,
|
|
249
|
+
selector: this.getSelector(filterLanguages, filterPaths),
|
|
250
|
+
rerank: resolvedRerank,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async findRelated(source: Chunk | SearchResult, topK = 5): Promise<SearchResult[]> {
|
|
255
|
+
const target = "chunk" in source ? source.chunk : source;
|
|
256
|
+
const selector = target.language ? this.getSelector([target.language]) : undefined;
|
|
257
|
+
|
|
258
|
+
const results = await searchSemanticOnly({
|
|
259
|
+
query: target.content,
|
|
260
|
+
embeddings: this.embeddings,
|
|
261
|
+
semanticIndex: this.semanticIndex,
|
|
262
|
+
chunks: this.chunks,
|
|
263
|
+
topK: topK + 1,
|
|
264
|
+
selector,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return results.filter((r) => chunkKey(r.chunk) !== chunkKey(target)).slice(0, topK);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { basename, dirname } from "node:path";
|
|
2
|
+
import { splitIdentifier } from "../tokens.ts";
|
|
3
|
+
import type { Chunk } from "../types.ts";
|
|
4
|
+
import { chunkKey } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
const SYMBOL_QUERY_RE =
|
|
7
|
+
/^(?:(?:[A-Za-z_][A-Za-z0-9_]*(?:(?:::|\\|->|\.)[A-Za-z_][A-Za-z0-9_]*)+)|_(?:[A-Za-z0-9_]*)|(?:[A-Za-z][A-Za-z0-9]*[A-Z_][A-Za-z0-9_]*)|(?:[A-Z][A-Za-z0-9]*))$/;
|
|
8
|
+
|
|
9
|
+
const EMBEDDED_SYMBOL_RE =
|
|
10
|
+
/\b(?:[A-Z][a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*|[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+)\b/g;
|
|
11
|
+
|
|
12
|
+
const EMBEDDED_STEM_MIN_LEN = 4;
|
|
13
|
+
const EMBEDDED_SYMBOL_BOOST_SCALE = 0.5;
|
|
14
|
+
|
|
15
|
+
const DEFINITION_KEYWORDS = [
|
|
16
|
+
"class",
|
|
17
|
+
"module",
|
|
18
|
+
"defmodule",
|
|
19
|
+
"def",
|
|
20
|
+
"interface",
|
|
21
|
+
"struct",
|
|
22
|
+
"enum",
|
|
23
|
+
"trait",
|
|
24
|
+
"type",
|
|
25
|
+
"func",
|
|
26
|
+
"function",
|
|
27
|
+
"object",
|
|
28
|
+
"abstract class",
|
|
29
|
+
"data class",
|
|
30
|
+
"fn",
|
|
31
|
+
"fun",
|
|
32
|
+
"package",
|
|
33
|
+
"namespace",
|
|
34
|
+
"protocol",
|
|
35
|
+
"record",
|
|
36
|
+
"typedef",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SQL_DEFINITION_KEYWORDS = [
|
|
40
|
+
"CREATE TABLE",
|
|
41
|
+
"CREATE VIEW",
|
|
42
|
+
"CREATE PROCEDURE",
|
|
43
|
+
"CREATE FUNCTION",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const DEFINITION_BOOST_MULTIPLIER = 3.0;
|
|
47
|
+
const STEM_BOOST_MULTIPLIER = 1.0;
|
|
48
|
+
const FILE_COHERENCE_BOOST_FRAC = 0.2;
|
|
49
|
+
|
|
50
|
+
const STOPWORDS = new Set(
|
|
51
|
+
"a an and are as at be by do does for from has have how if in is it not of on or the to was what when where which who why with".split(
|
|
52
|
+
" ",
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const definitionPatternCache = new Map<string, [RegExp, RegExp]>();
|
|
57
|
+
|
|
58
|
+
function definitionPatterns(symbolName: string): [RegExp, RegExp] {
|
|
59
|
+
let cached = definitionPatternCache.get(symbolName);
|
|
60
|
+
if (cached) {
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
const nsPrefix = "(?:[A-Za-z_][A-Za-z0-9_]*(?:\\.|::))*";
|
|
65
|
+
const suffix = `)\\s+${nsPrefix}${escaped}(?:\\s|[<({:\\[;]|$)`;
|
|
66
|
+
const keywordBody = DEFINITION_KEYWORDS.map((k) => k.replace(/ /g, "\\s+")).join("|");
|
|
67
|
+
const sqlBody = SQL_DEFINITION_KEYWORDS.join("|");
|
|
68
|
+
const keywordPrefix = "(?:^|(?<=\\s))(?:";
|
|
69
|
+
cached = [
|
|
70
|
+
new RegExp(keywordPrefix + keywordBody + suffix, "m"),
|
|
71
|
+
new RegExp(keywordPrefix + sqlBody + suffix, "im"),
|
|
72
|
+
];
|
|
73
|
+
definitionPatternCache.set(symbolName, cached);
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function chunkDefinesSymbol(chunk: Chunk, symbolName: string): boolean {
|
|
78
|
+
const [general, sql] = definitionPatterns(symbolName);
|
|
79
|
+
return general.test(chunk.content) || sql.test(chunk.content);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stemMatches(stem: string, name: string): boolean {
|
|
83
|
+
const stemNorm = stem.replace(/_/g, "");
|
|
84
|
+
return (
|
|
85
|
+
stem === name ||
|
|
86
|
+
stemNorm === name ||
|
|
87
|
+
stem.replace(/s$/, "") === name ||
|
|
88
|
+
stemNorm.replace(/s$/, "") === name
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isSymbolQuery(query: string): boolean {
|
|
93
|
+
return SYMBOL_QUERY_RE.test(query.trim());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractSymbolName(query: string): string {
|
|
97
|
+
for (const sep of ["::", "\\", "->", "."]) {
|
|
98
|
+
if (query.includes(sep)) {
|
|
99
|
+
return query.split(sep).pop()?.trim() ?? query.trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return query.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function definitionTier(chunk: Chunk, names: Set<string>, boostUnit: number): number {
|
|
106
|
+
if (![...names].some((n) => chunkDefinesSymbol(chunk, n))) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const stem = basename(chunk.file_path)
|
|
110
|
+
.replace(/\.[^.]+$/, "")
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
return boostUnit * ([...names].some((n) => stemMatches(stem, n.toLowerCase())) ? 1.5 : 1.0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function boostMultiChunkFiles(
|
|
116
|
+
scores: Map<string, number>,
|
|
117
|
+
chunksByKey: Map<string, Chunk>,
|
|
118
|
+
): void {
|
|
119
|
+
if (scores.size === 0) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const maxScore = Math.max(...scores.values());
|
|
123
|
+
if (maxScore === 0) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const fileSum = new Map<string, number>();
|
|
128
|
+
const bestChunk = new Map<string, string>();
|
|
129
|
+
|
|
130
|
+
for (const [key, score] of scores) {
|
|
131
|
+
const chunk = chunksByKey.get(key);
|
|
132
|
+
if (!chunk) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const fp = chunk.file_path;
|
|
136
|
+
fileSum.set(fp, (fileSum.get(fp) ?? 0) + score);
|
|
137
|
+
const best = bestChunk.get(fp);
|
|
138
|
+
if (!best || score > (scores.get(best) ?? 0)) {
|
|
139
|
+
bestChunk.set(fp, key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const maxFileSum = Math.max(...fileSum.values());
|
|
144
|
+
const boostUnit = maxScore * FILE_COHERENCE_BOOST_FRAC;
|
|
145
|
+
|
|
146
|
+
for (const [fp, key] of bestChunk) {
|
|
147
|
+
const current = scores.get(key) ?? 0;
|
|
148
|
+
scores.set(key, current + boostUnit * ((fileSum.get(fp) ?? 0) / maxFileSum));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function applyQueryBoost(
|
|
153
|
+
combinedScores: Map<string, number>,
|
|
154
|
+
query: string,
|
|
155
|
+
allChunks: Chunk[],
|
|
156
|
+
chunksByKey: Map<string, Chunk>,
|
|
157
|
+
): Map<string, number> {
|
|
158
|
+
if (combinedScores.size === 0) {
|
|
159
|
+
return combinedScores;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const maxScore = Math.max(...combinedScores.values());
|
|
163
|
+
if (isSymbolQuery(query)) {
|
|
164
|
+
boostSymbolDefinitions(combinedScores, query, maxScore, allChunks, chunksByKey);
|
|
165
|
+
} else {
|
|
166
|
+
boostStemMatches(combinedScores, query, maxScore, chunksByKey);
|
|
167
|
+
boostEmbeddedSymbols(combinedScores, query, maxScore, allChunks, chunksByKey);
|
|
168
|
+
}
|
|
169
|
+
return combinedScores;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function boostSymbolDefinitions(
|
|
173
|
+
boosted: Map<string, number>,
|
|
174
|
+
query: string,
|
|
175
|
+
maxScore: number,
|
|
176
|
+
allChunks: Chunk[],
|
|
177
|
+
chunksByKey: Map<string, Chunk>,
|
|
178
|
+
): void {
|
|
179
|
+
const symbolName = extractSymbolName(query);
|
|
180
|
+
const names = new Set([symbolName]);
|
|
181
|
+
if (symbolName !== query.trim()) {
|
|
182
|
+
names.add(query.trim());
|
|
183
|
+
}
|
|
184
|
+
const boostUnit = maxScore * DEFINITION_BOOST_MULTIPLIER;
|
|
185
|
+
|
|
186
|
+
for (const key of [...boosted.keys()]) {
|
|
187
|
+
const chunk = chunksByKey.get(key);
|
|
188
|
+
if (!chunk) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const tier = definitionTier(chunk, names, boostUnit);
|
|
192
|
+
if (tier) {
|
|
193
|
+
boosted.set(key, (boosted.get(key) ?? 0) + tier);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const chunk of allChunks) {
|
|
198
|
+
const key = chunkKey(chunk);
|
|
199
|
+
if (boosted.has(key)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const stem = basename(chunk.file_path)
|
|
203
|
+
.replace(/\.[^.]+$/, "")
|
|
204
|
+
.toLowerCase();
|
|
205
|
+
if (![...names].some((n) => stemMatches(stem, n.toLowerCase()))) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const tier = definitionTier(chunk, names, boostUnit);
|
|
209
|
+
if (tier) {
|
|
210
|
+
boosted.set(key, tier);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function boostEmbeddedSymbols(
|
|
216
|
+
boosted: Map<string, number>,
|
|
217
|
+
query: string,
|
|
218
|
+
maxScore: number,
|
|
219
|
+
allChunks: Chunk[],
|
|
220
|
+
chunksByKey: Map<string, Chunk>,
|
|
221
|
+
): void {
|
|
222
|
+
const names = new Set(query.match(EMBEDDED_SYMBOL_RE) ?? []);
|
|
223
|
+
if (names.size === 0) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const boostUnit = maxScore * DEFINITION_BOOST_MULTIPLIER * EMBEDDED_SYMBOL_BOOST_SCALE;
|
|
228
|
+
const symbolsLower = new Set([...names].map((s) => s.toLowerCase()));
|
|
229
|
+
|
|
230
|
+
for (const key of [...boosted.keys()]) {
|
|
231
|
+
const chunk = chunksByKey.get(key);
|
|
232
|
+
if (!chunk) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const tier = definitionTier(chunk, names, boostUnit);
|
|
236
|
+
if (tier) {
|
|
237
|
+
boosted.set(key, (boosted.get(key) ?? 0) + tier);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const chunk of allChunks) {
|
|
242
|
+
const key = chunkKey(chunk);
|
|
243
|
+
if (boosted.has(key)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const stem = basename(chunk.file_path)
|
|
247
|
+
.replace(/\.[^.]+$/, "")
|
|
248
|
+
.toLowerCase();
|
|
249
|
+
const stemNorm = stem.replace(/_/g, "");
|
|
250
|
+
const ok = [...symbolsLower].some(
|
|
251
|
+
(symbolLower) =>
|
|
252
|
+
stem === symbolLower ||
|
|
253
|
+
stemNorm === symbolLower ||
|
|
254
|
+
(stem.length >= EMBEDDED_STEM_MIN_LEN && symbolLower.startsWith(stem)) ||
|
|
255
|
+
(stemNorm.length >= EMBEDDED_STEM_MIN_LEN && symbolLower.startsWith(stemNorm)),
|
|
256
|
+
);
|
|
257
|
+
if (!ok) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const tier = definitionTier(chunk, names, boostUnit);
|
|
261
|
+
if (tier) {
|
|
262
|
+
boosted.set(key, tier);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function countKeywordMatches(keywords: Set<string>, parts: Set<string>): number {
|
|
268
|
+
const exact = [...keywords].filter((k) => parts.has(k));
|
|
269
|
+
if (exact.length === keywords.size) {
|
|
270
|
+
return exact.length;
|
|
271
|
+
}
|
|
272
|
+
let nMatches = exact.length;
|
|
273
|
+
for (const keyword of [...keywords].filter((k) => !parts.has(k))) {
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
const shorter = keyword.length <= part.length ? keyword : part;
|
|
276
|
+
const longer = keyword.length <= part.length ? part : keyword;
|
|
277
|
+
if (shorter.length >= 3 && longer.startsWith(shorter)) {
|
|
278
|
+
nMatches++;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return nMatches;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function boostStemMatches(
|
|
287
|
+
boosted: Map<string, number>,
|
|
288
|
+
query: string,
|
|
289
|
+
maxScore: number,
|
|
290
|
+
chunksByKey: Map<string, Chunk>,
|
|
291
|
+
): void {
|
|
292
|
+
const keywords = new Set(
|
|
293
|
+
(query.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) ?? [])
|
|
294
|
+
.filter((w) => w.length > 2 && !STOPWORDS.has(w.toLowerCase()))
|
|
295
|
+
.map((w) => w.toLowerCase()),
|
|
296
|
+
);
|
|
297
|
+
if (keywords.size === 0) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const boost = maxScore * STEM_BOOST_MULTIPLIER;
|
|
302
|
+
const pathCache = new Map<string, Set<string>>();
|
|
303
|
+
|
|
304
|
+
for (const key of [...boosted.keys()]) {
|
|
305
|
+
const chunk = chunksByKey.get(key);
|
|
306
|
+
if (!chunk) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
let parts = pathCache.get(chunk.file_path);
|
|
310
|
+
if (!parts) {
|
|
311
|
+
const stem = basename(chunk.file_path).replace(/\.[^.]+$/, "");
|
|
312
|
+
parts = new Set(splitIdentifier(stem));
|
|
313
|
+
const parent = basename(dirname(chunk.file_path));
|
|
314
|
+
if (parent && parent !== "." && parent !== "..") {
|
|
315
|
+
for (const p of splitIdentifier(parent)) {
|
|
316
|
+
parts.add(p);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
pathCache.set(chunk.file_path, parts);
|
|
320
|
+
}
|
|
321
|
+
const nMatches = countKeywordMatches(keywords, parts);
|
|
322
|
+
if (nMatches > 0) {
|
|
323
|
+
const matchRatio = nMatches / keywords.size;
|
|
324
|
+
if (matchRatio >= 0.1) {
|
|
325
|
+
boosted.set(key, (boosted.get(key) ?? 0) + boost * matchRatio);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|