@takara-ai/miru-code 1.0.5 → 1.1.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.
@@ -1,5 +1,7 @@
1
1
  import { watch } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve } from "node:path";
3
+ import { walkFiles } from "../index/file-walker.ts";
4
+ import { getExtensions } from "../index/files.ts";
3
5
  import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
4
6
  import { MiruIndex } from "../miru-index.ts";
5
7
  import type { ContentType } from "../types.ts";
@@ -118,7 +120,12 @@ export class IndexCache {
118
120
  const task = (async () => {
119
121
  const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
120
122
  if (!isGitUrl(source)) {
121
- await index.saveToDefaultCache(resolve(source));
123
+ await index.saveToCache(resolve(source));
124
+ }
125
+ if (index.loadedFromDisk) {
126
+ // Await reconciliation here (not fire-and-forget) so callers of `get()`
127
+ // never observe an index that's stale relative to what's on disk.
128
+ await this.checkAndQueueStaleFiles(source, index, cacheKey);
122
129
  }
123
130
  return index;
124
131
  })();
@@ -129,7 +136,6 @@ export class IndexCache {
129
136
  .then((index) => {
130
137
  entry.index = index;
131
138
  this.maybeStartWatcher(source);
132
- void this.flushFileUpdates(cacheKey, source);
133
139
  return index;
134
140
  })
135
141
  .catch(() => {
@@ -162,7 +168,7 @@ export class IndexCache {
162
168
  if (!entry.flushQueued) {
163
169
  entry.flushQueued = true;
164
170
  queueMicrotask(() => {
165
- this.flushFileUpdates(cacheKey, source);
171
+ void this.flushFileUpdates(cacheKey, source);
166
172
  });
167
173
  }
168
174
  }
@@ -219,10 +225,10 @@ export class IndexCache {
219
225
  this.scheduleFlush(cacheKey, source, entry);
220
226
  }
221
227
 
222
- private flushFileUpdates(cacheKey: string, source: string): void {
228
+ private flushFileUpdates(cacheKey: string, source: string): Promise<void> {
223
229
  const entry = this.entries.get(cacheKey);
224
230
  if (!entry) {
225
- return;
231
+ return Promise.resolve();
226
232
  }
227
233
  entry.flushQueued = false;
228
234
 
@@ -254,7 +260,7 @@ export class IndexCache {
254
260
  try {
255
261
  await index.applyFileChanges(paths);
256
262
  if (!isGitUrl(source)) {
257
- await index.persistToCache(resolve(source));
263
+ await index.saveToCache(resolve(source), { force: true });
258
264
  }
259
265
  } catch {
260
266
  for (const p of paths) {
@@ -264,6 +270,57 @@ export class IndexCache {
264
270
  };
265
271
 
266
272
  entry.updateChain = entry.updateChain.then(run, run);
273
+ return entry.updateChain;
274
+ }
275
+
276
+ private async checkAndQueueStaleFiles(
277
+ source: string,
278
+ index: MiruIndex,
279
+ cacheKey: string,
280
+ ): Promise<void> {
281
+ if (isGitUrl(source)) {
282
+ return;
283
+ }
284
+
285
+ const root = index.root;
286
+ if (!root) {
287
+ return;
288
+ }
289
+
290
+ const storedMtimes = index.getStoredFileMtimes();
291
+ const entry = this.ensureEntry(cacheKey, source);
292
+
293
+ try {
294
+ await Promise.all(
295
+ [...storedMtimes].map(async ([filePath, storedMtime]) => {
296
+ try {
297
+ const currentStat = await Bun.file(resolve(root, filePath)).stat();
298
+ const currentMtime = Math.floor(currentStat.mtime?.getTime() ?? 0);
299
+
300
+ if (currentMtime !== storedMtime) {
301
+ entry.pendingPaths.add(normalizeRelativePath(filePath));
302
+ }
303
+ } catch {
304
+ entry.pendingPaths.add(normalizeRelativePath(filePath));
305
+ }
306
+ }),
307
+ );
308
+
309
+ // Files created on disk while this index wasn't loaded (or never indexed
310
+ // before) have no entry in `storedMtimes` and are otherwise invisible to
311
+ // the mtime comparison above; a directory walk is the only way to find them.
312
+ const extensions = getExtensions(index.contentTypes);
313
+ for await (const absolutePath of walkFiles(root, extensions)) {
314
+ const relativePath = normalizeRelativePath(relative(root, absolutePath));
315
+ if (!storedMtimes.has(relativePath)) {
316
+ entry.pendingPaths.add(relativePath);
317
+ }
318
+ }
319
+
320
+ if (entry.pendingPaths.size > 0) {
321
+ await this.flushFileUpdates(cacheKey, source);
322
+ }
323
+ } catch {}
267
324
  }
268
325
 
269
326
  private maybeStartWatcher(source: string): void {
package/src/mcp/server.ts CHANGED
@@ -87,12 +87,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
87
87
  file_path: z
88
88
  .string()
89
89
  .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
90
- line: z
90
+ anchor_line: z
91
91
  .number()
92
92
  .int()
93
- .describe(
94
- "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
95
- ),
93
+ .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
96
94
  repo: z.string().describe(REPO_DESCRIPTION),
97
95
  before: z
98
96
  .number()
@@ -108,7 +106,7 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
108
106
  .describe("Extra chunks after the anchor (default 1)."),
109
107
  },
110
108
  },
111
- async ({ file_path: filePath, line, repo, before, after }) => {
109
+ async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
112
110
  try {
113
111
  const index = await getIndexForRepo(repo, cache);
114
112
  const repoRoot = localRepoRoot(repo);
@@ -117,19 +115,19 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
117
115
  const { anchor, chunks: expanded } = expandChunksAtLine(
118
116
  index.chunks,
119
117
  filePath,
120
- line,
118
+ anchorLine,
121
119
  repoRoot,
122
120
  beforeCount,
123
121
  afterCount,
124
122
  );
125
123
  if (!anchor) {
126
124
  return toolText(
127
- `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
125
+ `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
128
126
  );
129
127
  }
130
128
  return toolText(
131
129
  JSON.stringify(
132
- formatExpandResults(filePath, line, anchor, expanded, {
130
+ formatExpandResults(filePath, anchorLine, anchor, expanded, {
133
131
  repoRoot,
134
132
  before: beforeCount,
135
133
  after: afterCount,
@@ -150,12 +148,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
150
148
  file_path: z
151
149
  .string()
152
150
  .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
153
- line: z
151
+ anchor_line: z
154
152
  .number()
155
153
  .int()
156
- .describe(
157
- "Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
158
- ),
154
+ .describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
159
155
  repo: z.string().describe(REPO_DESCRIPTION),
160
156
  top_k: z
161
157
  .number()
@@ -168,25 +164,25 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
168
164
  ),
169
165
  },
170
166
  },
171
- async ({ file_path: filePath, line, repo, top_k: topK }) => {
167
+ async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
172
168
  try {
173
169
  const index = await getIndexForRepo(repo, cache);
174
170
  const repoRoot = localRepoRoot(repo);
175
- const chunk = resolveChunk(index.chunks, filePath, line, repoRoot);
171
+ const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
176
172
  if (!chunk) {
177
173
  return toolText(
178
- `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
174
+ `No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
179
175
  );
180
176
  }
181
177
  const results = await index.findRelated(chunk, clampMcpTopK(topK));
182
178
  if (results.length === 0) {
183
179
  return toolText(
184
- JSON.stringify({ error: `No related chunks found for ${filePath}:${line}.` }),
180
+ JSON.stringify({ error: `No related chunks found for ${filePath}:${anchorLine}.` }),
185
181
  );
186
182
  }
187
183
  return toolText(
188
184
  JSON.stringify(
189
- formatResults(`Chunks related to ${filePath}:${line}`, results, {
185
+ formatResults(`Chunks related to ${filePath}:${anchorLine}`, results, {
190
186
  repoRoot,
191
187
  snippet: true,
192
188
  }),
package/src/miru-index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./embeddings/openai.ts";
10
10
  import { cloneGitRepository } from "./git.ts";
11
11
  import type { BM25Index } from "./index/bm25.ts";
12
+ import { buildChunkSelector } from "./index/chunk-selector.ts";
12
13
  import { createIndexFromPath } from "./index/create.ts";
13
14
  import { applyIncrementalFileChanges } from "./index/incremental.ts";
14
15
  import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
@@ -19,27 +20,89 @@ import { chunkKey, chunkToDict } from "./types.ts";
19
20
  import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
20
21
  import { indexCacheEpoch } from "./version.ts";
21
22
 
23
+ /**
24
+ * In-memory search index over a codebase: BM25 keyword scores plus semantic
25
+ * embeddings, with optional disk cache for reuse across runs.
26
+ *
27
+ * Construct via `fromPath`, `fromGit`, or `fromSource`; load a saved bundle
28
+ * with `loadFromDisk`. Search combines both signals; `findRelated` is
29
+ * semantic-only similarity from an existing chunk or hit.
30
+ *
31
+ * Lifecycle:
32
+ * - **Build** — parse files, chunk, embed, score with BM25
33
+ * - **Cache** — `save` writes a bundle keyed by source path or git URL + ref
34
+ * - **Query** — `search` blends BM25 and vectors; filters use prebuilt mappings
35
+ * - **Update** — `applyFileChanges` re-indexes only touched paths (local roots)
36
+ */
22
37
  export class MiruIndex {
38
+ /** OpenAI (or configured) client used for query embeddings and reranking. */
23
39
  readonly embeddings: EmbeddingBackend;
40
+
41
+ /** Flat list of all chunks; index position is the stable chunk id. */
24
42
  private chunksInternal: Chunk[];
43
+
44
+ /** Inverted index for lexical (BM25) retrieval. */
25
45
  private bm25Index: BM25Index;
46
+
47
+ /** Vector store for semantic nearest-neighbor search. */
26
48
  private semanticIndex: SemanticIndex;
49
+
50
+ /**
51
+ * True when hydrated from disk via `loadFromDisk`.
52
+ * Affects `saveToCache`, which skips re-saving already-cached indexes unless forced.
53
+ */
27
54
  private loadedFromDiskFlag: boolean;
28
55
 
56
+ /** Model id stored in cache metadata and used for new embeddings. */
29
57
  readonly embeddingModel: string;
30
- private readonly root: string | null;
58
+
59
+ /**
60
+ * Absolute path to the indexed tree on disk, or `null` for git-only indexes
61
+ * (clone is deleted after indexing; incremental updates need a local root).
62
+ */
63
+ private readonly _root: string | null;
64
+
65
+ /** Content kinds indexed at build time (e.g. `code`, `markdown`). */
31
66
  private readonly content: ContentType[];
67
+
68
+ /** Repo-relative file path → chunk indices in `chunksInternal`. */
32
69
  private fileMapping: Map<string, number[]>;
70
+
71
+ /** Language tag → chunk indices, for `filterLanguages` in search. */
33
72
  private languageMapping: Map<string, number[]>;
34
73
 
74
+ /** Stored file mtimes (file path → mtime_ms) for cache freshness checking. */
75
+ private storedFileMtimes: Map<string, number>;
76
+
77
+ /** Read-only view of indexed chunks. */
35
78
  get chunks(): Chunk[] {
36
79
  return this.chunksInternal;
37
80
  }
38
81
 
82
+ /** Whether this instance was loaded from a cache bundle rather than freshly built. */
39
83
  get loadedFromDisk(): boolean {
40
84
  return this.loadedFromDiskFlag;
41
85
  }
42
86
 
87
+ /** Get stored file mtimes for cache validation. */
88
+ getStoredFileMtimes(): Map<string, number> {
89
+ return this.storedFileMtimes;
90
+ }
91
+
92
+ /** Get the root path for local indexes (null for git indexes). */
93
+ get root(): string | null {
94
+ return this._root;
95
+ }
96
+
97
+ /** Content kinds indexed at build time, for re-walking the tree during freshness checks. */
98
+ get contentTypes(): ContentType[] {
99
+ return this.content;
100
+ }
101
+
102
+ /**
103
+ * Prefer static factories (`fromPath`, `fromGit`, `loadFromDisk`) over calling
104
+ * directly; the constructor is public for tests and advanced composition.
105
+ */
43
106
  constructor(options: {
44
107
  embeddings: EmbeddingBackend;
45
108
  bm25Index: BM25Index;
@@ -49,20 +112,26 @@ export class MiruIndex {
49
112
  root?: string | null;
50
113
  content?: ContentType[];
51
114
  loadedFromDisk?: boolean;
115
+ storedFileMtimes?: Record<string, number>;
52
116
  }) {
53
117
  this.embeddings = options.embeddings;
54
118
  this.bm25Index = options.bm25Index;
55
119
  this.semanticIndex = options.semanticIndex;
56
120
  this.chunksInternal = options.chunks;
57
121
  this.embeddingModel = options.embeddingModel;
58
- this.root = options.root ?? null;
122
+ this._root = options.root ?? null;
59
123
  this.content = options.content ?? ["code"];
60
124
  this.loadedFromDiskFlag = options.loadedFromDisk ?? false;
125
+ this.storedFileMtimes = new Map(Object.entries(options.storedFileMtimes ?? {}));
61
126
  this.fileMapping = new Map();
62
127
  this.languageMapping = new Map();
63
128
  this.rebuildMappings();
64
129
  }
65
130
 
131
+ /**
132
+ * Rebuild file/language → chunk-index maps after chunks change.
133
+ * Called from the constructor and after incremental updates.
134
+ */
66
135
  private rebuildMappings(): void {
67
136
  this.fileMapping = new Map();
68
137
  this.languageMapping = new Map();
@@ -85,6 +154,12 @@ export class MiruIndex {
85
154
  }
86
155
  }
87
156
 
157
+ /**
158
+ * Clone a remote repo, index it, persist to cache, and return the in-memory index.
159
+ *
160
+ * Cache key is derived from URL + ref. On hit, skips clone and embedding work.
161
+ * The temporary clone directory is always removed in `finally`.
162
+ */
88
163
  static async fromGit(
89
164
  url: string,
90
165
  content: ContentType[] = ["code"],
@@ -113,6 +188,7 @@ export class MiruIndex {
113
188
  semanticIndex: semantic,
114
189
  chunks,
115
190
  embeddingModel: model,
191
+ // No durable local root — clone is deleted; incremental updates unavailable.
116
192
  root: null,
117
193
  content,
118
194
  });
@@ -123,6 +199,7 @@ export class MiruIndex {
123
199
  }
124
200
  }
125
201
 
202
+ /** Resolve a local path or git URL and delegate to `fromPath` or `fromGit`. */
126
203
  static async fromSource(
127
204
  source: string,
128
205
  content: ContentType[] = ["code"],
@@ -135,6 +212,13 @@ export class MiruIndex {
135
212
  return MiruIndex.fromPath(source, content, embeddingModel);
136
213
  }
137
214
 
215
+ /**
216
+ * Index a directory on disk, reusing a validated cache hit when available.
217
+ *
218
+ * `root` is set to the resolved path so `applyFileChanges` can re-chunk files
219
+ * later. Unlike `fromGit`, this does not auto-save to cache; callers use
220
+ * `saveToCache` when they want persistence.
221
+ */
138
222
  static async fromPath(
139
223
  path: string,
140
224
  content: ContentType[] = ["code"],
@@ -178,11 +262,18 @@ export class MiruIndex {
178
262
  });
179
263
  }
180
264
 
265
+ /**
266
+ * Hydrate BM25, semantic vectors, and chunks from a saved index bundle.
267
+ *
268
+ * Validates embedding model compatibility via cache metadata. Sets
269
+ * `loadedFromDisk` so callers can avoid redundant cache writes.
270
+ */
181
271
  static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
182
272
  const model = embeddingModel ?? resolveEmbeddingModel();
183
273
  const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
184
274
  const content = (metadata.content_type as ContentType[]) ?? ["code"];
185
275
  const root = metadata.root_path ? String(metadata.root_path) : null;
276
+ const storedFileMtimes = (metadata.file_mtimes as Record<string, number>) ?? {};
186
277
 
187
278
  return new MiruIndex({
188
279
  embeddings: getEmbeddingBackend(model),
@@ -193,12 +284,32 @@ export class MiruIndex {
193
284
  root,
194
285
  content,
195
286
  loadedFromDisk: true,
287
+ storedFileMtimes,
196
288
  });
197
289
  }
198
290
 
291
+ /**
292
+ * Write BM25, semantic vectors, chunk metadata, and index metadata to `path`.
293
+ *
294
+ * Metadata includes `index_epoch` for invalidation when Miru's on-disk format
295
+ * changes, plus embedding model/dimensions for cache validation on reload.
296
+ */
199
297
  async save(path: string): Promise<void> {
200
298
  const paths = persistencePaths(path);
201
299
  await mkdir(paths.root, { recursive: true });
300
+
301
+ const fileMtimes: Record<string, number> = {};
302
+ if (this._root) {
303
+ for (const filePath of this.fileMapping.keys()) {
304
+ try {
305
+ const stat = await Bun.file(resolve(this._root, filePath)).stat();
306
+ fileMtimes[filePath] = Math.floor(stat.mtime?.getTime() ?? 0);
307
+ } catch {
308
+ fileMtimes[filePath] = 0;
309
+ }
310
+ }
311
+ }
312
+
202
313
  await saveIndexBundle({
203
314
  paths,
204
315
  bm25: this.bm25Index,
@@ -206,7 +317,7 @@ export class MiruIndex {
206
317
  chunks: this.chunks.map(chunkToDict),
207
318
  metadata: {
208
319
  index_epoch: indexCacheEpoch(),
209
- root_path: this.root,
320
+ root_path: this._root,
210
321
  time: Date.now() / 1000,
211
322
  embedding_model: this.embeddingModel,
212
323
  embedding_dimensions:
@@ -214,23 +325,31 @@ export class MiruIndex {
214
325
  embedding_provider: "openai",
215
326
  content_type: this.content,
216
327
  file_paths: [...this.fileMapping.keys()].sort(),
328
+ file_mtimes: fileMtimes,
217
329
  },
218
330
  });
219
331
  }
220
332
 
221
- async saveToDefaultCache(sourcePath: string): Promise<void> {
222
- if (!this.loadedFromDiskFlag) {
333
+ /**
334
+ * Persist to the default cache location for `sourcePath`.
335
+ *
336
+ * By default this is a no-op for unchanged indexes loaded from disk, avoiding
337
+ * redundant cache writes. Pass `force: true` after mutating a cached index.
338
+ */
339
+ async saveToCache(sourcePath: string, options: { force?: boolean } = {}): Promise<void> {
340
+ if (!this.loadedFromDiskFlag || options.force) {
223
341
  await this.save(findIndexCachePath(sourcePath));
224
342
  }
225
343
  }
226
344
 
227
- async persistToCache(sourcePath: string): Promise<void> {
228
- await this.save(findIndexCachePath(sourcePath));
229
- }
230
-
231
- /** Re-chunk and re-embed only the given repo-relative paths; drop their old chunks. */
345
+ /**
346
+ * Re-chunk and re-embed only the given repo-relative paths; drop their old chunks.
347
+ *
348
+ * Requires `root` (local `fromPath` indexes only). Updates BM25, semantic
349
+ * index, and mappings in place; clears `loadedFromDisk` since data changed.
350
+ */
232
351
  async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
233
- if (!this.root) {
352
+ if (!this._root) {
234
353
  throw new Error("Incremental update requires a local index with root_path set.");
235
354
  }
236
355
  if (relativePaths.length === 0) {
@@ -238,7 +357,7 @@ export class MiruIndex {
238
357
  }
239
358
 
240
359
  const updated = await applyIncrementalFileChanges({
241
- root: this.root,
360
+ root: this._root,
242
361
  content: this.content,
243
362
  embeddings: this.embeddings,
244
363
  chunks: this.chunksInternal,
@@ -253,20 +372,27 @@ export class MiruIndex {
253
372
  this.rebuildMappings();
254
373
  }
255
374
 
256
- private getSelector(filterLanguages?: string[], filterPaths?: string[]): number[] | undefined {
257
- const selector: number[] = [];
258
- for (const lang of filterLanguages ?? []) {
259
- selector.push(...(this.languageMapping.get(lang) ?? []));
260
- }
261
- for (const fp of filterPaths ?? []) {
262
- selector.push(...(this.fileMapping.get(fp) ?? []));
263
- }
264
- if (selector.length === 0) {
265
- return undefined;
266
- }
267
- return [...new Set(selector)];
375
+ private getSelector(
376
+ filterLanguages?: string[],
377
+ filterPaths?: string[],
378
+ ): readonly number[] | undefined {
379
+ return buildChunkSelector(
380
+ {
381
+ fileMapping: this.fileMapping,
382
+ languageMapping: this.languageMapping,
383
+ },
384
+ filterLanguages,
385
+ filterPaths,
386
+ );
268
387
  }
269
388
 
389
+ /**
390
+ * Hybrid BM25 + semantic search over indexed chunks.
391
+ *
392
+ * `alpha` blends keyword vs vector scores (default depends on content type).
393
+ * `filterLanguages` / `filterPaths` restrict candidates before ranking.
394
+ * `rerank` defaults to on for code indexes (cross-encoder style reranking).
395
+ */
270
396
  async search(options: {
271
397
  query: string;
272
398
  topK?: number;
@@ -296,6 +422,13 @@ export class MiruIndex {
296
422
  });
297
423
  }
298
424
 
425
+ /**
426
+ * Semantic neighbors of a chunk or search hit, excluding the source itself.
427
+ *
428
+ * Uses the chunk's text as the query embedding. When the source has a
429
+ * language, search is scoped to same-language chunks. Requests `topK + 1`
430
+ * hits so filtering out the source still yields `topK` results.
431
+ */
299
432
  async findRelated(source: Chunk | SearchResult, topK = 5): Promise<SearchResult[]> {
300
433
  const target = "chunk" in source ? source.chunk : source;
301
434
  const selector = target.language ? this.getSelector([target.language]) : undefined;
package/src/search.ts CHANGED
@@ -35,7 +35,7 @@ function semanticFromQueryVector(
35
35
  semanticIndex: SemanticIndex,
36
36
  chunks: Chunk[],
37
37
  topK: number,
38
- selector?: number[],
38
+ selector?: readonly number[],
39
39
  ): SearchResult[] {
40
40
  const { indices, distances } = semanticIndex.query(queryVec, topK, selector);
41
41
  return indices.flatMap((index, i) => {
@@ -52,7 +52,7 @@ async function searchBm25(
52
52
  bm25Index: BM25Index,
53
53
  chunks: Chunk[],
54
54
  topK: number,
55
- selector?: number[],
55
+ selector?: readonly number[],
56
56
  ): Promise<SearchResult[]> {
57
57
  const tokens = tokenize(query);
58
58
  if (tokens.length === 0) {
@@ -79,7 +79,7 @@ export async function hybridSearch(options: {
79
79
  chunks: Chunk[];
80
80
  topK: number;
81
81
  alpha?: number | null;
82
- selector?: number[];
82
+ selector?: readonly number[];
83
83
  rerank?: boolean;
84
84
  }): Promise<SearchResult[]> {
85
85
  const {
@@ -170,7 +170,7 @@ export async function searchSemanticOnly(options: {
170
170
  semanticIndex: SemanticIndex;
171
171
  chunks: Chunk[];
172
172
  topK: number;
173
- selector?: number[];
173
+ selector?: readonly number[];
174
174
  }): Promise<SearchResult[]> {
175
175
  const queryVec = await options.embeddings.embedQuery(options.query);
176
176
  return semanticFromQueryVector(
package/src/setup.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { brandTitle, divider, fail, hint, info, success, writeStderr } from "./cli-ui.ts";
1
+ import { divider, fail, hint, info, printBrandBanner, success, writeStderr } from "./cli-ui.ts";
2
2
  import {
3
3
  clearStoredCredentials,
4
4
  loadStoredCredentials,
@@ -55,7 +55,8 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<RunSetupR
55
55
  }
56
56
 
57
57
  writeStderr("");
58
- writeStderr(`${brandTitle()} setup`);
58
+ // stderr banner: setup runs before stdout may be a TTY (e.g. piped miru search).
59
+ printBrandBanner(process.stderr);
59
60
  divider("─", 48, process.stderr);
60
61
  writeStderr("Miru needs a Takara API key for code embeddings.");
61
62
  hint("Get a bearer token from Takara, then enter it below.");
@@ -129,6 +130,11 @@ export async function ensureCredentials(options?: { interactive?: boolean }): Pr
129
130
  return;
130
131
  }
131
132
 
133
+ writeStderr("");
134
+ // Brand on stderr when credentials are missing in non-interactive mode (stdout may not be a TTY).
135
+ printBrandBanner(process.stderr);
136
+ writeStderr("");
137
+
132
138
  throw new Error(
133
139
  "Takara API key required. Run `miru setup` in a terminal, or set TAKARA_API_KEY " +
134
140
  "in your MCP server env (Cursor mcp.json) or .env.local.",