@takara-ai/miru-code 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,12 @@ Find code by meaning, not grep. Miru returns the best **chunks** (path, lines, s
6
6
 
7
7
  **Requires:** [Bun](https://bun.sh) 1.1+ · [Takara API key](https://takara.ai)
8
8
 
9
+ ## Privacy and API usage
10
+
11
+ Miru sends **file contents** to the [Takara inference API](https://takara.ai) when building an index and when embedding search queries. Chunks from your repo are transmitted over HTTPS to generate embeddings. API usage may incur cost depending on your Takara plan.
12
+
13
+ If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. Use `MIRU_WORKSPACE_ROOT` to restrict MCP indexing to a single workspace directory.
14
+
9
15
  ## Install
10
16
 
11
17
  ```bash
@@ -143,6 +149,9 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
143
149
  |----------|-------|
144
150
  | `TAKARA_API_KEY` | Required |
145
151
  | `MIRU_OPENAI_BASE_URL` | Default `https://infer.takara.ai/v1` |
152
+ | `MIRU_WORKSPACE_ROOT` | Restrict MCP local `repo` paths to this directory |
153
+ | `MIRU_MAX_INDEX_FILES` | Cap files indexed per operation |
154
+ | `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
146
155
  | `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
147
156
  | `NO_COLOR` | Disable CLI colors |
148
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,8 @@
53
53
  "benchmark:ab": "bun run scripts/search-ab-local.ts",
54
54
  "benchmark:ab-multi": "bun run scripts/search-ab-multi.ts",
55
55
  "benchmark:recall": "bun run scripts/benchmark-recall-tokens.ts",
56
+ "benchmark:vs-grep": "bun run scripts/benchmark-miru-vs-grep.ts",
57
+ "benchmark:workflow": "bun run scripts/benchmark-workflow-tokens.ts",
56
58
  "benchmark:quality": "bun run scripts/benchmark-quality.ts",
57
59
  "benchmark:quantization": "bun run scripts/benchmark-quantization.ts"
58
60
  },
package/src/agents.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { mkdir, readFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import {
4
- buildSubagentBody,
5
- type NativeToolNames,
6
- } from "./installer/search-policy.ts";
3
+ import { buildSubagentBody, type NativeToolNames } from "./installer/search-policy.ts";
7
4
 
8
5
  export type AgentId = "claude" | "copilot" | "cursor" | "gemini" | "kiro" | "opencode";
9
6
 
package/src/cli-ui.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SearchResult } from "./types.ts";
2
+ import { formatRelevanceScore } from "./utils.ts";
2
3
 
3
4
  const ANSI = {
4
5
  reset: "\x1b[0m",
@@ -145,6 +146,8 @@ export function formatSearchResultsPretty(query: string, results: SearchResult[]
145
146
  lines.push(bold(label) + dim(` for `) + cyan(`"${query}"`));
146
147
  lines.push("");
147
148
 
149
+ const maxScore = results.reduce((max, result) => Math.max(max, result?.score ?? 0), 0);
150
+
148
151
  for (let i = 0; i < results.length; i++) {
149
152
  const result = results[i];
150
153
  if (!result) {
@@ -154,7 +157,7 @@ export function formatSearchResultsPretty(query: string, results: SearchResult[]
154
157
  const location = `${chunk.file_path}:${chunk.start_line}-${chunk.end_line}`;
155
158
  const lang = chunk.language ? dim(` ${chunk.language}`) : "";
156
159
  const rank = dim(`[${i + 1}]`);
157
- const scoreText = magenta(score.toFixed(3));
160
+ const scoreText = magenta(formatRelevanceScore(score, maxScore));
158
161
 
159
162
  lines.push(`${rank} ${scoreText} ${bold(location)}${lang}`);
160
163
  lines.push(dim("─".repeat(Math.min(PREVIEW_WIDTH, 52))));
package/src/cli.ts CHANGED
@@ -397,15 +397,7 @@ async function runCli(argv: string[]): Promise<void> {
397
397
  }
398
398
  const line = Number(lineRaw);
399
399
  const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
400
- try {
401
- await runFindRelated(path, filePath, line, topK, content, jsonFlag);
402
- } catch (err) {
403
- if (err instanceof RelatedChunkNotFoundError) {
404
- fail(err.message);
405
- process.exit(1);
406
- }
407
- throw err;
408
- }
400
+ await runFindRelated(path, filePath, line, topK, content, jsonFlag);
409
401
  return;
410
402
  }
411
403
 
@@ -11,11 +11,7 @@ export function resolveCpuCount(): number {
11
11
 
12
12
  /** Worker pool size: logical CPUs minus 2, minimum 1. Override with MIRU_CONCURRENCY. */
13
13
  export function resolveWorkerConcurrency(): number {
14
- const raw =
15
- process.env.MIRU_CONCURRENCY ??
16
- process.env.SEMBLE_CONCURRENCY ??
17
- process.env.MIRU_WORKERS ??
18
- process.env.SEMBLE_WORKERS;
14
+ const raw = process.env.MIRU_CONCURRENCY ?? process.env.MIRU_WORKERS;
19
15
  if (raw) {
20
16
  const n = Number(raw);
21
17
  if (Number.isFinite(n) && n >= 1) {
@@ -32,14 +32,14 @@ export interface EmbeddingBackend {
32
32
 
33
33
  export function resolveEmbeddingModel(): string {
34
34
  return envFirstString(
35
- ["MIRU_OPENAI_EMBEDDING_MODEL", "SEMBLE_OPENAI_EMBEDDING_MODEL", "OPENAI_EMBEDDING_MODEL"],
35
+ ["MIRU_OPENAI_EMBEDDING_MODEL", "OPENAI_EMBEDDING_MODEL"],
36
36
  DEFAULT_MODEL,
37
37
  );
38
38
  }
39
39
 
40
40
  export function resolveMaxEmbedChars(): number {
41
41
  return (
42
- envOptionalInt(["MIRU_MAX_EMBED_CHARS", "SEMBLE_MAX_EMBED_CHARS"], 256) ??
42
+ envOptionalInt(["MIRU_MAX_EMBED_CHARS"], 256) ??
43
43
  DEFAULT_MAX_EMBED_CHARS
44
44
  );
45
45
  }
@@ -73,13 +73,26 @@ function stripLoneSurrogates(text: string): string {
73
73
 
74
74
  export function sanitizeEmbeddingInput(text: string): string {
75
75
  const out = stripLoneSurrogates(text);
76
- // Some OpenAI-compatible gateways mis-handle backslash escapes in JSON text
77
- // payloads. Keep a benchmark fallback mode to forcefully neutralize them.
78
- const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? process.env.SEMBLE_EMBED_ESCAPE_MODE ?? "quad";
76
+ const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? "preserve";
77
+ if (mode === "quad") {
78
+ return out.replaceAll("\\", "\\\\\\\\");
79
+ }
79
80
  if (mode === "strip") {
80
81
  return out.replaceAll("\\", "/");
81
82
  }
82
- return out.replaceAll("\\", "\\\\\\\\");
83
+ return out;
84
+ }
85
+
86
+ const TRANSIENT_EMBED_STATUSES = new Set([429, 500, 502, 503, 504]);
87
+ const MAX_TRANSIENT_EMBED_RETRIES = 4;
88
+
89
+ function isTransientEmbeddingError(err: unknown): boolean {
90
+ const status = (err as { status?: number }).status;
91
+ return status !== undefined && TRANSIENT_EMBED_STATUSES.has(status);
92
+ }
93
+
94
+ function transientEmbedBackoffMs(attempt: number): number {
95
+ return Math.min(250 * 2 ** attempt, 4000);
83
96
  }
84
97
 
85
98
  function isPayloadTooLargeError(err: unknown): boolean {
@@ -95,11 +108,7 @@ function isPayloadTooLargeError(err: unknown): boolean {
95
108
  }
96
109
 
97
110
  export function resolveEmbeddingDimensions(model?: string): number | undefined {
98
- const fromEnv = envOptionalInt([
99
- "MIRU_EMBEDDING_DIMENSIONS",
100
- "SEMBLE_EMBEDDING_DIMENSIONS",
101
- "OPENAI_EMBEDDING_DIMENSIONS",
102
- ]);
111
+ const fromEnv = envOptionalInt(["MIRU_EMBEDDING_DIMENSIONS", "OPENAI_EMBEDDING_DIMENSIONS"]);
103
112
  if (fromEnv != null) {
104
113
  return fromEnv;
105
114
  }
@@ -108,17 +117,14 @@ export function resolveEmbeddingDimensions(model?: string): number | undefined {
108
117
 
109
118
  export function resolveEmbeddingBatchSize(): number {
110
119
  return (
111
- envOptionalInt([
112
- "MIRU_EMBEDDING_BATCH_SIZE",
113
- "SEMBLE_EMBEDDING_BATCH_SIZE",
114
- "OPENAI_EMBEDDING_BATCH_SIZE",
115
- ]) ?? DEFAULT_BATCH_SIZE
120
+ envOptionalInt(["MIRU_EMBEDDING_BATCH_SIZE", "OPENAI_EMBEDDING_BATCH_SIZE"]) ??
121
+ DEFAULT_BATCH_SIZE
116
122
  );
117
123
  }
118
124
 
119
125
  export function resolveEmbeddingBaseUrl(): string {
120
126
  return envFirstString(
121
- ["MIRU_OPENAI_BASE_URL", "SEMBLE_OPENAI_BASE_URL", "OPENAI_BASE_URL"],
127
+ ["MIRU_OPENAI_BASE_URL", "OPENAI_BASE_URL"],
122
128
  DEFAULT_BASE_URL,
123
129
  ).replace(/\/$/, "");
124
130
  }
@@ -282,23 +288,42 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
282
288
  return out;
283
289
  }
284
290
 
291
+ private async requestEmbeddings(texts: string[]): Promise<EmbeddingResponse> {
292
+ let lastErr: unknown;
293
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_EMBED_RETRIES; attempt++) {
294
+ try {
295
+ const started = performance.now();
296
+ const response = await this.client.createEmbeddings(
297
+ texts,
298
+ this.model,
299
+ this.requestedDimensions,
300
+ );
301
+ const elapsed = performance.now() - started;
302
+ this.stats.requests += 1;
303
+ this.stats.inputItems += texts.length;
304
+ this.stats.inputChars += texts.reduce((acc, text) => acc + text.length, 0);
305
+ this.stats.totalRttMs += elapsed;
306
+ this.stats.maxRttMs = Math.max(this.stats.maxRttMs, elapsed);
307
+ return response;
308
+ } catch (err: unknown) {
309
+ lastErr = err;
310
+ if (isTransientEmbeddingError(err) && attempt < MAX_TRANSIENT_EMBED_RETRIES) {
311
+ this.stats.retries += 1;
312
+ await Bun.sleep(transientEmbedBackoffMs(attempt));
313
+ continue;
314
+ }
315
+ throw err;
316
+ }
317
+ }
318
+ throw lastErr;
319
+ }
320
+
285
321
  private async embedBatchRawWithRetry(texts: string[]): Promise<Float32Array[]> {
286
322
  if (texts.length === 0) {
287
323
  return [];
288
324
  }
289
325
  try {
290
- const started = performance.now();
291
- const response = await this.client.createEmbeddings(
292
- texts,
293
- this.model,
294
- this.requestedDimensions,
295
- );
296
- const elapsed = performance.now() - started;
297
- this.stats.requests += 1;
298
- this.stats.inputItems += texts.length;
299
- this.stats.inputChars += texts.reduce((acc, text) => acc + text.length, 0);
300
- this.stats.totalRttMs += elapsed;
301
- this.stats.maxRttMs = Math.max(this.stats.maxRttMs, elapsed);
326
+ const response = await this.requestEmbeddings(texts);
302
327
  const vectors = vectorsFromResponse(response.data, texts.length);
303
328
  for (const vec of vectors) {
304
329
  if (this.requestedDimensions != null && vec.length !== this.requestedDimensions) {
@@ -341,7 +366,12 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
341
366
  const rightText = text.slice(mid);
342
367
  const left = await this.embedBatchRawWithRetry([leftText]);
343
368
  const right = await this.embedBatchRawWithRetry([rightText]);
344
- return [...left, ...right];
369
+ const parts = [left[0], right[0]].filter((vec): vec is Float32Array => vec !== undefined);
370
+ if (parts.length === 0) {
371
+ this.stats.errors += 1;
372
+ throw err;
373
+ }
374
+ return [poolWindowVectors(parts)];
345
375
  }
346
376
  this.stats.errors += 1;
347
377
  throw err;
package/src/git.ts CHANGED
@@ -7,7 +7,7 @@ const DEFAULT_CLONE_TIMEOUT_SEC = 60;
7
7
 
8
8
  export async function cloneGitRepository(url: string, ref?: string | null): Promise<string> {
9
9
  const timeoutSec =
10
- envOptionalInt(["MIRU_CLONE_TIMEOUT", "SEMBLE_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
10
+ envOptionalInt(["MIRU_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
11
11
  const dir = await mkdtemp(join(tmpdir(), "miru-git-"));
12
12
  const args = ["clone", "--depth", "1"];
13
13
  if (ref) {
@@ -3,47 +3,52 @@ import { chunkSource } from "../chunking/chunking.ts";
3
3
  import { resolveWorkerConcurrency } from "../concurrency.ts";
4
4
  import type { EmbeddingBackend } from "../embeddings/openai.ts";
5
5
  import { envOptionalInt } from "../env.ts";
6
+ import { searchImprovementsEnabled } from "../ranking/features.ts";
6
7
  import { tokenize } from "../tokens.ts";
7
8
  import type { Chunk, ContentType } from "../types.ts";
8
9
  import { BM25Index } from "./bm25.ts";
10
+ import { loadRootEntryChunks } from "./entry-chunks.ts";
9
11
  import { walkFiles } from "./file-walker.ts";
10
12
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
11
13
  import type { SemanticIndex } from "./semantic-index.ts";
12
- import { loadRootEntryChunks } from "./entry-chunks.ts";
13
14
  import { enrichForBm25 } from "./sparse.ts";
14
15
  import { buildSemanticIndex } from "./vector-storage.ts";
15
- import { searchImprovementsEnabled } from "../ranking/features.ts";
16
16
 
17
17
  const DEFAULT_PIPELINE_EMBED_BATCH = 64;
18
18
  const DEFAULT_PIPELINE_EMBED_INFLIGHT = 8;
19
19
 
20
20
  function resolvePipelineEmbedBatch(): number {
21
21
  return (
22
- envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH", "SEMBLE_PIPELINE_EMBED_BATCH"], 1) ??
22
+ envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH"], 1) ??
23
23
  DEFAULT_PIPELINE_EMBED_BATCH
24
24
  );
25
25
  }
26
26
 
27
27
  function resolvePipelineEmbedInflight(): number {
28
28
  return (
29
- envOptionalInt(["MIRU_PIPELINE_EMBED_INFLIGHT", "SEMBLE_PIPELINE_EMBED_INFLIGHT"], 1) ??
29
+ envOptionalInt(["MIRU_PIPELINE_EMBED_INFLIGHT"], 1) ??
30
30
  DEFAULT_PIPELINE_EMBED_INFLIGHT
31
31
  );
32
32
  }
33
33
 
34
+ function resolveMaxIndexFiles(): number | undefined {
35
+ return envOptionalInt(["MIRU_MAX_INDEX_FILES"], 1);
36
+ }
37
+
34
38
  export async function createIndexFromPath(
35
39
  path: string,
36
40
  embeddings: EmbeddingBackend,
37
41
  content: ContentType[] = ["code"],
38
42
  displayRoot?: string,
39
43
  ): Promise<{ bm25: BM25Index; semantic: SemanticIndex; chunks: Chunk[] }> {
40
- const profile = process.env.MIRU_PROFILE === "1" || process.env.SEMBLE_PROFILE === "1";
44
+ const profile = process.env.MIRU_PROFILE === "1";
41
45
  const started = performance.now();
42
46
  let fileProcessMs = 0;
43
47
  let embedBackpressureWaitMs = 0;
44
48
  let fileEnumerated = 0;
45
49
  let fileTasks = 0;
46
50
  let emptyFileTasks = 0;
51
+ let fileErrors = 0;
47
52
 
48
53
  if (profile && "resetStats" in embeddings && typeof embeddings.resetStats === "function") {
49
54
  embeddings.resetStats();
@@ -55,6 +60,7 @@ export async function createIndexFromPath(
55
60
  const fileConcurrency = resolveWorkerConcurrency();
56
61
  const embedBatchSize = resolvePipelineEmbedBatch();
57
62
  const maxEmbedInflight = resolvePipelineEmbedInflight();
63
+ const maxIndexFiles = resolveMaxIndexFiles();
58
64
  const readyBySeq = new Map<number, Chunk[]>();
59
65
  const pendingEmbedChunks: Chunk[] = [];
60
66
  const emittedChunkBatches: Chunk[][] = [];
@@ -76,7 +82,7 @@ export async function createIndexFromPath(
76
82
  const chunkPath = displayRoot ? relative(root, filePath).replace(/\\/g, "/") : filePath;
77
83
  return chunkSource(source, chunkPath, language);
78
84
  } catch {
79
- emptyFileTasks++;
85
+ fileErrors++;
80
86
  return [];
81
87
  } finally {
82
88
  fileProcessMs += performance.now() - t;
@@ -136,6 +142,9 @@ export async function createIndexFromPath(
136
142
 
137
143
  for await (const filePath of walkFiles(resolved, extensions)) {
138
144
  fileEnumerated++;
145
+ if (maxIndexFiles != null && fileEnumerated > maxIndexFiles) {
146
+ throw new Error(`Index file budget exceeded: max ${maxIndexFiles} files per operation`);
147
+ }
139
148
  while (runningFiles.size >= fileConcurrency) {
140
149
  await Promise.race(runningFiles);
141
150
  }
@@ -148,10 +157,7 @@ export async function createIndexFromPath(
148
157
  }
149
158
 
150
159
  if (searchImprovementsEnabled()) {
151
- const entryChunks = await loadRootEntryChunks(
152
- resolved,
153
- displayRoot ? root : undefined,
154
- );
160
+ const entryChunks = await loadRootEntryChunks(resolved, displayRoot ? root : undefined);
155
161
  if (entryChunks.length > 0) {
156
162
  readyBySeq.set(fileSeq++, entryChunks);
157
163
  flushReadySequentialChunks();
@@ -187,6 +193,7 @@ export async function createIndexFromPath(
187
193
  file_enumerated: fileEnumerated,
188
194
  file_tasks: fileTasks,
189
195
  empty_or_skipped_files: emptyFileTasks,
196
+ file_errors: fileErrors,
190
197
  chunks: chunks.length,
191
198
  vectors: vectors.length,
192
199
  pipeline: {
@@ -6,7 +6,7 @@ export type SemanticStorage = "int8" | "float32";
6
6
 
7
7
  /** Default: int8 quantized vectors (~4x less RAM). Set MIRU_FLOAT_VECTORS=1 for float32. */
8
8
  export function resolveSemanticStorage(): SemanticStorage {
9
- return process.env.MIRU_FLOAT_VECTORS === "1" || process.env.SEMBLE_FLOAT_VECTORS === "1"
9
+ return process.env.MIRU_FLOAT_VECTORS === "1"
10
10
  ? "float32"
11
11
  : "int8";
12
12
  }
@@ -21,23 +21,55 @@ function writeRaw(text: string): void {
21
21
  output.write(text);
22
22
  }
23
23
 
24
- function createFrameWriter(): (lines: string[]) => void {
25
- let previousLineCount = 0;
24
+ function moveCursorUp(lines: number): void {
25
+ if (lines > 0) {
26
+ writeRaw(`${ESC}[${lines}A`);
27
+ }
28
+ }
26
29
 
27
- return (lines: string[]) => {
28
- if (previousLineCount > 0) {
29
- writeRaw(`${ESC}[${previousLineCount}A${CURSOR_START}`);
30
- }
31
- writeRaw(`${CLEAR_LINE}${lines.join(`\n${CLEAR_LINE}`)}\n`);
32
- const extra = previousLineCount - lines.length;
33
- if (extra > 0) {
34
- for (let i = 0; i < extra; i++) {
35
- writeRaw(`${CLEAR_LINE}\n`);
36
- }
37
- writeRaw(`${ESC}[${extra}A${CURSOR_START}`);
38
- }
39
- previousLineCount = lines.length;
40
- };
30
+ function moveCursorDown(lines: number): void {
31
+ if (lines > 0) {
32
+ writeRaw(`${ESC}[${lines}B`);
33
+ }
34
+ }
35
+
36
+ function rewriteLineAtOffsetFromBottom(offsetFromBottom: number, content: string): void {
37
+ moveCursorUp(offsetFromBottom);
38
+ writeRaw(`${CURSOR_START}${CLEAR_LINE}${content}`);
39
+ moveCursorDown(offsetFromBottom);
40
+ }
41
+
42
+ const MULTI_SELECT_HEADER_LINES = 3;
43
+ const MULTI_SELECT_TRAILER_LINES = 2;
44
+
45
+ function multiSelectTotalLines(itemCount: number): number {
46
+ return MULTI_SELECT_HEADER_LINES + itemCount + MULTI_SELECT_TRAILER_LINES;
47
+ }
48
+
49
+ function multiSelectItemOffsetFromBottom(itemIndex: number, itemCount: number): number {
50
+ return multiSelectTotalLines(itemCount) - (MULTI_SELECT_HEADER_LINES + itemIndex);
51
+ }
52
+
53
+ function formatMultiSelectItemLine(
54
+ item: { label: string; checked: boolean },
55
+ selected: boolean,
56
+ ): string {
57
+ const pointer = selected ? cyan("›") : " ";
58
+ const mark = item.checked ? cyan("[x]") : dim("[ ]");
59
+ const label = selected ? bold(item.label) : item.label;
60
+ return ` ${pointer} ${mark} ${label}`;
61
+ }
62
+
63
+ function updateMultiSelectItemRow(
64
+ item: { label: string; checked: boolean },
65
+ itemIndex: number,
66
+ itemCount: number,
67
+ selected: boolean,
68
+ ): void {
69
+ rewriteLineAtOffsetFromBottom(
70
+ multiSelectItemOffsetFromBottom(itemIndex, itemCount),
71
+ formatMultiSelectItemLine(item, selected),
72
+ );
41
73
  }
42
74
 
43
75
  function readKey(): Promise<Buffer> {
@@ -125,11 +157,7 @@ function buildMultiSelectLines<T>(
125
157
  if (!item) {
126
158
  continue;
127
159
  }
128
- const selected = i === cursor;
129
- const pointer = selected ? cyan("›") : " ";
130
- const mark = item.checked ? cyan("[x]") : dim("[ ]");
131
- const label = selected ? bold(item.label) : item.label;
132
- lines.push(` ${pointer} ${mark} ${label}`);
160
+ lines.push(formatMultiSelectItemLine(item, i === cursor));
133
161
  }
134
162
  lines.push("");
135
163
  lines.push(dim(footer));
@@ -150,11 +178,12 @@ export async function promptMultiSelect<T>(
150
178
 
151
179
  const footer = "↑↓ move space toggle a all enter confirm ctrl-c cancel";
152
180
  let cursor = 0;
153
- const drawFrame = createFrameWriter();
181
+ const itemCount = items.length;
154
182
 
155
183
  return withRawMode(async () => {
184
+ writeRaw(`${buildMultiSelectLines(title, items, cursor, footer).join("\n")}\n`);
185
+
156
186
  while (true) {
157
- drawFrame(buildMultiSelectLines(title, items, cursor, footer));
158
187
  const key = parseKey(await readKey());
159
188
 
160
189
  if (key === "ctrl-c") {
@@ -166,33 +195,74 @@ export async function promptMultiSelect<T>(
166
195
  return items.filter((item) => item.checked).map((item) => item.value);
167
196
  }
168
197
  if (key === "up") {
169
- cursor = Math.max(0, cursor - 1);
198
+ if (cursor > 0) {
199
+ const previous = cursor;
200
+ cursor--;
201
+ const previousItem = items[previous];
202
+ const currentItem = items[cursor];
203
+ if (previousItem) {
204
+ updateMultiSelectItemRow(previousItem, previous, itemCount, false);
205
+ }
206
+ if (currentItem) {
207
+ updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
208
+ }
209
+ }
170
210
  } else if (key === "down") {
171
- cursor = Math.min(items.length - 1, cursor + 1);
211
+ if (cursor < items.length - 1) {
212
+ const previous = cursor;
213
+ cursor++;
214
+ const previousItem = items[previous];
215
+ const currentItem = items[cursor];
216
+ if (previousItem) {
217
+ updateMultiSelectItemRow(previousItem, previous, itemCount, false);
218
+ }
219
+ if (currentItem) {
220
+ updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
221
+ }
222
+ }
172
223
  } else if (key === "space") {
173
224
  const item = items[cursor];
174
225
  if (item) {
175
226
  item.checked = !item.checked;
227
+ updateMultiSelectItemRow(item, cursor, itemCount, true);
176
228
  }
177
229
  } else if (key === "all") {
178
230
  const allChecked = items.every((item) => item.checked);
179
- for (const item of items) {
231
+ for (let i = 0; i < items.length; i++) {
232
+ const item = items[i];
233
+ if (!item) {
234
+ continue;
235
+ }
180
236
  item.checked = !allChecked;
237
+ updateMultiSelectItemRow(item, i, itemCount, i === cursor);
181
238
  }
182
239
  }
183
240
  }
184
241
  });
185
242
  }
186
243
 
187
- function buildConfirmLines(question: string, yesSelected: boolean): string[] {
244
+ function buildConfirmPromptLine(question: string, yesSelected: boolean): string {
188
245
  const yes = yesSelected ? green(bold(" Yes ")) : dim(" Yes ");
189
246
  const no = yesSelected ? dim(" No ") : green(bold(" No "));
247
+ return `${question} ${yes} / ${no}`;
248
+ }
249
+
250
+ function buildConfirmLines(question: string, yesSelected: boolean): string[] {
190
251
  return [
191
- `${question} ${yes} / ${no}`,
252
+ buildConfirmPromptLine(question, yesSelected),
192
253
  dim("←→ or y/n to choose enter to confirm ctrl-c to cancel"),
193
254
  ];
194
255
  }
195
256
 
257
+ const CONFIRM_PROMPT_LINES = 2;
258
+
259
+ function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
260
+ rewriteLineAtOffsetFromBottom(
261
+ CONFIRM_PROMPT_LINES,
262
+ buildConfirmPromptLine(question, yesSelected),
263
+ );
264
+ }
265
+
196
266
  export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
197
267
  if (!input.isTTY) {
198
268
  const rl = readline.createInterface({ input, output });
@@ -209,11 +279,11 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
209
279
  }
210
280
 
211
281
  let yesSelected = defaultYes;
212
- const drawFrame = createFrameWriter();
213
282
 
214
283
  return withRawMode(async () => {
284
+ writeRaw(`${buildConfirmLines(question, yesSelected).join("\n")}\n`);
285
+
215
286
  while (true) {
216
- drawFrame(buildConfirmLines(question, yesSelected));
217
287
  const key = parseKey(await readKey());
218
288
 
219
289
  if (key === "ctrl-c") {
@@ -225,9 +295,15 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
225
295
  return yesSelected;
226
296
  }
227
297
  if (key === "left" || key === "no") {
228
- yesSelected = false;
298
+ if (yesSelected) {
299
+ yesSelected = false;
300
+ updateConfirmPromptLine(question, yesSelected);
301
+ }
229
302
  } else if (key === "right" || key === "yes") {
230
- yesSelected = true;
303
+ if (!yesSelected) {
304
+ yesSelected = true;
305
+ updateConfirmPromptLine(question, yesSelected);
306
+ }
231
307
  }
232
308
  }
233
309
  });
@@ -3,7 +3,12 @@ import { resolve } from "node:path";
3
3
  import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
4
4
  import { MiruIndex } from "../miru-index.ts";
5
5
  import type { ContentType } from "../types.ts";
6
- import { computeSourceCacheKey, isGitUrl } from "../utils.ts";
6
+ import {
7
+ computeSourceCacheKey,
8
+ isAllowedRepoSource,
9
+ isGitUrl,
10
+ validateLocalRepoPath,
11
+ } from "../utils.ts";
7
12
 
8
13
  const CACHE_MAX_SIZE = 10;
9
14
 
@@ -28,7 +33,10 @@ const WATCH_IGNORED_DIR_NAMES = new Set([
28
33
  ".eggs",
29
34
  ]);
30
35
 
36
+ type WatcherHandle = ReturnType<typeof watch>;
37
+
31
38
  type CacheEntry = {
39
+ source: string;
32
40
  index: MiruIndex | null;
33
41
  task: Promise<MiruIndex> | null;
34
42
  pendingPaths: Set<string>;
@@ -37,7 +45,7 @@ type CacheEntry = {
37
45
  };
38
46
 
39
47
  export function mcpWatchEnabled(): boolean {
40
- const raw = process.env.MIRU_MCP_WATCH ?? process.env.SEMBLE_MCP_WATCH;
48
+ const raw = process.env.MIRU_MCP_WATCH;
41
49
  return raw !== "0" && raw !== "false";
42
50
  }
43
51
 
@@ -59,15 +67,14 @@ export class IndexCache {
59
67
  private readonly content: ContentType[];
60
68
  private readonly defaultRef: string | null;
61
69
  private readonly entries = new Map<string, CacheEntry>();
62
- private watcher: ReturnType<typeof watch> | null = null;
63
- private watchedSource: string | null = null;
70
+ readonly watchers = new Map<string, WatcherHandle>();
64
71
 
65
72
  constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
66
73
  this.content = content;
67
74
  this.defaultRef = defaultRef;
68
75
  }
69
76
 
70
- private ensureEntry(cacheKey: string): CacheEntry {
77
+ private ensureEntry(cacheKey: string, source: string): CacheEntry {
71
78
  let entry = this.entries.get(cacheKey);
72
79
  if (!entry) {
73
80
  if (this.entries.size >= CACHE_MAX_SIZE) {
@@ -77,6 +84,7 @@ export class IndexCache {
77
84
  }
78
85
  }
79
86
  entry = {
87
+ source,
80
88
  index: null,
81
89
  task: null,
82
90
  pendingPaths: new Set(),
@@ -89,9 +97,19 @@ export class IndexCache {
89
97
  }
90
98
 
91
99
  private clearEntry(cacheKey: string): void {
100
+ const entry = this.entries.get(cacheKey);
101
+ if (entry && !isGitUrl(entry.source)) {
102
+ this.stopWatcher(resolve(entry.source));
103
+ }
92
104
  this.entries.delete(cacheKey);
93
105
  }
94
106
 
107
+ private stopWatcher(resolvedPath: string): void {
108
+ const watcher = this.watchers.get(resolvedPath);
109
+ watcher?.close();
110
+ this.watchers.delete(resolvedPath);
111
+ }
112
+
95
113
  private startBuild(
96
114
  source: string,
97
115
  ref: string | null | undefined,
@@ -105,7 +123,7 @@ export class IndexCache {
105
123
  return index;
106
124
  })();
107
125
 
108
- const entry = this.ensureEntry(cacheKey);
126
+ const entry = this.ensureEntry(cacheKey, source);
109
127
  entry.task = task;
110
128
  void task
111
129
  .then((index) => {
@@ -126,7 +144,7 @@ export class IndexCache {
126
144
  async get(source: string, ref?: string | null): Promise<MiruIndex> {
127
145
  const resolvedRef = ref ?? this.defaultRef;
128
146
  const cacheKey = computeSourceCacheKey(source, resolvedRef);
129
- const entry = this.ensureEntry(cacheKey);
147
+ const entry = this.ensureEntry(cacheKey, source);
130
148
 
131
149
  if (!entry.task) {
132
150
  this.startBuild(source, resolvedRef, cacheKey);
@@ -151,7 +169,7 @@ export class IndexCache {
151
169
 
152
170
  private queueIndexedPaths(source: string, index: MiruIndex): void {
153
171
  const cacheKey = computeSourceCacheKey(source);
154
- const entry = this.ensureEntry(cacheKey);
172
+ const entry = this.ensureEntry(cacheKey, source);
155
173
  for (const chunk of index.chunks) {
156
174
  entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
157
175
  }
@@ -161,7 +179,10 @@ export class IndexCache {
161
179
  /** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
162
180
  private noteAmbiguousDirectoryChange(source: string): void {
163
181
  const cacheKey = computeSourceCacheKey(source);
164
- const entry = this.ensureEntry(cacheKey);
182
+ const entry = this.entries.get(cacheKey);
183
+ if (!entry) {
184
+ return;
185
+ }
165
186
 
166
187
  if (entry.index) {
167
188
  this.queueIndexedPaths(source, entry.index);
@@ -186,7 +207,10 @@ export class IndexCache {
186
207
  }
187
208
 
188
209
  const cacheKey = computeSourceCacheKey(source);
189
- const entry = this.ensureEntry(cacheKey);
210
+ const entry = this.entries.get(cacheKey);
211
+ if (!entry) {
212
+ return;
213
+ }
190
214
  const rel = relativePathFromRoot(source, filename);
191
215
  if (!rel) {
192
216
  return;
@@ -246,29 +270,31 @@ export class IndexCache {
246
270
  if (!mcpWatchEnabled() || isGitUrl(source)) {
247
271
  return;
248
272
  }
249
- const resolved = resolve(source);
250
- if (this.watchedSource === resolved) {
251
- return;
252
- }
253
- this.watchedSource = resolved;
254
- this.startWatcher(resolved);
273
+ this.startWatcher(source);
255
274
  }
256
275
 
257
276
  startWatcher(path: string): void {
258
277
  const resolved = resolve(path);
259
- if (this.watcher) {
260
- this.watcher.close();
278
+ if (this.watchers.has(resolved)) {
279
+ return;
261
280
  }
262
281
 
263
- this.watcher = watch(resolved, { recursive: true }, (_event, filename) => {
282
+ const watcher = watch(resolved, { recursive: true }, (_event, filename) => {
264
283
  this.noteFileChange(resolved, filename);
265
284
  });
285
+ this.watchers.set(resolved, watcher);
286
+ }
287
+
288
+ get watcher(): WatcherHandle | null {
289
+ const handles = [...this.watchers.values()];
290
+ return handles[handles.length - 1] ?? null;
266
291
  }
267
292
 
268
293
  close(): void {
269
- this.watcher?.close();
270
- this.watcher = null;
271
- this.watchedSource = null;
294
+ for (const watcher of this.watchers.values()) {
295
+ watcher.close();
296
+ }
297
+ this.watchers.clear();
272
298
  this.entries.clear();
273
299
  }
274
300
  }
@@ -284,12 +310,19 @@ export async function getIndexForRepo(
284
310
  );
285
311
  }
286
312
 
287
- if (isGitUrl(repo) && !repo.startsWith("https://") && !repo.startsWith("http://")) {
313
+ if (isGitUrl(repo) && !isAllowedRepoSource(repo)) {
314
+ if (repo.startsWith("http://")) {
315
+ throw new Error(
316
+ "Plain http:// git URLs are disabled by default. Set MIRU_ALLOW_HTTP_GIT=1 to opt in.",
317
+ );
318
+ }
288
319
  throw new Error(
289
- `Only https://, http://, or local directory paths are accepted as \`repo\`. Got: ${repo}`,
320
+ `Only https:// git URLs or local directory paths are accepted as \`repo\`. Got: ${repo}`,
290
321
  );
291
322
  }
292
323
 
324
+ validateLocalRepoPath(repo);
325
+
293
326
  try {
294
327
  return await cache.get(repo, ref);
295
328
  } catch (err) {
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
+ import packageJson from "../../package.json";
4
+ import {
5
+ MCP_EXPAND_TOOL_DESCRIPTION,
6
+ MCP_FIND_RELATED_TOOL_DESCRIPTION,
7
+ MCP_SEARCH_TOOL_DESCRIPTION,
8
+ MCP_SERVER_INSTRUCTIONS,
9
+ } from "../installer/search-policy.ts";
3
10
  import type { ContentType } from "../types.ts";
4
11
  import {
5
12
  clampMcpTopK,
@@ -12,12 +19,6 @@ import {
12
19
  MAX_MCP_TOP_K,
13
20
  resolveChunk,
14
21
  } from "../utils.ts";
15
- import {
16
- MCP_EXPAND_TOOL_DESCRIPTION,
17
- MCP_FIND_RELATED_TOOL_DESCRIPTION,
18
- MCP_SEARCH_TOOL_DESCRIPTION,
19
- MCP_SERVER_INSTRUCTIONS,
20
- } from "../installer/search-policy.ts";
21
22
  import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
22
23
 
23
24
  const REPO_DESCRIPTION =
@@ -29,7 +30,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
29
30
  const server = new McpServer(
30
31
  {
31
32
  name: "miru",
32
- version: "0.1.0",
33
+ version: packageJson.version,
33
34
  },
34
35
  {
35
36
  instructions: MCP_SERVER_INSTRUCTIONS,
@@ -39,8 +40,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
39
40
  server.registerTool(
40
41
  "search",
41
42
  {
42
- description:
43
- `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
43
+ description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
44
44
  inputSchema: {
45
45
  query: z.string().describe("Natural language or code query."),
46
46
  repo: z.string().describe(REPO_DESCRIPTION),
@@ -86,7 +86,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
86
86
  line: z
87
87
  .number()
88
88
  .int()
89
- .describe("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
89
+ .describe(
90
+ "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
91
+ ),
90
92
  repo: z.string().describe(REPO_DESCRIPTION),
91
93
  before: z
92
94
  .number()
@@ -143,13 +145,13 @@ export function createMcpServer(cache: IndexCache): McpServer {
143
145
  inputSchema: {
144
146
  file_path: z
145
147
  .string()
146
- .describe(
147
- "Path from a search hit (`file_path` or `absolute_path` for local repos).",
148
- ),
148
+ .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
149
149
  line: z
150
150
  .number()
151
151
  .int()
152
- .describe("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
152
+ .describe(
153
+ "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
154
+ ),
153
155
  repo: z.string().describe(REPO_DESCRIPTION),
154
156
  top_k: z
155
157
  .number()
@@ -157,7 +159,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
157
159
  .min(1)
158
160
  .max(MAX_MCP_TOP_K)
159
161
  .optional()
160
- .describe(`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
162
+ .describe(
163
+ `Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
164
+ ),
161
165
  },
162
166
  },
163
167
  async ({ file_path: filePath, line, repo, top_k: topK }) => {
@@ -172,10 +172,7 @@ export function penalizeInstallerForLocation(
172
172
  }
173
173
  }
174
174
 
175
- function hasSpecificImplementationTarget(
176
- query: string,
177
- chunksByKey: Map<string, Chunk>,
178
- ): boolean {
175
+ function hasSpecificImplementationTarget(query: string, chunksByKey: Map<string, Chunk>): boolean {
179
176
  const lowered = query.toLowerCase();
180
177
  for (const hint of IMPLEMENTATION_HINTS) {
181
178
  if (!lowered.includes(hint)) {
@@ -234,7 +231,6 @@ export function boostExactStemMatches(
234
231
  }
235
232
  const parts = keyword.split("-").filter((p) => p.length > 2);
236
233
  if (parts.length > 1 && parts.some((p) => p === stem)) {
237
- continue;
238
234
  }
239
235
  }
240
236
  }
package/src/setup.ts CHANGED
@@ -63,7 +63,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
63
63
  spinner.start();
64
64
  const result = await validateEmbeddingApiKey({ apiKey });
65
65
  if (!result.valid) {
66
- spinner.fail(result.message);
66
+ spinner.stop();
67
67
  throw new Error(result.message);
68
68
  }
69
69
  spinner.succeed("API key validated");
package/src/spinner.ts CHANGED
@@ -57,7 +57,7 @@ export async function withSpinner<T>(
57
57
  spinner.succeed(options?.successMessage ?? "");
58
58
  return result;
59
59
  } catch (err) {
60
- spinner.fail(options?.failMessage);
60
+ spinner.stop();
61
61
  throw err;
62
62
  }
63
63
  }
package/src/utils.ts CHANGED
@@ -17,11 +17,22 @@ export function isGitUrl(path: string): boolean {
17
17
  return GIT_URL_SCHEMES.some((scheme) => path.startsWith(scheme)) || SCP_GIT_URL_RE.test(path);
18
18
  }
19
19
 
20
+ export function httpGitCloneAllowed(): boolean {
21
+ const raw = process.env.MIRU_ALLOW_HTTP_GIT;
22
+ return raw === "1" || raw === "true";
23
+ }
24
+
20
25
  export function isAllowedRepoSource(repo: string): boolean {
21
26
  if (!isGitUrl(repo)) {
22
27
  return true;
23
28
  }
24
- return repo.startsWith("https://") || repo.startsWith("http://");
29
+ if (repo.startsWith("https://")) {
30
+ return true;
31
+ }
32
+ if (repo.startsWith("http://")) {
33
+ return httpGitCloneAllowed();
34
+ }
35
+ return false;
25
36
  }
26
37
 
27
38
  /** Resolved local repo root for MCP output; null for remote git URLs. */
@@ -29,6 +40,20 @@ export function localRepoRoot(repo: string): string | null {
29
40
  return isGitUrl(repo) ? null : resolve(repo);
30
41
  }
31
42
 
43
+ /** When MIRU_WORKSPACE_ROOT is set, local repo paths must stay inside it. */
44
+ export function validateLocalRepoPath(repo: string): void {
45
+ const workspaceRoot = process.env.MIRU_WORKSPACE_ROOT?.trim();
46
+ if (!workspaceRoot || isGitUrl(repo)) {
47
+ return;
48
+ }
49
+ const resolvedRepo = resolve(repo);
50
+ const resolvedWorkspace = resolve(workspaceRoot);
51
+ const prefix = resolvedWorkspace.endsWith(sep) ? resolvedWorkspace : `${resolvedWorkspace}${sep}`;
52
+ if (resolvedRepo !== resolvedWorkspace && !resolvedRepo.startsWith(prefix)) {
53
+ throw new Error(`Local repo path is outside workspace: ${repo}`);
54
+ }
55
+ }
56
+
32
57
  /** Map an absolute or repo-relative path to the index-relative form. */
33
58
  export function toIndexedFilePath(filePath: string, repoRoot?: string | null): string {
34
59
  if (!repoRoot) {
@@ -155,6 +180,15 @@ export function expandChunksAtLine(
155
180
  return { anchor, chunks: fileChunks.slice(start, end) };
156
181
  }
157
182
 
183
+ /** Relative relevance for MCP/CLI output (top hit in a batch is always 100%). */
184
+ export function formatRelevanceScore(score: number, maxScore: number): string {
185
+ if (maxScore <= 0) {
186
+ return "0%";
187
+ }
188
+ const pct = Math.round((score / maxScore) * 100);
189
+ return `${pct}%`;
190
+ }
191
+
158
192
  export function formatResults(
159
193
  query: string,
160
194
  results: SearchResult[],
@@ -163,6 +197,7 @@ export function formatResults(
163
197
  const repoRoot = options?.repoRoot ?? null;
164
198
  const useSnippet = options?.snippet ?? searchSnippetsEnabled();
165
199
  const payload = useSnippet ? applySnippetsToResults(results, query) : null;
200
+ const maxScore = results.reduce((max, result) => Math.max(max, result.score), 0);
166
201
 
167
202
  return {
168
203
  query,
@@ -170,6 +205,7 @@ export function formatResults(
170
205
  const entry = payload?.[index];
171
206
  const chunk = entry?.result.chunk ?? result.chunk;
172
207
  const dict = chunkToResponseDict(chunk, repoRoot);
208
+ const score = formatRelevanceScore(result.score, maxScore);
173
209
  if (entry?.meta.truncated) {
174
210
  return {
175
211
  chunk: {
@@ -179,12 +215,12 @@ export function formatResults(
179
215
  full_start_line: entry.meta.full_start_line,
180
216
  full_end_line: entry.meta.full_end_line,
181
217
  },
182
- score: result.score,
218
+ score,
183
219
  };
184
220
  }
185
221
  return {
186
222
  chunk: dict,
187
- score: result.score,
223
+ score,
188
224
  };
189
225
  }),
190
226
  };