@takara-ai/miru-code 1.8.1 → 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.1",
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
+ }
@@ -1,8 +1,8 @@
1
1
  /** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
2
2
 
3
3
  export const SNIPPET_GUIDANCE =
4
- "Search returns compact snippets (~±15 lines around the best match). " +
5
- "When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` — do not re-search or Read the whole file.";
4
+ "Search returns compact snippets (~±15 lines). On `truncated: true`, call `expand` with " +
5
+ "`file_path` and `anchor_line` — only if the snippet doesn't already answer the question.";
6
6
 
7
7
  export interface NativeToolNames {
8
8
  explorationDenied: string;
@@ -13,30 +13,36 @@ export interface NativeToolNames {
13
13
  export function buildSearchPolicyTable(native: NativeToolNames): string {
14
14
  return `| Task | Use | Not |
15
15
  |------|-----|-----|
16
- | Quick lookup — where is X handled/defined? | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
17
- | How/where/what handles X? | Miru MCP \`search\` (once) | ${native.explorationDenied}, repeat searches |
16
+ | Exact literal (identifier, quoted string, env var, error code) | Miru MCP \`locate\` | Miru \`search\`, ${native.grep} |
17
+ | How/where/what handles X? (conceptual) | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
18
18
  | Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
19
19
  | Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
20
20
  | Search docs or config | Miru \`search\` | ${native.grep} README paths |
21
- | Exact literal string in a file? | Miru MCP \`locate\` | ${native.grep}, Miru \`search\` |
22
21
  | Edit a known file:line | ${native.read} (after Miru found it) | ${native.read}-before-search |`;
23
22
  }
24
23
 
25
24
  export function buildSearchPolicyBody(native: NativeToolNames): string {
26
25
  return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
27
26
 
27
+ **Check first:** exact token (identifier, quoted string, env var, error code) → \`locate\`. Otherwise → \`search\`.
28
+
28
29
  ${SNIPPET_GUIDANCE}
29
30
 
30
31
  Use Miru MCP tools:
31
- - \`search\` — one call per question; pass project root as \`repo\`
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\`; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
33
34
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
34
35
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
35
36
 
37
+ Call shapes:
38
+ - \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
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
+
36
41
  Stop rules:
37
- - Answer from the first \`search\` — do not re-search with paraphrases
38
- - On \`truncated: true\`, call \`expand\` — not another \`search\` or a full-file ${native.read}
39
- - ${native.read} is for editing a path Miru already gave you, not for exploration
42
+ - Literal in the request → \`locate\`, not \`search\`
43
+ - One \`search\` per question — no paraphrase retries
44
+ - \`expand\` only if the snippet doesn't already answer
45
+ - ${native.read} only to edit a path Miru already found, not to explore
40
46
 
41
47
  Native tools are allowed ONLY when:
42
48
  - reading a file you already located via Miru, to edit it
@@ -50,7 +56,7 @@ export function buildMcpWorkflow(native: NativeToolNames): string {
50
56
 
51
57
  1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
52
58
  2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
53
- 3. If a hit has \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\`.
59
+ 3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
54
60
  4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
55
61
  5. ${native.read} via \`absolute_path\` only when editing or when \`expand\` still lacks context.`;
56
62
  }
@@ -64,6 +70,7 @@ miru expand src/auth.ts 42 .
64
70
  miru find-related src/auth.ts 42 .
65
71
  \`\`\`
66
72
 
73
+ After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
67
74
  If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
68
75
 
69
76
  export function buildSubagentBody(native: NativeToolNames): string {
@@ -90,16 +97,7 @@ ${SEARCH_POLICY_BODY}
90
97
 
91
98
  Run \`miru setup\` once — the MCP server loads credentials from \`credentials.json\`, and interactive first use can auto-start device login when nothing is stored.
92
99
 
93
- CLI fallback when MCP is unavailable:
94
-
95
- \`\`\`bash
96
- miru search "authentication flow" .
97
- miru expand src/auth.ts 42 .
98
- miru find-related src/auth.ts 42 .
99
- \`\`\`
100
-
101
- After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
102
- If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
100
+ ${CLI_FALLBACK}`;
103
101
 
104
102
  export const CURSOR_RULES_MDC = `---
105
103
  description: Miru MCP is the default for all code exploration
@@ -110,20 +108,19 @@ alwaysApply: true
110
108
 
111
109
  ${SEARCH_POLICY_BODY}
112
110
 
113
- When Miru MCP is connected, call \`search\` once per question. On \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\`. Use \`locate\` for exact literals — not Grep. Never use Cursor SemanticSearch for codebase questions.`;
111
+ When Miru MCP is connected: literal in the request → \`locate\`, not Grep or \`search\`. Otherwise \`search\` once. \`expand\` only if the snippet doesn't already answer. Never use Cursor SemanticSearch for codebase questions.`;
114
112
 
115
113
  export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
116
114
 
117
115
  export const MCP_SERVER_INSTRUCTIONS =
118
116
  "Miru `search` is your default for all code search queries in indexed repos — the best, fastest, and cheapest way to find code; better than any other tool. " +
119
117
  "DO NOT use Grep, grep_search, codebase_search, Glob, SemanticSearch, or Read to explore code when this server is available. " +
118
+ "Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
120
119
  `${SNIPPET_GUIDANCE} ` +
121
- "Workflow: (1) `search` once per question — do NOT re-search with paraphrases. " +
122
- "(2) On `truncated: true`, call `expand` with `file_path` and `anchor_line` — do NOT read the whole file. " +
123
- "(3) `find_related` traces similar code in other files, not more context in the same file. " +
124
- "(4) `locate` finds exact substrings (env vars, symbols, error codes) — prefer over Grep; use mode=count or locations when possible. " +
125
- "Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
120
+ "`find_related` traces similar code elsewhere, not more context in the same file. " +
121
+ "Always pass the project root as `repo`; local repos return `absolute_path` — use Read only to edit. " +
126
122
  "Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
123
+ "Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
127
124
  "On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
128
125
 
129
126
  export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
@@ -137,33 +134,32 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
137
134
 
138
135
  export const MCP_SEARCH_TOOL_DESCRIPTION =
139
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. " +
140
- "Returns compact snippets (~±15 lines). One call per question is usually enough. " +
141
- "For exact literals (env vars, symbols, error codes), use `locate` instead. " +
142
- "When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` — not re-search or Read.";
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
+ "Exact literal (env var, symbol, error code, quoted text)? Use `locate` instead. " +
139
+ "On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
143
140
 
144
141
  export const MCP_LOCATE_TOOL_DESCRIPTION =
145
- "Exact substring locator over the Miru index. Use for known literals (env vars, symbols, error codes, quoted text) — not meaning-based questions (`search`). " +
142
+ "Exact substring locator over the Miru index. Use for known literals (env vars, symbols, error codes, quoted text) — not meaning-based questions (`search`), " +
143
+ 'even ones phrased as "where is X" or "what handles X" if X is a literal. ' +
146
144
  "Returns ALL matches by default as compact {n,files,hits} — do NOT fall back to Grep/rg when n is large. " +
147
145
  "Prefer mode=locations (or count for totals only); use lines when you need matching line text. " +
148
146
  "Optional `limit` only if you intentionally want a sample. " +
149
- "Pass an array to `literal` to match several spellings in one call; " +
150
- 'or set `match_variants: true` to also match other ways the same word is written in code (e.g. "rateLimit" also finds "rate_limit"). ' +
151
- "Use `include`/`exclude` (gitignore-style globs) to scope to part of a monorepo. " +
152
- "Use `context_lines` in `lines` mode to get surrounding lines inline instead of a follow-up `expand`/Read. " +
147
+ "Pass an array to `literal` for several spellings in one call. " +
148
+ '`match_variants: true` also matches other casings (e.g. "rateLimit" also finds "rate_limit"). ' +
149
+ "`include`/`exclude` (gitignore-style globs) scope to part of a monorepo; " +
150
+ "`context_lines` in `lines` mode gets inline context instead of a follow-up `expand`. " +
153
151
  "In benchmark mode, includes compact token savings vs agent Grep.";
154
152
 
155
153
  export const MCP_EXPAND_TOOL_DESCRIPTION =
156
- "More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit; " +
157
- "returns adjacent indexed chunks. Use when `truncated: true` — NOT for similar code in other files (use find_related). " +
158
- "Prefer this over re-searching or reading the whole file.";
154
+ "More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit. " +
155
+ "Use only if `truncated: true` and the snippet doesn't already answer the question — not for similar code elsewhere (use find_related).";
159
156
 
160
157
  export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
161
- "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`. " +
162
159
  "For more context in the same file, use `expand` instead.";
163
160
 
164
161
  export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
165
162
  "Cumulative Miru vs Grep token savings from saved `search` and `locate` calls. Returns compact totals {n,saved,save_pct,miru,grep}. " +
166
163
  "Do not call unless the user asks about savings.";
167
164
 
168
- export const SEARCH_GUARD_EXPAND_HINT =
169
- "If a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` — do not re-search or read the whole file.";
165
+ export const SEARCH_GUARD_EXPAND_HINT = SNIPPET_GUIDANCE;
@@ -1,5 +1,6 @@
1
1
  import { watch } from "node:fs";
2
2
  import { relative, resolve } from "node:path";
3
+ import { isCredentialsError } from "../auth/errors.ts";
3
4
  import { walkFiles } from "../index/file-walker.ts";
4
5
  import { getExtensions } from "../index/files.ts";
5
6
  import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
@@ -13,7 +14,6 @@ import {
13
14
  } from "../utils.ts";
14
15
 
15
16
  const CACHE_MAX_SIZE = 10;
16
- const BUN_WATCH_RECONCILE_INTERVAL_MS = 500;
17
17
 
18
18
  /** Directory names we skip for MCP fs.watch update triggers (aligned with file-walker). */
