@takara-ai/miru-code 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +43 -48
  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 +10 -45
  24. package/src/credentials.ts +8 -13
  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 +5 -4
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/create.ts +5 -9
  32. package/src/index/files.ts +5 -1
  33. package/src/index/incremental.ts +48 -8
  34. package/src/index/sparse.ts +14 -2
  35. package/src/installer/agents.ts +85 -65
  36. package/src/installer/benchmark-mode.ts +40 -11
  37. package/src/installer/config.ts +105 -42
  38. package/src/installer/installer.ts +59 -34
  39. package/src/installer/legacy-hooks.ts +131 -0
  40. package/src/installer/prompt.ts +20 -22
  41. package/src/installer/search-policy.ts +8 -8
  42. package/src/mcp/auth-tool.ts +93 -33
  43. package/src/mcp/index-cache.ts +16 -3
  44. package/src/mcp/server.ts +42 -22
  45. package/src/mcp/stdio.ts +17 -2
  46. package/src/miru-index.ts +54 -18
  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 +4 -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 +0 -4
  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
@@ -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
@@ -44,8 +44,7 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
44
44
  import { MiruMcpServer } from "./runtime.ts";
45
45
 
46
46
  const REPO_DESCRIPTION =
47
- "https:// or http:// git URL (e.g. https://github.com/org/repo) or local directory path to index and search. " +
48
- "Pass the project root for local workspaces. " +
47
+ "`repo` is optional. Set a local path or Git URL for another repo. " +
49
48
  "The index is built on the first tool call and cached for the session.";
50
49
 
51
50
  const BENCHMARK_SKIP_NOTES = {
@@ -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,14 +155,14 @@ 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),
165
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
150
166
  include: z
151
167
  .array(z.string().min(1))
152
168
  .min(1)
@@ -169,15 +185,16 @@ export function createMcpServer(
169
185
  },
