@takara-ai/miru-code 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +4 -4
- package/src/mcp/server.ts +23 -24
- package/src/miru-index.ts +36 -4
- package/src/search.ts +4 -0
- package/src/utils.ts +1 -11
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
|
+
}
|
|
@@ -30,13 +30,13 @@ ${SNIPPET_GUIDANCE}
|
|
|
30
30
|
|
|
31
31
|
Use Miru MCP tools:
|
|
32
32
|
- \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
|
|
33
|
-
- \`search\` — one call per conceptual question; pass project root as \`repo\`
|
|
33
|
+
- \`search\` — one call per conceptual question; pass project root as \`repo\`; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
|
|
34
34
|
- \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
|
|
35
35
|
- \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
|
|
36
36
|
|
|
37
37
|
Call shapes:
|
|
38
38
|
- \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
|
|
39
|
-
- \`search(query="<natural language>", repo="<project root>")\`
|
|
39
|
+
- \`search(query="<natural language>", repo="<project root>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
|
|
40
40
|
|
|
41
41
|
Stop rules:
|
|
42
42
|
- Literal in the request → \`locate\`, not \`search\`
|
|
@@ -134,7 +134,7 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
|
134
134
|
|
|
135
135
|
export const MCP_SEARCH_TOOL_DESCRIPTION =
|
|
136
136
|
"Your default search for all code search queries in this indexed repo — the best, fastest, and cheapest way to find code; better than any other tool. " +
|
|
137
|
-
"Returns compact snippets (~±15 lines). One call per question. " +
|
|
137
|
+
"Returns up to 5 compact snippets (~±15 lines). One call per question. Use `include` / `exclude` Gitignore-style globs to scope files; these work the same way on `locate`. " +
|
|
138
138
|
"Exact literal (env var, symbol, error code, quoted text)? Use `locate` instead. " +
|
|
139
139
|
"On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
|
|
140
140
|
|
|
@@ -155,7 +155,7 @@ export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
|
155
155
|
"Use only if `truncated: true` and the snippet doesn't already answer the question — not for similar code elsewhere (use find_related).";
|
|
156
156
|
|
|
157
157
|
export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
|
|
158
|
-
"Find
|
|
158
|
+
"Find up to 5 chunks similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
|
|
159
159
|
"For more context in the same file, use `expand` instead.";
|
|
160
160
|
|
|
161
161
|
export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
|
package/src/mcp/server.ts
CHANGED
|
@@ -24,17 +24,15 @@ import {
|
|
|
24
24
|
import { formatLiteralLocate } from "../literal.ts";
|
|
25
25
|
import type { Chunk, ContentType, SearchResult } from "../types.ts";
|
|
26
26
|
import {
|
|
27
|
-
clampMcpTopK,
|
|
28
27
|
DEFAULT_EXPAND_AFTER,
|
|
29
28
|
DEFAULT_EXPAND_BEFORE,
|
|
30
|
-
DEFAULT_MCP_TOP_K,
|
|
31
29
|
dedupeResultsByFile,
|
|
32
30
|
expandChunksAtLine,
|
|
33
31
|
formatExpandResults,
|
|
34
32
|
formatResults,
|
|
35
33
|
localRepoRoot,
|
|
36
|
-
MAX_MCP_TOP_K,
|
|
37
34
|
resolveChunk,
|
|
35
|
+
SEARCH_RESULT_COUNT,
|
|
38
36
|
} from "../utils.ts";
|
|
39
37
|
import { registerAuthTool, toolErrorText } from "./auth-tool.ts";
|
|
40
38
|
import {
|
|
@@ -52,6 +50,8 @@ const REPO_DESCRIPTION =
|
|
|
52
50
|
|
|
53
51
|
const BENCHMARK_SKIP_NOTES = {
|
|
54
52
|
local_repo_only: "Benchmark comparisons require a local repo path; git URL repos are skipped.",
|
|
53
|
+
filtered_search:
|
|
54
|
+
"Benchmark comparisons are skipped when search is restricted by include/exclude patterns.",
|
|
55
55
|
limited_locate:
|
|
56
56
|
"Benchmark comparison skipped: locate.limit is global, while rg/grep limits are per file. Omit limit for a valid token and recall comparison.",
|
|
57
57
|
incompatible_literal_baseline:
|
|
@@ -147,27 +147,35 @@ export function createMcpServer(
|
|
|
147
147
|
"Natural language or code query — your default for all code search in this repo.",
|
|
148
148
|
),
|
|
149
149
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
150
|
-
|
|
151
|
-
.
|
|
152
|
-
.
|
|
150
|
+
include: z
|
|
151
|
+
.array(z.string().min(1))
|
|
152
|
+
.min(1)
|
|
153
|
+
.optional()
|
|
154
|
+
.describe(
|
|
155
|
+
'Gitignore-style glob patterns; only matching files are searched (e.g. ["src/auth/**", "docs/**/*.md"]). Same semantics as `locate.include`.',
|
|
156
|
+
),
|
|
157
|
+
exclude: z
|
|
158
|
+
.array(z.string().min(1))
|
|
153
159
|
.min(1)
|
|
154
|
-
.max(MAX_MCP_TOP_K)
|
|
155
160
|
.optional()
|
|
156
|
-
.describe(
|
|
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
|
}
|
|
@@ -397,18 +405,9 @@ export function createMcpServer(
|
|
|
397
405
|
.int()
|
|
398
406
|
.describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
|
|
399
407
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
400
|
-
top_k: z
|
|
401
|
-
.number()
|
|
402
|
-
.int()
|
|
403
|
-
.min(1)
|
|
404
|
-
.max(MAX_MCP_TOP_K)
|
|
405
|
-
.optional()
|
|
406
|
-
.describe(
|
|
407
|
-
`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
|
|
408
|
-
),
|
|
409
408
|
},
|
|
410
409
|
},
|
|
411
|
-
async ({ file_path: filePath, anchor_line: anchorLine, repo
|
|
410
|
+
async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
|
|
412
411
|
try {
|
|
413
412
|
const index = await getIndexForRepo(repo, cache);
|
|
414
413
|
const repoRoot = localRepoRoot(repo);
|
|
@@ -418,7 +417,7 @@ export function createMcpServer(
|
|
|
418
417
|
`No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
|
|
419
418
|
);
|
|
420
419
|
}
|
|
421
|
-
const results = await index.findRelated(chunk,
|
|
420
|
+
const results = await index.findRelated(chunk, SEARCH_RESULT_COUNT);
|
|
422
421
|
if (results.length === 0) {
|
|
423
422
|
return toolText(`No related chunks found for ${filePath}:${anchorLine}.`);
|
|
424
423
|
}
|
package/src/miru-index.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { createIndexFromPath } from "./index/create.ts";
|
|
|
14
14
|
import { applyIncrementalFileChanges, normalizeRelativePath } from "./index/incremental.ts";
|
|
15
15
|
import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
|
|
16
16
|
import type { SemanticIndex } from "./index/semantic-index.ts";
|
|
17
|
-
import {
|
|
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/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) {
|