@takara-ai/miru-code 1.1.3 → 1.2.1
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/README.md +178 -107
- package/package.json +9 -5
- package/src/benchmark/compare.ts +278 -0
- package/src/benchmark/grep.ts +352 -0
- package/src/benchmark/history.ts +206 -0
- package/src/benchmark/locate-compare.ts +65 -0
- package/src/benchmark/rg-literal.ts +143 -0
- package/src/benchmark/summary.ts +29 -0
- package/src/benchmark/types.ts +65 -0
- package/src/cli.ts +183 -4
- package/src/credentials.ts +8 -0
- package/src/help.ts +45 -2
- package/src/index/create.ts +2 -2
- package/src/index/incremental.ts +4 -3
- package/src/index.ts +3 -1
- package/src/installer/benchmark-mode.ts +248 -0
- package/src/installer/config.ts +14 -2
- package/src/installer/hooks/opencode-plugin.ts +6 -20
- package/src/installer/hooks/search-guard.ts +4 -5
- package/src/installer/installer.ts +58 -5
- package/src/installer/search-policy.ts +28 -9
- package/src/literal.ts +139 -0
- package/src/mcp/index-cache.ts +2 -2
- package/src/mcp/serve.ts +4 -3
- package/src/mcp/server.ts +166 -9
- package/src/miru-index.ts +15 -6
- package/src/snippet.ts +6 -3
- package/src/token-count.ts +62 -0
- package/src/tokenizer/bert-wordpiece.ts +219 -0
- package/src/tokenizer/load.ts +14 -0
- package/src/types.ts +8 -0
- package/src/utils.ts +8 -1
- package/tokenizer/tokenizer.json +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { relativePathFromRoot } from "../index/incremental.ts";
|
|
4
|
+
import type { MiruIndex } from "../miru-index.ts";
|
|
5
|
+
import { applySnippetsToResults, estimateResultTokens } from "../snippet.ts";
|
|
6
|
+
import { countTokens, tokenCountMethod, tokenizerJsonPath } from "../token-count.ts";
|
|
7
|
+
import type { SearchResult } from "../types.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_EXPAND_AFTER,
|
|
10
|
+
DEFAULT_EXPAND_BEFORE,
|
|
11
|
+
dedupeResultsByFile,
|
|
12
|
+
expandChunksAtLine,
|
|
13
|
+
} from "../utils.ts";
|
|
14
|
+
import { type GrepFileHit, grepSearch } from "./grep.ts";
|
|
15
|
+
import { agentBenchmarkFromTokens, attachAgentBenchmark, tokenSavingsPct } from "./summary.ts";
|
|
16
|
+
import type { AgentBenchmarkSummary, SearchBenchmarkBlock } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** Repo-relative `/`-separated path for all benchmark file identity. */
|
|
19
|
+
function canonicalRepoPath(repoPath: string, filePath: string): string {
|
|
20
|
+
// Normalize separators first — Node's resolve treats `\` as a literal on POSIX.
|
|
21
|
+
return relativePathFromRoot(repoPath, filePath.replace(/\\/g, "/"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonicalRepoPaths(repoPath: string, files: string[]): string[] {
|
|
25
|
+
return files.map((file) => canonicalRepoPath(repoPath, file));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstGrepMatchLine(hit: GrepFileHit | undefined): number | null {
|
|
29
|
+
if (!hit?.output) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
for (const line of hit.output.split("\n")) {
|
|
33
|
+
if (!line.trim() || line === "--") {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const withPath = line.match(/:(\d+)[:-]/);
|
|
37
|
+
if (withPath?.[1]) {
|
|
38
|
+
return Number(withPath[1]);
|
|
39
|
+
}
|
|
40
|
+
const bare = line.match(/^(\d+)[:-]/);
|
|
41
|
+
if (bare?.[1]) {
|
|
42
|
+
return Number(bare[1]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function expandLineSpan(chunks: { start_line: number; end_line: number }[]): number {
|
|
49
|
+
if (chunks.length === 0) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const start = Math.min(...chunks.map((c) => c.start_line));
|
|
53
|
+
const end = Math.max(...chunks.map((c) => c.end_line));
|
|
54
|
+
return end - start + 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readFileTokens(absPath: string): Promise<number> {
|
|
58
|
+
try {
|
|
59
|
+
const text = await readFile(absPath, "utf-8");
|
|
60
|
+
return countTokens(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readLineWindowTokens(
|
|
67
|
+
absPath: string,
|
|
68
|
+
centerLine: number,
|
|
69
|
+
lineSpan: number,
|
|
70
|
+
): Promise<number> {
|
|
71
|
+
try {
|
|
72
|
+
const text = await readFile(absPath, "utf-8");
|
|
73
|
+
const lines = text.split("\n");
|
|
74
|
+
if (lines.length === 0) {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const half = Math.max(1, Math.floor(lineSpan / 2));
|
|
78
|
+
const start = Math.max(1, centerLine - half);
|
|
79
|
+
const end = Math.min(lines.length, centerLine + half);
|
|
80
|
+
return countTokens(lines.slice(start - 1, end).join("\n"));
|
|
81
|
+
} catch {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function miruExpandTokens(
|
|
87
|
+
index: MiruIndex,
|
|
88
|
+
repoPath: string,
|
|
89
|
+
top: SearchResult | undefined,
|
|
90
|
+
query: string,
|
|
91
|
+
): { tokens: number; lineSpan: number } {
|
|
92
|
+
if (!top) {
|
|
93
|
+
return { tokens: 0, lineSpan: 0 };
|
|
94
|
+
}
|
|
95
|
+
const [{ meta }] = applySnippetsToResults([top], query);
|
|
96
|
+
const line = meta.truncated ? meta.anchor_line : top.chunk.start_line;
|
|
97
|
+
const { chunks } = expandChunksAtLine(
|
|
98
|
+
index.chunks,
|
|
99
|
+
top.chunk.file_path,
|
|
100
|
+
line,
|
|
101
|
+
repoPath,
|
|
102
|
+
DEFAULT_EXPAND_BEFORE,
|
|
103
|
+
DEFAULT_EXPAND_AFTER,
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
tokens: chunks.reduce((sum, chunk) => sum + countTokens(chunk.content), 0),
|
|
107
|
+
lineSpan: expandLineSpan(chunks),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function topKOverlapPct(miruFiles: string[], grepFiles: string[]): number {
|
|
112
|
+
if (miruFiles.length === 0 && grepFiles.length === 0) {
|
|
113
|
+
return 100;
|
|
114
|
+
}
|
|
115
|
+
const union = new Set([...miruFiles, ...grepFiles]);
|
|
116
|
+
if (union.size === 0) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const overlap = miruFiles.filter((file) => grepFiles.includes(file)).length;
|
|
120
|
+
return Math.round((overlap / union.size) * 100);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function uniqueFiles(files: string[], other: string[]): string[] {
|
|
124
|
+
return files.filter((file) => !other.includes(file));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function grepReadEstimates(
|
|
128
|
+
repoPath: string,
|
|
129
|
+
grep: Awaited<ReturnType<typeof grepSearch>>,
|
|
130
|
+
matchedLineSpan: number,
|
|
131
|
+
): Promise<{ readFull: number; readWindow: number; latencyMs: number }> {
|
|
132
|
+
const start = performance.now();
|
|
133
|
+
const topGrepHit = grep.hits[0];
|
|
134
|
+
const grepTop = topGrepHit?.file ?? null;
|
|
135
|
+
const grepAbsPath = grepTop ? join(repoPath, grepTop) : null;
|
|
136
|
+
const grepMatchLine = firstGrepMatchLine(topGrepHit);
|
|
137
|
+
const readFull = grepAbsPath ? await readFileTokens(grepAbsPath) : 0;
|
|
138
|
+
const span = matchedLineSpan > 0 ? matchedLineSpan : 60;
|
|
139
|
+
const readWindow =
|
|
140
|
+
grepAbsPath && grepMatchLine != null
|
|
141
|
+
? await readLineWindowTokens(grepAbsPath, grepMatchLine, span)
|
|
142
|
+
: 0;
|
|
143
|
+
return {
|
|
144
|
+
readFull,
|
|
145
|
+
readWindow,
|
|
146
|
+
latencyMs: performance.now() - start,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function benchmarkSearchComparison(options: {
|
|
151
|
+
query: string;
|
|
152
|
+
repoPath: string;
|
|
153
|
+
index: MiruIndex;
|
|
154
|
+
topK: number;
|
|
155
|
+
/** Keep only the best hit per file (default true). Matches MCP `dedupe_by_file`. */
|
|
156
|
+
dedupeByFile?: boolean;
|
|
157
|
+
relevant?: string[];
|
|
158
|
+
}): Promise<{ benchmark: SearchBenchmarkBlock; results: SearchResult[] }> {
|
|
159
|
+
const { query, repoPath, index, topK, relevant } = options;
|
|
160
|
+
const dedupeByFile = options.dedupeByFile !== false;
|
|
161
|
+
const parallelStart = performance.now();
|
|
162
|
+
|
|
163
|
+
const miruPromise = (async () => {
|
|
164
|
+
const started = performance.now();
|
|
165
|
+
const raw = await index.search({ query, topK, rerank: true });
|
|
166
|
+
const results = (dedupeByFile ? dedupeResultsByFile(raw) : raw).slice(0, topK);
|
|
167
|
+
const snippetResults = applySnippetsToResults(results, query).map((entry) => entry.result);
|
|
168
|
+
const searchTokens = estimateResultTokens(snippetResults);
|
|
169
|
+
const expand = miruExpandTokens(index, repoPath, results[0], query);
|
|
170
|
+
return {
|
|
171
|
+
results,
|
|
172
|
+
searchTokens,
|
|
173
|
+
workflowTokens: searchTokens + expand.tokens,
|
|
174
|
+
lineSpan: expand.lineSpan,
|
|
175
|
+
latencyMs: performance.now() - started,
|
|
176
|
+
};
|
|
177
|
+
})();
|
|
178
|
+
|
|
179
|
+
const grepSearchPromise = (async () => {
|
|
180
|
+
const started = performance.now();
|
|
181
|
+
const grep = await grepSearch(repoPath, query, topK);
|
|
182
|
+
return { grep, latencyMs: performance.now() - started };
|
|
183
|
+
})();
|
|
184
|
+
|
|
185
|
+
const [miruOutcome, grepSearchOutcome] = await Promise.all([miruPromise, grepSearchPromise]);
|
|
186
|
+
const grepReads = await grepReadEstimates(repoPath, grepSearchOutcome.grep, miruOutcome.lineSpan);
|
|
187
|
+
|
|
188
|
+
const parallelTotalMs = performance.now() - parallelStart;
|
|
189
|
+
const grepOutcome = {
|
|
190
|
+
grep: grepSearchOutcome.grep,
|
|
191
|
+
readFull: grepReads.readFull,
|
|
192
|
+
readWindow: grepReads.readWindow,
|
|
193
|
+
latencyMs: grepSearchOutcome.latencyMs + grepReads.latencyMs,
|
|
194
|
+
};
|
|
195
|
+
const miruFiles = canonicalRepoPaths(
|
|
196
|
+
repoPath,
|
|
197
|
+
miruOutcome.results.map((result) => result.chunk.file_path),
|
|
198
|
+
);
|
|
199
|
+
const grepFiles = canonicalRepoPaths(repoPath, grepOutcome.grep.files);
|
|
200
|
+
const miruTop = miruFiles[0] ?? null;
|
|
201
|
+
const grepTop = grepFiles[0] ?? null;
|
|
202
|
+
const workflowFull = grepOutcome.grep.tokens + grepOutcome.readFull;
|
|
203
|
+
const workflowWindow = grepOutcome.grep.tokens + grepOutcome.readWindow;
|
|
204
|
+
const tokenSavings = tokenSavingsPct(miruOutcome.workflowTokens, workflowFull);
|
|
205
|
+
|
|
206
|
+
const block: SearchBenchmarkBlock = {
|
|
207
|
+
mode: true,
|
|
208
|
+
token_count_method: tokenCountMethod(),
|
|
209
|
+
tokenizer_json: tokenizerJsonPath(),
|
|
210
|
+
miru: {
|
|
211
|
+
search_tokens: miruOutcome.searchTokens,
|
|
212
|
+
workflow_tokens: miruOutcome.workflowTokens,
|
|
213
|
+
latency_ms: Math.round(miruOutcome.latencyMs),
|
|
214
|
+
top_file: miruTop,
|
|
215
|
+
top_files: miruFiles,
|
|
216
|
+
},
|
|
217
|
+
grep_read: {
|
|
218
|
+
search_tokens: grepOutcome.grep.tokens,
|
|
219
|
+
read_full_tokens: grepOutcome.readFull,
|
|
220
|
+
read_window_tokens: grepOutcome.readWindow,
|
|
221
|
+
workflow_full_tokens: workflowFull,
|
|
222
|
+
workflow_window_tokens: workflowWindow,
|
|
223
|
+
latency_ms: Math.round(grepOutcome.latencyMs),
|
|
224
|
+
top_file: grepTop,
|
|
225
|
+
top_files: grepFiles,
|
|
226
|
+
pattern: grepOutcome.grep.pattern,
|
|
227
|
+
keywords: grepOutcome.grep.keywords,
|
|
228
|
+
},
|
|
229
|
+
efficiency: {
|
|
230
|
+
token_savings_pct: tokenSavings,
|
|
231
|
+
baseline: "grep_search_plus_read_full",
|
|
232
|
+
},
|
|
233
|
+
accuracy: {
|
|
234
|
+
rank1_match: miruTop != null && miruTop === grepTop,
|
|
235
|
+
top_k_overlap_pct: topKOverlapPct(miruFiles, grepFiles),
|
|
236
|
+
miru_only: uniqueFiles(miruFiles, grepFiles),
|
|
237
|
+
grep_only: uniqueFiles(grepFiles, miruFiles),
|
|
238
|
+
},
|
|
239
|
+
overhead: {
|
|
240
|
+
parallel_total_ms: Math.round(parallelTotalMs),
|
|
241
|
+
miru_share_ms: Math.round(miruOutcome.latencyMs),
|
|
242
|
+
grep_share_ms: Math.round(grepOutcome.latencyMs),
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (relevant && relevant.length > 0) {
|
|
247
|
+
const relevantFiles = new Set(canonicalRepoPaths(repoPath, relevant));
|
|
248
|
+
block.accuracy.labeled_recall = {
|
|
249
|
+
miru: miruFiles.some((file) => relevantFiles.has(file)),
|
|
250
|
+
grep: grepFiles.some((file) => relevantFiles.has(file)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { benchmark: block, results: miruOutcome.results };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const MAX_AGENT_MIRU_ONLY = 3;
|
|
258
|
+
|
|
259
|
+
/** Strip internal/debug fields down to the compact MCP agent payload. */
|
|
260
|
+
export function toAgentBenchmarkSummary(block: SearchBenchmarkBlock): AgentBenchmarkSummary {
|
|
261
|
+
const summary = agentBenchmarkFromTokens(
|
|
262
|
+
block.miru.workflow_tokens,
|
|
263
|
+
block.grep_read.workflow_full_tokens,
|
|
264
|
+
block.accuracy.rank1_match,
|
|
265
|
+
);
|
|
266
|
+
summary.save_pct = block.efficiency.token_savings_pct;
|
|
267
|
+
if (block.accuracy.miru_only.length > 0) {
|
|
268
|
+
summary.miru_only = block.accuracy.miru_only.slice(0, MAX_AGENT_MIRU_ONLY);
|
|
269
|
+
}
|
|
270
|
+
return summary;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function attachSearchBenchmark<T extends Record<string, unknown>>(
|
|
274
|
+
payload: T,
|
|
275
|
+
benchmark: SearchBenchmarkBlock,
|
|
276
|
+
): T & { benchmark: AgentBenchmarkSummary } {
|
|
277
|
+
return attachAgentBenchmark(payload, toAgentBenchmarkSummary(benchmark));
|
|
278
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/** Native grep baseline for Miru benchmark comparisons. */
|
|
2
|
+
|
|
3
|
+
import { relative } from "node:path";
|
|
4
|
+
import { countTokens } from "../token-count.ts";
|
|
5
|
+
|
|
6
|
+
const STOPWORDS = new Set(
|
|
7
|
+
"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(
|
|
8
|
+
" ",
|
|
9
|
+
),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const GREP_LINES_PER_FILE = 3;
|
|
13
|
+
export const GREP_CONTEXT = 2;
|
|
14
|
+
|
|
15
|
+
/** Shared `-g` excludes for ripgrep baselines (search + locate). */
|
|
16
|
+
export const RG_EXCLUDE_ARGS = [
|
|
17
|
+
"-g",
|
|
18
|
+
"!node_modules",
|
|
19
|
+
"-g",
|
|
20
|
+
"!.git",
|
|
21
|
+
"-g",
|
|
22
|
+
"!tokenizer",
|
|
23
|
+
"-g",
|
|
24
|
+
"!tokenizer/**",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export interface GrepFileHit {
|
|
28
|
+
file: string;
|
|
29
|
+
matchCount: number;
|
|
30
|
+
output: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GrepSearchResult {
|
|
34
|
+
files: string[];
|
|
35
|
+
hits: GrepFileHit[];
|
|
36
|
+
tokens: number;
|
|
37
|
+
pattern: string | null;
|
|
38
|
+
keywords: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type BenchmarkSearchTool = "rg" | "grep" | "findstr";
|
|
42
|
+
|
|
43
|
+
export function selectBenchmarkSearchTool(options?: {
|
|
44
|
+
platform?: NodeJS.Platform;
|
|
45
|
+
hasRg?: boolean;
|
|
46
|
+
hasGrep?: boolean;
|
|
47
|
+
hasFindstr?: boolean;
|
|
48
|
+
}): BenchmarkSearchTool | null {
|
|
49
|
+
const platform = options?.platform ?? process.platform;
|
|
50
|
+
const hasRg = options?.hasRg ?? Bun.which("rg") != null;
|
|
51
|
+
const hasGrep = options?.hasGrep ?? Bun.which("grep") != null;
|
|
52
|
+
const hasFindstr = options?.hasFindstr ?? Bun.which("findstr") != null;
|
|
53
|
+
if (hasRg) {
|
|
54
|
+
return "rg";
|
|
55
|
+
}
|
|
56
|
+
if (hasGrep) {
|
|
57
|
+
return "grep";
|
|
58
|
+
}
|
|
59
|
+
if (platform === "win32" && hasFindstr) {
|
|
60
|
+
return "findstr";
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function queryKeywords(query: string): string[] {
|
|
65
|
+
const words = (query.match(/[a-zA-Z_][a-zA-Z0-9_-]*/g) ?? [])
|
|
66
|
+
.map((w) => w.toLowerCase())
|
|
67
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
68
|
+
|
|
69
|
+
const unique = [...new Set(words)];
|
|
70
|
+
unique.sort((a, b) => b.length - a.length);
|
|
71
|
+
return unique.slice(0, 6);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escapeRegex(s: string): string {
|
|
75
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildGrepPattern(keywords: string[]): string | null {
|
|
79
|
+
if (keywords.length === 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return keywords.map(escapeRegex).join("|");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeRepoFile(repoRoot: string, filePath: string): string {
|
|
86
|
+
const rootNorm = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
87
|
+
const fileNorm = filePath.replace(/\\/g, "/");
|
|
88
|
+
const rootPrefix = `${rootNorm}/`;
|
|
89
|
+
if (fileNorm === rootNorm) {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
if (fileNorm.toLowerCase().startsWith(rootPrefix.toLowerCase())) {
|
|
93
|
+
return fileNorm.slice(rootPrefix.length);
|
|
94
|
+
}
|
|
95
|
+
const rel = relative(repoRoot, filePath).replace(/\\/g, "/");
|
|
96
|
+
if (rel.startsWith("../") || /^[A-Za-z]:\//.test(rel)) {
|
|
97
|
+
return fileNorm;
|
|
98
|
+
}
|
|
99
|
+
return rel;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parsePathLinePrefix(line: string): { path: string; line: number } | null {
|
|
103
|
+
for (let i = 0; i < line.length; i++) {
|
|
104
|
+
const delim = line[i];
|
|
105
|
+
if (delim !== ":" && delim !== "-") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let j = i + 1;
|
|
109
|
+
while (j < line.length && line[j] >= "0" && line[j] <= "9") {
|
|
110
|
+
j++;
|
|
111
|
+
}
|
|
112
|
+
if (j === i + 1 || j >= line.length) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const trailing = line[j];
|
|
116
|
+
if (trailing !== ":" && trailing !== "-") {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const lineNo = Number(line.slice(i + 1, j));
|
|
120
|
+
if (!Number.isFinite(lineNo)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return { path: line.slice(0, i), line: lineNo };
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function grepSearch(
|
|
129
|
+
repoRoot: string,
|
|
130
|
+
query: string,
|
|
131
|
+
topK: number,
|
|
132
|
+
): Promise<GrepSearchResult> {
|
|
133
|
+
const keywords = queryKeywords(query);
|
|
134
|
+
const pattern = buildGrepPattern(keywords);
|
|
135
|
+
if (!pattern) {
|
|
136
|
+
return { files: [], hits: [], tokens: 0, pattern: null, keywords };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tool = selectBenchmarkSearchTool();
|
|
140
|
+
if (!tool) {
|
|
141
|
+
throw new Error("No search tool found in PATH (tried rg, grep, and findstr on Windows)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ranked =
|
|
145
|
+
tool === "rg"
|
|
146
|
+
? await rgRankedMatches(repoRoot, pattern, topK)
|
|
147
|
+
: tool === "grep"
|
|
148
|
+
? await grepRankedMatches(repoRoot, pattern, topK)
|
|
149
|
+
: await findstrRankedMatches(repoRoot, keywords, topK);
|
|
150
|
+
|
|
151
|
+
const hits: GrepFileHit[] = [];
|
|
152
|
+
let tokens = 0;
|
|
153
|
+
|
|
154
|
+
for (const row of ranked) {
|
|
155
|
+
const output =
|
|
156
|
+
tool === "rg"
|
|
157
|
+
? await rgFilePreview(row.absPath, pattern)
|
|
158
|
+
: tool === "grep"
|
|
159
|
+
? await grepFilePreview(row.absPath, pattern)
|
|
160
|
+
: await findstrFilePreview(row.absPath, keywords);
|
|
161
|
+
hits.push({ file: row.file, matchCount: row.matchCount, output });
|
|
162
|
+
tokens += countTokens(output);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
files: ranked.map((r) => r.file),
|
|
167
|
+
hits,
|
|
168
|
+
tokens,
|
|
169
|
+
pattern,
|
|
170
|
+
keywords,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function rgRankedMatches(repoRoot: string, pattern: string, topK: number) {
|
|
175
|
+
const countProc = Bun.spawn(
|
|
176
|
+
["rg", "-i", "--count-matches", pattern, repoRoot, ...RG_EXCLUDE_ARGS],
|
|
177
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
178
|
+
);
|
|
179
|
+
const countText = await new Response(countProc.stdout).text();
|
|
180
|
+
await countProc.exited;
|
|
181
|
+
return parseCountMatches(repoRoot, countText, topK);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function grepRankedMatches(repoRoot: string, pattern: string, topK: number) {
|
|
185
|
+
const countProc = Bun.spawn(
|
|
186
|
+
[
|
|
187
|
+
"grep",
|
|
188
|
+
"-R",
|
|
189
|
+
"-I",
|
|
190
|
+
"-i",
|
|
191
|
+
"-E",
|
|
192
|
+
"-o",
|
|
193
|
+
"--exclude-dir=node_modules",
|
|
194
|
+
"--exclude-dir=.git",
|
|
195
|
+
"--exclude-dir=tokenizer",
|
|
196
|
+
pattern,
|
|
197
|
+
repoRoot,
|
|
198
|
+
],
|
|
199
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
200
|
+
);
|
|
201
|
+
const countText = await new Response(countProc.stdout).text();
|
|
202
|
+
await countProc.exited;
|
|
203
|
+
const counts = new Map<string, number>();
|
|
204
|
+
for (const line of countText.split("\n")) {
|
|
205
|
+
if (!line) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const split = splitSearchToolLine(line);
|
|
209
|
+
if (!split) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const absPath = split.path;
|
|
213
|
+
counts.set(absPath, (counts.get(absPath) ?? 0) + 1);
|
|
214
|
+
}
|
|
215
|
+
return [...counts.entries()]
|
|
216
|
+
.map(([absPath, matchCount]) => ({
|
|
217
|
+
file: normalizeRepoFile(repoRoot, absPath),
|
|
218
|
+
absPath,
|
|
219
|
+
matchCount,
|
|
220
|
+
}))
|
|
221
|
+
.sort((a, b) => b.matchCount - a.matchCount)
|
|
222
|
+
.slice(0, topK);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function splitSearchToolLine(line: string): { path: string; rest: string } | null {
|
|
226
|
+
if (/^[A-Za-z]:/.test(line)) {
|
|
227
|
+
const colon = line.indexOf(":", 2);
|
|
228
|
+
if (colon < 0) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return { path: line.slice(0, colon), rest: line.slice(colon + 1) };
|
|
232
|
+
}
|
|
233
|
+
const colon = line.indexOf(":");
|
|
234
|
+
if (colon < 0) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return { path: line.slice(0, colon), rest: line.slice(colon + 1) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function findstrRankedMatches(repoRoot: string, keywords: string[], topK: number) {
|
|
241
|
+
const counts = new Map<string, number>();
|
|
242
|
+
for (const keyword of keywords) {
|
|
243
|
+
const proc = Bun.spawn(["findstr", "/S", "/N", "/I", "/P", `/C:${keyword}`, "*"], {
|
|
244
|
+
stdout: "pipe",
|
|
245
|
+
stderr: "pipe",
|
|
246
|
+
cwd: repoRoot,
|
|
247
|
+
});
|
|
248
|
+
const text = await new Response(proc.stdout).text();
|
|
249
|
+
await proc.exited;
|
|
250
|
+
for (const line of text.split("\n")) {
|
|
251
|
+
const parsed = parsePathLinePrefix(line);
|
|
252
|
+
if (!parsed?.path) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const pathText = parsed.path.replace(/\\/g, "/");
|
|
256
|
+
const absPath = /^[A-Za-z]:\//.test(pathText)
|
|
257
|
+
? pathText
|
|
258
|
+
: `${repoRoot}/${pathText}`.replace(/\\/g, "/");
|
|
259
|
+
if (
|
|
260
|
+
absPath.includes("/node_modules/") ||
|
|
261
|
+
absPath.includes("/.git/") ||
|
|
262
|
+
absPath.includes("/tokenizer/")
|
|
263
|
+
) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
counts.set(absPath, (counts.get(absPath) ?? 0) + 1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...counts.entries()]
|
|
270
|
+
.map(([absPath, matchCount]) => ({
|
|
271
|
+
file: normalizeRepoFile(repoRoot, absPath),
|
|
272
|
+
absPath,
|
|
273
|
+
matchCount,
|
|
274
|
+
}))
|
|
275
|
+
.sort((a, b) => b.matchCount - a.matchCount)
|
|
276
|
+
.slice(0, topK);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseCountMatches(repoRoot: string, countText: string, topK: number) {
|
|
280
|
+
return countText
|
|
281
|
+
.split("\n")
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.map((line) => {
|
|
284
|
+
const colon = line.lastIndexOf(":");
|
|
285
|
+
if (colon < 0) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const absPath = line.slice(0, colon);
|
|
289
|
+
const matchCount = Number(line.slice(colon + 1));
|
|
290
|
+
if (!Number.isFinite(matchCount)) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
file: normalizeRepoFile(repoRoot, absPath),
|
|
295
|
+
absPath,
|
|
296
|
+
matchCount,
|
|
297
|
+
};
|
|
298
|
+
})
|
|
299
|
+
.filter((x): x is NonNullable<typeof x> => x != null)
|
|
300
|
+
.sort((a, b) => b.matchCount - a.matchCount)
|
|
301
|
+
.slice(0, topK);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function rgFilePreview(absPath: string, pattern: string): Promise<string> {
|
|
305
|
+
const proc = Bun.spawn(
|
|
306
|
+
[
|
|
307
|
+
"rg",
|
|
308
|
+
"-i",
|
|
309
|
+
"-n",
|
|
310
|
+
"-C",
|
|
311
|
+
String(GREP_CONTEXT),
|
|
312
|
+
"-m",
|
|
313
|
+
String(GREP_LINES_PER_FILE),
|
|
314
|
+
pattern,
|
|
315
|
+
absPath,
|
|
316
|
+
],
|
|
317
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
318
|
+
);
|
|
319
|
+
const output = await new Response(proc.stdout).text();
|
|
320
|
+
await proc.exited;
|
|
321
|
+
return output;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function grepFilePreview(absPath: string, pattern: string): Promise<string> {
|
|
325
|
+
const proc = Bun.spawn(
|
|
326
|
+
[
|
|
327
|
+
"grep",
|
|
328
|
+
"-I",
|
|
329
|
+
"-i",
|
|
330
|
+
"-n",
|
|
331
|
+
"-E",
|
|
332
|
+
"-C",
|
|
333
|
+
String(GREP_CONTEXT),
|
|
334
|
+
"-m",
|
|
335
|
+
String(GREP_LINES_PER_FILE),
|
|
336
|
+
pattern,
|
|
337
|
+
absPath,
|
|
338
|
+
],
|
|
339
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
340
|
+
);
|
|
341
|
+
const output = await new Response(proc.stdout).text();
|
|
342
|
+
await proc.exited;
|
|
343
|
+
return output;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function findstrFilePreview(absPath: string, keywords: string[]): Promise<string> {
|
|
347
|
+
const args = ["findstr", "/N", "/I", "/P", ...keywords.map((k) => `/C:${k}`), absPath];
|
|
348
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
349
|
+
const output = await new Response(proc.stdout).text();
|
|
350
|
+
await proc.exited;
|
|
351
|
+
return output;
|
|
352
|
+
}
|