@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 +24 -7
- package/package.json +1 -1
- package/src/cli.ts +44 -53
- package/src/credentials.ts +6 -1
- package/src/help.ts +2 -3
- package/src/index/chunk-selector.ts +39 -11
- package/src/installer/search-policy.ts +36 -40
- package/src/mcp/index-cache.ts +27 -72
- package/src/mcp/server.ts +38 -28
- package/src/miru-index.ts +36 -4
- package/src/search.ts +4 -0
- package/src/types.ts +2 -2
- package/src/utils.ts +1 -11
- package/src/version.ts +7 -1
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
|
-
|
|
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"
|
|
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
|
-
| `
|
|
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
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,
|
|
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
|
-
|
|
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 =
|
|
545
|
+
const query = contentRest[0];
|
|
559
546
|
if (!query) {
|
|
560
547
|
printCommandHelp("search");
|
|
561
548
|
process.exit(1);
|
|
562
549
|
}
|
|
563
|
-
const path = resolveSearchPath(
|
|
564
|
-
await runSearch(path, query,
|
|
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 =
|
|
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 <
|
|
589
|
-
const arg =
|
|
590
|
-
if (arg === "--mode" &&
|
|
591
|
-
const value =
|
|
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" &&
|
|
600
|
-
options.limit = positiveInt("--limit",
|
|
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" &&
|
|
612
|
-
include.push(
|
|
598
|
+
if (arg === "--include" && contentRest[i + 1]) {
|
|
599
|
+
include.push(contentRest[++i] as string);
|
|
613
600
|
continue;
|
|
614
601
|
}
|
|
615
|
-
if (arg === "--exclude" &&
|
|
616
|
-
exclude.push(
|
|
602
|
+
if (arg === "--exclude" && contentRest[i + 1]) {
|
|
603
|
+
exclude.push(contentRest[++i] as string);
|
|
617
604
|
continue;
|
|
618
605
|
}
|
|
619
|
-
if (arg === "--context" &&
|
|
620
|
-
options.context_lines = positiveInt("--context",
|
|
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 =
|
|
640
|
-
const lineRaw =
|
|
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(
|
|
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 <
|
|
650
|
-
const arg =
|
|
651
|
-
if (arg === "--before" &&
|
|
652
|
-
before = Number(
|
|
653
|
-
} else if (arg === "--after" &&
|
|
654
|
-
after = Number(
|
|
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 =
|
|
663
|
-
const lineRaw =
|
|
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(
|
|
670
|
-
await runFindRelated(path, filePath, line,
|
|
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;
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
|
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 .
|
|
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)
|
|
12
|
+
* (search all chunks), and an empty array when filters match no chunks.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
* callers).
|
|
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
|
|
33
|
+
return [];
|
|
32
34
|
}
|
|
33
35
|
const indices = mappings.languageMapping.get(lang);
|
|
34
|
-
return indices && indices.length > 0 ? indices :
|
|
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
|
|
42
|
+
return [];
|
|
41
43
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5
|
-
"
|
|
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
|
-
|
|
|
17
|
-
| How/where/what handles X? | Miru MCP \`search\` (once) | ${native.
|
|
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
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
122
|
-
"
|
|
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
|
|
141
|
-
"
|
|
142
|
-
"
|
|
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`
|
|
150
|
-
'
|
|
151
|
-
"
|
|
152
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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;
|
package/src/mcp/index-cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
264
|
-
entry.pendingPaths.add(p);
|
|
265
|
-
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
requeue(err);
|
|
266
251
|
return;
|
|
267
252
|
}
|
|
268
253
|
}
|
|
269
254
|
if (!index) {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
368
|
-
//
|
|
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
|
-
|
|
151
|
-
.
|
|
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(
|
|
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,
|
|
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 =
|
|
168
|
-
|
|
174
|
+
const k = SEARCH_RESULT_COUNT;
|
|
169
175
|
let skip: BenchmarkSkipReason | undefined;
|
|
170
|
-
if (benchmark &&
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
-
* `
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|