@takara-ai/miru-code 1.8.2 → 1.9.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/README.md CHANGED
@@ -38,6 +38,23 @@ Restart Claude Code (or reload plugins) when prompted. Update: `/plugin marketpl
38
38
 
39
39
  **Any other IDE, or if marketplace install isn't available:** use the CLI install below.
40
40
 
41
+ ### What you get from a plugin install vs the CLI
42
+
43
+ Plugin installs don't carry full CLI parity — what each one gives you depends on the IDE's own plugin capabilities, not just Miru's packaging:
44
+
45
+ | | Claude Code | Codex | Cursor |
46
+ |---|---|---|---|
47
+ | MCP tools (`search`, `locate`, `expand`, `find_related`) | ✅ | ✅ | ❌ *(plugin ships skills + rules only — no MCP entry yet)* |
48
+ | `miru` / Caveman / STE skills | ✅ | ✅ | ✅ |
49
+ | Dedicated sub-agent (`miru:miru-code`) | ✅ | — | — |
50
+ | Benchmark mode toggle (`/plugin configure`) | ✅ | — | — |
51
+ | Search hooks | — | — | — |
52
+ | Credentials | own plugin-scoped dir | own plugin-scoped dir | n/a |
53
+
54
+ `—` means the IDE's plugin schema has no equivalent mechanism to port these to (not a packaging gap we can close): Codex's and Cursor's plugin manifests have no `agents` or `userConfig` fields, so the dedicated sub-agent and the benchmark toggle are Claude-Code-only. Search hooks aren't shipped to any plugin — they're slated for removal from Miru entirely (tracked internally), so not worth packaging.
55
+
56
+ **Credentials are plugin-scoped, not shared with your CLI install.** Claude Code and Codex plugins each store auth in their own IDE-managed data directory (survives plugin updates, removed on uninstall). This means `miru setup` done via the CLI does **not** carry over to a plugin install, or vice versa — each authenticates independently on first use (interactive device-code login bootstraps automatically). If you use both the CLI and a plugin, you'll sign in twice.
57
+
41
58
  ## Install (CLI)
42
59
 
43
60
  ```bash
@@ -105,10 +122,8 @@ miru uninstall # remove miru config
105
122
 
106
123
  ### Plugin packaging
107
124
 
108
- This repo now includes plugin packaging for:
109
-
110
- - Codex: `.codex-plugin/plugin.json` and `.agents/plugins/marketplace.json`
111
- - Claude Code: `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`
125
+ - Codex: `.codex-plugin/plugin.json`, `.agents/plugins/marketplace.json`, shares the root `mcp.json`
126
+ - Claude Code: `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, its own `.claude-plugin/mcp.json` (needed for the `userConfig` benchmark toggle — see [What you get from a plugin install vs the CLI](#what-you-get-from-a-plugin-install-vs-the-cli))
112
127
  - Cursor: `plugin.json` and `.cursor/rules/miru-code-search.mdc`
113
128
 
114
129
  Current limitation:
@@ -285,7 +300,7 @@ bun add @takara-ai/miru-code
285
300
  import { MiruIndex } from "@takara-ai/miru-code";
286
301
 
287
302
  const index = await MiruIndex.fromPath("./src");
288
- const results = await index.search({ query: "BM25 tokenize", topK: 10 });
303
+ const results = await index.search({ query: "BM25 tokenize" });
289
304
  ```
290
305
 
291
306
  ## Environment
@@ -353,7 +368,8 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
353
368
  | ---------------- | -------- | --------------------------------------- |
354
369
  | `query` | yes | Natural language or code query |
355
370
  | `repo` | yes | Project root or git URL |
356
- | `top_k` | no | Results to return (default 3, max 10) |
371
+ | `include` | no | Gitignore-style glob patterns; only matching files are searched (same as `locate.include`) |
372
+ | `exclude` | no | Gitignore-style glob patterns; matching files are skipped (same as `locate.exclude`) |
357
373
  | `dedupe_by_file` | no | Keep best hit per file (default `true`) |
358
374
 
359
375
 
