@takara-ai/miru-code 1.8.2 → 1.10.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.
Files changed (60) hide show
  1. package/README.md +65 -53
  2. package/docs/coverage-badge.json +6 -0
  3. package/grammars/manifest.json +10 -2
  4. package/grammars/tree-sitter-astro.wasm +0 -0
  5. package/grammars/tree-sitter-php.wasm +0 -0
  6. package/grammars/tree-sitter-php_only.wasm +0 -0
  7. package/grammars/tree-sitter-sql.wasm +0 -0
  8. package/grammars/tree-sitter-svelte.wasm +0 -0
  9. package/grammars/tree-sitter-vue.wasm +0 -0
  10. package/package.json +10 -2
  11. package/src/agents.ts +4 -4
  12. package/src/auth/device.ts +26 -21
  13. package/src/auth/providers.ts +26 -11
  14. package/src/benchmark/compare.ts +3 -10
  15. package/src/benchmark/grep.ts +64 -37
  16. package/src/benchmark/locate-compare.ts +8 -1
  17. package/src/benchmark/rg-literal.ts +9 -4
  18. package/src/cache.ts +3 -3
  19. package/src/chunking/ast.ts +21 -3
  20. package/src/chunking/grammars.ts +8 -3
  21. package/src/chunking/structural.ts +13 -24
  22. package/src/cli-mode.ts +18 -0
  23. package/src/cli.ts +54 -98
  24. package/src/credentials.ts +14 -14
  25. package/src/embeddings/openai.ts +6 -3
  26. package/src/embeddings/sagemaker.ts +18 -3
  27. package/src/embeddings/validate.ts +10 -7
  28. package/src/git.ts +6 -2
  29. package/src/help.ts +7 -7
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/chunk-selector.ts +39 -11
  32. package/src/index/create.ts +5 -9
  33. package/src/index/files.ts +5 -1
  34. package/src/index/incremental.ts +48 -8
  35. package/src/index/sparse.ts +14 -2
  36. package/src/installer/agents.ts +85 -65
  37. package/src/installer/benchmark-mode.ts +40 -11
  38. package/src/installer/config.ts +105 -42
  39. package/src/installer/installer.ts +59 -34
  40. package/src/installer/legacy-hooks.ts +131 -0
  41. package/src/installer/prompt.ts +20 -22
  42. package/src/installer/search-policy.ts +10 -10
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +65 -46
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +90 -22
  47. package/src/prompt.ts +51 -14
  48. package/src/ranking/boosting.ts +5 -6
  49. package/src/ranking/location.ts +6 -10
  50. package/src/ranking/penalties.ts +14 -22
  51. package/src/search.ts +8 -12
  52. package/src/setup.ts +25 -13
  53. package/src/snippet.ts +8 -26
  54. package/src/spinner.ts +26 -16
  55. package/src/tokenizer/bert-wordpiece.ts +0 -6
  56. package/src/utils.ts +1 -15
  57. package/src/version.ts +4 -4
  58. package/src/installer/hooks/install.ts +0 -549
  59. package/src/installer/hooks/opencode-plugin.ts +0 -54
  60. package/src/installer/hooks/search-guard.ts +0 -272
@@ -113,18 +113,9 @@ function parseArrowKey(chunk: Buffer, text: string): string | null {
113
113
  const introducer = text.charAt(1);
114
114
  if (introducer === "[" || introducer === "O") {
115
115
  const arrow = text.charAt(text.length - 1);
116
- if (arrow === "A") {
117
- return "up";
118
- }
119
- if (arrow === "B") {
120
- return "down";
121
- }
122
- if (arrow === "C") {
123
- return "right";
124
- }
125
- if (arrow === "D") {
126
- return "left";
127
- }
116
+ return (
117
+ ({ A: "up", B: "down", C: "right", D: "left" } as Record<string, string>)[arrow] ?? null
118
+ );
128
119
  }
129
120
  }
130
121
 
@@ -308,9 +299,13 @@ function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
308
299
  );
309
300
  }
310
301
 
