@takara-ai/miru-code 1.11.0 → 1.12.1
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 +4 -4
- 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/embeddings/openai.ts +21 -12
- package/src/installer/search-policy.ts +41 -33
- package/src/literal.ts +133 -11
- package/src/mcp/auth-tool.ts +5 -7
- package/src/mcp/format-text.ts +93 -10
- package/src/mcp/runtime.ts +29 -1
- package/src/mcp/server.ts +47 -45
- package/src/snippet.ts +42 -1
- package/src/utils.ts +3 -0
package/README.md
CHANGED
|
@@ -200,9 +200,9 @@ When wired via `miru install`, the MCP server exposes `search`, `locate`, `expan
|
|
|
200
200
|
### Workflow
|
|
201
201
|
|
|
202
202
|
1. **`search`** with `query` — returns compact snippets (~±15 lines) and relevance scores.
|
|
203
|
-
2. If a hit has **`truncated: true`**,
|
|
204
|
-
3. To trace similar patterns elsewhere, call **`find_related`** with the same `file_path` and `anchor_line`.
|
|
205
|
-
4. Use your editor's **Read** on `absolute_path` only
|
|
203
|
+
2. If a hit has **`truncated: true`**, its header names the symbol and its exact lines (`path:start-end Symbol`) plus `anchor_line`. Read just those lines with a ranged **Read** (`offset`/`limit`) — not another search or a full-file read.
|
|
204
|
+
3. If that range lacks context, call **`expand`** with `file_path` and `anchor_line` for neighbouring chunks. To trace similar patterns elsewhere, call **`find_related`** with the same `file_path` and `anchor_line`.
|
|
205
|
+
4. Use your editor's **Read** on `absolute_path` only for a hit's line range, to see a truncated body or edit it.
|
|
206
206
|
|
|
207
207
|
Keep `repo` on follow-up calls when set.
|
|
208
208
|
|
|
@@ -376,7 +376,7 @@ Override with `MIRU_BENCHMARK_HISTORY_PATH`. Append-only JSONL of compact token
|
|
|
376
376
|
| `repo` | no | Startup directory by default; set for another repo |
|
|
377
377
|
| `include` | no | Gitignore-style glob patterns; only matching files are searched |
|
|
378
378
|
| `exclude` | no | Gitignore-style glob patterns; matching files are skipped |
|
|
379
|
-
| `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 |
|
|
380
380
|
| `limit` | no | Cap returned hits. Omit to return all matches |
|
|
381
381
|
| `ignore_case` | no | Case-insensitive match (default `false`) |
|
|
382
382
|
|
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/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,15 @@
|
|
|
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. A `truncated: true` hit names its symbol and exact lines (`path:start-end Symbol`) plus `anchor_line`: " +
|
|
5
|
+
"to see the full body, read exactly those lines with a ranged read (`offset`/`limit`), never the whole file. " +
|
|
6
|
+
"`expand` (`file_path` + `anchor_line`) is the fallback for neighbouring chunks — only if the snippet doesn't already answer.";
|
|
7
|
+
|
|
8
|
+
export const WHEN_TO_REACH_FOR_MIRU =
|
|
9
|
+
"Reach for Miru FIRST, before opening files, whenever you don't know the exact path: starting in unfamiliar code, " +
|
|
10
|
+
"finding where to edit, how/where/why questions, tracing callers or similar code, docs/config. " +
|
|
11
|
+
"`locate` also does regex (`/re/`) and lists files by name (`include` globs, no literal). " +
|
|
12
|
+
"Grep/Glob only for files Miru does not index (lockfiles, binaries, generated or git-ignored).";
|
|
6
13
|
|
|
7
14
|
export interface NativeToolNames {
|
|
8
15
|
explorationDenied: string;
|
|
@@ -14,16 +21,21 @@ export function buildSearchPolicyTable(native: NativeToolNames): string {
|
|
|
14
21
|
return `| Task | Use | Not |
|
|
15
22
|
|------|-----|-----|
|
|
16
23
|
| Exact literal (identifier, quoted string, env var, error code) | Miru MCP \`locate\` | Miru \`search\`, ${native.grep} |
|
|
24
|
+
| Regex over code | Miru MCP \`locate\` with \`/regex/\` (\`mode=files\` for paths only) | ${native.grep} |
|
|
25
|
+
| List files by name pattern | Miru MCP \`locate\` with \`include\` globs and no literal | Glob |
|
|
17
26
|
| How/where/what handles X? (conceptual) | Miru MCP \`search\` (once) | ${native.grep}, ${native.explorationDenied} |
|
|
18
|
-
|
|
|
27
|
+
| Full body of a \`truncated: true\` hit | ${native.read} the hit's \`start-end\` lines (\`offset\`/\`limit\`) | Re-search, ${native.read} whole file |
|
|
28
|
+
| Same file, neighbouring chunks | Miru MCP \`expand\` (fallback) | Re-search |
|
|
19
29
|
| Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
|
|
20
30
|
| Search docs or config | Miru \`search\` | ${native.grep} README paths |
|
|
21
|
-
| Edit a known file:line | ${native.read} (after Miru found it) | ${native.read}-before-search |`;
|
|
31
|
+
| Edit a known file:line | ${native.read} the hit's \`start-end\` lines (after Miru found it) | ${native.read}-before-search |`;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
export function buildSearchPolicyBody(native: NativeToolNames): string {
|
|
25
35
|
return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
|
|
26
36
|
|
|
37
|
+
${WHEN_TO_REACH_FOR_MIRU}
|
|
38
|
+
|
|
27
39
|
**Check first:** exact token (identifier, quoted string, env var, error code) → \`locate\`. Otherwise → \`search\`.
|
|
28
40
|
|
|
29
41
|
${SNIPPET_GUIDANCE}
|
|
@@ -31,10 +43,10 @@ ${SNIPPET_GUIDANCE}
|
|
|
31
43
|
\`repo\` is optional, set it for another repo.
|
|
32
44
|
|
|
33
45
|
Use Miru MCP tools:
|
|
34
|
-
- \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
|
|
46
|
+
- \`locate\` — exact substring (env var, symbol, error code) or \`/regex/\`; no literal + \`include\` globs lists files; prefer \`mode=count\`, \`files\` or \`locations\`
|
|
35
47
|
- \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
|
|
36
|
-
- \`expand\` —
|
|
37
|
-
- \`find_related\` — similar code in other files (hits may also be snippets;
|
|
48
|
+
- \`expand\` — fallback: neighbouring chunks in the same file around \`anchor_line\` (\`file_path\` + \`anchor_line\`)
|
|
49
|
+
- \`find_related\` — similar code in other files (hits may also be snippets; read the hit's lines if truncated)
|
|
38
50
|
|
|
39
51
|
Call shapes:
|
|
40
52
|
- \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
|
|
@@ -43,12 +55,13 @@ Call shapes:
|
|
|
43
55
|
Stop rules:
|
|
44
56
|
- Literal in the request → \`locate\`, not \`search\`
|
|
45
57
|
- One \`search\` per question — no paraphrase retries
|
|
46
|
-
- \`expand\` only if
|
|
47
|
-
- ${native.read} only
|
|
58
|
+
- On \`truncated: true\`, ${native.read} only the hit's \`start-end\` lines (\`offset\`/\`limit\`); \`expand\` only if that doesn't answer
|
|
59
|
+
- ${native.read} only a path Miru already found, to see a truncated body or edit it — never to explore, never the whole file
|
|
48
60
|
|
|
49
61
|
Native tools are allowed ONLY when:
|
|
50
62
|
- reading a file you already located via Miru, to edit it
|
|
51
63
|
- searching outside the indexed repo
|
|
64
|
+
- files Miru does not index (lockfiles, binaries, generated or git-ignored files)
|
|
52
65
|
|
|
53
66
|
${buildSearchPolicyTable(native)}`;
|
|
54
67
|
}
|
|
@@ -58,9 +71,9 @@ export function buildMcpWorkflow(native: NativeToolNames): string {
|
|
|
58
71
|
|
|
59
72
|
1. Call \`search\`; add \`repo\` only for another repo.
|
|
60
73
|
2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
|
|
61
|
-
3. On \`truncated: true\`,
|
|
62
|
-
4. Use \`
|
|
63
|
-
5. ${native.read} via \`absolute_path\` only
|
|
74
|
+
3. On \`truncated: true\`, ${native.read} the hit's \`start-end\` lines (\`offset\`/\`limit\`) — only if the snippet doesn't already answer.
|
|
75
|
+
4. Use \`expand\` for neighbouring chunks if that range lacks context; \`find_related\` to trace similar code in other files.
|
|
76
|
+
5. ${native.read} via \`absolute_path\` only for the hit's range, to see a truncated body or edit it.`;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
const CLI_FALLBACK = `### CLI fallback (no MCP in this session)
|
|
@@ -116,14 +129,14 @@ export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
|
|
|
116
129
|
|
|
117
130
|
export const MCP_SERVER_INSTRUCTIONS =
|
|
118
131
|
"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
|
-
|
|
132
|
+
`${WHEN_TO_REACH_FOR_MIRU} ` +
|
|
133
|
+
"DO NOT use Grep, grep_search, codebase_search, Glob, SemanticSearch, or Read to explore code while this server is available. " +
|
|
120
134
|
"Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
|
|
121
135
|
`${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.";
|
|
136
|
+
"`find_related` finds similar code elsewhere. " +
|
|
137
|
+
"`repo` is optional (other repos). " +
|
|
138
|
+
"Indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
|
|
139
|
+
"On credential errors, call this server's `auth` tool, not the host `mcp_auth`.";
|
|
127
140
|
|
|
128
141
|
export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
129
142
|
`${MCP_SERVER_INSTRUCTIONS} ` +
|
|
@@ -135,26 +148,21 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
|
|
|
135
148
|
"To leave benchmark mode, tell the user to run `miru benchmark off` and restart the agent.";
|
|
136
149
|
|
|
137
150
|
export const MCP_SEARCH_TOOL_DESCRIPTION =
|
|
151
|
+
"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
152
|
"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.";
|
|
153
|
+
"Up to 5 snippets (~±15 lines), one call per question; exact literals → `locate`. " +
|
|
154
|
+
"On `truncated: true`, `expand` only if the snippet doesn't already answer.";
|
|
142
155
|
|
|
143
156
|
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.";
|
|
157
|
+
"Exact substring locator over the Miru index, also `/regex/` and file lists (grep for identifiers, env vars, error strings, quoted text, without the noise). " +
|
|
158
|
+
'Use for known literals, not meaning-based questions (`search`), even when phrased "where is X" if X is a literal. ' +
|
|
159
|
+
"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. " +
|
|
160
|
+
"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. " +
|
|
161
|
+
"`context_lines` (mode=lines) saves a follow-up `expand`. In benchmark mode, includes token savings vs Grep.";
|
|
154
162
|
|
|
155
163
|
export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
156
|
-
"
|
|
157
|
-
"
|
|
164
|
+
"Neighbouring chunks in the SAME file as a search hit (`file_path` + `anchor_line` from the hit). " +
|
|
165
|
+
"For a `truncated: true` hit's own full body, read its `start-end` lines instead; use this only if that doesn't answer — not for similar code elsewhere (use find_related).";
|
|
158
166
|
|
|
159
167
|
export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
|
|
160
168
|
"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`. " +
|
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. */
|
|
@@ -11,16 +12,58 @@ export function formatResultsText(payload: {
|
|
|
11
12
|
|
|
12
13
|
const blocks = payload.results.map((result) => {
|
|
13
14
|
const chunk = result.chunk as Record<string, unknown>;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
15
|
+
// A truncated hit names the matched symbol's exact range (not the source file's) so the full
|
|
16
|
+
// body is one ranged read away; `anchor_line` is the matched line for `expand`/`find_related`.
|
|
17
|
+
const startLine = chunk.truncated
|
|
18
|
+
? (chunk.match_start_line ?? chunk.full_start_line)
|
|
19
|
+
: chunk.start_line;
|
|
20
|
+
const endLine = chunk.truncated
|
|
21
|
+
? (chunk.match_end_line ?? chunk.full_end_line)
|
|
22
|
+
: chunk.end_line;
|
|
23
|
+
const symbol = chunk.truncated && chunk.symbol ? ` ${chunk.symbol}` : "";
|
|
24
|
+
const truncatedNote = chunk.truncated
|
|
25
|
+
? ` [truncated: anchor_line ${chunk.anchor_line}; read lines ${startLine}-${endLine} for the full symbol, or call expand]`
|
|
26
|
+
: "";
|
|
27
|
+
const header = `${chunk.file_path}:${startLine}-${endLine}${symbol}${truncatedNote}`;
|
|
18
28
|
return `${header}\n${chunk.content}`;
|
|
19
29
|
});
|
|
20
30
|
|
|
21
31
|
return blocks.join("\n\n");
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/** Very long lines (minified bundles, lockfiles) cost far more than they inform. */
|
|
35
|
+
const MAX_LINE_CHARS = 400;
|
|
36
|
+
|
|
37
|
+
function clipLine(text: string): string {
|
|
38
|
+
return text.length > MAX_LINE_CHARS
|
|
39
|
+
? `${text.slice(0, MAX_LINE_CHARS)}… [+${text.length - MAX_LINE_CHARS} chars]`
|
|
40
|
+
: text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Group sorted repo-relative paths by directory so each directory prints once. */
|
|
44
|
+
export function formatPathList(paths: readonly string[]): string {
|
|
45
|
+
const byDir = new Map<string, string[]>();
|
|
46
|
+
for (const path of paths) {
|
|
47
|
+
const slash = path.lastIndexOf("/");
|
|
48
|
+
const dir = slash < 0 ? "./" : path.slice(0, slash + 1);
|
|
49
|
+
const names = byDir.get(dir);
|
|
50
|
+
const name = slash < 0 ? path : path.slice(slash + 1);
|
|
51
|
+
if (names) {
|
|
52
|
+
names.push(name);
|
|
53
|
+
} else {
|
|
54
|
+
byDir.set(dir, [name]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// A directory holding one match prints as a plain path; grouping only pays off for two or more.
|
|
58
|
+
return [...byDir]
|
|
59
|
+
.map(([dir, names]) =>
|
|
60
|
+
names.length === 1
|
|
61
|
+
? `${dir === "./" ? "" : dir}${names[0]}`
|
|
62
|
+
: `${dir}\n ${names.join("\n ")}`,
|
|
63
|
+
)
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
24
67
|
/** Plain-text rendering of `formatLiteralLocate()` output. */
|
|
25
68
|
export function formatLiteralLocateText(payload: Record<string, unknown>): string {
|
|
26
69
|
const label =
|
|
@@ -28,7 +71,19 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
|
|
|
28
71
|
const n = payload.n as number;
|
|
29
72
|
const files = payload.files as number;
|
|
30
73
|
const truncatedNote = payload.truncated ? " (truncated — pass a narrower include/exclude)" : "";
|
|
31
|
-
const
|
|
74
|
+
const fileCount = `${files} file${files === 1 ? "" : "s"}`;
|
|
75
|
+
// Short plain literals are worth echoing; a regex the caller just sent is not (it tokenizes poorly).
|
|
76
|
+
const counts = `${n} match${n === 1 ? "" : "es"} across ${fileCount}${truncatedNote}`;
|
|
77
|
+
const header = !label
|
|
78
|
+
? fileCount
|
|
79
|
+
: hasRegexLiteral(label.split(" | "))
|
|
80
|
+
? counts
|
|
81
|
+
: `"${label}": ${counts}`;
|
|
82
|
+
|
|
83
|
+
const paths = payload.paths as string[] | undefined;
|
|
84
|
+
if (paths) {
|
|
85
|
+
return paths.length === 0 ? header : `${header}\n\n${formatPathList(paths)}`;
|
|
86
|
+
}
|
|
32
87
|
|
|
33
88
|
const hits = payload.hits as
|
|
34
89
|
| Array<{ f: string; l: number; t?: string; ctx?: string[]; ctx_l?: number }>
|
|
@@ -38,12 +93,40 @@ export function formatLiteralLocateText(payload: Record<string, unknown>): strin
|
|
|
38
93
|
}
|
|
39
94
|
|
|
40
95
|
const lines = [header, ""];
|
|
96
|
+
if (hits.some((hit) => hit.ctx != null)) {
|
|
97
|
+
for (const hit of hits) {
|
|
98
|
+
lines.push(
|
|
99
|
+
hit.t !== undefined ? `${hit.f}:${hit.l}: ${clipLine(hit.t)}` : `${hit.f}:${hit.l}`,
|
|
100
|
+
);
|
|
101
|
+
if (hit.ctx != null && hit.ctx_l !== undefined) {
|
|
102
|
+
const ctxStart = hit.ctx_l;
|
|
103
|
+
for (let i = 0; i < hit.ctx.length; i++) {
|
|
104
|
+
lines.push(` ${ctxStart + i}: ${clipLine(hit.ctx[i] ?? "")}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Each path once: `path` then indented `line: text`, or `path: 3, 9, 40` without text.
|
|
112
|
+
const byFile = new Map<string, Array<{ l: number; t?: string }>>();
|
|
41
113
|
for (const hit of hits) {
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
114
|
+
const group = byFile.get(hit.f);
|
|
115
|
+
if (group) {
|
|
116
|
+
group.push({ l: hit.l, t: hit.t });
|
|
117
|
+
} else {
|
|
118
|
+
byFile.set(hit.f, [{ l: hit.l, t: hit.t }]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [file, group] of byFile) {
|
|
122
|
+
if (group.every((hit) => hit.t === undefined)) {
|
|
123
|
+
lines.push(`${file}: ${group.map((hit) => hit.l).join(", ")}`);
|
|
124
|
+
} else if (group.length === 1) {
|
|
125
|
+
lines.push(`${file}:${group[0]?.l}: ${clipLine(group[0]?.t ?? "")}`);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(file);
|
|
128
|
+
for (const hit of group) {
|
|
129
|
+
lines.push(` ${hit.l}: ${clipLine(hit.t ?? "")}`);
|
|
47
130
|
}
|
|
48
131
|
}
|
|
49
132
|
}
|
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
|
},
|
package/src/snippet.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface SnippetMeta {
|
|
|
13
13
|
anchor_line: number;
|
|
14
14
|
full_start_line: number;
|
|
15
15
|
full_end_line: number;
|
|
16
|
+
/** Exact lines of the matched (ranked) chunk — the symbol-sized range to read, not the source file. */
|
|
17
|
+
match_start_line: number;
|
|
18
|
+
match_end_line: number;
|
|
19
|
+
/** Name of the symbol the matched chunk declares, when one is recognizable. */
|
|
20
|
+
symbol: string | null;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export interface SnippetResult {
|
|
@@ -73,6 +78,24 @@ function scoreLine(line: string, terms: Set<string>): number {
|
|
|
73
78
|
|
|
74
79
|
const DECLARATION_RE =
|
|
75
80
|
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|enum|type|struct|trait|impl|record|namespace|contract|library|object)\b|^\s*(?:export\s+)?(?:const|let|var)\s+[$\w]+\s*=\s*[{[(]|^\s*(?:async\s+)?def\s+\w+|^\s*def\s+\w+|^\s*fn\s+\w+|^\s*func\s+\w+|^\s*defmodule\b|^\s*def\w*\b|^\s*(?:(?:public|private|protected|internal|static|final|abstract|virtual|override|sealed|partial|unsafe|extern)\s+)+(?:[A-Za-z_$][\w:<>[\]?.,\s]*\s+)+[A-Za-z_$]\w*\s*\(|^\s*(?:void|bool|boolean|char|short|int|long|float|double|string|[A-Z][\w:<>[\]?.,]*)\s+[A-Za-z_$]\w*\s*\(|^\s*(?:function\s+)?[A-Za-z_]\w*\s*\(\)\s*\{/;
|
|
81
|
+
const DECLARED_NAME_RE =
|
|
82
|
+
/\b(?:function\*?|class|interface|enum|type|struct|trait|impl|record|namespace|contract|library|object|def|fn|func|defmodule)\s+([A-Za-z_$][\w$]*)|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
|
|
83
|
+
|
|
84
|
+
/** Name declared by the first declaration line in `content`, or null when none is recognizable. */
|
|
85
|
+
export function declaredSymbol(content: string): string | null {
|
|
86
|
+
for (const line of content.split("\n").slice(0, MAX_DECLARATION_LOOKBACK)) {
|
|
87
|
+
if (!DECLARATION_RE.test(line)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const match = DECLARED_NAME_RE.exec(line);
|
|
91
|
+
const name = match?.[1] ?? match?.[2];
|
|
92
|
+
if (name) {
|
|
93
|
+
return name;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
const BRACE_BODY_RE =
|
|
77
100
|
/\b(?:function|class|interface|enum|struct|trait|impl|record|namespace|contract|library|object|fn|func)\b|=\s*[{[(]|[A-Za-z_$]\w*\s*\([^)]*\)\s*\{/;
|
|
78
101
|
const RUBY_OR_ELIXIR_BODY_RE = /^\s*def\w*\b|^\s*defmodule\b/;
|
|
@@ -419,6 +442,9 @@ export function trimChunkToSnippet(
|
|
|
419
442
|
anchor_line: chunk.start_line + anchorOffset,
|
|
420
443
|
full_start_line: chunk.start_line,
|
|
421
444
|
full_end_line: chunk.end_line,
|
|
445
|
+
match_start_line: chunk.start_line,
|
|
446
|
+
match_end_line: chunk.end_line,
|
|
447
|
+
symbol: declaredSymbol(chunk.content),
|
|
422
448
|
},
|
|
423
449
|
};
|
|
424
450
|
}
|
|
@@ -436,6 +462,9 @@ export function trimChunkToSnippet(
|
|
|
436
462
|
anchor_line: chunk.start_line + anchorOffset,
|
|
437
463
|
full_start_line: chunk.start_line,
|
|
438
464
|
full_end_line: chunk.end_line,
|
|
465
|
+
match_start_line: chunk.start_line,
|
|
466
|
+
match_end_line: chunk.end_line,
|
|
467
|
+
symbol: declaredSymbol(chunk.content),
|
|
439
468
|
},
|
|
440
469
|
};
|
|
441
470
|
}
|
|
@@ -460,8 +489,20 @@ export function applySnippetsToResults(
|
|
|
460
489
|
});
|
|
461
490
|
return contextualResults.map(({ result, anchorLine }, rank) => {
|
|
462
491
|
const { chunk, meta } = trimChunkToSnippet(result.chunk, query, radius, anchorLine);
|
|
492
|
+
// `result.chunk` may be the whole source file; the ranked chunk is the symbol-sized match.
|
|
493
|
+
const ranked = results[rank]?.chunk;
|
|
463
494
|
const snippet = compactSupportingSnippet(
|
|
464
|
-
{
|
|
495
|
+
{
|
|
496
|
+
chunk,
|
|
497
|
+
meta: ranked
|
|
498
|
+
? {
|
|
499
|
+
...meta,
|
|
500
|
+
match_start_line: ranked.start_line,
|
|
501
|
+
match_end_line: ranked.end_line,
|
|
502
|
+
symbol: declaredSymbol(ranked.content) ?? meta.symbol,
|
|
503
|
+
}
|
|
504
|
+
: meta,
|
|
505
|
+
},
|
|
465
506
|
rank === 0 ? primaryMaxLines : supportingMaxLines,
|
|
466
507
|
);
|
|
467
508
|
return { result: { chunk: snippet.chunk, score: result.score }, meta: snippet.meta };
|
package/src/utils.ts
CHANGED
|
@@ -210,6 +210,9 @@ export function formatResults(
|
|
|
210
210
|
anchor_line: entry.meta.anchor_line,
|
|
211
211
|
full_start_line: entry.meta.full_start_line,
|
|
212
212
|
full_end_line: entry.meta.full_end_line,
|
|
213
|
+
match_start_line: entry.meta.match_start_line,
|
|
214
|
+
match_end_line: entry.meta.match_end_line,
|
|
215
|
+
...(entry.meta.symbol ? { symbol: entry.meta.symbol } : {}),
|
|
213
216
|
},
|
|
214
217
|
score,
|
|
215
218
|
};
|