@@ -364,6 +380,8 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
364
380
  | ------------- | -------- | ----------------------------------------------------------------------------------- |
365
381
  | `literal` | yes | Exact substring to find |
366
382
  | `repo` | yes | Project root or git URL |
383
+ | `include` | no | Gitignore-style glob patterns; only matching files are searched |
384
+ | `exclude` | no | Gitignore-style glob patterns; matching files are skipped |
367
385
  | `mode` | no | `count` · `locations` · `lines` (default). Prefer `count`/`locations` when possible |
368
386
  | `limit` | no | Cap returned hits. Omit to return all matches |
369
387
  | `ignore_case` | no | Case-insensitive match (default `false`) |
@@ -388,7 +406,6 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
388
406
  | `file_path` | yes | From a search hit |
389
407
  | `anchor_line` | yes | From the search hit |
390
408
  | `repo` | yes | Same repo as the search |
391
- | `top_k` | no | Related chunks to return (default 3, max 10) |
392
409
 
393
410
 
394
411
  `**read_benchmark**` *(benchmark mode only)*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  resolveChunk,
57
57
  resolveContent,
58
58
  resolveSearchPath,
59
+ SEARCH_RESULT_COUNT,
59
60
  } from "./utils.ts";
60
61
  import { maybeNotifyUpdate, miruVersion } from "./version.ts";
61
62
 
@@ -132,25 +133,6 @@ function parseContentArgv(argv: string[]): { content: ContentType[]; rest: strin
132
133
  return { content: resolveContent(content), rest };
133
134
  }
134
135
 
