@takara-ai/miru-code 1.10.1 → 1.12.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 +3 -2
- package/docs/self-hosted-sagemaker.md +24 -0
- package/package.json +1 -1
- package/src/agents/claude.md +1 -2
- package/src/chunking/ast.ts +2 -15
- package/src/chunking/chunking.ts +21 -2
- package/src/cli.ts +2 -2
- package/src/credentials.ts +29 -0
- package/src/embeddings/openai.ts +21 -12
- package/src/installer/search-policy.ts +28 -22
- package/src/literal.ts +133 -11
- package/src/mcp/auth-tool.ts +5 -7
- package/src/mcp/format-text.ts +80 -6
- package/src/mcp/runtime.ts +29 -1
- package/src/mcp/server.ts +47 -45
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ Plugin installs don't carry full CLI parity — what each one gives you depends
|
|
|
48
48
|
| `miru` / Caveman / STE skills | ✅ | ✅ | ✅ |
|
|
49
49
|
| Dedicated sub-agent (`miru:miru-code`) | ✅ | — | — |
|
|
50
50
|
| Benchmark mode toggle (`/plugin configure`) | ✅ | — | — |
|
|
51
|
+
| Self-hosted SageMaker embeddings (`/plugin configure`) | ✅ | — | — |
|
|
51
52
|
| Credentials | own plugin-scoped dir | own plugin-scoped dir | n/a |
|
|
52
53
|
|
|
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.
|
|
@@ -323,7 +324,7 @@ Miru sends **file contents** to the [Takara inference API](https://takara.ai) wh
|
|
|
323
324
|
|
|
324
325
|
If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. `MIRU_WORKSPACE_ROOT` is an opt-in boundary for MCP local `repo` paths only, and restricts indexing to a single workspace directory when set.
|
|
325
326
|
|
|
326
|
-
Enterprise self-hosted embeddings (no Takara egress): see [docs/self-hosted-sagemaker.md](docs/self-hosted-sagemaker.md).
|
|
327
|
+
Enterprise self-hosted embeddings (no Takara egress): see [docs/self-hosted-sagemaker.md](docs/self-hosted-sagemaker.md), including the [Claude Code plugin settings](docs/self-hosted-sagemaker.md#claude-code-plugin).
|
|
327
328
|
|
|
328
329
|
## Benchmark mode
|
|
329
330
|
|
|
@@ -375,7 +376,7 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
|
|
|
375
376
|
| `repo` | no | Startup directory by default; set for another repo |
|
|
376
377
|
| `include` | no | Gitignore-style glob patterns; only matching files are searched |
|
|
377
378
|
| `exclude` | no | Gitignore-style glob patterns; matching files are skipped |
|
|
378
|
-
| `mode` | no | `count` · `locations` · `lines` (default). Prefer `count`/`locations` when possible |
|
|
379
|
+
| `mode` | no | `count` · `files` (unique paths) · `locations` · `lines` (default). Prefer `count`/`files`/`locations` when possible |
|
|
379
380
|
| `limit` | no | Cap returned hits. Omit to return all matches |
|
|
380
381
|
| `ignore_case` | no | Case-insensitive match (default `false`) |
|
|
381
382
|
|
|
@@ -174,6 +174,28 @@ After that, indexing and search embed via SageMaker — no egress to `infer.taka
|
|
|
174
174
|
|
|
175
175
|
If `TAKARA_API_KEY` is still set in your shell or `.env.local`, remove it there too so it cannot conflict later.
|
|
176
176
|
|
|
177
|
+
### Claude Code plugin
|
|
178
|
+
|
|
179
|
+
The plugin has no terminal step. Do steps 1–3 first, then set the plugin settings. Run `/plugin`, open **miru**, and set:
|
|
180
|
+
|
|
181
|
+
| Setting | Value |
|
|
182
|
+
| ------------------------ | ---------------------------------------------------------------------------------------- |
|
|
183
|
+
| `SageMaker endpoint ARN` | The `EndpointArn` output from step 2 |
|
|
184
|
+
| `AWS profile` | The profile from step 3. It must already exist in `~/.aws`; Miru never creates one. |
|
|
185
|
+
|
|
186
|
+
Or from a shell, then restart Claude Code:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
echo '{"sagemaker_endpoint_arn":"arn:aws:sagemaker:<region>:<account-id>:endpoint/<name>","aws_profile":"miru"}' \
|
|
190
|
+
| claude plugin configure miru@miru --values-stdin
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
When the ARN is set, it **takes priority over any stored Takara sign-in** and Miru does not contact `infer.takara.ai`. Nothing is deleted, so clearing the ARN returns you to Takara. Leaving the ARN blank means Takara is used. The profile is only read when an ARN is set.
|
|
194
|
+
|
|
195
|
+
If the profile uses SSO, run `aws sso login --profile <name>` again whenever the token expires.
|
|
196
|
+
|
|
197
|
+
The plugin's `auth` tool only handles Takara sign-in; SageMaker is configured through these settings only.
|
|
198
|
+
|
|
177
199
|
## Switch back to Takara
|
|
178
200
|
|
|
179
201
|
```bash
|
|
@@ -184,6 +206,8 @@ Enter your Takara API key. Setup validates it, saves it, and **deletes any store
|
|
|
184
206
|
|
|
185
207
|
Also unset any `MIRU_SAGEMAKER_*` variables if you set them in the environment or `.env.local`.
|
|
186
208
|
|
|
209
|
+
In the Claude Code plugin, clear the **SageMaker endpoint ARN** setting and restart.
|
|
210
|
+
|
|
187
211
|
## Environment alternatives
|
|
188
212
|
|
|
189
213
|
Prefer `miru setup --sagemaker` so auth is checked automatically. You can also set:
|
package/package.json
CHANGED
package/src/agents/claude.md
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: miru-code
|
|
3
|
-
description:
|
|
4
|
-
tools: Bash, Read
|
|
3
|
+
description: Use proactively for any codebase exploration — "where is X handled", "how does Y work", "what calls Z", mapping an unfamiliar area, or finding the right file before an edit. Searches by meaning via Miru MCP instead of Grep/Glob/Read sweeps.
|
|
5
4
|
---
|
package/src/chunking/ast.ts
CHANGED
|
@@ -68,19 +68,6 @@ function mergeNode(node: Node, desiredLength: number): ChunkBoundary[] {
|
|
|
68
68
|
return mergeAdjacentChunks(rawChunks, desiredLength);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function byteBoundariesToCharBoundaries(
|
|
72
|
-
source: string,
|
|
73
|
-
byteBoundaries: ChunkBoundary[],
|
|
74
|
-
): ChunkBoundary[] {
|
|
75
|
-
const sourceBytes = new TextEncoder().encode(source);
|
|
76
|
-
const decoder = new TextDecoder();
|
|
77
|
-
|
|
78
|
-
return byteBoundaries.map((boundary) => ({
|
|
79
|
-
start: decoder.decode(sourceBytes.subarray(0, boundary.start)).length,
|
|
80
|
-
end: decoder.decode(sourceBytes.subarray(0, boundary.end)).length,
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
71
|
/** AST-aware chunk boundaries via vendored tree-sitter grammars; null to fall back. */
|
|
85
72
|
export async function chunkAst(
|
|
86
73
|
source: string,
|
|
@@ -129,8 +116,8 @@ function parseBoundaries(
|
|
|
129
116
|
}
|
|
130
117
|
|
|
131
118
|
try {
|
|
132
|
-
|
|
133
|
-
return
|
|
119
|
+
// web-tree-sitter reports UTF-16 string indices (not UTF-8 bytes), so boundaries slice `source` directly.
|
|
120
|
+
return mergeNode(tree.rootNode, desiredLength);
|
|
134
121
|
} finally {
|
|
135
122
|
tree.delete();
|
|
136
123
|
}
|
package/src/chunking/chunking.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import type { Chunk } from "../types.ts";
|
|
2
2
|
import { chunkAst } from "./ast.ts";
|
|
3
|
-
import { chunkLines } from "./lines.ts";
|
|
3
|
+
import { type ChunkBoundary, chunkLines } from "./lines.ts";
|
|
4
4
|
import { chunkStructural } from "./structural.ts";
|
|
5
5
|
|
|
6
6
|
/** Match upstream hybrid-search chunking target length. */
|
|
7
7
|
const DESIRED_CHUNK_LENGTH_CHARS = 1500;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Sibling AST nodes split mid-line leave their inter-token whitespace in no chunk, so rejoining
|
|
11
|
+
* the line later yields `exportasync`. Extend the earlier chunk across a same-line, whitespace-only
|
|
12
|
+
* gap; gaps containing a newline (between declarations) are left exactly as they were.
|
|
13
|
+
*/
|
|
14
|
+
function closeSameLineGap(
|
|
15
|
+
source: string,
|
|
16
|
+
boundary: ChunkBoundary,
|
|
17
|
+
next: ChunkBoundary | undefined,
|
|
18
|
+
): ChunkBoundary {
|
|
19
|
+
if (!next || next.start <= boundary.end) {
|
|
20
|
+
return boundary;
|
|
21
|
+
}
|
|
22
|
+
return /^[^\S\n]+$/.test(source.slice(boundary.end, next.start))
|
|
23
|
+
? { start: boundary.start, end: next.start }
|
|
24
|
+
: boundary;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
export async function chunkSource(
|
|
10
28
|
source: string,
|
|
11
29
|
filePath: string,
|
|
@@ -26,7 +44,8 @@ export async function chunkSource(
|
|
|
26
44
|
prefixNewlineCounts[i + 1] = prevCount + (source[i] === "\n" ? 1 : 0);
|
|
27
45
|
}
|
|
28
46
|
|
|
29
|
-
for (const
|
|
47
|
+
for (const [i, raw] of boundaries.entries()) {
|
|
48
|
+
const boundary = closeSameLineGap(source, raw, boundaries[i + 1]);
|
|
30
49
|
const endIndex = Math.max(boundary.end - 1, boundary.start);
|
|
31
50
|
const text = source.slice(boundary.start, endIndex + 1);
|
|
32
51
|
chunks.push({
|
package/src/cli.ts
CHANGED
|
@@ -541,8 +541,8 @@ async function runCli(argv: string[]): Promise<void> {
|
|
|
541
541
|
const arg = contentRest[i];
|
|
542
542
|
if (arg === "--mode" && contentRest[i + 1]) {
|
|
543
543
|
const value = contentRest[++i];
|
|
544
|
-
if (value !== "count" && value !== "locations" && value !== "lines") {
|
|
545
|
-
fail(`Unknown locate mode "${value}". Use count, locations, or lines.`);
|
|
544
|
+
if (value !== "count" && value !== "files" && value !== "locations" && value !== "lines") {
|
|
545
|
+
fail(`Unknown locate mode "${value}". Use count, files, locations, or lines.`);
|
|
546
546
|
process.exit(1);
|
|
547
547
|
}
|
|
548
548
|
options.mode = value as LiteralMode;
|
package/src/credentials.ts
CHANGED
|
@@ -116,6 +116,32 @@ function clearTakaraEnv(): void {
|
|
|
116
116
|
delete process.env.TAKARA_API_KEY;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** A plugin `userConfig` value, or undefined when blank or left as an unsubstituted `${...}`. */
|
|
120
|
+
function pluginConfigValue(name: string): string | undefined {
|
|
121
|
+
const value = process.env[name]?.trim();
|
|
122
|
+
return value && !isUnsubstitutedPlaceholder(value) ? value : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Plugin settings (`sagemaker_endpoint_arn` / `aws_profile` in plugin.json) arrive as
|
|
127
|
+
* MIRU_PLUGIN_* env vars. When an endpoint is configured there it is an explicit choice, so it
|
|
128
|
+
* wins over any stored credentials; Takara env is dropped because the modes are exclusive.
|
|
129
|
+
*/
|
|
130
|
+
function applyPluginSageMakerConfig(): boolean {
|
|
131
|
+
const arn = pluginConfigValue("MIRU_PLUGIN_SAGEMAKER_ENDPOINT_ARN");
|
|
132
|
+
if (!arn) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
process.env.MIRU_SAGEMAKER_ENDPOINT_ARN = arn;
|
|
136
|
+
const profile = pluginConfigValue("MIRU_PLUGIN_AWS_PROFILE");
|
|
137
|
+
if (profile) {
|
|
138
|
+
process.env.AWS_PROFILE = profile;
|
|
139
|
+
}
|
|
140
|
+
clearTakaraEnv();
|
|
141
|
+
activeStoredToken = null;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
function hydrateSageMakerEnv(sagemaker: StoredSageMakerCredentials): void {
|
|
120
146
|
process.env.MIRU_SAGEMAKER_ENDPOINT_ARN = sagemaker.endpoint_arn;
|
|
121
147
|
if (sagemaker.profile && !process.env.AWS_PROFILE) {
|
|
@@ -144,6 +170,9 @@ function markLoadedToken(token: string): void {
|
|
|
144
170
|
/** Hydrate TAKARA_API_KEY / SageMaker env vars from the credentials file when env is unset. */
|
|
145
171
|
export async function loadStoredCredentials(): Promise<boolean> {
|
|
146
172
|
normalizeTakaraApiKeyEnv();
|
|
173
|
+
if (applyPluginSageMakerConfig()) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
147
176
|
const stored = await readStoredCredentials();
|
|
148
177
|
if (!stored) {
|
|
149
178
|
return false;
|
package/src/embeddings/openai.ts
CHANGED
|
@@ -324,6 +324,8 @@ export interface EmbeddingClient {
|
|
|
324
324
|
input: string[] | string,
|
|
325
325
|
model: string,
|
|
326
326
|
dimensions?: number,
|
|
327
|
+
/** True for search queries; sent as a tiny `X-Q` header. Indexing sends nothing. */
|
|
328
|
+
query?: boolean,
|
|
327
329
|
): Promise<EmbeddingResponse>;
|
|
328
330
|
}
|
|
329
331
|
|
|
@@ -364,6 +366,7 @@ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
|
|
|
364
366
|
input: string[] | string,
|
|
365
367
|
model: string,
|
|
366
368
|
dimensions?: number,
|
|
369
|
+
query?: boolean,
|
|
367
370
|
): Promise<EmbeddingResponse> {
|
|
368
371
|
await loadStoredCredentials();
|
|
369
372
|
const apiKey = resolveEmbeddingApiKey();
|
|
@@ -376,6 +379,7 @@ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
|
|
|
376
379
|
headers: {
|
|
377
380
|
Authorization: `Bearer ${apiKey}`,
|
|
378
381
|
"Content-Type": "application/json",
|
|
382
|
+
...(query ? { "X-Q": "1" } : {}),
|
|
379
383
|
},
|
|
380
384
|
body: JSON.stringify(body),
|
|
381
385
|
});
|
|
@@ -429,11 +433,11 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
429
433
|
}
|
|
430
434
|
|
|
431
435
|
/** Embed pre-windowed inputs; issues a single API request (may 413-split). */
|
|
432
|
-
async embedInputs(texts: string[]): Promise<Float32Array[]> {
|
|
436
|
+
async embedInputs(texts: string[], query = false): Promise<Float32Array[]> {
|
|
433
437
|
if (texts.length === 0) {
|
|
434
438
|
return [];
|
|
435
439
|
}
|
|
436
|
-
const vectors = await this.embedBatchRawWithRetry(texts.map(sanitizeEmbeddingInput));
|
|
440
|
+
const vectors = await this.embedBatchRawWithRetry(texts.map(sanitizeEmbeddingInput), query);
|
|
437
441
|
for (const vec of vectors) {
|
|
438
442
|
if (this.dimensions === 0 && vec.length > 0) {
|
|
439
443
|
this.dimensions = vec.length;
|
|
@@ -442,7 +446,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
442
446
|
return vectors;
|
|
443
447
|
}
|
|
444
448
|
|
|
445
|
-
async embedDocuments(texts: string[]): Promise<Float32Array[]> {
|
|
449
|
+
async embedDocuments(texts: string[], query = false): Promise<Float32Array[]> {
|
|
446
450
|
if (texts.length === 0) {
|
|
447
451
|
return [];
|
|
448
452
|
}
|
|
@@ -455,7 +459,10 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
455
459
|
const batches = batchEmbeddingWindowJobs(jobs, this.batchSize);
|
|
456
460
|
const concurrency = resolveWorkerConcurrency();
|
|
457
461
|
await mapPool(batches, concurrency, async (batch) => {
|
|
458
|
-
const vectors = await this.embedInputs(
|
|
462
|
+
const vectors = await this.embedInputs(
|
|
463
|
+
batch.map((job) => job.text),
|
|
464
|
+
query,
|
|
465
|
+
);
|
|
459
466
|
assignEmbeddingWindowVectors(batch, vectors, buckets);
|
|
460
467
|
});
|
|
461
468
|
|
|
@@ -468,7 +475,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
468
475
|
return out;
|
|
469
476
|
}
|
|
470
477
|
|
|
471
|
-
private async requestEmbeddings(texts: string[]): Promise<EmbeddingResponse> {
|
|
478
|
+
private async requestEmbeddings(texts: string[], query: boolean): Promise<EmbeddingResponse> {
|
|
472
479
|
let lastErr: unknown;
|
|
473
480
|
for (let attempt = 0; attempt <= MAX_TRANSIENT_EMBED_RETRIES; attempt++) {
|
|
474
481
|
try {
|
|
@@ -477,6 +484,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
477
484
|
texts,
|
|
478
485
|
this.model,
|
|
479
486
|
this.requestedDimensions,
|
|
487
|
+
query,
|
|
480
488
|
);
|
|
481
489
|
const elapsed = performance.now() - started;
|
|
482
490
|
this.stats.requests += 1;
|
|
@@ -498,12 +506,13 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
498
506
|
throw lastErr;
|
|
499
507
|
}
|
|
500
508
|
|
|
501
|
-
private async embedBatchRawWithRetry(texts: string[]): Promise<Float32Array[]> {
|
|
509
|
+
private async embedBatchRawWithRetry(texts: string[], query: boolean): Promise<Float32Array[]> {
|
|
502
510
|
if (texts.length === 0) {
|
|
503
511
|
return [];
|
|
504
512
|
}
|
|
513
|
+
const retry = (batch: string[]) => this.embedBatchRawWithRetry(batch, query);
|
|
505
514
|
try {
|
|
506
|
-
const response = await this.requestEmbeddings(texts);
|
|
515
|
+
const response = await this.requestEmbeddings(texts, query);
|
|
507
516
|
const vectors = vectorsFromResponse(response.data, texts.length);
|
|
508
517
|
for (const vec of vectors) {
|
|
509
518
|
if (this.requestedDimensions != null && vec.length !== this.requestedDimensions) {
|
|
@@ -525,8 +534,8 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
525
534
|
this.stats.payloadTooLarge += 1;
|
|
526
535
|
this.stats.retries += 1;
|
|
527
536
|
const mid = Math.ceil(texts.length / 2);
|
|
528
|
-
const left = await
|
|
529
|
-
const right = await
|
|
537
|
+
const left = await retry(texts.slice(0, mid));
|
|
538
|
+
const right = await retry(texts.slice(mid));
|
|
530
539
|
return [...left, ...right];
|
|
531
540
|
}
|
|
532
541
|
if (isPayloadTooLargeError(err) && texts.length === 1) {
|
|
@@ -544,8 +553,8 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
544
553
|
const mid = Math.floor(text.length / 2);
|
|
545
554
|
const leftText = text.slice(0, mid);
|
|
546
555
|
const rightText = text.slice(mid);
|
|
547
|
-
const left = await
|
|
548
|
-
const right = await
|
|
556
|
+
const left = await retry([leftText]);
|
|
557
|
+
const right = await retry([rightText]);
|
|
549
558
|
const parts = [left[0], right[0]].filter((vec): vec is Float32Array => vec !== undefined);
|
|
550
559
|
if (parts.length === 0) {
|
|
551
560
|
this.stats.errors += 1;
|
|
@@ -559,7 +568,7 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
559
568
|
}
|
|
560
569
|
|
|
561
570
|
async embedQuery(text: string): Promise<Float32Array> {
|
|
562
|
-
const [vec] = await this.embedDocuments([text]);
|
|
571
|
+
const [vec] = await this.embedDocuments([text], true);
|
|
563
572
|
if (!vec) {
|
|
564
573
|
throw new Error("OpenAI returned no embedding for query");
|
|
565
574
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
|
|
2
2
|
|
|
3
3
|
export const SNIPPET_GUIDANCE =
|
|
4
|
-
"
|
|
5
|
-
"`
|
|
4
|
+
"Snippets are ~±15 lines. On `truncated: true`, call `expand` with `file_path` and " +
|
|
5
|
+
"`anchor_line` — only if the snippet doesn't already answer.";
|
|
6
|
+
|
|
7
|
+
export const WHEN_TO_REACH_FOR_MIRU =
|
|
8
|
+
"Reach for Miru FIRST, before opening files, whenever you don't know the exact path: starting in unfamiliar code, " +
|
|
9
|
+
"finding where to edit, how/where/why questions, tracing callers or similar code, docs/config. " +
|
|
10
|
+
"`locate` also does regex (`/re/`) and lists files by name (`include` globs, no literal). " +
|
|
11
|
+
"Grep/Glob only for files Miru does not index (lockfiles, binaries, generated or git-ignored).";
|
|
6
12
|
|
|
7
13
|
export interface NativeToolNames {
|
|
8
14
|
explorationDenied: string;
|
|
@@ -14,6 +20,8 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
|
|
|
14
20
|
return `| Task | Use | Not |
|
|
15
21
|
|------|-----|-----|
|
|
16
22
|
| Exact literal (identifier, quoted string, env var, error code) | Miru MCP \`locate\` | Miru \`search\`, ${native.grep} |
|
|
23
|
+
| Regex over code | Miru MCP \`locate\` with \`/regex/\` (\`mode=files\` for paths only) | ${native.grep} |
|
|
24
|
+
| List files by name pattern | Miru MCP \`locate\` with \`include\` globs and no literal | Glob |
|
|
17
25
|
| How/where/what handles X? (conceptual) | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
|
|
18
26
|
| Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
|
|
19
27
|
| Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
|
|
@@ -24,6 +32,8 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
|
|
|
24
32
|
export function buildSearchPolicyBody(native: NativeToolNames): string {
|
|
25
33
|
return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
|
|
26
34
|
|
|
35
|
+
${WHEN_TO_REACH_FOR_MIRU}
|
|
36
|
+
|
|
27
37
|
**Check first:** exact token (identifier, quoted string, env var, error code) → \`locate\`. Otherwise → \`search\`.
|
|
28
38
|
|
|
29
39
|
${SNIPPET_GUIDANCE}
|
|
@@ -31,7 +41,7 @@ ${SNIPPET_GUIDANCE}
|
|
|
31
41
|
\`repo\` is optional, set it for another repo.
|
|
32
42
|
|
|
33
43
|
Use Miru MCP tools:
|
|
34
|
-
- \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
|
|
44
|
+
- \`locate\` — exact substring (env var, symbol, error code) or \`/regex/\`; no literal + \`include\` globs lists files; prefer \`mode=count\`, \`files\` or \`locations\`
|
|
35
45
|
- \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
|
|
36
46
|
- \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
|
|
37
47
|
- \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
|
|
@@ -49,6 +59,7 @@ Stop rules:
|
|
|
49
59
|
Native tools are allowed ONLY when:
|
|
50
60
|
- reading a file you already located via Miru, to edit it
|
|
51
61
|
- searching outside the indexed repo
|
|
62
|
+
- files Miru does not index (lockfiles, binaries, generated or git-ignored files)
|
|
52
63
|
|
|
53
64
|
${buildSearchPolicyTable(native)}`;
|
|
54
65
|
}
|
|
@@ -116,14 +127,14 @@ export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
|
|
|
116
127
|
|
|
117
128
|
export const MCP_SERVER_INSTRUCTIONS =
|
|
118
129
|
"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
|
-
|
|
130
|
+
`${WHEN_TO_REACH_FOR_MIRU} ` +
|
|
131
|
+
"DO NOT use Grep, grep_search, codebase_search, Glob, SemanticSearch, or Read to explore code while this server is available. " +
|
|
120
132
|
"Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
|
|
121
133
|
`${SNIPPET_GUIDANCE} ` +
|
|
122
|
-
"`find_related`
|
|
123
|
-
"`repo` is optional
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
|
|
134
|
+
"`find_related` finds similar code elsewhere. " +
|
|
135
|
+
"`repo` is optional (other repos). " +
|
|
136
|
+
"Indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
|
|
137
|
+
"On credential errors, call this server's `auth` tool, not the host `mcp_auth`.";
|
|
127
138
|
|
|
128
139
|
export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
129
140
|
`${MCP_SERVER_INSTRUCTIONS} ` +
|
|
@@ -135,22 +146,17 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
|
135
146
|
"To leave benchmark mode, tell the user to run `miru benchmark off` and restart the agent.";
|
|
136
147
|
|
|
137
148
|
export const MCP_SEARCH_TOOL_DESCRIPTION =
|
|
149
|
+
"Semantic codebase search: find code by meaning (where/how/what handles X, callers, related code, docs, config) instead of grepping or reading files. Use FIRST when you don't know the exact path. " +
|
|
138
150
|
"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. " +
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
|
|
151
|
+
"Up to 5 snippets (~±15 lines), one call per question; exact literals → `locate`. " +
|
|
152
|
+
"On `truncated: true`, `expand` only if the snippet doesn't already answer.";
|
|
142
153
|
|
|
143
154
|
export const MCP_LOCATE_TOOL_DESCRIPTION =
|
|
144
|
-
"Exact substring locator over the Miru index
|
|
145
|
-
'
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"Pass an array to `literal` for several spellings in one call. " +
|
|
150
|
-
'`match_variants: true` also matches other casings (e.g. "rateLimit" also finds "rate_limit"). ' +
|
|
151
|
-
"`include`/`exclude` (gitignore-style globs) scope to part of a monorepo; " +
|
|
152
|
-
"`context_lines` in `lines` mode gets inline context instead of a follow-up `expand`. " +
|
|
153
|
-
"In benchmark mode, includes compact token savings vs agent Grep.";
|
|
155
|
+
"Exact substring locator over the Miru index, also `/regex/` and file lists (grep for identifiers, env vars, error strings, quoted text, without the noise). " +
|
|
156
|
+
'Use for known literals, not meaning-based questions (`search`), even when phrased "where is X" if X is a literal. ' +
|
|
157
|
+
"A literal in slashes (`/foo\\w+Bar/`, `/…/i`) is a regex. No literal + `include` globs lists files by name pattern; mode=files returns just matching paths. " +
|
|
158
|
+
"Returns ALL matches by default — do NOT fall back to Grep/rg when n is large; prefer mode=count/files/locations unless you need line text. " +
|
|
159
|
+
"`context_lines` (mode=lines) saves a follow-up `expand`. In benchmark mode, includes token savings vs Grep.";
|
|
154
160
|
|
|
155
161
|
export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
156
162
|
"More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` from the hit. " +
|
package/src/literal.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import ignore from "ignore";
|
|
4
4
|
import type { Chunk } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
export type LiteralMode = "count" | "locations" | "lines";
|
|
6
|
+
export type LiteralMode = "count" | "files" | "locations" | "lines";
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_LITERAL_MODE: LiteralMode = "lines";
|
|
9
9
|
|
|
@@ -31,6 +31,8 @@ export interface LiteralLocateResult {
|
|
|
31
31
|
/** True only when an explicit `limit` cut the hit list short. */
|
|
32
32
|
truncated: boolean;
|
|
33
33
|
hits: LiteralHit[];
|
|
34
|
+
/** Sorted unique repo-relative paths. Present only in `files` mode and for file listings. */
|
|
35
|
+
file_list?: string[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface LiteralLocateOptions {
|
|
@@ -54,6 +56,26 @@ export interface LiteralLocateOptions {
|
|
|
54
56
|
context_lines?: number;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/** A `/body/` (optionally `/body/i`) literal is a regex only if the body has regex syntax, so `/api/users/` stays a plain substring. */
|
|
60
|
+
const SLASH_REGEX = /^\/(.+)\/(i?)$/s;
|
|
61
|
+
const REGEX_SYNTAX = /[\\^$.*+?()[\]{}|]/;
|
|
62
|
+
/** Regex is tested per line; cap the tested prefix so a minified line cannot stall the scan. */
|
|
63
|
+
const MAX_REGEX_LINE_CHARS = 4096;
|
|
64
|
+
|
|
65
|
+
function parseRegexLiteral(literal: string, ignoreCase: boolean): RegExp | null {
|
|
66
|
+
const m = SLASH_REGEX.exec(literal);
|
|
67
|
+
if (!m || !REGEX_SYNTAX.test(m[1] ?? "")) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return new RegExp(m[1] ?? "", ignoreCase || m[2] === "i" ? "i" : "");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Invalid regex ${literal}: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
function resolveLimit(limit: number | undefined): number | null {
|
|
58
80
|
if (limit == null || !Number.isFinite(limit)) {
|
|
59
81
|
return null;
|
|
@@ -61,6 +83,18 @@ function resolveLimit(limit: number | undefined): number | null {
|
|
|
61
83
|
return Math.max(1, Math.floor(limit));
|
|
62
84
|
}
|
|
63
85
|
|
|
86
|
+
/** True when any literal would be treated as a regex (so fixed-string baselines are not comparable). */
|
|
87
|
+
export function hasRegexLiteral(literal: string | readonly string[]): boolean {
|
|
88
|
+
return (Array.isArray(literal) ? literal : [literal]).some(
|
|
89
|
+
(lit) => parseRegexLiteral(lit, false) !== null,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function regexMatchesLine(line: string, regexes: readonly RegExp[]): boolean {
|
|
94
|
+
const tested = line.length > MAX_REGEX_LINE_CHARS ? line.slice(0, MAX_REGEX_LINE_CHARS) : line;
|
|
95
|
+
return regexes.some((re) => re.test(tested));
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
function lineMatchesAny(
|
|
65
99
|
haystack: string,
|
|
66
100
|
needles: readonly string[],
|
|
@@ -147,6 +181,46 @@ export function filterChunksByGlob(
|
|
|
147
181
|
});
|
|
148
182
|
}
|
|
149
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The AST chunker can split one source line across two adjacent chunks (it divides large
|
|
186
|
+
* declarations on token boundaries), so neither fragment alone contains the full line. Rejoin
|
|
187
|
+
* chunks that share a boundary line before scanning, or matches spanning the split are lost.
|
|
188
|
+
*/
|
|
189
|
+
export function stitchSplitLines(chunks: readonly Chunk[]): readonly Chunk[] {
|
|
190
|
+
const byFile = new Map<string, Chunk[]>();
|
|
191
|
+
for (const chunk of chunks) {
|
|
192
|
+
const list = byFile.get(chunk.file_path);
|
|
193
|
+
if (list) {
|
|
194
|
+
list.push(chunk);
|
|
195
|
+
} else {
|
|
196
|
+
byFile.set(chunk.file_path, [chunk]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const stitched: Chunk[] = [];
|
|
200
|
+
for (const list of byFile.values()) {
|
|
201
|
+
list.sort((a, b) => a.start_line - b.start_line || a.end_line - b.end_line);
|
|
202
|
+
let current = list[0];
|
|
203
|
+
if (!current) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
for (let i = 1; i < list.length; i++) {
|
|
207
|
+
const next = list[i] as Chunk;
|
|
208
|
+
if (next.start_line === current.end_line && next.end_line > current.start_line) {
|
|
209
|
+
current = {
|
|
210
|
+
...current,
|
|
211
|
+
content: current.content + next.content,
|
|
212
|
+
end_line: Math.max(current.end_line, next.end_line),
|
|
213
|
+
};
|
|
214
|
+
} else {
|
|
215
|
+
stitched.push(current);
|
|
216
|
+
current = next;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
stitched.push(current);
|
|
220
|
+
}
|
|
221
|
+
return stitched;
|
|
222
|
+
}
|
|
223
|
+
|
|
150
224
|
/**
|
|
151
225
|
* Scan indexed chunks for an exact substring (or, with an array `literal`/`match_variants`,
|
|
152
226
|
* any of several OR-matched substrings). Dedupes by file:line when chunks overlap.
|
|
@@ -168,13 +242,27 @@ export function locateLiteral(
|
|
|
168
242
|
|
|
169
243
|
const inputLiterals = (Array.isArray(literal) ? literal : [literal]).filter((l) => l.length > 0);
|
|
170
244
|
const label = inputLiterals.join(" | ");
|
|
171
|
-
const
|
|
245
|
+
const regexes: RegExp[] = [];
|
|
246
|
+
const plainLiterals: string[] = [];
|
|
247
|
+
for (const lit of inputLiterals) {
|
|
248
|
+
const re = parseRegexLiteral(lit, ignoreCase);
|
|
249
|
+
if (re) {
|
|
250
|
+
regexes.push(re);
|
|
251
|
+
} else {
|
|
252
|
+
plainLiterals.push(lit);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const needles = expandLiterals(plainLiterals, options.match_variants === true);
|
|
256
|
+
const matchesLine = (text: string): boolean =>
|
|
257
|
+
lineMatchesAny(text, needles, ignoreCase) || regexMatchesLine(text, regexes);
|
|
172
258
|
|
|
173
|
-
if (needles.length === 0) {
|
|
259
|
+
if (needles.length === 0 && regexes.length === 0) {
|
|
174
260
|
return { literal: label, mode, n: 0, files: 0, truncated: false, hits: [] };
|
|
175
261
|
}
|
|
176
262
|
|
|
177
|
-
const scopedChunks =
|
|
263
|
+
const scopedChunks = stitchSplitLines(
|
|
264
|
+
filterChunksByGlob(chunks, options.include, options.exclude),
|
|
265
|
+
);
|
|
178
266
|
|
|
179
267
|
const seen = new Set<string>();
|
|
180
268
|
const fileSet = new Set<string>();
|
|
@@ -182,13 +270,13 @@ export function locateLiteral(
|
|
|
182
270
|
let n = 0;
|
|
183
271
|
|
|
184
272
|
for (const chunk of scopedChunks) {
|
|
185
|
-
if (!lineMatchesAny(chunk.content, needles, ignoreCase)) {
|
|
273
|
+
if (regexes.length === 0 && !lineMatchesAny(chunk.content, needles, ignoreCase)) {
|
|
186
274
|
continue;
|
|
187
275
|
}
|
|
188
276
|
const lines = chunk.content.split("\n");
|
|
189
277
|
for (let i = 0; i < lines.length; i++) {
|
|
190
278
|
const lineText = lines[i] ?? "";
|
|
191
|
-
if (!
|
|
279
|
+
if (!matchesLine(lineText)) {
|
|
192
280
|
continue;
|
|
193
281
|
}
|
|
194
282
|
const line = chunk.start_line + i;
|
|
@@ -199,7 +287,7 @@ export function locateLiteral(
|
|
|
199
287
|
seen.add(key);
|
|
200
288
|
fileSet.add(chunk.file_path);
|
|
201
289
|
n += 1;
|
|
202
|
-
if (mode === "count") {
|
|
290
|
+
if (mode === "count" || mode === "files") {
|
|
203
291
|
continue;
|
|
204
292
|
}
|
|
205
293
|
if (limit != null && hits.length >= limit) {
|
|
@@ -224,15 +312,44 @@ export function locateLiteral(
|
|
|
224
312
|
mode,
|
|
225
313
|
n,
|
|
226
314
|
files: fileSet.size,
|
|
227
|
-
truncated: mode !== "count" && limit != null && n > hits.length,
|
|
228
|
-
hits: mode === "count" ? [] : hits,
|
|
315
|
+
truncated: mode !== "count" && mode !== "files" && limit != null && n > hits.length,
|
|
316
|
+
hits: mode === "count" || mode === "files" ? [] : hits,
|
|
229
317
|
};
|
|
230
|
-
if (
|
|
231
|
-
result.
|
|
318
|
+
if (mode === "files") {
|
|
319
|
+
result.file_list = [...fileSet].sort();
|
|
320
|
+
}
|
|
321
|
+
const searched = [...needles, ...inputLiterals.filter((l) => !plainLiterals.includes(l))];
|
|
322
|
+
if (searched.length > 1) {
|
|
323
|
+
result.literals = searched;
|
|
232
324
|
}
|
|
233
325
|
return result;
|
|
234
326
|
}
|
|
235
327
|
|
|
328
|
+
/**
|
|
329
|
+
* List indexed files, optionally scoped by gitignore-style `include`/`exclude` globs
|
|
330
|
+
* (the `locate` answer when no literal is given). Only indexed files appear.
|
|
331
|
+
*/
|
|
332
|
+
export function listIndexedFiles(
|
|
333
|
+
chunks: readonly Chunk[],
|
|
334
|
+
options: Pick<LiteralLocateOptions, "include" | "exclude" | "mode"> = {},
|
|
335
|
+
): LiteralLocateResult {
|
|
336
|
+
const mode = options.mode === "count" ? "count" : "files";
|
|
337
|
+
const files = [
|
|
338
|
+
...new Set(
|
|
339
|
+
filterChunksByGlob(chunks, options.include, options.exclude).map((c) => c.file_path),
|
|
340
|
+
),
|
|
341
|
+
].sort();
|
|
342
|
+
return {
|
|
343
|
+
literal: "",
|
|
344
|
+
mode,
|
|
345
|
+
n: files.length,
|
|
346
|
+
files: files.length,
|
|
347
|
+
truncated: false,
|
|
348
|
+
hits: [],
|
|
349
|
+
...(mode === "files" ? { file_list: files } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
236
353
|
/** Compact agent-facing JSON (minimal keys). */
|
|
237
354
|
export function formatLiteralLocate(result: LiteralLocateResult): Record<string, unknown> {
|
|
238
355
|
const base: Record<string, unknown> = {
|
|
@@ -249,6 +366,11 @@ export function formatLiteralLocate(result: LiteralLocateResult): Record<string,
|
|
|
249
366
|
return base;
|
|
250
367
|
}
|
|
251
368
|
|
|
369
|
+
if (result.mode === "files") {
|
|
370
|
+
base.paths = result.file_list ?? [];
|
|
371
|
+
return base;
|
|
372
|
+
}
|
|
373
|
+
|
|
252
374
|
if (result.truncated) {
|
|
253
375
|
base.truncated = true;
|
|
254
376
|
}
|
package/src/mcp/auth-tool.ts
CHANGED
|
@@ -34,12 +34,10 @@ const RECOVERY_HINT =
|
|
|
34
34
|
"signing in, check the account token balance.";
|
|
35
35
|
|
|
36
36
|
const AUTH_TOOL_DESCRIPTION =
|
|
37
|
-
"Sign in with Takara
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
"waits (up to about 45 seconds) for them to approve and returns once they have. If it " +
|
|
42
|
-
'returns "still waiting", tell the user the URL and code and call `auth` again.';
|
|
37
|
+
"Sign in with Takara device-code login. Only call in direct response to a tool error about " +
|
|
38
|
+
"missing, expired, rejected, or invalid credentials — never speculatively: it starts a real " +
|
|
39
|
+
"sign-in prompt. No arguments: opens the login page in the user's browser and waits up to ~45s " +
|
|
40
|
+
'for approval. If it returns "still waiting", tell the user the URL and code and call `auth` again.';
|
|
43
41
|
|
|
44
42
|
/** Browser opener seam; the real one spawns a detached `open`/`xdg-open`/`start`. */
|
|
45
43
|
export type BrowserOpener = (url: string) => boolean;
|
|
@@ -207,7 +205,7 @@ export function registerAuthTool(
|
|
|
207
205
|
.enum(["start", "check"])
|
|
208
206
|
.optional()
|
|
209
207
|
.describe(
|
|
210
|
-
'"start" begins
|
|
208
|
+
'"start" begins/resumes login and waits for approval (default); "check" polls once.',
|
|
211
209
|
),
|
|
212
210
|
},
|
|
213
211
|
},
|
package/src/mcp/format-text.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasRegexLiteral } from "../literal.ts";
|
|
1
2
|
import type { ExpandResults } from "../utils.ts";
|
|
2
3
|
|
|
3
4
|
/** Plain-text rendering of `formatResults()` output — same info as the JSON shape, no scaffolding. */
|
|
@@ -21,6 +22,39 @@ export function formatResultsText(payload: {
|
|
|
21
22
|
return blocks.join("\n\n");
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Very long lines (minified bundles, lockfiles) cost far more than they inform. */
|
|
26
|
+
const MAX_LINE_CHARS = 400;
|
|
27
|
+
|
|
28
|
+
function clipLine(text: string): string {
|
|
29
|
+
return text.length > MAX_LINE_CHARS
|
|
30
|
+
? `${text.slice(0, MAX_LINE_CHARS)}… [+${text.length - MAX_LINE_CHARS} chars]`
|
|
31
|
+
: text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Group sorted repo-relative paths by directory so each directory prints once. */
|
|
35
|
+
export function formatPathList(paths: readonly string[]): string {
|
|
36
|
+
const byDir = new Map<string, string[]>();
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
const slash = path.lastIndexOf("/");
|
|
39
|
+
const dir = slash < 0 ? "./" : path.slice(0, slash + 1);
|
|
40
|
+
const names = byDir.get(dir);
|
|
41
|
+
const name = slash < 0 ? path : path.slice(slash + 1);
|
|
42
|
+
if (names) {
|
|
43
|
+
names.push(name);
|
|
44
|
+
} else {
|
|
45
|
+
byDir.set(dir, [name]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A directory holding one match prints as a plain path; grouping only pays off for two or more.
|
|
49
|
+
return [...byDir]
|
|
50
|
+
.map(([dir, names]) =>
|
|
51
|
+
names.length === 1
|
|
52
|
+
? `${dir === "./" ? "" : dir}${names[0]}`
|
|
53
|
+
: `${dir}\n ${names.join("\n ")}`,
|
|
54
|
+
)
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
/** Plain-text rendering of `formatLiteralLocate()` output. */
|
|
25
59
|
export function formatLiteralLocateText(payload: Record<string, unknown>): string {
|
|
26
60
|
const label =
|
|
@@ -28,7 +62,19 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
|
|
|
28
62
|
const n = payload.n as number;
|
|
29
63
|
const files = payload.files as number;
|
|
30
64
|
const truncatedNote = payload.truncated ? " (truncated — pass a narrower include/exclude)" : "";
|
|
31
|
-
const
|
|
65
|
+
const fileCount = `${files} file${files === 1 ? "" : "s"}`;
|
|
66
|
+
// Short plain literals are worth echoing; a regex the caller just sent is not (it tokenizes poorly).
|
|
67
|
+
const counts = `${n} match${n === 1 ? "" : "es"} across ${fileCount}${truncatedNote}`;
|
|
68
|
+
const header = !label
|
|
69
|
+
? fileCount
|
|
70
|
+
: hasRegexLiteral(label.split(" | "))
|
|
71
|
+
? counts
|
|
72
|
+
: `"${label}": ${counts}`;
|
|
73
|
+
|
|
74
|
+
const paths = payload.paths as string[] | undefined;
|
|
75
|
+
if (paths) {
|
|
76
|
+
return paths.length === 0 ? header : `${header}\n\n${formatPathList(paths)}`;
|
|
77
|
+
}
|
|
32
78
|
|
|
33
79
|
const hits = payload.hits as
|
|
34
80
|
| Array<{ f: string; l: number; t?: string; ctx?: string[]; ctx_l?: number }>
|
|
@@ -38,12 +84,40 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
|
|
|
38
84
|
}
|
|
39
85
|
|
|
40
86
|
const lines = [header, ""];
|
|
87
|
+
if (hits.some((hit) => hit.ctx != null)) {
|
|
88
|
+
for (const hit of hits) {
|
|
89
|
+
lines.push(
|
|
90
|
+
hit.t !== undefined ? `${hit.f}:${hit.l}: ${clipLine(hit.t)}` : `${hit.f}:${hit.l}`,
|
|
91
|
+
);
|
|
92
|
+
if (hit.ctx != null && hit.ctx_l !== undefined) {
|
|
93
|
+
const ctxStart = hit.ctx_l;
|
|
94
|
+
for (let i = 0; i < hit.ctx.length; i++) {
|
|
95
|
+
lines.push(` ${ctxStart + i}: ${clipLine(hit.ctx[i] ?? "")}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Each path once: `path` then indented `line: text`, or `path: 3, 9, 40` without text.
|
|
103
|
+
const byFile = new Map<string, Array<{ l: number; t?: string }>>();
|
|
41
104
|
for (const hit of hits) {
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
105
|
+
const group = byFile.get(hit.f);
|
|
106
|
+
if (group) {
|
|
107
|
+
group.push({ l: hit.l, t: hit.t });
|
|
108
|
+
} else {
|
|
109
|
+
byFile.set(hit.f, [{ l: hit.l, t: hit.t }]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const [file, group] of byFile) {
|
|
113
|
+
if (group.every((hit) => hit.t === undefined)) {
|
|
114
|
+
lines.push(`${file}: ${group.map((hit) => hit.l).join(", ")}`);
|
|
115
|
+
} else if (group.length === 1) {
|
|
116
|
+
lines.push(`${file}:${group[0]?.l}: ${clipLine(group[0]?.t ?? "")}`);
|
|
117
|
+
} else {
|
|
118
|
+
lines.push(file);
|
|
119
|
+
for (const hit of group) {
|
|
120
|
+
lines.push(` ${hit.l}: ${clipLine(hit.t ?? "")}`);
|
|
47
121
|
}
|
|
48
122
|
}
|
|
49
123
|
}
|
package/src/mcp/runtime.ts
CHANGED
|
@@ -24,6 +24,7 @@ type ToolHandler<Args> = (args: Args) => Promise<{
|
|
|
24
24
|
|
|
25
25
|
type RegisteredTool = {
|
|
26
26
|
description: string;
|
|
27
|
+
meta?: Record<string, unknown>;
|
|
27
28
|
inputSchema: Record<string, z.ZodType>;
|
|
28
29
|
handler: ToolHandler<Record<string, unknown>>;
|
|
29
30
|
};
|
|
@@ -49,11 +50,13 @@ export class MiruMcpServer {
|
|
|
49
50
|
config: {
|
|
50
51
|
description: string;
|
|
51
52
|
inputSchema: S;
|
|
53
|
+
_meta?: Record<string, unknown>;
|
|
52
54
|
},
|
|
53
55
|
handler: ToolHandler<z.infer<z.ZodObject<S>>>,
|
|
54
56
|
): void {
|
|
55
57
|
this.tools.set(name, {
|
|
56
58
|
description: config.description,
|
|
59
|
+
meta: config._meta,
|
|
57
60
|
inputSchema: config.inputSchema,
|
|
58
61
|
handler: handler as ToolHandler<Record<string, unknown>>,
|
|
59
62
|
});
|
|
@@ -163,6 +166,7 @@ export class MiruMcpServer {
|
|
|
163
166
|
name,
|
|
164
167
|
description: tool.description,
|
|
165
168
|
inputSchema: shapeToJsonSchema(tool.inputSchema),
|
|
169
|
+
...(tool.meta ? { _meta: tool.meta } : {}),
|
|
166
170
|
}));
|
|
167
171
|
}
|
|
168
172
|
|
|
@@ -234,8 +238,32 @@ function isNotification(message: JsonRpcMessage): message is { jsonrpc: "2.0"; m
|
|
|
234
238
|
return "method" in message && !("id" in message);
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Trim schema noise that costs tokens on every session but tells the model nothing: zod emits
|
|
243
|
+
* Number.MAX_SAFE_INTEGER / MIN_SAFE_INTEGER bounds for every `.int()`, plus `minLength: 1` and
|
|
244
|
+
* `minItems: 1` on non-empty strings and arrays. Validation still runs in zod at call time.
|
|
245
|
+
*/
|
|
246
|
+
function stripImplicitIntegerBounds(node: unknown): unknown {
|
|
247
|
+
if (Array.isArray(node)) {
|
|
248
|
+
return node.map(stripImplicitIntegerBounds);
|
|
249
|
+
}
|
|
250
|
+
if (node && typeof node === "object") {
|
|
251
|
+
return Object.fromEntries(
|
|
252
|
+
Object.entries(node as Record<string, unknown>)
|
|
253
|
+
.filter(
|
|
254
|
+
([key, value]) =>
|
|
255
|
+
!(key === "maximum" && value === Number.MAX_SAFE_INTEGER) &&
|
|
256
|
+
!(key === "minimum" && value === Number.MIN_SAFE_INTEGER) &&
|
|
257
|
+
!((key === "minLength" || key === "minItems") && value === 1),
|
|
258
|
+
)
|
|
259
|
+
.map(([key, value]) => [key, stripImplicitIntegerBounds(value)]),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return node;
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
function shapeToJsonSchema(shape: Record<string, z.ZodType>): Record<string, unknown> {
|
|
238
266
|
const schema = z.toJSONSchema(z.object(shape)) as Record<string, unknown>;
|
|
239
267
|
const { $schema: _schema, ...rest } = schema;
|
|
240
|
-
return rest
|
|
268
|
+
return stripImplicitIntegerBounds(rest) as Record<string, unknown>;
|
|
241
269
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
MCP_SEARCH_TOOL_DESCRIPTION,
|
|
22
22
|
MCP_SERVER_INSTRUCTIONS,
|
|
23
23
|
} from "../installer/search-policy.ts";
|
|
24
|
-
import { formatLiteralLocate } from "../literal.ts";
|
|
24
|
+
import { formatLiteralLocate, hasRegexLiteral, listIndexedFiles } from "../literal.ts";
|
|
25
25
|
import type { Chunk, ContentType, SearchResult } from "../types.ts";
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_EXPAND_AFTER,
|
|
@@ -44,8 +44,7 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
|
|
|
44
44
|
import { MiruMcpServer } from "./runtime.ts";
|
|
45
45
|
|
|
46
46
|
const REPO_DESCRIPTION =
|
|
47
|
-
"`repo` is optional
|
|
48
|
-
"The index is built on the first tool call and cached for the session.";
|
|
47
|
+
"`repo` is optional: local path or Git URL (default: the server's directory).";
|
|
49
48
|
|
|
50
49
|
const BENCHMARK_SKIP_NOTES = {
|
|
51
50
|
local_repo_only: "Benchmark comparisons require a local repo path; git URL repos are skipped.",
|
|
@@ -55,6 +54,8 @@ const BENCHMARK_SKIP_NOTES = {
|
|
|
55
54
|
"Benchmark comparison skipped: locate.limit is global, while rg/grep limits are per file. Omit limit for a valid token and recall comparison.",
|
|
56
55
|
incompatible_literal_baseline:
|
|
57
56
|
"Benchmark comparison skipped: a comparable literal baseline requires rg or compatible grep. Windows findstr does not preserve locate scope, context, or count semantics.",
|
|
57
|
+
regex_or_file_list:
|
|
58
|
+
"Benchmark comparison skipped: regex and file-list locate calls have no equivalent fixed-string rg baseline.",
|
|
58
59
|
grep_timeout:
|
|
59
60
|
"Benchmark Grep baseline timed out; Miru results are still returned. " +
|
|
60
61
|
"Raise MIRU_BENCHMARK_SEARCH_TIMEOUT (seconds) if needed.",
|
|
@@ -118,6 +119,9 @@ async function persistBenchmarkQuery(
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** Claude Code defers MCP tools behind ToolSearch by default; keep Miru visible next to Grep/Glob/Read, including to subagents. */
|
|
123
|
+
const LOAD_UPFRONT_META = { "anthropic/alwaysLoad": true };
|
|
124
|
+
|
|
121
125
|
export function createMcpServer(
|
|
122
126
|
cache: IndexCache,
|
|
123
127
|
options?: {
|
|
@@ -155,32 +159,24 @@ export function createMcpServer(
|
|
|
155
159
|
server.registerTool(
|
|
156
160
|
"search",
|
|
157
161
|
{
|
|
158
|
-
description:
|
|
162
|
+
description: MCP_SEARCH_TOOL_DESCRIPTION,
|
|
163
|
+
_meta: LOAD_UPFRONT_META,
|
|
159
164
|
inputSchema: {
|
|
160
|
-
query: z
|
|
161
|
-
.string()
|
|
162
|
-
.describe(
|
|
163
|
-
"Natural language or code query — your default for all code search in this repo.",
|
|
164
|
-
),
|
|
165
|
+
query: z.string().describe("Natural language or code query."),
|
|
165
166
|
repo: z.string().optional().describe(REPO_DESCRIPTION),
|
|
166
167
|
include: z
|
|
167
168
|
.array(z.string().min(1))
|
|
168
169
|
.min(1)
|
|
169
170
|
.optional()
|
|
170
171
|
.describe(
|
|
171
|
-
'Gitignore-style
|
|
172
|
+
'Gitignore-style globs; only matching files are searched, e.g. ["src/auth/**"]. Same as `locate.include`.',
|
|
172
173
|
),
|
|
173
174
|
exclude: z
|
|
174
175
|
.array(z.string().min(1))
|
|
175
176
|
.min(1)
|
|
176
177
|
.optional()
|
|
177
|
-
.describe(
|
|
178
|
-
|
|
179
|
-
),
|
|
180
|
-
dedupe_by_file: z
|
|
181
|
-
.boolean()
|
|
182
|
-
.optional()
|
|
183
|
-
.describe("Keep only the best hit per file (default true)."),
|
|
178
|
+
.describe("Gitignore-style globs; matching files are skipped. Same as `locate.exclude`."),
|
|
179
|
+
dedupe_by_file: z.boolean().optional().describe("One best hit per file (default true)."),
|
|
184
180
|
},
|
|
185
181
|
},
|
|
186
182
|
async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
|
|
@@ -248,26 +244,24 @@ export function createMcpServer(
|
|
|
248
244
|
"locate",
|
|
249
245
|
{
|
|
250
246
|
description: MCP_LOCATE_TOOL_DESCRIPTION,
|
|
247
|
+
_meta: LOAD_UPFRONT_META,
|
|
251
248
|
inputSchema: {
|
|
252
249
|
literal: z
|
|
253
250
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
254
251
|
.optional()
|
|
255
252
|
.describe(
|
|
256
|
-
"Exact substring
|
|
257
|
-
"Pass an array to OR-match several substrings (e.g. spelling/casing variants) in one call.",
|
|
253
|
+
"Exact substring, or a regex in slashes like `/foo\\w+Bar/` (`/…/i` ignores case). An array ORs several. Omit and pass `include` to list files.",
|
|
258
254
|
),
|
|
259
255
|
query: z
|
|
260
256
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
261
257
|
.optional()
|
|
262
|
-
.describe(
|
|
263
|
-
"Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
|
|
264
|
-
),
|
|
258
|
+
.describe("Alias for `literal`."),
|
|
265
259
|
repo: z.string().optional().describe(REPO_DESCRIPTION),
|
|
266
260
|
mode: z
|
|
267
|
-
.enum(["count", "locations", "lines"])
|
|
261
|
+
.enum(["count", "files", "locations", "lines"])
|
|
268
262
|
.optional()
|
|
269
263
|
.describe(
|
|
270
|
-
"count=totals
|
|
264
|
+
"count=totals; files=unique paths; locations=path:lines; lines=path:line+text (default).",
|
|
271
265
|
),
|
|
272
266
|
limit: z
|
|
273
267
|
.number()
|
|
@@ -275,46 +269,56 @@ export function createMcpServer(
|
|
|
275
269
|
.min(1)
|
|
276
270
|
.optional()
|
|
277
271
|
.describe(
|
|
278
|
-
"
|
|
272
|
+
"Cap on hits. Omit for ALL matches (recommended); if n is large use mode=count/files, not Grep.",
|
|
279
273
|
),
|
|
280
|
-
ignore_case: z.boolean().optional().describe("Case-insensitive
|
|
274
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive (default false)."),
|
|
281
275
|
match_variants: z
|
|
282
276
|
.boolean()
|
|
283
277
|
.optional()
|
|
284
|
-
.describe(
|
|
285
|
-
'Also match other ways the same word might be written in code, e.g. "rateLimit" also finds "rate_limit" and "RATE_LIMIT".',
|
|
286
|
-
),
|
|
278
|
+
.describe('Also match other casings, e.g. "rateLimit" finds "rate_limit", "RATE_LIMIT".'),
|
|
287
279
|
include: z
|
|
288
280
|
.array(z.string().min(1))
|
|
289
281
|
.optional()
|
|
290
282
|
.describe(
|
|
291
|
-
'Gitignore-style
|
|
283
|
+
'Gitignore-style globs; only matching files are searched, e.g. ["apps/**/*.go"].',
|
|
292
284
|
),
|
|
293
285
|
exclude: z
|
|
294
286
|
.array(z.string().min(1))
|
|
295
287
|
.optional()
|
|
296
|
-
.describe("Gitignore-style
|
|
288
|
+
.describe("Gitignore-style globs; matching files are skipped."),
|
|
297
289
|
context_lines: z
|
|
298
290
|
.number()
|
|
299
291
|
.int()
|
|
300
292
|
.min(0)
|
|
301
293
|
.optional()
|
|
302
|
-
.describe("
|
|
294
|
+
.describe("Context lines around each match (mode=lines only)."),
|
|
303
295
|
},
|
|
304
296
|
},
|
|
305
297
|
async ({ literal, query, repo, ...locateOpts }) => {
|
|
306
298
|
const lit = literal ?? query;
|
|
307
|
-
if (lit == null) {
|
|
308
|
-
return toolErrorText(
|
|
299
|
+
if (lit == null && !locateOpts.include?.length) {
|
|
300
|
+
return toolErrorText(
|
|
301
|
+
new Error(
|
|
302
|
+
"locate requires `literal` (exact substring or /regex/), or `include` globs to list files.",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
309
305
|
}
|
|
310
306
|
try {
|
|
311
307
|
const source = repo ?? defaultRepo;
|
|
312
308
|
const index = await getIndexForRepo(source, cache);
|
|
313
309
|
|
|
310
|
+
if (lit == null) {
|
|
311
|
+
const listing = listIndexedFiles(index.chunks, locateOpts);
|
|
312
|
+
const body = formatLiteralLocateText(formatLiteralLocate(listing));
|
|
313
|
+
return toolText(benchmark ? withBenchmarkSkippedNote(body, "regex_or_file_list") : body);
|
|
314
|
+
}
|
|
315
|
+
|
|
314
316
|
let skip: BenchmarkSkipReason | undefined;
|
|
315
317
|
const repoRoot = localRepoRoot(source);
|
|
316
318
|
if (benchmark && repoRoot && typeof lit === "string") {
|
|
317
|
-
if (locateOpts.
|
|
319
|
+
if (locateOpts.mode === "files" || hasRegexLiteral(lit)) {
|
|
320
|
+
skip = "regex_or_file_list";
|
|
321
|
+
} else if (locateOpts.limit != null) {
|
|
318
322
|
skip = "limited_locate";
|
|
319
323
|
} else if (!selectLiteralBaseline()) {
|
|
320
324
|
skip = "incompatible_literal_baseline";
|
|
@@ -353,27 +357,26 @@ export function createMcpServer(
|
|
|
353
357
|
"expand",
|
|
354
358
|
{
|
|
355
359
|
description: MCP_EXPAND_TOOL_DESCRIPTION,
|
|
360
|
+
_meta: LOAD_UPFRONT_META,
|
|
356
361
|
inputSchema: {
|
|
357
|
-
file_path: z
|
|
358
|
-
.string()
|
|
359
|
-
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
362
|
+
file_path: z.string().describe("Path from a search hit."),
|
|
360
363
|
anchor_line: z
|
|
361
364
|
.number()
|
|
362
365
|
.int()
|
|
363
|
-
.describe("Line from the
|
|
366
|
+
.describe("Line from the hit (`anchor_line` if truncated, else `start_line`)."),
|
|
364
367
|
repo: z.string().optional().describe(REPO_DESCRIPTION),
|
|
365
368
|
before: z
|
|
366
369
|
.number()
|
|
367
370
|
.int()
|
|
368
371
|
.min(0)
|
|
369
372
|
.optional()
|
|
370
|
-
.describe(`Extra chunks before
|
|
373
|
+
.describe(`Extra chunks before (default ${DEFAULT_EXPAND_BEFORE}).`),
|
|
371
374
|
after: z
|
|
372
375
|
.number()
|
|
373
376
|
.int()
|
|
374
377
|
.min(0)
|
|
375
378
|
.optional()
|
|
376
|
-
.describe(`Extra chunks after
|
|
379
|
+
.describe(`Extra chunks after (default ${DEFAULT_EXPAND_AFTER}).`),
|
|
377
380
|
},
|
|
378
381
|
},
|
|
379
382
|
async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
|
|
@@ -415,14 +418,13 @@ export function createMcpServer(
|
|
|
415
418
|
"find_related",
|
|
416
419
|
{
|
|
417
420
|
description: MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
421
|
+
_meta: LOAD_UPFRONT_META,
|
|
418
422
|
inputSchema: {
|
|
419
|
-
file_path: z
|
|
420
|
-
.string()
|
|
421
|
-
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
423
|
+
file_path: z.string().describe("Path from a search hit."),
|
|
422
424
|
anchor_line: z
|
|
423
425
|
.number()
|
|
424
426
|
.int()
|
|
425
|
-
.describe("Line from the
|
|
427
|
+
.describe("Line from the hit (`anchor_line` if truncated, else `start_line`)."),
|
|
426
428
|
repo: z.string().optional().describe(REPO_DESCRIPTION),
|
|
427
429
|
},
|
|
428
430
|
},
|