19
19
  const WATCH_IGNORED_DIR_NAMES = new Set([
@@ -45,6 +45,8 @@ type CacheEntry = {
45
45
  pendingPaths: Set<string>;
46
46
  flushQueued: boolean;
47
47
  updateChain: Promise<void>;
48
+ /** Dead-credentials failure from a background re-embed; `get()` throws it until a retry clears it. */
49
+ lastError: Error | null;
48
50
  };
49
51
 
50
52
  export function mcpWatchEnabled(): boolean {
@@ -98,6 +100,7 @@ export class IndexCache {
98
100
  pendingPaths: new Set(),
99
101
  flushQueued: false,
100
102
  updateChain: Promise.resolve(),
103
+ lastError: null,
101
104
  };
102
105
  this.entries.set(cacheKey, entry);
103
106
  }
@@ -171,6 +174,9 @@ export class IndexCache {
171
174
  throw new Error(`Failed to load index for ${source}`);
172
175
  }
173
176
  await entry.updateChain;
177
+ if (entry.lastError) {
178
+ throw entry.lastError;
179
+ }
174
180
  return index;
175
181
  }
176
182
 
@@ -183,39 +189,11 @@ export class IndexCache {
183
189
  }
184
190
  }
185
191
 
186
- private queueIndexedPaths(source: string, index: MiruIndex): void {
187
- const cacheKey = computeSourceCacheKey(source);
188
- const entry = this.ensureEntry(cacheKey, source);
189
- for (const chunk of index.chunks) {
190
- entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
191
- }
192
- this.scheduleFlush(cacheKey, source, entry);
193
- }
194
-
195
- /** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
196
- private noteAmbiguousDirectoryChange(source: string): void {
197
- const cacheKey = computeSourceCacheKey(source);
198
- const entry = this.entries.get(cacheKey);
199
- if (!entry) {
200
- return;
201
- }
202
-
203
- if (entry.index) {
204
- this.queueIndexedPaths(source, entry.index);
205
- return;
206
- }
207
-
208
- if (entry.task) {
209
- void entry.task.then((index) => {
210
- entry.index = index;
211
- this.queueIndexedPaths(source, index);
212
- });
213
- }
214
- }
215
-
216
192
  private noteFileChange(source: string, filename: string | null | undefined): void {
217
193
  if (!filename) {
218
- this.noteAmbiguousDirectoryChange(source);
194
+ // macOS recursive fs.watch could omit the filename on old Bun releases (pre-1.3.14
195
+ // fs.watch rewrite); confirmed fixed on current Bun. No reconcile fallback for
196
+ // this case anymore -- an event with no filename is simply dropped.
219
197
  return;
220
198
  }
221
199
  if (shouldIgnoreWatchPath(filename)) {
@@ -253,34 +231,38 @@ export class IndexCache {
253
231
  return;
254
232
  }
255
233
 
234
+ const requeue = (err?: unknown): void => {
235
+ if (err !== undefined && isCredentialsError(err)) {
236
+ entry.lastError = err instanceof Error ? err : new Error(String(err));
237
+ }
238
+ for (const p of paths) {
239
+ entry.pendingPaths.add(p);
240
+ }
241
+ };
242
+
256
243
  // Prefer an index already in hand (or published on the entry). Never await
257
244
  // entry.task while that task is itself waiting on this flush — that deadlocks.
258
245
  let index = knownIndex ?? entry.index;
259
246
  if (!index && entry.task) {
260
247
  try {
261
248
  index = await entry.task;
262
- } catch {
263
- for (const p of paths) {
264
- entry.pendingPaths.add(p);
265
- }
249
+ } catch (err) {
250
+ requeue(err);
266
251
  return;
267
252
  }
268
253
  }
269
254
  if (!index) {
270
- for (const p of paths) {
271
- entry.pendingPaths.add(p);
272
- }
255
+ requeue();
273
256
  return;
274
257
  }
275
258
 
276
259
  try {
277
260
  await index.applyFileChanges(paths);
278
- } catch {
279
- for (const p of paths) {
280
- entry.pendingPaths.add(p);
281
- }
261
+ } catch (err) {
262
+ requeue(err);
282
263
  return;
283
264
  }
265
+ entry.lastError = null;
284
266
  if (!isGitUrl(source)) {
285
267
  try {
286
268
  await index.saveToCache(resolve(source), { force: true });
@@ -364,40 +346,13 @@ export class IndexCache {
364
346
  this.noteFileChange(path, filename);
365
347
  });
366
348
  } catch {
367
- // Recursive fs.watch is unavailable on some platforms. The reconciliation
368
- // fallback below keeps local indexes current there.
349
+ // Recursive fs.watch is unavailable on some platforms (no fallback here
350
+ // anymore -- such a local index will only refresh on next cold load).
369
351
  }
370
352
 
371
- // Bun 1.3 on macOS can create a recursive watcher without delivering file
372
- // events. Reconcile periodically in Bun so a silent watcher cannot leave an
373
- // MCP index stale. Native watchers remain the fast path everywhere else.
374
- let reconcileInFlight = false;
375
- const reconcile = (): void => {
376
- if (reconcileInFlight) {
377
- return;
378
- }
379
- const cacheKey = computeSourceCacheKey(path);
380
- const entry = this.entries.get(cacheKey);
381
- if (!entry?.index) {
382
- return;
383
- }
384
- reconcileInFlight = true;
385
- void this.checkAndQueueStaleFiles(path, entry.index, cacheKey).finally(() => {
386
- reconcileInFlight = false;
387
- });
388
- };
389
- const interval =
390
- process.versions.bun || !nativeWatcher
391
- ? setInterval(reconcile, BUN_WATCH_RECONCILE_INTERVAL_MS)
392
- : null;
393
- interval?.unref();
394
-
395
353
  this.watchers.set(resolved, {
396
354
  close: () => {
397
355
  nativeWatcher?.close();
398
- if (interval) {
399
- clearInterval(interval);
400
- }
401
356
  },
402
357
  });
403
358
  }
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))
153
152
  .min(1)
154
- .max(MAX_MCP_TOP_K)
155
153
  .optional()
156
- .describe(`Number of results (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
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))
159
+ .min(1)
160
+ .optional()
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
  }
@@ -226,10 +234,17 @@ export function createMcpServer(
226
234
  inputSchema: {
227
235
  literal: z
228
236
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
237
+ .optional()
229
238
  .describe(
230
239
  "Exact substring to find (env var, symbol, error code, quoted text). " +
231
240
  "Pass an array to OR-match several substrings (e.g. spelling/casing variants) in one call.",
232
241
  ),
242
+ query: z
243
+ .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
244
+ .optional()
245
+ .describe(
246
+ "Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
247
+ ),
233
248
  repo: z.string().describe(REPO_DESCRIPTION),
234
249
  mode: z
235
250
  .enum(["count", "locations", "lines"])
@@ -270,13 +285,17 @@ export function createMcpServer(
270
285
  .describe("Lines of context before/after each match, like `grep -C` (mode=lines only)."),
271
286
  },
272
287
  },
273
- async ({ literal, repo, ...locateOpts }) => {
288
+ async ({ literal, query, repo, ...locateOpts }) => {
289
+ const lit = literal ?? query;
290
+ if (lit == null) {
291
+ return toolErrorText(new Error("locate requires `literal` (exact substring to find)."));
292
+ }
274
293
  try {
275
294
  const index = await getIndexForRepo(repo, cache);
276
295
 
277
296
  let skip: BenchmarkSkipReason | undefined;
278
297
  const repoRoot = localRepoRoot(repo);
279
- if (benchmark && repoRoot && typeof literal === "string") {
298
+ if (benchmark && repoRoot && typeof lit === "string") {
280
299
  if (locateOpts.limit != null) {
281
300
  skip = "limited_locate";
282
301
  } else if (!selectComparableLiteralSearchTool()) {
@@ -285,7 +304,7 @@ export function createMcpServer(
285
304
  if (!skip) {
286
305
  const comparison = await withGrepTimeoutFallback(() =>
287
306
  benchmarkLocateComparison({
288
- literal,
307
+ literal: lit,
289
308
  repoPath: repoRoot,
290
309
  index,
291
310
  locate: locateOpts,
@@ -302,7 +321,7 @@ export function createMcpServer(
302
321
  skip = "local_repo_only";
303
322
  }
304
323
 
305
- const result = index.locateLiteral(literal, locateOpts);
324
+ const result = index.locateLiteral(lit, locateOpts);
306
325
  const payload = formatLiteralLocate(result);
307
326
  const body = formatLiteralLocateText(payload);
308
327
  return toolText(skip ? withBenchmarkSkippedNote(body, skip) : body);
@@ -386,18 +405,9 @@ export function createMcpServer(
386
405
  .int()
387
406
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
388
407
  repo: z.string().describe(REPO_DESCRIPTION),
389
- top_k: z
390
- .number()
391
- .int()
392
- .min(1)
393
- .max(MAX_MCP_TOP_K)
394
- .optional()
395
- .describe(
396
- `Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
397
- ),
398
408
  },
399
409
  },
400
- async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
410
+ async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
401
411
  try {
402
412
  const index = await getIndexForRepo(repo, cache);
403
413
  const repoRoot = localRepoRoot(repo);
@@ -407,7 +417,7 @@ export function createMcpServer(
407
417
  `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
408
418
  );
409
419
  }
410
- const results = await index.findRelated(chunk, clampMcpTopK(topK));
420
+ const results = await index.findRelated(chunk, SEARCH_RESULT_COUNT);
411
421
  if (results.length === 0) {
412
422
  return toolText(`No related chunks found for ${filePath}:${anchorLine}.`);
413
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/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type ContentType = "code" | "docs" | "config";
2
2
 
3
- /** Default index corpus: source code plus config (json/yaml/toml/…). */
4
- export const DEFAULT_CONTENT_TYPES: readonly ContentType[] = ["code", "config"];
3
+ /** Default index corpus: source code, config (json/yaml/toml/…), and docs (md/rst/txt). */
4
+ export const DEFAULT_CONTENT_TYPES: readonly ContentType[] = ["code", "config", "docs"];
5
5
 
6
6
  /** Mutable copy of {@link DEFAULT_CONTENT_TYPES} for API defaults. */
7
7
  export function defaultContentTypes(): ContentType[] {
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) {
package/src/version.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import packageJson from "../package.json";
3
4
  import { resolveCacheFolder } from "./cache.ts";
@@ -79,7 +80,12 @@ function updateCheckPath(): string {
79
80
 
80
81
  async function readUpdateCheckCache(): Promise<UpdateCheckCache | null> {
81
82
  try {
82
- const raw = await Bun.file(updateCheckPath()).text();
83
+ // node:fs/promises, not Bun.file().text(): a rejecting Bun.file() read
84
+ // (the common case here — no cache on a fresh install) doesn't keep
85
+ // Bun's Windows event loop alive, so the process can exit silently
86
+ // before this ever settles. Confirmed regression in Bun 1.4.0-1.4.2:
87
+ // github.com/oven-sh/bun/issues/39787 (fix open, not yet released).
88
+ const raw = await readFile(updateCheckPath(), "utf8");
83
89
  const parsed = JSON.parse(raw) as UpdateCheckCache;
84
90
  if (typeof parsed.checkedAt !== "number" || typeof parsed.latest !== "string") {
85
91
  return null;