311
- export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
302
+ export async function promptConfirm(
303
+ question: string,
304
+ defaultYes = true,
305
+ io: { input?: typeof input; output?: typeof output } = {},
306
+ ): Promise<boolean> {
312
307
  if (!input.isTTY) {
313
- const rl = readline.createInterface({ input, output });
308
+ const rl = readline.createInterface({ input: io.input ?? input, output: io.output ?? output });
314
309
  try {
315
310
  const hintText = defaultYes ? dim("[Y/n]") : dim("[y/N]");
316
311
  const answer = (await rl.question(`${question} ${hintText} `)).trim().toLowerCase();
@@ -354,10 +349,13 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
354
349
  export async function promptMultiSelectLegacy<T>(
355
350
  title: string,
356
351
  items: Array<{ label: string; value: T; checked: boolean }>,
352
+ io: { input?: typeof input; output?: typeof output } = {},
357
353
  ): Promise<T[] | null> {
358
- output.write("\n");
359
- output.write(`${bold(title)}\n`);
360
- divider("─", 48, output);
354
+ const promptInput = io.input ?? input;
355
+ const promptOutput = io.output ?? output;
356
+ promptOutput.write("\n");
357
+ promptOutput.write(`${bold(title)}\n`);
358
+ divider("─", 48, promptOutput);
361
359
 
362
360
  for (let i = 0; i < items.length; i++) {
363
361
  const item = items[i];
@@ -365,13 +363,13 @@ export async function promptMultiSelectLegacy<T>(
365
363
  continue;
366
364
  }
367
365
  const mark = item.checked ? cyan("[x]") : dim("[ ]");
368
- output.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
366
+ promptOutput.write(` ${dim(String(i + 1).padStart(2))}. ${mark} ${item.label}\n`);
369
367
  }
370
368
 
371
- output.write("\n");
372
- hint("Enter numbers (1,3), 'all', or press Enter for defaults", output);
369
+ promptOutput.write("\n");
370
+ hint("Enter numbers (1,3), 'all', or press Enter for defaults", promptOutput);
373
371
 
374
- const rl = readline.createInterface({ input, output });
372
+ const rl = readline.createInterface({ input: promptInput, output: promptOutput });
375
373
  try {
376
374
  const answer = (await rl.question(`${dim("> ")}`)).toLowerCase();
377
375
  if (!answer) {
@@ -390,7 +388,7 @@ export async function promptMultiSelectLegacy<T>(
390
388
  }
391
389
 
392
390
  if (indices.size === 0) {
393
- warn("No valid selection.", output);
391
+ warn("No valid selection.", promptOutput);
394
392
  return null;
395
393
  }
396
394
 
@@ -28,15 +28,17 @@ export function buildSearchPolicyBody(native: NativeToolNames): string {
28
28
 
29
29
  ${SNIPPET_GUIDANCE}
30
30
 
31
+ \`repo\` is optional, set it for another repo.
32
+
31
33
  Use Miru MCP tools:
32
34
  - \`locate\` — exact substring (env var, symbol, error code); prefer \`mode=count\` or \`locations\`
33
- - \`search\` — one call per conceptual question; pass project root as \`repo\`
35
+ - \`search\` — one call per conceptual question; use \`include\` / \`exclude\` Gitignore-style globs to scope files (same syntax as \`locate\`)
34
36
  - \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\`)
35
37
  - \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
36
38
 
37
39
  Call shapes:
38
- - \`locate(literal="<exact substring>", repo="<project root>")\` — prefer \`mode="locations"\`/\`"count"\`
39
- - \`search(query="<natural language>", repo="<project root>")\`
40
+ - \`locate(literal="<exact substring>")\` — prefer \`mode="locations"\`/\`"count"\`
41
+ - \`search(query="<natural language>", include=["src/auth/**"])\` — use the same \`include\` / \`exclude\` globs on \`locate\` when scoping an exact-string search
40
42
 
41
43
  Stop rules:
42
44
  - Literal in the request → \`locate\`, not \`search\`
@@ -54,7 +56,7 @@ ${buildSearchPolicyTable(native)}`;
54
56
  export function buildMcpWorkflow(native: NativeToolNames): string {
55
57
  return `### MCP workflow
56
58
 
57
- 1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
59
+ 1. Call \`search\`; add \`repo\` only for another repo.
58
60
  2. For exact literals, call \`locate\` (prefer \`mode=count\` or \`locations\`).
59
61
  3. On \`truncated: true\`, call \`expand\` — only if the snippet doesn't already answer.
60
62
  4. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
@@ -71,7 +73,7 @@ miru find-related src/auth.ts 42 .
71
73
  \`\`\`
72
74
 
73
75
  After large refactors or CLI-only use, run \`miru clear <path>\` to rebuild the disk cache.
74
- If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
76
+ If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code@latest\`.`;
75
77
 
76
78
  export function buildSubagentBody(native: NativeToolNames): string {
77
79
  return `When Miru MCP is available, use MCP \`search\`, \`locate\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
@@ -118,7 +120,7 @@ export const MCP_SERVER_INSTRUCTIONS =
118
120
  "Exact token (identifier, quoted string, env var, error code) → `locate`; everything else → `search` once, no paraphrase retries. " +
119
121
  `${SNIPPET_GUIDANCE} ` +
120
122
  "`find_related` traces similar code elsewhere, not more context in the same file. " +
121
- "Always pass the project root as `repo`; local repos return `absolute_path` — use Read only to edit. " +
123
+ "`repo` is optional, good for multi repo workflows. " +
122
124
  "Native Grep/Glob only outside the indexed repo or for non-code tasks. " +
123
125
  "Default indexed scope is code, config, and docs; an empty result can mean out-of-scope, not nonexistent. " +
124
126
  "On credential errors, call this server's `auth` tool — not the host `mcp_auth` tool.";
@@ -134,7 +136,7 @@ export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
134
136
 
135
137
  export const MCP_SEARCH_TOOL_DESCRIPTION =
136
138
  "Your default search for all code search queries in this indexed repo — the best, fastest, and cheapest way to find code; better than any other tool. " +
137
- "Returns compact snippets (~±15 lines). One call per question. " +
139
+ "Returns up to 5 compact snippets (~±15 lines). One call per question. Use `include` / `exclude` Gitignore-style globs to scope files; these work the same way on `locate`. " +
138
140
  "Exact literal (env var, symbol, error code, quoted text)? Use `locate` instead. " +
139
141
  "On `truncated: true`, call `expand` — only if the snippet doesn't already answer the question.";
140
142
 
@@ -155,11 +157,9 @@ export const MCP_EXPAND_TOOL_DESCRIPTION =
155
157
  "Use only if `truncated: true` and the snippet doesn't already answer the question — not for similar code elsewhere (use find_related).";
156
158
 
157
159
  export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
158
- "Find code similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
160
+ "Find up to 5 chunks similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
159
161
  "For more context in the same file, use `expand` instead.";
160
162
 
161
163
  export const MCP_READ_BENCHMARK_TOOL_DESCRIPTION =
162
164
  "Cumulative Miru vs Grep token savings from saved `search` and `locate` calls. Returns compact totals {n,saved,save_pct,miru,grep}. " +
163
165
  "Do not call unless the user asks about savings.";
164
-
165
- export const SEARCH_GUARD_EXPAND_HINT = SNIPPET_GUIDANCE;
@@ -49,6 +49,11 @@ type CacheEntry = {
49
49
  lastError: Error | null;
50
50
  };
51
51
 
52
+ export interface IndexCacheDependencies {
53
+ fromSource?: typeof MiruIndex.fromSource;
54
+ watch?: typeof watch;
55
+ }
56
+
52
57
  export function mcpWatchEnabled(): boolean {
53
58
  const raw = process.env.MIRU_MCP_WATCH;
54
59
  return raw !== "0" && raw !== "false";
@@ -76,12 +81,20 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
76
81
  export class IndexCache {
77
82
  private readonly content: ContentType[];
78
83
  private readonly defaultRef: string | null;
84
+ private readonly fromSource: typeof MiruIndex.fromSource;
85
+ private readonly watchSource: typeof watch;
79
86
  private readonly entries = new Map<string, CacheEntry>();
80
87
  readonly watchers = new Map<string, WatcherHandle>();
81
88
 
82
- constructor(content: ContentType[] = defaultContentTypes(), defaultRef: string | null = null) {
89
+ constructor(
90
+ content: ContentType[] = defaultContentTypes(),
91
+ defaultRef: string | null = null,
92
+ dependencies: IndexCacheDependencies = {},
93
+ ) {
83
94
  this.content = content;
84
95
  this.defaultRef = defaultRef;
96
+ this.fromSource = dependencies.fromSource ?? MiruIndex.fromSource;
97
+ this.watchSource = dependencies.watch ?? watch;
85
98
  }
86
99
 
87
100
  private ensureEntry(cacheKey: string, source: string): CacheEntry {
@@ -128,7 +141,7 @@ export class IndexCache {
128
141
  ): Promise<MiruIndex> {
129
142
  const entry = this.ensureEntry(cacheKey, source);
130
143
  const task = (async () => {
131
- const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
144
+ const index = await this.fromSource(source, this.content, undefined, ref);
132
145
  if (!isGitUrl(source)) {
133
146
  await index.saveToCache(resolve(source));
134
147
  }
@@ -342,7 +355,7 @@ export class IndexCache {
342
355
 
343
356
  let nativeWatcher: ReturnType<typeof watch> | null = null;
344
357
  try {
345
- nativeWatcher = watch(resolved, { recursive: true }, (_event, filename) => {
358
+ nativeWatcher = this.watchSource(resolved, { recursive: true }, (_event, filename) => {
346
359
  this.noteFileChange(path, filename);
347
360
  });
348
361
  } catch {
package/src/mcp/server.ts CHANGED
@@ -24,17 +24,15 @@ import {
24
24
  import { formatLiteralLocate } from "../literal.ts";
25
25
  import type { Chunk, ContentType, SearchResult } from "../types.ts";
26
26
  import {
27
- clampMcpTopK,
28
27
  DEFAULT_EXPAND_AFTER,
29
28
  DEFAULT_EXPAND_BEFORE,
30
- DEFAULT_MCP_TOP_K,
31
29
  dedupeResultsByFile,
32
30
  expandChunksAtLine,
33
31
  formatExpandResults,
34
32
  formatResults,
35
33
  localRepoRoot,
36
- MAX_MCP_TOP_K,
37
34
  resolveChunk,
35
+ SEARCH_RESULT_COUNT,
38
36
  } from "../utils.ts";
39
37
  import { registerAuthTool, toolErrorText } from "./auth-tool.ts";
40
38
  import {
@@ -46,12 +44,13 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
46
44
  import { MiruMcpServer } from "./runtime.ts";
47
45
 
48
46
  const REPO_DESCRIPTION =
49
- "https:// or http:// git URL (e.g. https://github.com/org/repo) or local directory path to index and search. " +
50
- "Pass the project root for local workspaces. " +
47
+ "`repo` is optional. Set a local path or Git URL for another repo. " +
51
48
  "The index is built on the first tool call and cached for the session.";
52
49
 
53
50
  const BENCHMARK_SKIP_NOTES = {
54
51
  local_repo_only: "Benchmark comparisons require a local repo path; git URL repos are skipped.",
52
+ filtered_search:
53
+ "Benchmark comparisons are skipped when search is restricted by include/exclude patterns.",
55
54
  limited_locate:
56
55
  "Benchmark comparison skipped: locate.limit is global, while rg/grep limits are per file. Omit limit for a valid token and recall comparison.",
57
56
  incompatible_literal_baseline:
@@ -121,9 +120,26 @@ async function persistBenchmarkQuery(
121
120
 
122
121
  export function createMcpServer(
123
122
  cache: IndexCache,
124
- options?: { benchmark?: boolean },
123
+ options?: {
124
+ benchmark?: boolean;
125
+ dependencies?: {
126
+ withGrepTimeoutFallback?: typeof withGrepTimeoutFallback;
127
+ benchmarkSearchComparison?: typeof benchmarkSearchComparison;
128
+ benchmarkLocateComparison?: typeof benchmarkLocateComparison;
129
+ selectComparableLiteralSearchTool?: typeof selectComparableLiteralSearchTool;
130
+ readBenchmarkRollup?: typeof readBenchmarkRollup;
131
+ };
132
+ },
125
133
  ): MiruMcpServer {
134
+ const defaultRepo = process.cwd();
126
135
  const benchmark = options?.benchmark ?? false;
136
+ const dependencies = options?.dependencies;
137
+ const runWithGrepFallback = dependencies?.withGrepTimeoutFallback ?? withGrepTimeoutFallback;
138
+ const compareSearch = dependencies?.benchmarkSearchComparison ?? benchmarkSearchComparison;
139
+ const compareLocate = dependencies?.benchmarkLocateComparison ?? benchmarkLocateComparison;
140
+ const selectLiteralBaseline =
141
+ dependencies?.selectComparableLiteralSearchTool ?? selectComparableLiteralSearchTool;
142
+ const readRollup = dependencies?.readBenchmarkRollup ?? readBenchmarkRollup;
127
143
  const server = new MiruMcpServer(
128
144
  {
129
145
  name: "miru",
@@ -139,37 +155,46 @@ export function createMcpServer(
139
155
  server.registerTool(
140
156
  "search",
141
157
  {
142
- description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
158
+ description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes the selected directory or URL on first call; later calls reuse the session cache.`,
143
159
  inputSchema: {
144
160
  query: z
145
161
  .string()
146
162
  .describe(
147
163
  "Natural language or code query — your default for all code search in this repo.",
148
164
  ),
149
- repo: z.string().describe(REPO_DESCRIPTION),
150
- top_k: z
151
- .number()
152
- .int()
165
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
166
+ include: z
167
+ .array(z.string().min(1))
168
+ .min(1)
169
+ .optional()
170
+ .describe(
171
+ 'Gitignore-style glob patterns; only matching files are searched (e.g. ["src/auth/**", "docs/**/*.md"]). Same semantics as `locate.include`.',
172
+ ),
173
+ exclude: z
174
+ .array(z.string().min(1))
153
175
  .min(1)
154
- .max(MAX_MCP_TOP_K)
155
176
  .optional()
156
- .describe(`Number of results (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
177
+ .describe(
178
+ "Gitignore-style glob patterns; matching files are skipped. Same semantics as `locate.exclude`.",
179
+ ),
157
180
  dedupe_by_file: z
158
181
  .boolean()
159
182
  .optional()
160
183
  .describe("Keep only the best hit per file (default true)."),
161
184
  },
162
185
  },
163
- async ({ query, repo, top_k: topK, dedupe_by_file: dedupeByFile }) => {
186
+ async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
164
187
  try {
165
- const index = await getIndexForRepo(repo, cache);
166
- const repoRoot = localRepoRoot(repo);
167
- const k = clampMcpTopK(topK);
168
-
188
+ const source = repo ?? defaultRepo;
189
+ const index = await getIndexForRepo(source, cache);
190
+ const repoRoot = localRepoRoot(source);
191
+ const k = SEARCH_RESULT_COUNT;
169
192
  let skip: BenchmarkSkipReason | undefined;
170
- if (benchmark && repoRoot) {
171
- const comparison = await withGrepTimeoutFallback(() =>
172
- benchmarkSearchComparison({
193
+ if (benchmark && (include || exclude)) {
194
+ skip = "filtered_search";
195
+ } else if (benchmark && repoRoot) {
196
+ const comparison = await runWithGrepFallback(() =>
197
+ compareSearch({
173
198
  query,
174
199
  repoPath: repoRoot,
175
200
  index,
@@ -199,7 +224,7 @@ export function createMcpServer(
199
224
  skip = "local_repo_only";
200
225
  }
201
226
 
202
- let results = await index.search({ query, topK: k });
227
+ let results = await index.search({ query, topK: k, include, exclude });
203
228
  if (dedupeByFile !== false) {
204
229
  results = dedupeResultsByFile(results);
205
230
  }
@@ -237,7 +262,7 @@ export function createMcpServer(
237
262
  .describe(
238
263
  "Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
239
264
  ),
240
- repo: z.string().describe(REPO_DESCRIPTION),
265
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
241
266
  mode: z
242
267
  .enum(["count", "locations", "lines"])
243
268
  .optional()
@@ -283,19 +308,20 @@ export function createMcpServer(
283
308
  return toolErrorText(new Error("locate requires `literal` (exact substring to find)."));
284
309
  }
285
310
  try {
286
- const index = await getIndexForRepo(repo, cache);
311
+ const source = repo ?? defaultRepo;
312
+ const index = await getIndexForRepo(source, cache);
287
313
 
288
314
  let skip: BenchmarkSkipReason | undefined;
289
- const repoRoot = localRepoRoot(repo);
315
+ const repoRoot = localRepoRoot(source);
290
316
  if (benchmark && repoRoot && typeof lit === "string") {
291
317
  if (locateOpts.limit != null) {
292
318
  skip = "limited_locate";
293
- } else if (!selectComparableLiteralSearchTool()) {
319
+ } else if (!selectLiteralBaseline()) {
294
320
  skip = "incompatible_literal_baseline";
295
321
  }
296
322
  if (!skip) {
297
- const comparison = await withGrepTimeoutFallback(() =>
298
- benchmarkLocateComparison({
323
+ const comparison = await runWithGrepFallback(() =>
324
+ compareLocate({
299
325
  literal: lit,
300
326
  repoPath: repoRoot,
301
327
  index,
@@ -335,7 +361,7 @@ export function createMcpServer(
335
361
  .number()
336
362
  .int()
337
363
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
338
- repo: z.string().describe(REPO_DESCRIPTION),
364
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
339
365
  before: z
340
366
  .number()
341
367
  .int()
@@ -352,8 +378,9 @@ export function createMcpServer(
352
378
  },
353
379
  async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
354
380
  try {
355
- const index = await getIndexForRepo(repo, cache);
356
- const repoRoot = localRepoRoot(repo);
381
+ const source = repo ?? defaultRepo;
382
+ const index = await getIndexForRepo(source, cache);
383
+ const repoRoot = localRepoRoot(source);
357
384
  const beforeCount = before ?? DEFAULT_EXPAND_BEFORE;
358
385
  const afterCount = after ?? DEFAULT_EXPAND_AFTER;
359
386
  const { anchor, chunks: expanded } = expandChunksAtLine(
@@ -396,29 +423,21 @@ export function createMcpServer(
396
423
  .number()
397
424
  .int()
398
425
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
399
- repo: z.string().describe(REPO_DESCRIPTION),
400
- top_k: z
401
- .number()
402
- .int()
403
- .min(1)
404
- .max(MAX_MCP_TOP_K)
405
- .optional()
406
- .describe(
407
- `Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
408
- ),
426
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
409
427
  },
410
428
  },
411
- async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
429
+ async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
412
430
  try {
413
- const index = await getIndexForRepo(repo, cache);
414
- const repoRoot = localRepoRoot(repo);
431
+ const source = repo ?? defaultRepo;
432
+ const index = await getIndexForRepo(source, cache);
433
+ const repoRoot = localRepoRoot(source);
415
434
  const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
416
435
  if (!chunk) {
417
436
  return toolText(
418
437
  `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
419
438
  );
420
439
  }
421
- const results = await index.findRelated(chunk, clampMcpTopK(topK));
440
+ const results = await index.findRelated(chunk, SEARCH_RESULT_COUNT);
422
441
  if (results.length === 0) {
423
442
  return toolText(`No related chunks found for ${filePath}:${anchorLine}.`);
424
443
  }
@@ -453,7 +472,7 @@ export function createMcpServer(
453
472
  },
454
473
  async ({ repo }) => {
455
474
  try {
456
- const rollup = await readBenchmarkRollup({ repo });
475
+ const rollup = await readRollup({ repo });
457
476
  return toolText(JSON.stringify(rollup));
458
477
  } catch (err) {
459
478
  return toolText(err instanceof Error ? err.message : String(err));
package/src/mcp/stdio.ts CHANGED
@@ -45,6 +45,21 @@ export class StdioTransport {
45
45
  private buffer = "";
46
46
  private started = false;
47
47
  private abortController: AbortController | null = null;
48
+ private readonly inputStream: () => ReadableStream<Uint8Array<ArrayBufferLike>>;
49
+ private readonly writeText: (text: string) => Promise<unknown>;
50
+
51
+ constructor(
52
+ options: {
53
+ inputStream?: () => ReadableStream<Uint8Array<ArrayBufferLike>>;
54
+ writeText?: (text: string) => Promise<unknown>;
55
+ stdin?: { stream: () => ReadableStream<Uint8Array<ArrayBufferLike>> };
56
+ bunWrite?: (destination: typeof Bun.stdout, text: string) => Promise<unknown>;
57
+ } = {},
58
+ ) {
59
+ this.inputStream = options.inputStream ?? (() => (options.stdin ?? Bun.stdin).stream());
60
+ this.writeText =
61
+ options.writeText ?? ((text) => (options.bunWrite ?? Bun.write)(Bun.stdout, text));
62
+ }
48
63
 
49
64
  onmessage?: (message: JsonRpcMessage) => void | Promise<void>;
50
65
  onerror?: (error: Error) => void;
@@ -56,7 +71,7 @@ export class StdioTransport {
56
71
  }
57
72
  this.started = true;
58
73
  this.abortController = new AbortController();
59
- const reader = Bun.stdin.stream().getReader();
74
+ const reader = this.inputStream().getReader();
60
75
  const decoder = new TextDecoder();
61
76
 
62
77
  try {
@@ -85,7 +100,7 @@ export class StdioTransport {
85
100
 
86
101
  async send(message: JsonRpcMessage): Promise<void> {
87
102
  const line = `${JSON.stringify(message)}\n`;
88
- await Bun.write(Bun.stdout, line);
103
+ await this.writeText(line);
89
104
  }
90
105
 
91
106
  private async append(chunk: string): Promise<void> {