@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
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { type AgentId, writeAgentFile } from "./agents.ts";
3
3
  import { clearBenchmarkHistory, resolveBenchmarkHistoryPath } from "./benchmark/history.ts";
4
4
  import { clearCache } from "./cache.ts";
5
+ import { shouldRunMcp } from "./cli-mode.ts";
5
6
  import {
6
7
  fail,
7
8
  formatRelatedHeader,
@@ -26,7 +27,6 @@ import {
26
27
  printSageMakerHelp,
27
28
  } from "./help.ts";
28
29
  import { getBenchmarkModeStatus, setBenchmarkMode } from "./installer/benchmark-mode.ts";
29
- import { runSearchGuardFromStdin } from "./installer/hooks/search-guard.ts";
30
30
  import { runInstaller } from "./installer/installer.ts";
31
31
  import { promptConfirm } from "./installer/prompt.ts";
32
32
  import {
@@ -56,6 +56,7 @@ import {
56
56
  resolveChunk,
57
57
  resolveContent,
58
58
  resolveSearchPath,
59
+ SEARCH_RESULT_COUNT,
59
60
  } from "./utils.ts";
60
61
  import { maybeNotifyUpdate, miruVersion } from "./version.ts";
61
62
 
@@ -71,27 +72,6 @@ try {
71
72
  // Leave recovery to ensureCredentials / setup.
72
73
  }
73
74
 
74
- const CLI_COMMANDS = new Set([
75
- "search",
76
- "locate",
77
- "expand",
78
- "find-related",
79
- "init",
80
- "install",
81
- "uninstall",
82
- "setup",
83
- "clear",
84
- "benchmark",
85
- "env",
86
- "environment",
87
- "hook-guard",
88
- "help",
89
- "-h",
90
- "--help",
91
- "-v",
92
- "--version",
93
- ]);
94
-
95
75
  const AGENTS = new Set<AgentId>(AGENT_IDS);
96
76
 
97
77
  function parseFlagArgv(argv: string[], flag: string): { present: boolean; rest: string[] } {
@@ -132,25 +112,6 @@ function parseContentArgv(argv: string[]): { content: ContentType[]; rest: strin
132
112
  return { content: resolveContent(content), rest };
133
113
  }
134
114
 
135
- function parseTopK(argv: string[]): { topK: number; rest: string[] } {
136
- const rest: string[] = [];
137
- let topK = 5;
138
- for (let i = 0; i < argv.length; i++) {
139
- const arg = argv[i];
140
- if (arg === "-k" || arg === "--top-k") {
141
- const raw = argv[++i];
142
- if (raw) {
143
- topK = Number(raw);
144
- }
145
- continue;
146
- }
147
- if (arg !== undefined) {
148
- rest.push(arg);
149
- }
150
- }
151
- return { topK: Number.isFinite(topK) && topK >= 1 ? Math.floor(topK) : 5, rest };
152
- }
153
-
154
115
  function emitSearchOutput(
155
116
  query: string,
156
117
  results: SearchResult[],
@@ -177,7 +138,6 @@ function emitSearchOutput(
177
138
  async function runSearch(
178
139
  path: string,
179
140
  query: string,
180
- topK: number,
181
141
  content: ContentType[],
182
142
  jsonFlag: boolean,
183
143
  ): Promise<void> {
@@ -186,7 +146,7 @@ async function runSearch(
186
146
  const index = await withSpinner("Indexing and searching", async () => {
187
147
  const built = await MiruIndex.fromSource(path, content);
188
148
  await built.saveToCache(path);
189
- const results = await built.search({ query, topK });
149
+ const results = await built.search({ query, topK: SEARCH_RESULT_COUNT });
190
150
  return { index: built, results };
191
151
  });
192
152
 
@@ -245,7 +205,6 @@ async function runFindRelated(
245
205
  path: string,
246
206
  filePath: string,
247
207
  line: number,
248
- topK: number,
249
208
  content: ContentType[],
250
209
  jsonFlag: boolean,
251
210
  ): Promise<void> {
@@ -257,7 +216,7 @@ async function runFindRelated(
257
216
  if (!chunk) {
258
217
  throw new RelatedChunkNotFoundError(filePath, line);
259
218
  }
260
- const hits = await built.findRelated(chunk, topK);
219
+ const hits = await built.findRelated(chunk, SEARCH_RESULT_COUNT);
261
220
  await built.saveToCache(path);
262
221
  return {
263
222
  results: hits,
@@ -412,21 +371,11 @@ async function runBenchmarkCommand(rest: string[]): Promise<void> {
412
371
  async function runCli(argv: string[]): Promise<void> {
413
372
  const [command, ...rest] = argv;
414
373
 
415
- if (command === undefined) {
374
+ if (command === undefined || command === "-h" || command === "--help") {
416
375
  printMainHelp();
417
376
  return;
418
377
  }
419
378
 
420
- if (command === "-h" || command === "--help") {
421
- printMainHelp();
422
- return;
423
- }
424
-
425
- if (command === "-v" || command === "--version") {
426
- console.log(miruVersion());
427
- return;
428
- }
429
-
430
379
  if (command === "help") {
431
380
  const topic = rest[0];
432
381
  if (!topic || topic === "-h" || topic === "--help") {
@@ -456,10 +405,6 @@ async function runCli(argv: string[]): Promise<void> {
456
405
  return;
457
406
  }
458
407
 
459
- if (command === "hook-guard") {
460
- process.exit(await runSearchGuardFromStdin());
461
- }
462
-
463
408
  if (command === "install" || command === "uninstall") {
464
409
  await runInstaller(command);
465
410
  return;
@@ -552,21 +497,28 @@ async function runCli(argv: string[]): Promise<void> {
552
497
 
553
498
  const { present: jsonFlag, rest: jsonRest } = parseFlagArgv(rest, "--json");
554
499
  const { content, rest: contentRest } = parseContentArgv(jsonRest);
555
- const { topK, rest: sizedRest } = parseTopK(contentRest);
500
+
501
+ if (
502
+ (command === "search" || command === "find-related") &&
503
+ contentRest.some((arg) => arg === "-k" || arg === "--top-k")
504
+ ) {
505
+ fail("The -k/--top-k option has been removed; Miru returns a fixed number of results.");
506
+ process.exit(1);
507
+ }
556
508
 
557
509
  if (command === "search") {
558
- const query = sizedRest[0];
510
+ const query = contentRest[0];
559
511
  if (!query) {
560
512
  printCommandHelp("search");
561
513
  process.exit(1);
562
514
  }
563
- const path = resolveSearchPath(sizedRest[1] ?? process.cwd());
564
- await runSearch(path, query, topK, content, jsonFlag);
515
+ const path = resolveSearchPath(contentRest[1] ?? process.cwd());
516
+ await runSearch(path, query, content, jsonFlag);
565
517
  return;
566
518
  }
567
519
 
568
520
  if (command === "locate") {
569
- const literal = sizedRest[0];
521
+ const literal = contentRest[0];
570
522
  if (!literal) {
571
523
  printCommandHelp("locate");
572
524
  process.exit(1);
@@ -585,10 +537,10 @@ async function runCli(argv: string[]): Promise<void> {
585
537
  }
586
538
  return Math.floor(n);
587
539
  };
588
- for (let i = 1; i < sizedRest.length; i++) {
589
- const arg = sizedRest[i];
590
- if (arg === "--mode" && sizedRest[i + 1]) {
591
- const value = sizedRest[++i];
540
+ for (let i = 1; i < contentRest.length; i++) {
541
+ const arg = contentRest[i];
542
+ if (arg === "--mode" && contentRest[i + 1]) {
543
+ const value = contentRest[++i];
592
544
  if (value !== "count" && value !== "locations" && value !== "lines") {
593
545
  fail(`Unknown locate mode "${value}". Use count, locations, or lines.`);
594
546
  process.exit(1);
@@ -596,8 +548,8 @@ async function runCli(argv: string[]): Promise<void> {
596
548
  options.mode = value as LiteralMode;
597
549
  continue;
598
550
  }
599
- if (arg === "--limit" && sizedRest[i + 1]) {
600
- options.limit = positiveInt("--limit", sizedRest[++i], 1);
551
+ if (arg === "--limit" && contentRest[i + 1]) {
552
+ options.limit = positiveInt("--limit", contentRest[++i], 1);
601
553
  continue;
602
554
  }
603
555
  if (arg === "--ignore-case") {
@@ -608,16 +560,16 @@ async function runCli(argv: string[]): Promise<void> {
608
560
  options.match_variants = true;
609
561
  continue;
610
562
  }
611
- if (arg === "--include" && sizedRest[i + 1]) {
612
- include.push(sizedRest[++i] as string);
563
+ if (arg === "--include" && contentRest[i + 1]) {
564
+ include.push(contentRest[++i] as string);
613
565
  continue;
614
566
  }
615
- if (arg === "--exclude" && sizedRest[i + 1]) {
616
- exclude.push(sizedRest[++i] as string);
567
+ if (arg === "--exclude" && contentRest[i + 1]) {
568
+ exclude.push(contentRest[++i] as string);
617
569
  continue;
618
570
  }
619
- if (arg === "--context" && sizedRest[i + 1]) {
620
- options.context_lines = positiveInt("--context", sizedRest[++i], 0);
571
+ if (arg === "--context" && contentRest[i + 1]) {
572
+ options.context_lines = positiveInt("--context", contentRest[++i], 0);
621
573
  continue;
622
574
  }
623
575
  if (arg !== undefined) {
@@ -636,22 +588,22 @@ async function runCli(argv: string[]): Promise<void> {
636
588
  }
637
589
 
638
590
  if (command === "expand") {
639
- const filePath = sizedRest[0];
640
- const lineRaw = sizedRest[1];
591
+ const filePath = contentRest[0];
592
+ const lineRaw = contentRest[1];
641
593
  if (!filePath || !lineRaw) {
642
594
  printCommandHelp("expand");
643
595
  process.exit(1);
644
596
  }
645
597
  const line = Number(lineRaw);
646
- const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
598
+ const path = resolveSearchPath(contentRest[2] ?? process.cwd());
647
599
  let before = DEFAULT_EXPAND_BEFORE;
648
600
  let after = DEFAULT_EXPAND_AFTER;
649
- for (let i = 3; i < sizedRest.length; i++) {
650
- const arg = sizedRest[i];
651
- if (arg === "--before" && sizedRest[i + 1]) {
652
- before = Number(sizedRest[++i]);
653
- } else if (arg === "--after" && sizedRest[i + 1]) {
654
- after = Number(sizedRest[++i]);
601
+ for (let i = 3; i < contentRest.length; i++) {
602
+ const arg = contentRest[i];
603
+ if (arg === "--before" && contentRest[i + 1]) {
604
+ before = Number(contentRest[++i]);
605
+ } else if (arg === "--after" && contentRest[i + 1]) {
606
+ after = Number(contentRest[++i]);
655
607
  }
656
608
  }
657
609
  await runExpand(path, filePath, line, before, after, content, jsonFlag);
@@ -659,15 +611,15 @@ async function runCli(argv: string[]): Promise<void> {
659
611
  }
660
612
 
661
613
  if (command === "find-related") {
662
- const filePath = sizedRest[0];
663
- const lineRaw = sizedRest[1];
614
+ const filePath = contentRest[0];
615
+ const lineRaw = contentRest[1];
664
616
  if (!filePath || !lineRaw) {
665
617
  printCommandHelp("find-related");
666
618
  process.exit(1);
667
619
  }
668
620
  const line = Number(lineRaw);
669
- const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
670
- await runFindRelated(path, filePath, line, topK, content, jsonFlag);
621
+ const path = resolveSearchPath(contentRest[2] ?? process.cwd());
622
+ await runFindRelated(path, filePath, line, content, jsonFlag);
671
623
  return;
672
624
  }
673
625
 
@@ -687,6 +639,10 @@ async function runMcp(argv: string[]): Promise<void> {
687
639
  benchmark = true;
688
640
  continue;
689
641
  }
642
+ if (arg === "--benchmark=true" || arg === "--benchmark=false") {
643
+ benchmark = arg === "--benchmark=true";
644
+ continue;
645
+ }
690
646
  if (arg === "--ref" && argv[i + 1]) {
691
647
  ref = argv[++i] ?? null;
692
648
  continue;
@@ -734,24 +690,24 @@ async function main(): Promise<void> {
734
690
  const argv = process.argv.slice(2);
735
691
  const first = argv[0];
736
692
 
693
+ // Old hook configs may survive until the next install/uninstall; let their
694
+ // search commands proceed normally while the installer removes the hooks.
695
+ if (first === "hook-guard") return;
696
+
737
697
  if (first === "-v" || first === "--version") {
738
698
  console.log(miruVersion());
739
699
  return;
740
700
  }
741
701
 
742
- if (first === "hook-guard") {
743
- process.exit(await runSearchGuardFromStdin());
744
- }
745
-
746
702
  const updateNotice = maybeNotifyUpdate();
747
703
 
748
- if (first && CLI_COMMANDS.has(first)) {
749
- await Promise.all([runCli(argv), updateNotice]);
704
+ if (shouldRunMcp(argv, Boolean(process.stdin.isTTY), Boolean(process.stdout.isTTY))) {
705
+ await updateNotice;
706
+ await runMcpWithCredentials(first === "mcp" ? argv.slice(1) : argv);
750
707
  return;
751
708
  }
752
709
 
753
- await updateNotice;
754
- await runMcpWithCredentials(argv);
710
+ await Promise.all([runCli(argv), updateNotice]);
755
711
  }
756
712
 
757
713
  main().catch((err) => {
@@ -20,17 +20,22 @@ export function setStoredCredentialsEnvToken(token: string): void {
20
20
  process.env.TAKARA_API_KEY = token;
21
21
  }
22
22
 
23
- export function resolveCredentialsDir(): string {
23
+ /** True for an unresolved `${...}` placeholder — a plugin host that didn't substitute it. */
24
+ function isUnsubstitutedPlaceholder(value: string): boolean {
25
+ return value.startsWith("${") && value.endsWith("}");
26
+ }
27
+
28
+ export function resolveCredentialsDir(platform: NodeJS.Platform = process.platform): string {
24
29
  const override = process.env.MIRU_CREDENTIALS_DIR;
25
- if (override) {
30
+ if (override && !isUnsubstitutedPlaceholder(override)) {
26
31
  return override;
27
32
  }
28
33
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
29
- if (process.platform === "win32") {
34
+ if (platform === "win32") {
30
35
  const base = process.env.APPDATA ?? join(home, "AppData", "Roaming");
31
36
  return join(base, "miru");
32
37
  }
33
- if (process.platform === "darwin") {
38
+ if (platform === "darwin") {
34
39
  return join(home, "Library", "Application Support", "miru");
35
40
  }
36
41
  const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
@@ -41,12 +46,12 @@ export function resolveCredentialsDir(): string {
41
46
  * Global Miru state directory (credentials, benchmark history, etc.).
42
47
  * Same location as `resolveCredentialsDir()`; prefer this name for non-secret files.
43
48
  */
44
- export function resolveMiruStateDir(): string {
45
- return resolveCredentialsDir();
49
+ export function resolveMiruStateDir(platform: NodeJS.Platform = process.platform): string {
50
+ return resolveCredentialsDir(platform);
46
51
  }
47
52
 
48
- export function resolveCredentialsPath(): string {
49
- return join(resolveCredentialsDir(), CREDENTIALS_FILENAME);
53
+ export function resolveCredentialsPath(platform: NodeJS.Platform = process.platform): string {
54
+ return join(resolveCredentialsDir(platform), CREDENTIALS_FILENAME);
50
55
  }
51
56
 
52
57
  export async function readStoredCredentials(): Promise<StoredCredentials | null> {
@@ -262,12 +267,7 @@ export async function clearStoredCredentials(): Promise<{ cleared: boolean; path
262
267
  }
263
268
 
264
269
  // Read for env cleanup, but never let parse errors block deletion.
265
- let stored: StoredCredentials | null = null;
266
- try {
267
- stored = await readStoredCredentials();
268
- } catch {
269
- // Ignore parse/read errors — removal still proceeds.
270
- }
270
+ const stored = await readStoredCredentials();
271
271
  await rm(path, { force: true });
272
272
 
273
273
  if (stored) {
@@ -353,7 +353,9 @@ export class EmbeddingApiError extends Error {
353
353
  }
354
354
  }
355
355
 
356
- function createClient(): EmbeddingClient {
356
+ type EmbeddingFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
357
+
358
+ function createClient(fetchImpl: EmbeddingFetch = fetch): EmbeddingClient {
357
359
  const baseUrl = resolveEmbeddingBaseUrl();
358
360
  const endpoint = `${baseUrl}/embeddings`;
359
361
 
@@ -369,7 +371,7 @@ function createClient(): EmbeddingClient {
369
371
  if (dimensions != null) {
370
372
  body.dimensions = dimensions;
371
373
  }
372
- const response = await fetch(endpoint, {
374
+ const response = await fetchImpl(endpoint, {
373
375
  method: "POST",
374
376
  headers: {
375
377
  Authorization: `Bearer ${apiKey}`,
@@ -407,9 +409,10 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
407
409
  maxEmbedChars?: number;
408
410
  dimensions?: number;
409
411
  client?: EmbeddingClient;
412
+ fetchImpl?: EmbeddingFetch;
410
413
  }) {
411
414
  this.model = options?.model ?? resolveEmbeddingModel();
412
- this.client = options?.client ?? createClient();
415
+ this.client = options?.client ?? createClient(options?.fetchImpl);
413
416
  this.batchSize = options?.batchSize ?? resolveEmbeddingBatchSize();
414
417
  this.maxEmbedChars = options?.maxEmbedChars ?? resolveMaxEmbedChars();
415
418
  this.requestedDimensions = options?.dimensions ?? resolveEmbeddingDimensions(this.model);
@@ -26,6 +26,11 @@ function loadAwsCredentialProvider(): Promise<AwsCredentialProviderModule> {
26
26
  return awsCredentialProviderPromise;
27
27
  }
28
28
 
29
+ /** Warm the lazily loaded AWS SDK modules before the first SageMaker request. */
30
+ export async function preloadSageMakerSdk(): Promise<void> {
31
+ await Promise.all([loadSageMakerRuntime(), loadAwsCredentialProvider()]);
32
+ }
33
+
29
34
  /** arn:aws:sagemaker:<region>:<account-id>:endpoint/<endpoint-name> (also covers aws-cn/aws-us-gov). */
30
35
  const ENDPOINT_ARN_PATTERN = /^arn:aws[a-z0-9-]*:sagemaker:([a-z0-9-]+):(\d{12}):endpoint\/(.+)$/;
31
36
 
@@ -344,7 +349,13 @@ function buildRequestBody(
344
349
  return body;
345
350
  }
346
351
 
347
- export function createSageMakerClient(config: SageMakerEmbeddingConfig): EmbeddingClient {
352
+ export function createSageMakerClient(
353
+ config: SageMakerEmbeddingConfig,
354
+ loaders: {
355
+ loadRuntime?: () => Promise<SageMakerRuntimeModule>;
356
+ loadCredentials?: () => Promise<AwsCredentialProviderModule>;
357
+ } = {},
358
+ ): EmbeddingClient {
348
359
  let client: InstanceType<SageMakerRuntimeModule["SageMakerRuntimeClient"]> | null = null;
349
360
 
350
361
  return {
@@ -354,7 +365,10 @@ export function createSageMakerClient(config: SageMakerEmbeddingConfig): Embeddi
354
365
  dimensions?: number,
355
366
  ): Promise<EmbeddingResponse> {
356
367
  const [{ InvokeEndpointCommand, SageMakerRuntimeClient }, { defaultProvider }] =
357
- await Promise.all([loadSageMakerRuntime(), loadAwsCredentialProvider()]);
368
+ await Promise.all([
369
+ (loaders.loadRuntime ?? loadSageMakerRuntime)(),
370
+ (loaders.loadCredentials ?? loadAwsCredentialProvider)(),
371
+ ]);
358
372
  if (!client) {
359
373
  const profile = config.profile?.trim() || process.env.AWS_PROFILE?.trim();
360
374
  const sdkCredentials = defaultProvider({ profile });
@@ -414,9 +428,10 @@ export interface ValidateSageMakerResult {
414
428
  /** Real InvokeEndpoint round trip, used by `miru setup --sagemaker` to confirm auth + shape. */
415
429
  export async function validateSageMakerConnection(
416
430
  config: SageMakerEmbeddingConfig,
431
+ createClient: typeof createSageMakerClient = createSageMakerClient,
417
432
  ): Promise<ValidateSageMakerResult> {
418
433
  try {
419
- const client = createSageMakerClient(config);
434
+ const client = createClient(config);
420
435
  const response = await client.createEmbeddings("miru setup validation", "sagemaker-setup");
421
436
  const embedding = response.data[0]?.embedding;
422
437
  if (!embedding) {
@@ -12,12 +12,15 @@ export interface ValidateApiKeyResult {
12
12
  message: string;
13
13
  }
14
14
 
15
- export async function validateEmbeddingApiKey(options: {
16
- apiKey: string;
17
- baseUrl?: string;
18
- model?: string;
19
- dimensions?: number;
20
- }): Promise<ValidateApiKeyResult> {
15
+ export async function validateEmbeddingApiKey(
16
+ options: {
17
+ apiKey: string;
18
+ baseUrl?: string;
19
+ model?: string;
20
+ dimensions?: number;
21
+ },
22
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch,
23
+ ): Promise<ValidateApiKeyResult> {
21
24
  const baseUrl = (options.baseUrl ?? resolveEmbeddingBaseUrl()).replace(/\/$/, "");
22
25
  const model = options.model ?? resolveTakaraEmbeddingModel();
23
26
  const dimensions = options.dimensions ?? resolveEmbeddingDimensions(model);
@@ -33,7 +36,7 @@ export async function validateEmbeddingApiKey(options: {
33
36
 
34
37
  let response: Response;
35
38
  try {
36
- response = await fetch(endpoint, {
39
+ response = await fetchImpl(endpoint, {
37
40
  method: "POST",
38
41
  headers: {
39
42
  Authorization: `Bearer ${options.apiKey}`,
package/src/git.ts CHANGED
@@ -5,7 +5,11 @@ import { envOptionalInt } from "./env.ts";
5
5
 
6
6
  const DEFAULT_CLONE_TIMEOUT_SEC = 60;
7
7
 
8
- export async function cloneGitRepository(url: string, ref?: string | null): Promise<string> {
8
+ export async function cloneGitRepository(
9
+ url: string,
10
+ ref?: string | null,
11
+ spawn: typeof Bun.spawn = Bun.spawn,
12
+ ): Promise<string> {
9
13
  const timeoutSec = envOptionalInt(["MIRU_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
10
14
  const dir = await mkdtemp(join(tmpdir(), "miru-git-"));
11
15
  const args = ["clone", "--depth", "1"];
@@ -16,7 +20,7 @@ export async function cloneGitRepository(url: string, ref?: string | null): Prom
16
20
 
17
21
  let proc: ReturnType<typeof Bun.spawn>;
18
22
  try {
19
- proc = Bun.spawn(["git", ...args], {
23
+ proc = spawn(["git", ...args], {
20
24
  stdout: "pipe",
21
25
  stderr: "pipe",
22
26
  stdin: "ignore",
package/src/help.ts CHANGED
@@ -28,7 +28,7 @@ export function printMainHelp(): void {
28
28
  header();
29
29
 
30
30
  section("Usage");
31
- writeStdout(" miru Start MCP server (stdio)");
31
+ writeStdout(" miru Show help in a terminal");
32
32
  writeStdout(" miru <command> [options]");
33
33
  divider();
34
34
 
@@ -37,6 +37,7 @@ export function printMainHelp(): void {
37
37
  commandRow("locate", "Exact substring location in the index");
38
38
  commandRow("expand", "Adjacent chunks in the same file as a hit");
39
39
  commandRow("find-related", "Find chunks related to a file:line");
40
+ commandRow("mcp", "Start MCP server (stdio)");
40
41
  commandRow("setup", "Authenticate with Takara");
41
42
  commandRow("install", "Configure miru across coding agents");
42
43
  commandRow("uninstall", "Remove miru agent configuration");
@@ -108,11 +109,10 @@ export function printCommandHelp(command: string): void {
108
109
  section("Usage");
109
110
  writeStdout(" miru search <query> [path] [options]");
110
111
  section("Options");
111
- writeStdout(" -k, --top-k N Number of results (default: 5)");
112
112
  writeStdout(" --content TYPE code | docs | config | all (default: code config)");
113
113
  writeStdout(" --json JSON output (default when piped)");
114
114
  section("Example");
115
- writeStdout(' miru search "where is auth" ./src -k 10 --content code docs');
115
+ writeStdout(' miru search "where is auth" ./src --content code docs');
116
116
  writeStdout("");
117
117
  return;
118
118
  case "locate":
@@ -149,7 +149,7 @@ export function printCommandHelp(command: string): void {
149
149
  section("Usage");
150
150
  writeStdout(" miru find-related <file> <line> [path] [options]");
151
151
  section("Example");
152
- writeStdout(" miru find-related src/auth.ts 42 . -k 8");
152
+ writeStdout(" miru find-related src/auth.ts 42 .");
153
153
  writeStdout("");
154
154
  return;
155
155
  case "setup":
@@ -178,7 +178,7 @@ export function printCommandHelp(command: string): void {
178
178
  case "install":
179
179
  commandHeader("install", "Interactive global agent setup.");
180
180
  writeStdout("Configures MCP server, instructions, sub-agent files, and optional");
181
- writeStdout("experimental integrations (search hooks, Caveman, STE writing) under");
181
+ writeStdout("experimental integrations (Caveman, STE writing) under");
182
182
  writeStdout("your user config (~/.claude, ~/.cursor, etc.).");
183
183
  writeStdout("");
184
184
  writeStdout(
@@ -244,9 +244,9 @@ export function printCommandHelp(command: string): void {
244
244
  writeStdout("");
245
245
  return;
246
246
  case "mcp":
247
- commandHeader("mcp", "Stdio MCP server (default with no subcommand).");
247
+ commandHeader("mcp", "Stdio MCP server.");
248
248
  section("Usage");
249
- writeStdout(" miru [--ref BRANCH] [--content TYPE ...] [--benchmark]");
249
+ writeStdout(" miru mcp [--ref BRANCH] [--content TYPE ...] [--benchmark]");
250
250
  writeStdout(" Default content: code config");
251
251
  writeStdout("");
252
252
  writeStdout("Indexes on the first search/expand/find_related tool call (repo argument).");
package/src/index/bm25.ts CHANGED
@@ -42,12 +42,21 @@ function runScoreWorker(job: Bm25ScoreJob): Promise<Bm25ScoreResult> {
42
42
  }
43
43
 
44
44
  export class BM25Index {
45
- private docFreq = new Map<string, number>();
46
- private docLengths: number[] = [];
47
- private postings = new Map<string, PostingsList>();
48
- private avgDocLength = 0;
49
- private numDocs = 0;
50
- private totalLen = 0;
45
+ private docFreq: Map<string, number>;
46
+ private docLengths: number[];
47
+ private postings: Map<string, PostingsList>;
48
+ private avgDocLength: number;
49
+ private numDocs: number;
50
+ private totalLen: number;
51
+
52
+ constructor() {
53
+ this.docFreq = new Map();
54
+ this.docLengths = [];
55
+ this.postings = new Map();
56
+ this.avgDocLength = 0;
57
+ this.numDocs = 0;
58
+ this.totalLen = 0;
59
+ }
51
60
 
52
61
  /** Append one tokenized document. Safe to call incrementally while indexing. */
53
62
  addDocument(doc: string[]): number {
@@ -73,6 +82,57 @@ export class BM25Index {
73
82
  return docIndex;
74
83
  }
75
84
 
85
+ get size(): number {
86
+ return this.numDocs;
87
+ }
88
+
89
+ /**
90
+ * A new index with the given documents removed and later doc indices shifted down,
91
+ * equal to re-indexing the surviving docs in order. `this` is left untouched, so
92
+ * searches already holding it never see a half-updated index.
93
+ */
94
+ withoutDocuments(removed: ReadonlySet<number>): BM25Index {
95
+ const next = new BM25Index();
96
+ const remap = new Int32Array(this.numDocs);
97
+ let kept = 0;
98
+ let firstRemoved = this.numDocs;
99
+ for (let doc = 0; doc < this.numDocs; doc++) {
100
+ if (removed.has(doc)) {
101
+ remap[doc] = -1;
102
+ if (firstRemoved === this.numDocs) {
103
+ firstRemoved = doc;
104
+ }
105
+ } else {
106
+ remap[doc] = kept++;
107
+ const length = this.docLengths[doc] ?? 0;
108
+ next.docLengths.push(length);
109
+ next.totalLen += length;
110
+ }
111
+ }
112
+ next.numDocs = kept;
113
+ next.avgDocLength = kept === 0 ? 0 : next.totalLen / kept;
114
+
115
+ for (const [term, list] of this.postings) {
116
+ // Lists ascend by doc index, so one ending before the first removal is untouched.
117
+ // Copy the array (addDocument appends to it) but share the immutable tuples.
118
+ const untouched = (list.at(-1)?.[0] ?? 0) < firstRemoved;
119
+ const nextList: PostingsList = untouched ? list.slice() : [];
120
+ if (!untouched) {
121
+ for (const entry of list) {
122
+ const mapped = remap[entry[0]] ?? -1;
123
+ if (mapped !== -1) {
124
+ nextList.push(mapped === entry[0] ? entry : [mapped, entry[1]]);
125
+ }
126
+ }
127
+ }
128
+ if (nextList.length > 0) {
129
+ next.postings.set(term, nextList);
130
+ next.docFreq.set(term, nextList.length);
131
+ }
132
+ }
133
+ return next;
134
+ }
135
+
76
136
  index(tokenizedDocs: string[][]): void {
77
137
  this.numDocs = 0;
78
138
  this.docFreq.clear();