135
- function parseTopK(argv: string[]): { topK: number; rest: string[] } {
136
- const rest: string[] = [];
137
- let topK = 5;
138
- for (let i = 0; i < argv.length; i++) {
139
- const arg = argv[i];
140
- if (arg === "-k" || arg === "--top-k") {
141
- const raw = argv[++i];
142
- if (raw) {
143
- topK = Number(raw);
144
- }
145
- continue;
146
- }
147
- if (arg !== undefined) {
148
- rest.push(arg);
149
- }
150
- }
151
- return { topK: Number.isFinite(topK) && topK >= 1 ? Math.floor(topK) : 5, rest };
152
- }
153
-
154
136
  function emitSearchOutput(
155
137
  query: string,
156
138
  results: SearchResult[],
@@ -177,7 +159,6 @@ function emitSearchOutput(
177
159
  async function runSearch(
178
160
  path: string,
179
161
  query: string,
180
- topK: number,
181
162
  content: ContentType[],
182
163
  jsonFlag: boolean,
183
164
  ): Promise<void> {
@@ -186,7 +167,7 @@ async function runSearch(
186
167
  const index = await withSpinner("Indexing and searching", async () => {
187
168
  const built = await MiruIndex.fromSource(path, content);
188
169
  await built.saveToCache(path);
189
- const results = await built.search({ query, topK });
170
+ const results = await built.search({ query, topK: SEARCH_RESULT_COUNT });
190
171
  return { index: built, results };
191
172
  });
192
173
 
@@ -245,7 +226,6 @@ async function runFindRelated(
245
226
  path: string,
246
227
  filePath: string,
247
228
  line: number,
248
- topK: number,
249
229
  content: ContentType[],
250
230
  jsonFlag: boolean,
251
231
  ): Promise<void> {
@@ -257,7 +237,7 @@ async function runFindRelated(
257
237
  if (!chunk) {
258
238
  throw new RelatedChunkNotFoundError(filePath, line);
259
239
  }
260
- const hits = await built.findRelated(chunk, topK);
240
+ const hits = await built.findRelated(chunk, SEARCH_RESULT_COUNT);
261
241
  await built.saveToCache(path);
262
242
  return {
263
243
  results: hits,
@@ -552,21 +532,28 @@ async function runCli(argv: string[]): Promise<void> {
552
532
 
553
533
  const { present: jsonFlag, rest: jsonRest } = parseFlagArgv(rest, "--json");
554
534
  const { content, rest: contentRest } = parseContentArgv(jsonRest);
555
- const { topK, rest: sizedRest } = parseTopK(contentRest);
535
+
536
+ if (
537
+ (command === "search" || command === "find-related") &&
538
+ contentRest.some((arg) => arg === "-k" || arg === "--top-k")
539
+ ) {
540
+ fail("The -k/--top-k option has been removed; Miru returns a fixed number of results.");
541
+ process.exit(1);
542
+ }
556
543
 
557
544
  if (command === "search") {
558
- const query = sizedRest[0];
545
+ const query = contentRest[0];
559
546
  if (!query) {
560
547
  printCommandHelp("search");
561
548
  process.exit(1);
562
549
  }
563
- const path = resolveSearchPath(sizedRest[1] ?? process.cwd());
564
- await runSearch(path, query, topK, content, jsonFlag);
550
+ const path = resolveSearchPath(contentRest[1] ?? process.cwd());
551
+ await runSearch(path, query, content, jsonFlag);
565
552
  return;
566
553
  }
567
554
 
568
555
  if (command === "locate") {
569
- const literal = sizedRest[0];
556
+ const literal = contentRest[0];
570
557
  if (!literal) {
571
558
  printCommandHelp("locate");
572
559
  process.exit(1);
@@ -585,10 +572,10 @@ async function runCli(argv: string[]): Promise<void> {
585
572
  }
586
573
  return Math.floor(n);
587
574
  };
588
- for (let i = 1; i < sizedRest.length; i++) {
589
- const arg = sizedRest[i];
590
- if (arg === "--mode" && sizedRest[i + 1]) {
591
- const value = sizedRest[++i];
575
+ for (let i = 1; i < contentRest.length; i++) {
576
+ const arg = contentRest[i];
577
+ if (arg === "--mode" && contentRest[i + 1]) {
578
+ const value = contentRest[++i];
592
579
  if (value !== "count" && value !== "locations" && value !== "lines") {
593
580
  fail(`Unknown locate mode "${value}". Use count, locations, or lines.`);
594
581
  process.exit(1);
@@ -596,8 +583,8 @@ async function runCli(argv: string[]): Promise<void> {
596
583
  options.mode = value as LiteralMode;
597
584
  continue;
598
585
  }
599
- if (arg === "--limit" && sizedRest[i + 1]) {
600
- options.limit = positiveInt("--limit", sizedRest[++i], 1);
586
+ if (arg === "--limit" && contentRest[i + 1]) {
587
+ options.limit = positiveInt("--limit", contentRest[++i], 1);
601
588
  continue;
602
589
  }
603
590
  if (arg === "--ignore-case") {
@@ -608,16 +595,16 @@ async function runCli(argv: string[]): Promise<void> {
608
595
  options.match_variants = true;
609
596
  continue;
610
597
  }
611
- if (arg === "--include" && sizedRest[i + 1]) {
612
- include.push(sizedRest[++i] as string);
598
+ if (arg === "--include" && contentRest[i + 1]) {
599
+ include.push(contentRest[++i] as string);
613
600
  continue;
614
601
  }
615
- if (arg === "--exclude" && sizedRest[i + 1]) {
616
- exclude.push(sizedRest[++i] as string);
602
+ if (arg === "--exclude" && contentRest[i + 1]) {
603
+ exclude.push(contentRest[++i] as string);
617
604
  continue;
618
605
  }
619
- if (arg === "--context" && sizedRest[i + 1]) {
620
- options.context_lines = positiveInt("--context", sizedRest[++i], 0);
606
+ if (arg === "--context" && contentRest[i + 1]) {
607
+ options.context_lines = positiveInt("--context", contentRest[++i], 0);
621
608
  continue;
622
609
  }
623
610
  if (arg !== undefined) {
@@ -636,22 +623,22 @@ async function runCli(argv: string[]): Promise<void> {
636
623
  }
637
624
 
638
625
  if (command === "expand") {
639
- const filePath = sizedRest[0];
640
- const lineRaw = sizedRest[1];
626
+ const filePath = contentRest[0];
627
+ const lineRaw = contentRest[1];
641
628
  if (!filePath || !lineRaw) {
642
629
  printCommandHelp("expand");
643
630
  process.exit(1);
644
631
  }
645
632
  const line = Number(lineRaw);
646
- const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
633
+ const path = resolveSearchPath(contentRest[2] ?? process.cwd());
647
634
  let before = DEFAULT_EXPAND_BEFORE;
648
635
  let after = DEFAULT_EXPAND_AFTER;
649
- for (let i = 3; i < sizedRest.length; i++) {
650
- const arg = sizedRest[i];
651
- if (arg === "--before" && sizedRest[i + 1]) {
652
- before = Number(sizedRest[++i]);
653
- } else if (arg === "--after" && sizedRest[i + 1]) {
654
- after = Number(sizedRest[++i]);
636
+ for (let i = 3; i < contentRest.length; i++) {
637
+ const arg = contentRest[i];
638
+ if (arg === "--before" && contentRest[i + 1]) {
639
+ before = Number(contentRest[++i]);
640
+ } else if (arg === "--after" && contentRest[i + 1]) {
641
+ after = Number(contentRest[++i]);
655
642
  }
656
643
  }
657
644
  await runExpand(path, filePath, line, before, after, content, jsonFlag);
@@ -659,15 +646,15 @@ async function runCli(argv: string[]): Promise<void> {
659
646
  }
660
647
 
661
648
  if (command === "find-related") {
662
- const filePath = sizedRest[0];
663
- const lineRaw = sizedRest[1];
649
+ const filePath = contentRest[0];
650
+ const lineRaw = contentRest[1];
664
651
  if (!filePath || !lineRaw) {
665
652
  printCommandHelp("find-related");
666
653
  process.exit(1);
667
654
  }
668
655
  const line = Number(lineRaw);
669
- const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
670
- await runFindRelated(path, filePath, line, topK, content, jsonFlag);
656
+ const path = resolveSearchPath(contentRest[2] ?? process.cwd());
657
+ await runFindRelated(path, filePath, line, content, jsonFlag);
671
658
  return;
672
659
  }
673
660
 
@@ -687,6 +674,10 @@ async function runMcp(argv: string[]): Promise<void> {
687
674
  benchmark = true;
688
675
  continue;
689
676
  }
677
+ if (arg === "--benchmark=true" || arg === "--benchmark=false") {
678
+ benchmark = arg === "--benchmark=true";
679
+ continue;
680
+ }
690
681
  if (arg === "--ref" && argv[i + 1]) {
691
682
  ref = argv[++i] ?? null;
692
683
  continue;
@@ -20,9 +20,14 @@ export function setStoredCredentialsEnvToken(token: string): void {
20
20
  process.env.TAKARA_API_KEY = token;
21
21
  }
22
22
 
23
+ /** True for an unresolved `${...}` placeholder — a plugin host that didn't substitute it. */
24
+ function isUnsubstitutedPlaceholder(value: string): boolean {
25
+ return value.startsWith("${") && value.endsWith("}");
26
+ }
27
+
23
28
  export function resolveCredentialsDir(): string {
24
29
  const override = process.env.MIRU_CREDENTIALS_DIR;
25
- if (override) {
30
+ if (override && !isUnsubstitutedPlaceholder(override)) {
26
31
  return override;
27
32
  }
28
33
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
package/src/help.ts CHANGED
@@ -108,11 +108,10 @@ export function printCommandHelp(command: string): void {
108
108
  section("Usage");
109
109
  writeStdout(" miru search <query> [path] [options]");
110
110
  section("Options");
111
- writeStdout(" -k, --top-k N Number of results (default: 5)");
112
111
  writeStdout(" --content TYPE code | docs | config | all (default: code config)");
113
112
  writeStdout(" --json JSON output (default when piped)");
114
113
  section("Example");
115
- writeStdout(' miru search "where is auth" ./src -k 10 --content code docs');
114
+ writeStdout(' miru search "where is auth" ./src --content code docs');
116
115
  writeStdout("");
117
116
  return;
118
117
  case "locate":
@@ -149,7 +148,7 @@ export function printCommandHelp(command: string): void {
149
148
  section("Usage");
150
149
  writeStdout(" miru find-related <file> <line> [path] [options]");
151
150
  section("Example");
152
- writeStdout(" miru find-related src/auth.ts 42 . -k 8");
151
+ writeStdout(" miru find-related src/auth.ts 42 .");
153
152
  writeStdout("");
154
153
  return;
155
154
  case "setup":
@@ -4,14 +4,16 @@ export type ChunkIndexMappings = {
4
4
  };
5
5
 
6
6
  /**
7
- * Union of chunk indices matching language and/or path filters.
7
+ * Union of chunk indices matching language and/or path filters. Path filters
8
+ * match an exact indexed file path or every file below a directory prefix.
8
9
  *
9
10
  * Filters combine with OR semantics: a chunk is included if it matches any
10
11
  * requested language or path. Returns `undefined` when no filters are given
11
- * (search all chunks) or when no chunks match.
12
+ * (search all chunks), and an empty array when filters match no chunks.
12
13
  *
13
- * Single-filter queries return the prebuilt map array directly (read-only for
14
- * callers). Multi-filter queries allocate a deduped index list.
14
+ * Exact-file and single-language queries return the prebuilt map array
15
+ * directly (read-only for callers). Directory and multi-filter queries
16
+ * allocate an index list.
15
17
  */
16
18
  export function buildChunkSelector(
17
19
  mappings: ChunkIndexMappings,
@@ -28,19 +30,28 @@ export function buildChunkSelector(
28
30
  if (langCount === 1 && pathCount === 0) {
29
31
  const lang = filterLanguages?.[0];
30
32
  if (!lang) {
31
- return undefined;
33
+ return [];
32
34
  }
33
35
  const indices = mappings.languageMapping.get(lang);
34
- return indices && indices.length > 0 ? indices : undefined;
36
+ return indices && indices.length > 0 ? indices : [];
35
37
  }
36
38
 
37
39
  if (pathCount === 1 && langCount === 0) {
38
40
  const fp = filterPaths?.[0];
39
41
  if (!fp) {
40
- return undefined;
42
+ return [];
41
43
  }
42
- const indices = mappings.fileMapping.get(fp);
43
- return indices && indices.length > 0 ? indices : undefined;
44
+ const normalized = normalizePathFilter(fp);
45
+ const exact = [...mappings.fileMapping.entries()].find(
46
+ ([filePath]) => filePath.replace(/\\/g, "/") === normalized,
47
+ )?.[1];
48
+ if (exact) {
49
+ return exact;
50
+ }
51
+ const prefix = `${normalized}/`;
52
+ return [...mappings.fileMapping.entries()]
53
+ .filter(([filePath]) => filePath.replace(/\\/g, "/").startsWith(prefix))
54
+ .flatMap(([, indices]) => indices);
44
55
  }
45
56
 
46
57
  const selector: number[] = [];
@@ -48,10 +59,27 @@ export function buildChunkSelector(
48
59
  selector.push(...(mappings.languageMapping.get(lang) ?? []));
49
60
  }
50
61
  for (const fp of filterPaths ?? []) {
51
- selector.push(...(mappings.fileMapping.get(fp) ?? []));
62
+ const normalized = normalizePathFilter(fp);
63
+ const exact = [...mappings.fileMapping.entries()].find(
64
+ ([filePath]) => filePath.replace(/\\/g, "/") === normalized,
65
+ )?.[1];
66
+ if (exact) {
67
+ selector.push(...exact);
68
+ continue;
69
+ }
70
+ const prefix = `${normalized}/`;
71
+ for (const [filePath, indices] of mappings.fileMapping) {
72
+ if (filePath.replace(/\\/g, "/").startsWith(prefix)) {
73
+ selector.push(...indices);
74
+ }
75
+ }
52
76
  }
53
77
  if (selector.length === 0) {
54
- return undefined;
78
+ return [];
55
79
  }
56
80
  return [...new Set(selector)];
57
81
  }
82
+
83
+ function normalizePathFilter(filePath: string): string {
84
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
85
+ }
@@ -30,13 +30,13 @@ ${SNIPPET_GUIDANCE}
30
30
 
31
31
  Use Miru MCP tools:
32
32
  - \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
33
- - \`search\` — one call per conceptual question; pass project root as \`repo\`
33
+ - \`search\` — one call per conceptual question; pass project root as \`repo\`; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
34
34
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
35
35
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
36
36
 
37
37
  Call shapes:
38
38
  - \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
39
- - \`search(query="<natural language>", repo="<project root>")\`
39
+ - \`search(query="<natural language>", repo="<project root>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
40
 
41
41
  Stop rules:
42
42
  - Literal in the request → \`locate\`, not \`search\`
@@ -134,7 +134,7 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
134
134
 
135
135
  export const MCP_SEARCH_TOOL_DESCRIPTION =
136
136
  "Your default search for all code search queries in this indexed repo — the best, fastest, and cheapest way to find code; better than any other tool. " +
137
- "Returns compact snippets (~±15 lines). One call per question. " +
137
+ "Returns up to 5 compact snippets (~±15 lines). One call per question. Use `include` / `exclude` Gitignore-style globs to scope files; these work the same way on `locate`. " +
138
138
  "Exact literal (env var, symbol, error code, quoted text)? Use `locate` instead. " +
139
139
  "On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
140
140
 
@@ -155,7 +155,7 @@ export const MCP_EXPAND_TOOL_DESCRIPTION =
155
155
  "Use only if `truncated: true` and the snippet doesn't already answer the question — not for similar code elsewhere (use find_related).";
156
156
 
157
157
  export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
158
- "Find code similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
158
+ "Find up to 5 chunks similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
159
159
  "For more context in the same file, use `expand` instead.";
160
160
 
161
161
  export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
package/src/mcp/server.ts CHANGED
@@ -24,17 +24,15 @@ import {
24
24
  import { formatLiteralLocate } from "../literal.ts";
25
25
  import type { Chunk, ContentType, SearchResult } from "../types.ts";
26
26
  import {
27
- clampMcpTopK,
28
27
  DEFAULT_EXPAND_AFTER,
29
28
  DEFAULT_EXPAND_BEFORE,
30
- DEFAULT_MCP_TOP_K,
31
29
  dedupeResultsByFile,
32
30
  expandChunksAtLine,
33
31
  formatExpandResults,
34
32
  formatResults,
35
33
  localRepoRoot,
36
- MAX_MCP_TOP_K,
37
34
  resolveChunk,
35
+ SEARCH_RESULT_COUNT,
38
36
  } from "../utils.ts";
39
37
  import { registerAuthTool, toolErrorText } from "./auth-tool.ts";
40
38
  import {
@@ -52,6 +50,8 @@ const REPO_DESCRIPTION =
52
50
 
53
51
  const BENCHMARK_SKIP_NOTES = {
54
52
  local_repo_only: "Benchmark comparisons require a local repo path; git URL repos are skipped.",
53
+ filtered_search:
54
+ "Benchmark comparisons are skipped when search is restricted by include/exclude patterns.",
55
55
  limited_locate:
56
56
  "Benchmark comparison skipped: locate.limit is global, while rg/grep limits are per file. Omit limit for a valid token and recall comparison.",
57
57
  incompatible_literal_baseline:
@@ -147,27 +147,35 @@ export function createMcpServer(
147
147
  "Natural language or code query — your default for all code search in this repo.",
148
148
  ),
149
149
  repo: z.string().describe(REPO_DESCRIPTION),
150
- top_k: z
151
- .number()
152
- .int()
150
+ include: z
151
+ .array(z.string().min(1))
152
+ .min(1)
153
+ .optional()
154
+ .describe(
155
+ 'Gitignore-style glob patterns; only matching files are searched (e.g. ["src/auth/**", "docs/**/*.md"]). Same semantics as `locate.include`.',
156
+ ),
157
+ exclude: z
158
+ .array(z.string().min(1))
153
159
  .min(1)
154
- .max(MAX_MCP_TOP_K)
155
160
  .optional()
156
- .describe(`Number of results (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
161
+ .describe(
162
+ "Gitignore-style glob patterns; matching files are skipped. Same semantics as `locate.exclude`.",
163
+ ),
157
164
  dedupe_by_file: z
158
165
  .boolean()
159
166
  .optional()
160
167
  .describe("Keep only the best hit per file (default true)."),
161
168
  },
162
169
  },
163
- async ({ query, repo, top_k: topK, dedupe_by_file: dedupeByFile }) => {
170
+ async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
164
171
  try {
165
172
  const index = await getIndexForRepo(repo, cache);
166
173
  const repoRoot = localRepoRoot(repo);
167
- const k = clampMcpTopK(topK);
168
-
174
+ const k = SEARCH_RESULT_COUNT;
169
175
  let skip: BenchmarkSkipReason | undefined;
170
- if (benchmark && repoRoot) {
176
+ if (benchmark && (include || exclude)) {
177
+ skip = "filtered_search";
178
+ } else if (benchmark && repoRoot) {
171
179
  const comparison = await withGrepTimeoutFallback(() =>
172
180
  benchmarkSearchComparison({
173
181
  query,
@@ -199,7 +207,7 @@ export function createMcpServer(
199
207
  skip = "local_repo_only";
200
208
  }
201
209
 
202
- let results = await index.search({ query, topK: k });
210
+ let results = await index.search({ query, topK: k, include, exclude });
203
211
  if (dedupeByFile !== false) {
204
212
  results = dedupeResultsByFile(results);
205
213
  }
@@ -397,18 +405,9 @@ export function createMcpServer(
397
405
  .int()
398
406
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
399
407
  repo: z.string().describe(REPO_DESCRIPTION),
400
- top_k: z
401
- .number()
402
- .int()
403
- .min(1)
404
- .max(MAX_MCP_TOP_K)
405
- .optional()
406
- .describe(
407
- `Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
408
- ),
409
408
  },
410
409
  },
411
- async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
410
+ async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
412
411
  try {
413
412
  const index = await getIndexForRepo(repo, cache);
414
413
  const repoRoot = localRepoRoot(repo);
@@ -418,7 +417,7 @@ export function createMcpServer(
418
417
  `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
419
418
  );
420
419
  }
421
- const results = await index.findRelated(chunk, clampMcpTopK(topK));
420
+ const results = await index.findRelated(chunk, SEARCH_RESULT_COUNT);
422
421
  if (results.length === 0) {
423
422
  return toolText(`No related chunks found for ${filePath}:${anchorLine}.`);
424
423
  }
package/src/miru-index.ts CHANGED
@@ -14,7 +14,12 @@ import { createIndexFromPath } from "./index/create.ts";
14
14
  import { applyIncrementalFileChanges, normalizeRelativePath } from "./index/incremental.ts";
15
15
  import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
16
16
  import type { SemanticIndex } from "./index/semantic-index.ts";
17
- import { type LiteralLocateOptions, type LiteralLocateResult, locateLiteral } from "./literal.ts";
17
+ import {
18
+ filterChunksByGlob,
19
+ type LiteralLocateOptions,
20
+ type LiteralLocateResult,
21
+ locateLiteral,
22
+ } from "./literal.ts";
18
23
  import { hybridSearch, searchSemanticOnly } from "./search.ts";
19
24
  import type { Chunk, ContentType, SearchResult } from "./types.ts";
20
25
  import { chunkKey, chunkToDict, defaultContentTypes } from "./types.ts";
@@ -407,11 +412,19 @@ export class MiruIndex {
407
412
  );
408
413
  }
409
414
 
415
+ private getPathSelector(include?: string[], exclude?: string[]): readonly number[] | undefined {
416
+ if (!include?.length && !exclude?.length) {
417
+ return undefined;
418
+ }
419
+ const included = new Set(filterChunksByGlob(this.chunks, include, exclude).map(chunkKey));
420
+ return this.chunks.flatMap((chunk, index) => (included.has(chunkKey(chunk)) ? [index] : []));
421
+ }
422
+
410
423
  /**
411
424
  * Hybrid BM25 + semantic search over indexed chunks.
412
425
  *
413
426
  * `alpha` blends keyword vs vector scores (default depends on content type).
414
- * `filterLanguages` / `filterPaths` restrict candidates before ranking.
427
+ * `include` / `exclude` glob patterns restrict candidate files before ranking.
415
428
  * `rerank` defaults to on for code indexes (cross-encoder style reranking).
416
429
  */
417
430
  async search(options: {
@@ -422,15 +435,34 @@ export class MiruIndex {
422
435
  alpha?: number | null;
423
436
  filterLanguages?: string[];
424
437
  filterPaths?: string[];
438
+ include?: string[];
439
+ exclude?: string[];
425
440
  rerank?: boolean;
426
441
  }): Promise<SearchResult[]> {
427
- const { query, queryVector, topK = 10, alpha, filterLanguages, filterPaths, rerank } = options;
442
+ const {
443
+ query,
444
+ queryVector,
445
+ topK = 10,
446
+ alpha,
447
+ filterLanguages,
448
+ filterPaths,
449
+ include,
450
+ exclude,
451
+ rerank,
452
+ } = options;
428
453
 
429
454
  if (!this.chunks.length || !query.trim()) {
430
455
  return [];
431
456
  }
432
457
 
433
458
  const resolvedRerank = rerank ?? this.content.includes("code");
459
+ const selector = this.getSelector(filterLanguages, filterPaths);
460
+ const pathSelector = this.getPathSelector(include, exclude);
461
+ let effectiveSelector = pathSelector ?? selector;
462
+ if (selector && pathSelector) {
463
+ const allowedIndices = new Set(selector);
464
+ effectiveSelector = pathSelector.filter((index) => allowedIndices.has(index));
465
+ }
434
466
 
435
467
  return hybridSearch({
436
468
  query,
@@ -441,7 +473,7 @@ export class MiruIndex {
441
473
  chunks: this.chunks,
442
474
  topK,
443
475
  alpha,
444
- selector: this.getSelector(filterLanguages, filterPaths),
476
+ selector: effectiveSelector,
445
477
  rerank: resolvedRerank,
446
478
  });
447
479
  }
package/src/search.ts CHANGED
@@ -96,6 +96,10 @@ export async function hybridSearch(options: {
96
96
  rerank = true,
97
97
  } = options;
98
98
 
99
+ if (selector?.length === 0) {
100
+ return [];
101
+ }
102
+
99
103
  const alphaWeight = resolveAlpha(query, alpha);
100
104
  const tuning = rankingConfig();
101
105
  const candidateCount =
package/src/utils.ts CHANGED
@@ -100,22 +100,12 @@ export type ExpandResults = {
100
100
  chunks: Record<string, unknown>[];
101
101
  };
102
102
 
103
- export const DEFAULT_MCP_TOP_K = 3;
104
- export const MAX_MCP_TOP_K = 10;
103
+ export const SEARCH_RESULT_COUNT = 5;
105
104
 
106
105
  /** Extra chunks before/after the expand anchor — MCP `expand` tool defaults. */
107
106
  export const DEFAULT_EXPAND_BEFORE = 1;
108
107
  export const DEFAULT_EXPAND_AFTER = 1;
109
108
 
110
- /** Clamp MCP top_k to a sane range; omit for the default. */
111
- export function clampMcpTopK(topK?: number): number {
112
- const value = topK ?? DEFAULT_MCP_TOP_K;
113
- if (!Number.isFinite(value) || value < 1) {
114
- return DEFAULT_MCP_TOP_K;
115
- }
116
- return Math.min(Math.floor(value), MAX_MCP_TOP_K);
117
- }
118
-
119
109
  function chunkToResponseDict(chunk: Chunk, repoRoot?: string | null): Record<string, unknown> {
120
110
  const dict = chunkToDict(chunk);
121
111
  if (!repoRoot) {