@takara-ai/miru-code 1.2.1 → 1.3.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/package.json +1 -1
- package/src/benchmark/locate-compare.ts +2 -5
- package/src/cli.ts +58 -25
- package/src/help.ts +7 -0
- package/src/installer/config.ts +100 -12
- package/src/installer/search-policy.ts +6 -1
- package/src/literal.ts +145 -17
- package/src/mcp/server.ts +30 -11
- package/src/miru-index.ts +4 -1
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
DEFAULT_LITERAL_MODE,
|
|
11
12
|
formatLiteralLocate,
|
|
12
13
|
type LiteralLocateOptions,
|
|
13
14
|
type LiteralLocateResult,
|
|
@@ -37,11 +38,7 @@ export async function benchmarkLocateComparison(options: {
|
|
|
37
38
|
index: MiruIndex;
|
|
38
39
|
locate?: LiteralLocateOptions;
|
|
39
40
|
}): Promise<LocateBenchmarkComparison> {
|
|
40
|
-
const locateOpts: LiteralLocateOptions = {
|
|
41
|
-
mode: options.locate?.mode ?? "lines",
|
|
42
|
-
ignore_case: options.locate?.ignore_case,
|
|
43
|
-
...(options.locate?.limit != null ? { limit: options.locate.limit } : {}),
|
|
44
|
-
};
|
|
41
|
+
const locateOpts: LiteralLocateOptions = { mode: DEFAULT_LITERAL_MODE, ...options.locate };
|
|
45
42
|
|
|
46
43
|
const miruStart = performance.now();
|
|
47
44
|
const result = options.index.locateLiteral(options.literal, locateOpts);
|
package/src/cli.ts
CHANGED
|
@@ -27,7 +27,12 @@ import { getBenchmarkModeStatus, setBenchmarkMode } from "./installer/benchmark-
|
|
|
27
27
|
import { runSearchGuardFromStdin } from "./installer/hooks/search-guard.ts";
|
|
28
28
|
import { runInstaller } from "./installer/installer.ts";
|
|
29
29
|
import { promptConfirm } from "./installer/prompt.ts";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_LITERAL_MODE,
|
|
32
|
+
formatLiteralLocate,
|
|
33
|
+
type LiteralLocateOptions,
|
|
34
|
+
type LiteralMode,
|
|
35
|
+
} from "./literal.ts";
|
|
31
36
|
import { serveMcp } from "./mcp/serve.ts";
|
|
32
37
|
import { MiruIndex } from "./miru-index.ts";
|
|
33
38
|
import {
|
|
@@ -264,22 +269,15 @@ async function runLocate(
|
|
|
264
269
|
literal: string,
|
|
265
270
|
content: ContentType[],
|
|
266
271
|
jsonFlag: boolean,
|
|
267
|
-
|
|
268
|
-
limit: number | undefined,
|
|
269
|
-
ignoreCase: boolean,
|
|
272
|
+
options: LiteralLocateOptions,
|
|
270
273
|
): Promise<void> {
|
|
271
274
|
await ensureCredentials({ interactive: true });
|
|
275
|
+
const mode = options.mode ?? DEFAULT_LITERAL_MODE;
|
|
272
276
|
|
|
273
277
|
const payload = await withSpinner("Locating literal", async () => {
|
|
274
278
|
const built = await MiruIndex.fromSource(path, content);
|
|
275
279
|
await built.saveToCache(path);
|
|
276
|
-
return formatLiteralLocate(
|
|
277
|
-
built.locateLiteral(literal, {
|
|
278
|
-
mode,
|
|
279
|
-
ignore_case: ignoreCase,
|
|
280
|
-
...(limit != null ? { limit } : {}),
|
|
281
|
-
}),
|
|
282
|
-
);
|
|
280
|
+
return formatLiteralLocate(built.locateLiteral(literal, options));
|
|
283
281
|
});
|
|
284
282
|
|
|
285
283
|
if (prefersJsonOutput(jsonFlag)) {
|
|
@@ -290,10 +288,18 @@ async function runLocate(
|
|
|
290
288
|
const n = Number(payload.n ?? 0);
|
|
291
289
|
const files = Number(payload.files ?? 0);
|
|
292
290
|
writeStdout(`literal=${literal} n=${n} files=${files} mode=${mode}`);
|
|
293
|
-
const hits = payload.hits as
|
|
291
|
+
const hits = payload.hits as
|
|
292
|
+
| Array<{ f: string; l: number; t?: string; ctx?: string[]; ctx_l?: number }>
|
|
293
|
+
| undefined;
|
|
294
294
|
if (hits) {
|
|
295
295
|
for (const hit of hits) {
|
|
296
|
-
if (hit.
|
|
296
|
+
if (hit.ctx !== undefined) {
|
|
297
|
+
writeStdout(` ${hit.f}:${hit.l}:`);
|
|
298
|
+
const startLine = hit.ctx_l ?? hit.l;
|
|
299
|
+
hit.ctx.forEach((line, i) => {
|
|
300
|
+
writeStdout(` ${startLine + i}: ${line}`);
|
|
301
|
+
});
|
|
302
|
+
} else if (hit.t !== undefined) {
|
|
297
303
|
writeStdout(` ${hit.f}:${hit.l}: ${hit.t}`);
|
|
298
304
|
} else {
|
|
299
305
|
writeStdout(` ${hit.f}:${hit.l}`);
|
|
@@ -528,10 +534,20 @@ async function runCli(argv: string[]): Promise<void> {
|
|
|
528
534
|
printCommandHelp("locate");
|
|
529
535
|
process.exit(1);
|
|
530
536
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
537
|
+
const options: LiteralLocateOptions = {};
|
|
538
|
+
const include: string[] = [];
|
|
539
|
+
const exclude: string[] = [];
|
|
534
540
|
const pathArgs: string[] = [];
|
|
541
|
+
const positiveInt = (flag: string, raw: string | undefined, min: number): number => {
|
|
542
|
+
const n = Number(raw);
|
|
543
|
+
if (!Number.isFinite(n) || n < min) {
|
|
544
|
+
fail(
|
|
545
|
+
`locate ${flag} must be a${min === 0 ? " non-negative" : ""} integer${min > 0 ? ` ≥ ${min}` : ""}.`,
|
|
546
|
+
);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
return Math.floor(n);
|
|
550
|
+
};
|
|
535
551
|
for (let i = 1; i < sizedRest.length; i++) {
|
|
536
552
|
const arg = sizedRest[i];
|
|
537
553
|
if (arg === "--mode" && sizedRest[i + 1]) {
|
|
@@ -540,28 +556,45 @@ async function runCli(argv: string[]): Promise<void> {
|
|
|
540
556
|
fail(`Unknown locate mode "${value}". Use count, locations, or lines.`);
|
|
541
557
|
process.exit(1);
|
|
542
558
|
}
|
|
543
|
-
mode = value;
|
|
559
|
+
options.mode = value as LiteralMode;
|
|
544
560
|
continue;
|
|
545
561
|
}
|
|
546
562
|
if (arg === "--limit" && sizedRest[i + 1]) {
|
|
547
|
-
|
|
548
|
-
if (!Number.isFinite(raw) || raw < 1) {
|
|
549
|
-
fail("locate --limit must be a positive integer.");
|
|
550
|
-
process.exit(1);
|
|
551
|
-
}
|
|
552
|
-
limit = Math.floor(raw);
|
|
563
|
+
options.limit = positiveInt("--limit", sizedRest[++i], 1);
|
|
553
564
|
continue;
|
|
554
565
|
}
|
|
555
566
|
if (arg === "--ignore-case") {
|
|
556
|
-
|
|
567
|
+
options.ignore_case = true;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (arg === "--match-variants") {
|
|
571
|
+
options.match_variants = true;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (arg === "--include" && sizedRest[i + 1]) {
|
|
575
|
+
include.push(sizedRest[++i] as string);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (arg === "--exclude" && sizedRest[i + 1]) {
|
|
579
|
+
exclude.push(sizedRest[++i] as string);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (arg === "--context" && sizedRest[i + 1]) {
|
|
583
|
+
options.context_lines = positiveInt("--context", sizedRest[++i], 0);
|
|
557
584
|
continue;
|
|
558
585
|
}
|
|
559
586
|
if (arg !== undefined) {
|
|
560
587
|
pathArgs.push(arg);
|
|
561
588
|
}
|
|
562
589
|
}
|
|
590
|
+
if (include.length > 0) {
|
|
591
|
+
options.include = include;
|
|
592
|
+
}
|
|
593
|
+
if (exclude.length > 0) {
|
|
594
|
+
options.exclude = exclude;
|
|
595
|
+
}
|
|
563
596
|
const path = resolveSearchPath(pathArgs[0] ?? process.cwd());
|
|
564
|
-
await runLocate(path, literal, content, jsonFlag,
|
|
597
|
+
await runLocate(path, literal, content, jsonFlag, options);
|
|
565
598
|
return;
|
|
566
599
|
}
|
|
567
600
|
|
package/src/help.ts
CHANGED
|
@@ -96,10 +96,17 @@ export function printCommandHelp(command: string): void {
|
|
|
96
96
|
writeStdout(" --mode MODE count | locations | lines (default: lines)");
|
|
97
97
|
writeStdout(" --limit N Optional hit cap (default: all matches)");
|
|
98
98
|
writeStdout(" --ignore-case Case-insensitive match");
|
|
99
|
+
writeStdout(" --match-variants Also match camelCase/snake_case/kebab-case/CONSTANT_CASE");
|
|
100
|
+
writeStdout(" --include GLOB Only search files matching this glob (repeatable)");
|
|
101
|
+
writeStdout(" --exclude GLOB Skip files matching this glob (repeatable)");
|
|
102
|
+
writeStdout(" --context N Lines of context around each match (mode=lines)");
|
|
99
103
|
writeStdout(" --content TYPE code | docs | config | all (default: code config)");
|
|
100
104
|
writeStdout(" --json JSON output (default when piped)");
|
|
101
105
|
section("Example");
|
|
102
106
|
writeStdout(" miru locate MIRU_BENCHMARK_HISTORY_PATH . --mode locations");
|
|
107
|
+
writeStdout(
|
|
108
|
+
' miru locate rateLimit . --match-variants --include "apps/tldr/**" --context 3',
|
|
109
|
+
);
|
|
103
110
|
writeStdout("");
|
|
104
111
|
return;
|
|
105
112
|
case "expand":
|
package/src/installer/config.ts
CHANGED
|
@@ -260,26 +260,114 @@ function removeJsonMemberText(text: string, sectionKey: string, memberKey: strin
|
|
|
260
260
|
return `${text.slice(0, removeStart)}${text.slice(removeEnd)}`;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/** True only when // or /* appear outside of strings (URLs like https:// must not match). */
|
|
263
264
|
function hasJsoncSyntax(text: string): boolean {
|
|
264
|
-
|
|
265
|
+
let inString = false;
|
|
266
|
+
let quote = "";
|
|
267
|
+
for (let i = 0; i < text.length; i++) {
|
|
268
|
+
const ch = text[i] ?? "";
|
|
269
|
+
const next = text[i + 1] ?? "";
|
|
270
|
+
if (inString) {
|
|
271
|
+
if (ch === "\\") {
|
|
272
|
+
i++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch === quote) {
|
|
276
|
+
inString = false;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (ch === '"' || ch === "'") {
|
|
281
|
+
inString = true;
|
|
282
|
+
quote = ch;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (ch === "/" && next === "/") {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
if (ch === "/" && next === "*") {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
265
293
|
}
|
|
266
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Locate a section object at the document root only.
|
|
297
|
+
* Nested keys with the same name (e.g. Claude Code projects.*.mcpServers) are ignored.
|
|
298
|
+
*/
|
|
267
299
|
function findSectionRange(
|
|
268
300
|
text: string,
|
|
269
301
|
sectionKey: string,
|
|
270
302
|
): { openBrace: number; closeBrace: number; indent: string } | null {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
303
|
+
const keyPrefix = new RegExp(`^"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`);
|
|
304
|
+
let depth = 0;
|
|
305
|
+
let inString = false;
|
|
306
|
+
let quote = "";
|
|
307
|
+
let inLineComment = false;
|
|
308
|
+
let inBlockComment = false;
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < text.length; i++) {
|
|
311
|
+
const ch = text[i] ?? "";
|
|
312
|
+
const next = text[i + 1] ?? "";
|
|
313
|
+
if (inLineComment) {
|
|
314
|
+
if (ch === "\n") inLineComment = false;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (inBlockComment) {
|
|
318
|
+
if (ch === "*" && next === "/") {
|
|
319
|
+
inBlockComment = false;
|
|
320
|
+
i++;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (inString) {
|
|
325
|
+
if (ch === "\\") {
|
|
326
|
+
i++;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (ch === quote) {
|
|
330
|
+
inString = false;
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (ch === "/" && next === "/") {
|
|
335
|
+
inLineComment = true;
|
|
336
|
+
i++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (ch === "/" && next === "*") {
|
|
340
|
+
inBlockComment = true;
|
|
341
|
+
i++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (depth === 1 && ch === '"') {
|
|
345
|
+
const match = keyPrefix.exec(text.slice(i));
|
|
346
|
+
if (match) {
|
|
347
|
+
const lineStart = text.lastIndexOf("\n", i) + 1;
|
|
348
|
+
const indent = /^[ \t]*/.exec(text.slice(lineStart, i))?.[0] ?? "";
|
|
349
|
+
const openBrace = i + match[0].length - 1;
|
|
350
|
+
const closeBrace = findMatchingBrace(text, openBrace);
|
|
351
|
+
if (closeBrace < 0) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return { openBrace, closeBrace, indent };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (ch === '"' || ch === "'") {
|
|
358
|
+
inString = true;
|
|
359
|
+
quote = ch;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (ch === "{") {
|
|
363
|
+
depth++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (ch === "}") {
|
|
367
|
+
depth--;
|
|
368
|
+
}
|
|
281
369
|
}
|
|
282
|
-
return
|
|
370
|
+
return null;
|
|
283
371
|
}
|
|
284
372
|
|
|
285
373
|
function findMatchingBrace(text: string, openIndex: number): number {
|
|
@@ -141,7 +141,12 @@ export const MCP_LOCATE_TOOL_DESCRIPTION =
|
|
|
141
141
|
"Exact substring locator over the Miru index. Use for known literals (env vars, symbols, error codes, quoted text) — not meaning-based questions (`search`). " +
|
|
142
142
|
"Returns ALL matches by default as compact {n,files,hits} — do NOT fall back to Grep/rg when n is large. " +
|
|
143
143
|
"Prefer mode=locations (or count for totals only); use lines when you need matching line text. " +
|
|
144
|
-
"Optional `limit` only if you intentionally want a sample.
|
|
144
|
+
"Optional `limit` only if you intentionally want a sample. " +
|
|
145
|
+
"Pass an array to `literal` to match several spellings in one call; " +
|
|
146
|
+
'or set `match_variants: true` to also match other ways the same word is written in code (e.g. "rateLimit" also finds "rate_limit"). ' +
|
|
147
|
+
"Use `include`/`exclude` (gitignore-style globs) to scope to part of a monorepo. " +
|
|
148
|
+
"Use `context_lines` in `lines` mode to get surrounding lines inline instead of a follow-up `expand`/Read. " +
|
|
149
|
+
"In benchmark mode, includes compact token savings vs agent Grep.";
|
|
145
150
|
|
|
146
151
|
export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
147
152
|
"More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit; " +
|
package/src/literal.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/** Exact literal location over already-indexed Miru chunks (no disk re-walk). */
|
|
2
2
|
|
|
3
|
+
import ignore from "ignore";
|
|
3
4
|
import type { Chunk } from "./types.ts";
|
|
4
5
|
|
|
5
6
|
export type LiteralMode = "count" | "locations" | "lines";
|
|
6
7
|
|
|
8
|
+
export const DEFAULT_LITERAL_MODE: LiteralMode = "lines";
|
|
9
|
+
|
|
7
10
|
export interface LiteralHit {
|
|
8
11
|
file_path: string;
|
|
9
12
|
line: number;
|
|
10
13
|
/** Present only in `lines` mode. */
|
|
11
14
|
text?: string;
|
|
15
|
+
/** Present only in `lines` mode when `context_lines` is set. */
|
|
16
|
+
context?: string[];
|
|
17
|
+
/** Line number of `context[0]`. */
|
|
18
|
+
context_start_line?: number;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export interface LiteralLocateResult {
|
|
22
|
+
/** Human-readable label — the input literal, or `|`-joined for multi-literal calls. */
|
|
15
23
|
literal: string;
|
|
24
|
+
/** All literal variants actually searched (OR-matched). Present only when more than one. */
|
|
25
|
+
literals?: string[];
|
|
16
26
|
mode: LiteralMode;
|
|
17
27
|
/** Total unique file:line matches. */
|
|
18
28
|
n: number;
|
|
@@ -31,6 +41,17 @@ export interface LiteralLocateOptions {
|
|
|
31
41
|
*/
|
|
32
42
|
limit?: number;
|
|
33
43
|
ignore_case?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Expand each literal into its identifier-casing variants (camelCase, PascalCase,
|
|
46
|
+
* snake_case, kebab-case, CONSTANT_CASE) and OR-match all of them.
|
|
47
|
+
*/
|
|
48
|
+
match_variants?: boolean;
|
|
49
|
+
/** Gitignore-style glob patterns; only chunks from matching files are searched. */
|
|
50
|
+
include?: string[];
|
|
51
|
+
/** Gitignore-style glob patterns; chunks from matching files are skipped. */
|
|
52
|
+
exclude?: string[];
|
|
53
|
+
/** Lines of context before/after each match, `lines` mode only (like `grep -C`). */
|
|
54
|
+
context_lines?: number;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
function resolveLimit(limit: number | undefined): number | null {
|
|
@@ -40,45 +61,134 @@ function resolveLimit(limit: number | undefined): number | null {
|
|
|
40
61
|
return Math.max(1, Math.floor(limit));
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
function lineMatchesAny(
|
|
65
|
+
haystack: string,
|
|
66
|
+
needles: readonly string[],
|
|
67
|
+
ignoreCase: boolean,
|
|
68
|
+
): boolean {
|
|
69
|
+
const hay = ignoreCase ? haystack.toLowerCase() : haystack;
|
|
70
|
+
for (const needle of needles) {
|
|
71
|
+
const n = ignoreCase ? needle.toLowerCase() : needle;
|
|
72
|
+
if (hay.includes(n)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Splits an identifier into lowercase words, honoring camelCase/PascalCase humps and _/- separators. */
|
|
80
|
+
function splitIdentifierWords(literal: string): string[] {
|
|
81
|
+
const normalized = literal.replace(/[_\-\s]+/g, " ");
|
|
82
|
+
const humped = normalized
|
|
83
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
84
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
|
|
85
|
+
return humped
|
|
86
|
+
.split(" ")
|
|
87
|
+
.map((w) => w.trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE variants of one identifier. */
|
|
92
|
+
function identifierVariants(literal: string): string[] {
|
|
93
|
+
const words = splitIdentifierWords(literal);
|
|
94
|
+
if (words.length === 0) {
|
|
95
|
+
return [];
|
|
46
96
|
}
|
|
47
|
-
|
|
97
|
+
const lower = words.map((w) => w.toLowerCase());
|
|
98
|
+
const capitalized = lower.map((w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
lower[0] + capitalized.slice(1).join(""),
|
|
102
|
+
capitalized.join(""),
|
|
103
|
+
lower.join("_"),
|
|
104
|
+
lower.join("-"),
|
|
105
|
+
lower.join("_").toUpperCase(),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function expandLiterals(literals: readonly string[], matchVariants: boolean): string[] {
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
for (const lit of literals) {
|
|
112
|
+
if (!lit) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
seen.add(lit);
|
|
116
|
+
if (matchVariants) {
|
|
117
|
+
for (const variant of identifierVariants(lit)) {
|
|
118
|
+
if (variant) {
|
|
119
|
+
seen.add(variant);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...seen];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function filterChunksByGlob(
|
|
128
|
+
chunks: readonly Chunk[],
|
|
129
|
+
include: string[] | undefined,
|
|
130
|
+
exclude: string[] | undefined,
|
|
131
|
+
): readonly Chunk[] {
|
|
132
|
+
if (!include?.length && !exclude?.length) {
|
|
133
|
+
return chunks;
|
|
134
|
+
}
|
|
135
|
+
const includeMatcher = include?.length ? ignore().add(include) : null;
|
|
136
|
+
const excludeMatcher = exclude?.length ? ignore().add(exclude) : null;
|
|
137
|
+
|
|
138
|
+
return chunks.filter((chunk) => {
|
|
139
|
+
const path = chunk.file_path.replace(/^\/+/, "");
|
|
140
|
+
if (includeMatcher && !includeMatcher.ignores(path)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (excludeMatcher && excludeMatcher.ignores(path)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
48
148
|
}
|
|
49
149
|
|
|
50
150
|
/**
|
|
51
|
-
* Scan indexed chunks for an exact substring
|
|
151
|
+
* Scan indexed chunks for an exact substring (or, with an array `literal`/`match_variants`,
|
|
152
|
+
* any of several OR-matched substrings). Dedupes by file:line when chunks overlap.
|
|
52
153
|
* Only covers indexed content (same corpus as `search` / `expand`).
|
|
53
154
|
* Returns every match unless `limit` is set.
|
|
54
155
|
*/
|
|
55
156
|
export function locateLiteral(
|
|
56
157
|
chunks: readonly Chunk[],
|
|
57
|
-
literal: string,
|
|
158
|
+
literal: string | readonly string[],
|
|
58
159
|
options: LiteralLocateOptions = {},
|
|
59
160
|
): LiteralLocateResult {
|
|
60
|
-
const
|
|
61
|
-
const mode = options.mode ?? "lines";
|
|
161
|
+
const mode = options.mode ?? DEFAULT_LITERAL_MODE;
|
|
62
162
|
const ignoreCase = options.ignore_case === true;
|
|
63
163
|
const limit = resolveLimit(options.limit);
|
|
164
|
+
const contextLines =
|
|
165
|
+
options.context_lines != null && Number.isFinite(options.context_lines)
|
|
166
|
+
? Math.max(0, Math.floor(options.context_lines))
|
|
167
|
+
: 0;
|
|
64
168
|
|
|
65
|
-
|
|
66
|
-
|
|
169
|
+
const inputLiterals = (Array.isArray(literal) ? literal : [literal]).filter((l) => l.length > 0);
|
|
170
|
+
const label = inputLiterals.join(" | ");
|
|
171
|
+
const needles = expandLiterals(inputLiterals, options.match_variants === true);
|
|
172
|
+
|
|
173
|
+
if (needles.length === 0) {
|
|
174
|
+
return { literal: label, mode, n: 0, files: 0, truncated: false, hits: [] };
|
|
67
175
|
}
|
|
68
176
|
|
|
177
|
+
const scopedChunks = filterChunksByGlob(chunks, options.include, options.exclude);
|
|
178
|
+
|
|
69
179
|
const seen = new Set<string>();
|
|
70
180
|
const fileSet = new Set<string>();
|
|
71
181
|
const hits: LiteralHit[] = [];
|
|
72
182
|
let n = 0;
|
|
73
183
|
|
|
74
|
-
for (const chunk of
|
|
75
|
-
if (!
|
|
184
|
+
for (const chunk of scopedChunks) {
|
|
185
|
+
if (!lineMatchesAny(chunk.content, needles, ignoreCase)) {
|
|
76
186
|
continue;
|
|
77
187
|
}
|
|
78
188
|
const lines = chunk.content.split("\n");
|
|
79
189
|
for (let i = 0; i < lines.length; i++) {
|
|
80
190
|
const lineText = lines[i] ?? "";
|
|
81
|
-
if (!
|
|
191
|
+
if (!lineMatchesAny(lineText, needles, ignoreCase)) {
|
|
82
192
|
continue;
|
|
83
193
|
}
|
|
84
194
|
const line = chunk.start_line + i;
|
|
@@ -98,19 +208,29 @@ export function locateLiteral(
|
|
|
98
208
|
const hit: LiteralHit = { file_path: chunk.file_path, line };
|
|
99
209
|
if (mode === "lines") {
|
|
100
210
|
hit.text = lineText;
|
|
211
|
+
if (contextLines > 0) {
|
|
212
|
+
const ctxStart = Math.max(0, i - contextLines);
|
|
213
|
+
const ctxEnd = Math.min(lines.length - 1, i + contextLines);
|
|
214
|
+
hit.context = lines.slice(ctxStart, ctxEnd + 1);
|
|
215
|
+
hit.context_start_line = chunk.start_line + ctxStart;
|
|
216
|
+
}
|
|
101
217
|
}
|
|
102
218
|
hits.push(hit);
|
|
103
219
|
}
|
|
104
220
|
}
|
|
105
221
|
|
|
106
|
-
|
|
107
|
-
literal:
|
|
222
|
+
const result: LiteralLocateResult = {
|
|
223
|
+
literal: label,
|
|
108
224
|
mode,
|
|
109
225
|
n,
|
|
110
226
|
files: fileSet.size,
|
|
111
227
|
truncated: mode !== "count" && limit != null && n > hits.length,
|
|
112
228
|
hits: mode === "count" ? [] : hits,
|
|
113
229
|
};
|
|
230
|
+
if (needles.length > 1) {
|
|
231
|
+
result.literals = needles;
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
114
234
|
}
|
|
115
235
|
|
|
116
236
|
/** Compact agent-facing JSON (minimal keys). */
|
|
@@ -121,6 +241,9 @@ export function formatLiteralLocate(result: LiteralLocateResult): Record<string,
|
|
|
121
241
|
n: result.n,
|
|
122
242
|
files: result.files,
|
|
123
243
|
};
|
|
244
|
+
if (result.literals) {
|
|
245
|
+
base.literals = result.literals;
|
|
246
|
+
}
|
|
124
247
|
|
|
125
248
|
if (result.mode === "count") {
|
|
126
249
|
return base;
|
|
@@ -130,10 +253,15 @@ export function formatLiteralLocate(result: LiteralLocateResult): Record<string,
|
|
|
130
253
|
base.truncated = true;
|
|
131
254
|
}
|
|
132
255
|
base.hits = result.hits.map((hit) => {
|
|
256
|
+
const h: Record<string, unknown> = { f: hit.file_path, l: hit.line };
|
|
133
257
|
if (hit.text !== undefined) {
|
|
134
|
-
|
|
258
|
+
h.t = hit.text;
|
|
259
|
+
}
|
|
260
|
+
if (hit.context !== undefined) {
|
|
261
|
+
h.ctx = hit.context;
|
|
262
|
+
h.ctx_l = hit.context_start_line;
|
|
135
263
|
}
|
|
136
|
-
return
|
|
264
|
+
return h;
|
|
137
265
|
});
|
|
138
266
|
return base;
|
|
139
267
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
MCP_SEARCH_TOOL_DESCRIPTION,
|
|
18
18
|
MCP_SERVER_INSTRUCTIONS,
|
|
19
19
|
} from "../installer/search-policy.ts";
|
|
20
|
-
import { formatLiteralLocate
|
|
20
|
+
import { formatLiteralLocate } from "../literal.ts";
|
|
21
21
|
import type { ContentType } from "../types.ts";
|
|
22
22
|
import {
|
|
23
23
|
clampMcpTopK,
|
|
@@ -149,9 +149,11 @@ export function createMcpServer(
|
|
|
149
149
|
description: MCP_LOCATE_TOOL_DESCRIPTION,
|
|
150
150
|
inputSchema: {
|
|
151
151
|
literal: z
|
|
152
|
-
.string()
|
|
153
|
-
.
|
|
154
|
-
|
|
152
|
+
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
153
|
+
.describe(
|
|
154
|
+
"Exact substring to find (env var, symbol, error code, quoted text). " +
|
|
155
|
+
"Pass an array to OR-match several substrings (e.g. spelling/casing variants) in one call.",
|
|
156
|
+
),
|
|
155
157
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
156
158
|
mode: z
|
|
157
159
|
.enum(["count", "locations", "lines"])
|
|
@@ -168,20 +170,37 @@ export function createMcpServer(
|
|
|
168
170
|
"Optional cap on returned hits. Omit to return ALL matches (recommended). Do not fall back to Grep if n is large — use mode=count or locations instead.",
|
|
169
171
|
),
|
|
170
172
|
ignore_case: z.boolean().optional().describe("Case-insensitive match (default false)."),
|
|
173
|
+
match_variants: z
|
|
174
|
+
.boolean()
|
|
175
|
+
.optional()
|
|
176
|
+
.describe(
|
|
177
|
+
'Also match other ways the same word might be written in code, e.g. "rateLimit" also finds "rate_limit" and "RATE_LIMIT".',
|
|
178
|
+
),
|
|
179
|
+
include: z
|
|
180
|
+
.array(z.string().min(1))
|
|
181
|
+
.optional()
|
|
182
|
+
.describe(
|
|
183
|
+
'Gitignore-style glob patterns; only matching files are searched (e.g. "apps/tldr/**/*.go").',
|
|
184
|
+
),
|
|
185
|
+
exclude: z
|
|
186
|
+
.array(z.string().min(1))
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("Gitignore-style glob patterns; matching files are skipped."),
|
|
189
|
+
context_lines: z
|
|
190
|
+
.number()
|
|
191
|
+
.int()
|
|
192
|
+
.min(0)
|
|
193
|
+
.optional()
|
|
194
|
+
.describe("Lines of context before/after each match, like `grep -C` (mode=lines only)."),
|
|
171
195
|
},
|
|
172
196
|
},
|
|
173
|
-
async ({ literal, repo,
|
|
197
|
+
async ({ literal, repo, ...locateOpts }) => {
|
|
174
198
|
try {
|
|
175
199
|
const index = await getIndexForRepo(repo, cache);
|
|
176
|
-
const locateOpts = {
|
|
177
|
-
mode: (mode as LiteralMode | undefined) ?? "lines",
|
|
178
|
-
...(limit != null ? { limit } : {}),
|
|
179
|
-
ignore_case: ignoreCase,
|
|
180
|
-
};
|
|
181
200
|
|
|
182
201
|
if (benchmark) {
|
|
183
202
|
const repoRoot = localRepoRoot(repo);
|
|
184
|
-
if (repoRoot) {
|
|
203
|
+
if (repoRoot && typeof literal === "string") {
|
|
185
204
|
const comparison = await benchmarkLocateComparison({
|
|
186
205
|
literal,
|
|
187
206
|
repoPath: repoRoot,
|
package/src/miru-index.ts
CHANGED
|
@@ -450,7 +450,10 @@ export class MiruIndex {
|
|
|
450
450
|
* Exact substring location over indexed chunks (no disk re-walk).
|
|
451
451
|
* Prefer for known literals; use `search` for meaning-based questions.
|
|
452
452
|
*/
|
|
453
|
-
locateLiteral(
|
|
453
|
+
locateLiteral(
|
|
454
|
+
literal: string | readonly string[],
|
|
455
|
+
options?: LiteralLocateOptions,
|
|
456
|
+
): LiteralLocateResult {
|
|
454
457
|
return locateLiteral(this.chunksInternal, literal, options);
|
|
455
458
|
}
|
|
456
459
|
}
|