170
186
  async ({ query, repo, include, exclude, dedupe_by_file: dedupeByFile }) => {
171
187
  try {
172
- const index = await getIndexForRepo(repo, cache);
173
- const repoRoot = localRepoRoot(repo);
188
+ const source = repo ?? defaultRepo;
189
+ const index = await getIndexForRepo(source, cache);
190
+ const repoRoot = localRepoRoot(source);
174
191
  const k = SEARCH_RESULT_COUNT;
175
192
  let skip: BenchmarkSkipReason | undefined;
176
193
  if (benchmark && (include || exclude)) {
177
194
  skip = "filtered_search";
178
195
  } else if (benchmark && repoRoot) {
179
- const comparison = await withGrepTimeoutFallback(() =>
180
- benchmarkSearchComparison({
196
+ const comparison = await runWithGrepFallback(() =>
197
+ compareSearch({
181
198
  query,
182
199
  repoPath: repoRoot,
183
200
  index,
@@ -245,7 +262,7 @@ export function createMcpServer(
245
262
  .describe(
246
263
  "Alias for `literal` — accepted so a call shaped like `search(query=...)` still resolves as an exact-substring lookup.",
247
264
  ),
248
- repo: z.string().describe(REPO_DESCRIPTION),
265
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
249
266
  mode: z
250
267
  .enum(["count", "locations", "lines"])
251
268
  .optional()
@@ -291,19 +308,20 @@ export function createMcpServer(
291
308
  return toolErrorText(new Error("locate requires `literal` (exact substring to find)."));
292
309
  }
293
310
  try {
294
- const index = await getIndexForRepo(repo, cache);
311
+ const source = repo ?? defaultRepo;
312
+ const index = await getIndexForRepo(source, cache);
295
313
 
296
314
  let skip: BenchmarkSkipReason | undefined;
297
- const repoRoot = localRepoRoot(repo);
315
+ const repoRoot = localRepoRoot(source);
298
316
  if (benchmark && repoRoot && typeof lit === "string") {
299
317
  if (locateOpts.limit != null) {
300
318
  skip = "limited_locate";
301
- } else if (!selectComparableLiteralSearchTool()) {
319
+ } else if (!selectLiteralBaseline()) {
302
320
  skip = "incompatible_literal_baseline";
303
321
  }
304
322
  if (!skip) {
305
- const comparison = await withGrepTimeoutFallback(() =>
306
- benchmarkLocateComparison({
323
+ const comparison = await runWithGrepFallback(() =>
324
+ compareLocate({
307
325
  literal: lit,
308
326
  repoPath: repoRoot,
309
327
  index,
@@ -343,7 +361,7 @@ export function createMcpServer(
343
361
  .number()
344
362
  .int()
345
363
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
346
- repo: z.string().describe(REPO_DESCRIPTION),
364
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
347
365
  before: z
348
366
  .number()
349
367
  .int()
@@ -360,8 +378,9 @@ export function createMcpServer(
360
378
  },
361
379
  async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
362
380
  try {
363
- const index = await getIndexForRepo(repo, cache);
364
- const repoRoot = localRepoRoot(repo);
381
+ const source = repo ?? defaultRepo;
382
+ const index = await getIndexForRepo(source, cache);
383
+ const repoRoot = localRepoRoot(source);
365
384
  const beforeCount = before ?? DEFAULT_EXPAND_BEFORE;
366
385
  const afterCount = after ?? DEFAULT_EXPAND_AFTER;
367
386
  const { anchor, chunks: expanded } = expandChunksAtLine(
@@ -404,13 +423,14 @@ export function createMcpServer(
404
423
  .number()
405
424
  .int()
406
425
  .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
407
- repo: z.string().describe(REPO_DESCRIPTION),
426
+ repo: z.string().optional().describe(REPO_DESCRIPTION),
408
427
  },
409
428
  },
410
429
  async ({ file_path: filePath, anchor_line: anchorLine, repo }) => {
411
430
  try {
412
- const index = await getIndexForRepo(repo, cache);
413
- const repoRoot = localRepoRoot(repo);
431
+ const source = repo ?? defaultRepo;
432
+ const index = await getIndexForRepo(source, cache);
433
+ const repoRoot = localRepoRoot(source);
414
434
  const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
415
435
  if (!chunk) {
416
436
  return toolText(
@@ -452,7 +472,7 @@ export function createMcpServer(
452
472
  },
453
473
  async ({ repo }) => {
454
474
  try {
455
- const rollup = await readBenchmarkRollup({ repo });
475
+ const rollup = await readRollup({ repo });
456
476
  return toolText(JSON.stringify(rollup));
457
477
  } catch (err) {
458
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> {
package/src/miru-index.ts CHANGED
@@ -26,6 +26,31 @@ import { chunkKey, chunkToDict, defaultContentTypes } from "./types.ts";
26
26
  import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
27
27
  import { indexCacheEpoch } from "./version.ts";
28
28
 
29
+ interface FactoryDependencies {
30
+ getValidatedCache: typeof getValidatedCache;
31
+ resolveEmbeddingModel: typeof resolveEmbeddingModel;
32
+ getEmbeddingBackend: (model: string) => EmbeddingBackend;
33
+ createIndexFromPath: typeof createIndexFromPath;
34
+ cloneGitRepository: typeof cloneGitRepository;
35
+ findIndexCachePath: typeof findIndexCachePath;
36
+ loadCachedIndex: typeof loadCachedIndex;
37
+ saveBuiltIndex: (index: MiruIndex, path: string) => Promise<void>;
38
+ }
39
+
40
+ function factoryDependencies(overrides: Partial<FactoryDependencies> = {}): FactoryDependencies {
41
+ return {
42
+ getValidatedCache,
43
+ resolveEmbeddingModel,
44
+ getEmbeddingBackend,
45
+ createIndexFromPath,
46
+ cloneGitRepository,
47
+ findIndexCachePath,
48
+ loadCachedIndex,
49
+ saveBuiltIndex: (index, path) => index.save(path),
50
+ ...overrides,
51
+ };
52
+ }
53
+
29
54
  /**
30
55
  * In-memory search index over a codebase: BM25 keyword scores plus semantic
31
56
  * embeddings, with optional disk cache for reuse across runs.
@@ -171,18 +196,20 @@ export class MiruIndex {
171
196
  content: ContentType[] = defaultContentTypes(),
172
197
  embeddingModel?: string,
173
198
  ref?: string | null,
199
+ dependencies?: Partial<FactoryDependencies>,
174
200
  ): Promise<MiruIndex> {
201
+ const deps = factoryDependencies(dependencies);
175
202
  const cacheKey = computeSourceCacheKey(url, ref);
176
- const model = embeddingModel ?? resolveEmbeddingModel();
177
- const cached = await getValidatedCache(cacheKey, model, content);
203
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
204
+ const cached = await deps.getValidatedCache(cacheKey, model, content);
178
205
  if (cached) {
179
- return MiruIndex.loadFromDisk(cached, model);
206
+ return MiruIndex.loadFromDisk(cached, model, deps);
180
207
  }
181
208
 
182
- const cloneDir = await cloneGitRepository(url, ref);
209
+ const cloneDir = await deps.cloneGitRepository(url, ref);
183
210
  try {
184
- const embeddings = getEmbeddingBackend(model);
185
- const { bm25, semantic, chunks } = await createIndexFromPath(
211
+ const embeddings = deps.getEmbeddingBackend(model);
212
+ const { bm25, semantic, chunks } = await deps.createIndexFromPath(
186
213
  cloneDir,
187
214
  embeddings,
188
215
  content,
@@ -198,7 +225,7 @@ export class MiruIndex {
198
225
  root: null,
199
226
  content,
200
227
  });
201
- await index.save(findIndexCachePath(cacheKey));
228
+ await deps.saveBuiltIndex(index, deps.findIndexCachePath(cacheKey));
202
229
  return index;
203
230
  } finally {
204
231
  await rm(cloneDir, { recursive: true, force: true });
@@ -211,11 +238,12 @@ export class MiruIndex {
211
238
  content: ContentType[] = defaultContentTypes(),
212
239
  embeddingModel?: string,
213
240
  ref?: string | null,
241
+ dependencies?: Partial<FactoryDependencies>,
214
242
  ): Promise<MiruIndex> {
215
243
  if (isGitUrl(source)) {
216
- return MiruIndex.fromGit(source, content, embeddingModel, ref);
244
+ return MiruIndex.fromGit(source, content, embeddingModel, ref, dependencies);
217
245
  }
218
- return MiruIndex.fromPath(source, content, embeddingModel);
246
+ return MiruIndex.fromPath(source, content, embeddingModel, dependencies);
219
247
  }
220
248
 
221
249
  /**
@@ -229,7 +257,9 @@ export class MiruIndex {
229
257
  path: string,
230
258
  content: ContentType[] = defaultContentTypes(),
231
259
  embeddingModel?: string,
260
+ dependencies?: Partial<FactoryDependencies>,
232
261
  ): Promise<MiruIndex> {
262
+ const deps = factoryDependencies(dependencies);
233
263
  const resolved = resolve(path);
234
264
  try {
235
265
  const st = await Bun.file(resolved).stat();
@@ -243,14 +273,14 @@ export class MiruIndex {
243
273
  throw new Error(`Path does not exist: ${path}`);
244
274
  }
245
275
 
246
- const model = embeddingModel ?? resolveEmbeddingModel();
247
- const cached = await getValidatedCache(resolved, model, content);
276
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
277
+ const cached = await deps.getValidatedCache(resolved, model, content);
248
278
  if (cached) {
249
- return MiruIndex.loadFromDisk(cached, model);
279
+ return MiruIndex.loadFromDisk(cached, model, deps);
250
280
  }
251
281
 
252
- const embeddings = getEmbeddingBackend(model);
253
- const { bm25, semantic, chunks } = await createIndexFromPath(
282
+ const embeddings = deps.getEmbeddingBackend(model);
283
+ const { bm25, semantic, chunks } = await deps.createIndexFromPath(
254
284
  resolved,
255
285
  embeddings,
256
286
  content,
@@ -274,15 +304,20 @@ export class MiruIndex {
274
304
  * Validates embedding model compatibility via cache metadata. Sets
275
305
  * `loadedFromDisk` so callers can avoid redundant cache writes.
276
306
  */
277
- static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
278
- const model = embeddingModel ?? resolveEmbeddingModel();
279
- const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
307
+ static async loadFromDisk(
308
+ path: string,
309
+ embeddingModel?: string,
310
+ dependencies?: Partial<FactoryDependencies>,
311
+ ): Promise<MiruIndex> {
312
+ const deps = factoryDependencies(dependencies);
313
+ const model = embeddingModel ?? deps.resolveEmbeddingModel();
314
+ const { bm25, semantic, chunks, metadata } = await deps.loadCachedIndex(path);
280
315
  const content = (metadata.content_type as ContentType[]) ?? defaultContentTypes();
281
316
  const root = metadata.root_path ? String(metadata.root_path) : null;
282
317
  const storedFileMtimes = (metadata.file_mtimes as Record<string, number>) ?? {};
283
318
 
284
319
  return new MiruIndex({
285
- embeddings: getEmbeddingBackend(model),
320
+ embeddings: deps.getEmbeddingBackend(model),
286
321
  bm25Index: bm25,
287
322
  semanticIndex: semantic,
288
323
  chunks,
@@ -387,6 +422,7 @@ export class MiruIndex {
387
422
  embeddings: this.embeddings,
388
423
  chunks: this.chunksInternal,
389
424
  semanticIndex: this.semanticIndex,
425
+ bm25: this.bm25Index,
390
426
  relativePaths,
391
427
  });
392
428
 
package/src/prompt.ts CHANGED
@@ -62,22 +62,49 @@ export function applyHiddenPromptChar(
62
62
  }
63
63
 
64
64
  /** Visible-input prompt (not for secrets — use promptHidden for those). */
65
- export async function promptText(message: string, defaultValue = ""): Promise<string> {
66
- const rl = readline.createInterface({ input, output });
65
+ export async function promptText(
66
+ message: string,
67
+ defaultValue = "",
68
+ createInterface: typeof createPromptInterface = createPromptInterface,
69
+ ): Promise<string> {
70
+ const rl = createInterface({ input, output });
71
+ return promptTextWith(
72
+ message,
73
+ defaultValue,
74
+ (question) => rl.question(question),
75
+ () => rl.close(),
76
+ );
77
+ }
78
+
79
+ export function createPromptInterface(
80
+ options: { input: typeof input; output: typeof output },
81
+ factory: typeof readline.createInterface = readline.createInterface,
82
+ ): Pick<readline.Interface, "question" | "close"> {
83
+ return factory(options);
84
+ }
85
+
86
+ export async function promptTextWith(
87
+ message: string,
88
+ defaultValue: string,
89
+ ask: (question: string) => Promise<string>,
90
+ close: () => void = () => {},
91
+ ): Promise<string> {
67
92
  try {
68
93
  const suffix = defaultValue ? ` (${defaultValue})` : "";
69
- const answer = (await rl.question(`${message}${suffix}: `)).trim();
94
+ const answer = (await ask(`${message}${suffix}: `)).trim();
70
95
  return answer || defaultValue;
71
96
  } finally {
72
- rl.close();
97
+ close();
73
98
  }
74
99
  }
75
100
 
76
101
  export async function promptHidden(
77
102
  message: string,
78
- stream: NodeJS.WriteStream = output,
103
+ stream: { write(text: string): unknown } = output,
104
+ io: { input?: HiddenPromptInput; exit?: (code: number) => void } = {},
79
105
  ): Promise<string> {
80
- if (!input.isTTY) {
106
+ const inputStream = io.input ?? input;
107
+ if (!inputStream.isTTY) {
81
108
  throw new Error(
82
109
  "Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
83
110
  );
@@ -85,9 +112,9 @@ export async function promptHidden(
85
112
 
86
113
  return new Promise((resolve, reject) => {
87
114
  stream.write(message);
88
- input.setRawMode?.(true);
89
- input.resume();
90
- input.setEncoding("utf8");
115
+ inputStream.setRawMode?.(true);
116
+ inputStream.resume();
117
+ inputStream.setEncoding("utf8");
91
118
 
92
119
  let state = createHiddenPromptState();
93
120
 
@@ -100,7 +127,7 @@ export async function promptHidden(
100
127
  cleanup();
101
128
  stream.write("\n");
102
129
  reject(new Error("Setup cancelled."));
103
- process.exit(130);
130
+ (io.exit ?? process.exit)(130);
104
131
  }
105
132
 
106
133
  if (result.submit) {
@@ -117,11 +144,21 @@ export async function promptHidden(
117
144
  };
118
145
 
119
146
  const cleanup = () => {
120
- input.setRawMode?.(false);
121
- input.pause();
122
- input.removeListener("data", onData);
147
+ inputStream.setRawMode?.(false);
148
+ inputStream.pause();
149
+ inputStream.removeListener("data", onData);
123
150
  };
124
151
 
125
- input.on("data", onData);
152
+ inputStream.on("data", onData);
126
153
  });
127
154
  }
155
+
156
+ interface HiddenPromptInput {
157
+ isTTY?: boolean;
158
+ setRawMode?: (mode: boolean) => void;
159
+ resume(): void;
160
+ pause(): void;
161
+ setEncoding(encoding: "utf8"): unknown;
162
+ on(event: "data", listener: (chunk: string) => void): unknown;
163
+ removeListener(event: "data", listener: (chunk: string) => void): unknown;
164
+ }
@@ -209,12 +209,11 @@ function boostSymbolDefinitions(
209
209
  const stem = basename(chunk.file_path)
210
210
  .replace(/\.[^.]+$/, "")
211
211
  .toLowerCase();
212
- if (![...names].some((n) => stemMatches(stem, n.toLowerCase()))) {
213
- continue;
214
- }
215
- const tier = definitionTier(chunk, names, boostUnit);
216
- if (tier) {
217
- boosted.set(key, tier);
212
+ if ([...names].some((n) => stemMatches(stem, n.toLowerCase()))) {
213
+ const tier = definitionTier(chunk, names, boostUnit);
214
+ if (tier) {
215
+ boosted.set(key, tier);
216
+ }
218
217
  }
219
218
  }
220
219
  }
@@ -222,16 +222,12 @@ export function boostExactStemMatches(
222
222
  if (GENERIC_STEM_DENY.has(keyword)) {
223
223
  continue;
224
224
  }
225
- if (preferImplementation && stem === keyword && !IMPLEMENTATION_HINTS.has(stem)) {
226
- continue;
227
- }
228
- const keywordNorm = keyword.replace(/[-_]/g, "");
229
- if (stem === keyword || stemNorm === keywordNorm) {
230
- boosted.set(key, (boosted.get(key) ?? 0) + boost);
231
- break;
232
- }
233
- const parts = keyword.split("-").filter((p) => p.length > 2);
234
- if (parts.length > 1 && parts.some((p) => p === stem)) {
225
+ if (!(preferImplementation && stem === keyword && !IMPLEMENTATION_HINTS.has(stem))) {
226
+ const keywordNorm = keyword.replace(/[-_]/g, "");
227
+ if (stem === keyword || stemNorm === keywordNorm) {
228
+ boosted.set(key, (boosted.get(key) ?? 0) + boost);
229
+ break;
230
+ }
235
231
  }
236
232
  }
237
233
  }
@@ -72,7 +72,7 @@ export function rerankTopk(
72
72
  const ranked = [...penalised.entries()].sort((a, b) => b[1] - a[1]);
73
73
 
74
74
  const fileSelected = new Map<string, number>();
75
- const selected: { score: number; key: string }[] = [];
75
+ const selected: { score: number; key: string; chunk: Chunk }[] = [];
76
76
  let minSelected = Infinity;
77
77
 
78
78
  for (const [key, penScore] of ranked) {
@@ -81,31 +81,23 @@ export function rerankTopk(
81
81
  continue;
82
82
  }
83
83
 
84
- if (selected.length >= topK && penScore <= minSelected) {
85
- break;
86
- }
87
-
88
- const already = fileSelected.get(chunk.file_path) ?? 0;
89
- let effScore = penScore;
90
- if (already >= FILE_SATURATION_THRESHOLD) {
91
- const excess = already - FILE_SATURATION_THRESHOLD + 1;
92
- effScore *= tuning.fileSaturationDecay ** excess;
93
- }
84
+ if (selected.length < topK || penScore > minSelected) {
85
+ const already = fileSelected.get(chunk.file_path) ?? 0;
86
+ let effScore = penScore;
87
+ if (already >= FILE_SATURATION_THRESHOLD) {
88
+ const excess = already - FILE_SATURATION_THRESHOLD + 1;
89
+ effScore *= tuning.fileSaturationDecay ** excess;
90
+ }
94
91
 
95
- selected.push({ score: effScore, key });
96
- fileSelected.set(chunk.file_path, already + 1);
92
+ selected.push({ score: effScore, key, chunk });
93
+ fileSelected.set(chunk.file_path, already + 1);
97
94
 
98
- if (selected.length >= topK) {
99
- minSelected = Math.min(...selected.map((s) => s.score));
95
+ if (selected.length >= topK) {
96
+ minSelected = Math.min(...selected.map((s) => s.score));
97
+ }
100
98
  }
101
99
  }
102
100
 
103
101
  selected.sort((a, b) => b.score - a.score);
104
- return selected.slice(0, topK).flatMap(({ key, score }) => {
105
- const chunk = chunksByKey.get(key);
106
- if (!chunk) {
107
- return [];
108
- }
109
- return [[chunk, score] as [Chunk, number]];
110
- });
102
+ return selected.slice(0, topK).map(({ chunk, score }) => [chunk, score]);
111
103
  }
package/src/search.ts CHANGED
@@ -137,11 +137,9 @@ export async function hybridSearch(options: {
137
137
 
138
138
  const allKeys = new Set([...normalizedSemantic.keys(), ...normalizedBm25.keys()]);
139
139
  const sortedKeys = [...allKeys].sort((a, b) => {
140
- const ca = chunksByKey.get(a);
141
- const cb = chunksByKey.get(b);
142
- if (!ca || !cb) {
143
- return 0;
144
- }
140
+ // Every score key was produced from a chunk in this query.
141
+ const ca = chunksByKey.get(a) as Chunk;
142
+ const cb = chunksByKey.get(b) as Chunk;
145
143
  return ca.start_line - cb.start_line;
146
144
  });
147
145
 
@@ -164,13 +162,7 @@ export async function hybridSearch(options: {
164
162
  return [...combinedScores.entries()]
165
163
  .sort((a, b) => b[1] - a[1])
166
164
  .slice(0, topK)
167
- .flatMap(([key, score]) => {
168
- const chunk = chunksByKey.get(key);
169
- if (!chunk) {
170
- return [];
171
- }
172
- return [{ chunk, score }];
173
- });
165
+ .map(([key, score]) => ({ chunk: chunksByKey.get(key) as Chunk, score }));
174
166
  }
175
167
 
176
168
  export async function searchSemanticOnly(options: {