@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 { formatLiteralLocate, type LiteralMode } from "./literal.ts";
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
- mode: LiteralMode,
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 Array<{ f: string; l: number; t?: string }> | undefined;
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.t !== undefined) {
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
- let mode: LiteralMode = "lines";
532
- let limit: number | undefined;
533
- let ignoreCase = false;
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
- const raw = Number(sizedRest[++i]);
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
- ignoreCase = true;
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, mode, limit, ignoreCase);
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":
@@ -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
- return text.includes("//") || text.includes("/*");
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 keyMatch = new RegExp(`(^|\\n)([ \\t]*)"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`).exec(
272
- text,
273
- );
274
- if (!keyMatch) {
275
- return null;
276
- }
277
- const openBrace = text.indexOf("{", keyMatch.index);
278
- const closeBrace = findMatchingBrace(text, openBrace);
279
- if (openBrace < 0 || closeBrace < 0) {
280
- return null;
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 { openBrace, closeBrace, indent: keyMatch[2] ?? "" };
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. In benchmark mode, includes compact token savings vs agent Grep.";
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 includesLiteral(haystack: string, needle: string, ignoreCase: boolean): boolean {
44
- if (!ignoreCase) {
45
- return haystack.includes(needle);
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
- return haystack.toLowerCase().includes(needle.toLowerCase());
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. Dedupes by file:line when chunks overlap.
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 needle = literal;
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
- if (!needle) {
66
- return { literal: needle, mode, n: 0, files: 0, truncated: false, hits: [] };
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 chunks) {
75
- if (!includesLiteral(chunk.content, needle, ignoreCase)) {
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 (!includesLiteral(lineText, needle, ignoreCase)) {
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
- return {
107
- literal: needle,
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
- return { f: hit.file_path, l: hit.line, t: hit.text };
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 { f: hit.file_path, l: hit.line };
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, type LiteralMode } from "../literal.ts";
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
- .min(1)
154
- .describe("Exact substring to find (env var, symbol, error code, quoted text)."),
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, mode, limit, ignore_case: ignoreCase }) => {
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(literal: string, options?: LiteralLocateOptions): LiteralLocateResult {
453
+ locateLiteral(
454
+ literal: string | readonly string[],
455
+ options?: LiteralLocateOptions,
456
+ ): LiteralLocateResult {
454
457
  return locateLiteral(this.chunksInternal, literal, options);
455
458
  }
456
459
  }