@takara-ai/miru-code 0.1.3 → 0.2.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.
@@ -0,0 +1,94 @@
1
+ import {
2
+ resolveEmbeddingBaseUrl,
3
+ resolveEmbeddingDimensions,
4
+ resolveEmbeddingModel,
5
+ } from "./openai.ts";
6
+
7
+ export interface ValidateApiKeyResult {
8
+ valid: boolean;
9
+ status?: number;
10
+ message: string;
11
+ }
12
+
13
+ export async function validateEmbeddingApiKey(options: {
14
+ apiKey: string;
15
+ baseUrl?: string;
16
+ model?: string;
17
+ dimensions?: number;
18
+ }): Promise<ValidateApiKeyResult> {
19
+ const baseUrl = (options.baseUrl ?? resolveEmbeddingBaseUrl()).replace(/\/$/, "");
20
+ const model = options.model ?? resolveEmbeddingModel();
21
+ const dimensions = options.dimensions ?? resolveEmbeddingDimensions(model);
22
+ const endpoint = `${baseUrl}/embeddings`;
23
+
24
+ const body: Record<string, unknown> = {
25
+ model,
26
+ input: "miru setup validation",
27
+ };
28
+ if (dimensions != null) {
29
+ body.dimensions = dimensions;
30
+ }
31
+
32
+ let response: Response;
33
+ try {
34
+ response = await fetch(endpoint, {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${options.apiKey}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ } catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ return {
45
+ valid: false,
46
+ message: `Could not reach embedding API at ${baseUrl}: ${message}`,
47
+ };
48
+ }
49
+
50
+ if (!response.ok) {
51
+ const text = await response.text();
52
+ if (response.status === 401 || response.status === 403) {
53
+ return {
54
+ valid: false,
55
+ status: response.status,
56
+ message: "Invalid API key (authentication failed).",
57
+ };
58
+ }
59
+ return {
60
+ valid: false,
61
+ status: response.status,
62
+ message: `Embedding API returned ${response.status}: ${text.slice(0, 200)}`,
63
+ };
64
+ }
65
+
66
+ try {
67
+ const payload = (await response.json()) as {
68
+ data?: Array<{ embedding?: number[] }>;
69
+ };
70
+ const embedding = payload.data?.[0]?.embedding;
71
+ if (!embedding || embedding.length === 0) {
72
+ return {
73
+ valid: false,
74
+ status: response.status,
75
+ message: "Embedding API returned an empty response.",
76
+ };
77
+ }
78
+ if (dimensions != null && embedding.length !== dimensions) {
79
+ return {
80
+ valid: false,
81
+ status: response.status,
82
+ message: `Expected ${dimensions} embedding dimensions, got ${embedding.length}.`,
83
+ };
84
+ }
85
+ } catch {
86
+ return {
87
+ valid: false,
88
+ status: response.status,
89
+ message: "Embedding API returned invalid JSON.",
90
+ };
91
+ }
92
+
93
+ return { valid: true, status: response.status, message: "API key is valid." };
94
+ }
package/src/env.ts CHANGED
@@ -38,12 +38,14 @@ const EMBEDDING_API_KEY_NAMES = [
38
38
  "SEMBLE_OPENAI_API_KEY",
39
39
  ] as const;
40
40
 
41
+ export { EMBEDDING_API_KEY_NAMES };
42
+
41
43
  export function resolveEmbeddingApiKey(): string {
42
44
  const key = envFirstString([...EMBEDDING_API_KEY_NAMES], "");
43
45
  if (!key) {
44
46
  throw new Error(
45
- "Embedding API key required. Set TAKARA_API_KEY, OPENAI_API_KEY, MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY " +
46
- "in your MCP server env (Cursor mcp.json) or .env.local.",
47
+ "Embedding API key required. Run `miru setup`, set TAKARA_API_KEY, OPENAI_API_KEY, " +
48
+ "MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY in your MCP server env or .env.local.",
47
49
  );
48
50
  }
49
51
  return key;
@@ -22,6 +22,14 @@ export class VectorIndex implements SemanticIndex {
22
22
  return this.vectors;
23
23
  }
24
24
 
25
+ vectorAt(docIndex: number): Float32Array {
26
+ const vec = this.vectors[docIndex];
27
+ if (!vec) {
28
+ throw new Error(`Missing vector at index ${docIndex}`);
29
+ }
30
+ return vec;
31
+ }
32
+
25
33
  memoryBytes(): number {
26
34
  return this.vectors.length * this.dimensions * 4;
27
35
  }
@@ -0,0 +1,130 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ import { chunkSource } from "../chunking/chunking.ts";
3
+ import type { EmbeddingBackend } from "../embeddings/openai.ts";
4
+ import { tokenize } from "../tokens.ts";
5
+ import type { Chunk, ContentType } from "../types.ts";
6
+ import { BM25Index } from "./bm25.ts";
7
+ import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
8
+ import type { SemanticIndex } from "./semantic-index.ts";
9
+ import { enrichForBm25 } from "./sparse.ts";
10
+ import { buildSemanticIndex } from "./vector-storage.ts";
11
+ import { vectorAt } from "./vectors.ts";
12
+
13
+ export function normalizeRelativePath(path: string): string {
14
+ return path.replace(/\\/g, "/").replace(/^\.\/+/, "");
15
+ }
16
+
17
+ async function chunksForFile(root: string, relativePath: string): Promise<Chunk[]> {
18
+ const rel = normalizeRelativePath(relativePath);
19
+ const absolute = join(root, rel);
20
+ try {
21
+ const status = await getFileStatus(absolute);
22
+ if (status !== "valid") {
23
+ return [];
24
+ }
25
+ const source = await readFileText(absolute);
26
+ const language = detectLanguage(absolute);
27
+ return chunkSource(source, rel, language);
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ function isIndexableRelativePath(relativePath: string, extensions: Set<string>): boolean {
34
+ const rel = normalizeRelativePath(relativePath);
35
+ const name = rel.split("/").pop() ?? rel;
36
+ if (name.toLowerCase() === "dockerfile") {
37
+ return true;
38
+ }
39
+ const dot = name.lastIndexOf(".");
40
+ const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
41
+ return extensions.has(ext);
42
+ }
43
+
44
+ /**
45
+ * Replace chunks for the given repo-relative paths only: remove old chunks,
46
+ * embed new ones, rebuild BM25 + semantic indexes from the merged vector set.
47
+ */
48
+ export async function applyIncrementalFileChanges(options: {
49
+ root: string;
50
+ content: ContentType[];
51
+ embeddings: EmbeddingBackend;
52
+ chunks: Chunk[];
53
+ semanticIndex: SemanticIndex;
54
+ relativePaths: readonly string[];
55
+ }): Promise<{ chunks: Chunk[]; bm25: BM25Index; semantic: SemanticIndex }> {
56
+ const root = resolve(options.root);
57
+ const extensions = new Set(getExtensions(options.content).map((e) => e.toLowerCase()));
58
+ const targets = new Set(
59
+ options.relativePaths
60
+ .map(normalizeRelativePath)
61
+ .filter((p) => p.length > 0 && isIndexableRelativePath(p, extensions)),
62
+ );
63
+
64
+ if (targets.size === 0) {
65
+ return {
66
+ chunks: options.chunks,
67
+ bm25: rebuildBm25(options.chunks),
68
+ semantic: options.semanticIndex,
69
+ };
70
+ }
71
+
72
+ const keptChunks: Chunk[] = [];
73
+ const keptVectors: Float32Array[] = [];
74
+
75
+ for (let i = 0; i < options.chunks.length; i++) {
76
+ const chunk = options.chunks[i];
77
+ if (!chunk) {
78
+ continue;
79
+ }
80
+ const rel = normalizeRelativePath(chunk.file_path);
81
+ if (targets.has(rel)) {
82
+ continue;
83
+ }
84
+ keptChunks.push(chunk);
85
+ keptVectors.push(vectorAt(options.semanticIndex, i));
86
+ }
87
+
88
+ const addedChunks: Chunk[] = [];
89
+ for (const rel of targets) {
90
+ addedChunks.push(...(await chunksForFile(root, rel)));
91
+ }
92
+
93
+ let addedVectors: Float32Array[] = [];
94
+ if (addedChunks.length > 0) {
95
+ addedVectors = await options.embeddings.embedDocuments(addedChunks.map((c) => c.content));
96
+ }
97
+
98
+ const chunks = [...keptChunks, ...addedChunks];
99
+ const vectors = [...keptVectors, ...addedVectors];
100
+
101
+ if (chunks.length === 0) {
102
+ throw new Error(`No indexed chunks remain under ${root}.`);
103
+ }
104
+
105
+ if (vectors.length !== chunks.length) {
106
+ throw new Error(`Vector count ${vectors.length} does not match chunk count ${chunks.length}.`);
107
+ }
108
+
109
+ return {
110
+ chunks,
111
+ bm25: rebuildBm25(chunks),
112
+ semantic: buildSemanticIndex(vectors),
113
+ };
114
+ }
115
+
116
+ function rebuildBm25(chunks: Chunk[]): BM25Index {
117
+ const bm25 = new BM25Index();
118
+ bm25.index(chunks.map((c) => tokenize(enrichForBm25(c))));
119
+ return bm25;
120
+ }
121
+
122
+ /** Map an absolute changed path to a repo-relative path for chunk keys. */
123
+ export function relativePathFromRoot(root: string, absoluteOrRelative: string): string {
124
+ const resolvedRoot = resolve(root);
125
+ const candidate = resolve(resolvedRoot, absoluteOrRelative);
126
+ if (candidate.startsWith(`${resolvedRoot}/`) || candidate === resolvedRoot) {
127
+ return normalizeRelativePath(relative(resolvedRoot, candidate));
128
+ }
129
+ return normalizeRelativePath(absoluteOrRelative);
130
+ }
@@ -86,6 +86,30 @@ export class QuantizedVectorIndex implements SemanticIndex {
86
86
  return this.codes.length * this.dim + this.scales.byteLength;
87
87
  }
88
88
 
89
+ vectorAt(docIndex: number): Float32Array {
90
+ const codes = this.codes[docIndex];
91
+ const scale = this.scales[docIndex];
92
+ if (!codes || scale === undefined) {
93
+ throw new Error(`Missing quantized vector at index ${docIndex}`);
94
+ }
95
+ const out = new Float32Array(codes.length);
96
+ for (let i = 0; i < codes.length; i++) {
97
+ out[i] = (codes[i] ?? 0) * scale;
98
+ }
99
+ let norm = 0;
100
+ for (let i = 0; i < out.length; i++) {
101
+ const v = out[i] ?? 0;
102
+ norm += v * v;
103
+ }
104
+ norm = Math.sqrt(norm);
105
+ if (norm > 0) {
106
+ for (let i = 0; i < out.length; i++) {
107
+ out[i] = (out[i] ?? 0) / norm;
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
89
113
  query(
90
114
  queryVector: Float32Array,
91
115
  k: number,
@@ -0,0 +1,18 @@
1
+ import { VectorIndex } from "./dense.ts";
2
+ import { QuantizedVectorIndex } from "./quantize.ts";
3
+ import type { SemanticIndex } from "./semantic-index.ts";
4
+
5
+ /** Recover a float embedding for incremental re-indexing (unchanged chunks). */
6
+ export function vectorAt(index: SemanticIndex, docIndex: number): Float32Array {
7
+ if (index instanceof VectorIndex) {
8
+ const vec = index.getVectors()[docIndex];
9
+ if (!vec) {
10
+ throw new Error(`Missing vector at index ${docIndex}`);
11
+ }
12
+ return vec;
13
+ }
14
+ if (index instanceof QuantizedVectorIndex) {
15
+ return index.vectorAt(docIndex);
16
+ }
17
+ throw new Error("Unsupported semantic index type");
18
+ }
@@ -1,14 +1,13 @@
1
1
  import { watch } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
3
4
  import { MiruIndex } from "../miru-index.ts";
4
- import { envOptionalInt } from "../env.ts";
5
5
  import type { ContentType } from "../types.ts";
6
6
  import { computeSourceCacheKey, isGitUrl } from "../utils.ts";
7
7
 
8
8
  const CACHE_MAX_SIZE = 10;
9
- const DEFAULT_REINDEX_DEBOUNCE_MS = 3000;
10
9
 
11
- /** Directory names we skip for MCP fs.watch re-index triggers (aligned with file-walker). */
10
+ /** Directory names we skip for MCP fs.watch update triggers (aligned with file-walker). */
12
11
  const WATCH_IGNORED_DIR_NAMES = new Set([
13
12
  ".git",
14
13
  ".hg",
@@ -29,28 +28,20 @@ const WATCH_IGNORED_DIR_NAMES = new Set([
29
28
  ".eggs",
30
29
  ]);
31
30
 
32
- class StaleIndexError extends Error {
33
- constructor() {
34
- super("Index build superseded by a newer change");
35
- this.name = "StaleIndexError";
36
- }
37
- }
38
-
39
31
  type CacheEntry = {
40
- generation: number;
32
+ index: MiruIndex | null;
41
33
  task: Promise<MiruIndex> | null;
34
+ pendingPaths: Set<string>;
35
+ flushQueued: boolean;
36
+ updateChain: Promise<void>;
42
37
  };
43
38
 
44
- function resolveReindexDebounceMs(): number {
45
- return envOptionalInt(["MIRU_MCP_REINDEX_DEBOUNCE_MS"], 0) ?? DEFAULT_REINDEX_DEBOUNCE_MS;
46
- }
47
-
48
39
  export function mcpWatchEnabled(): boolean {
49
40
  const raw = process.env.MIRU_MCP_WATCH ?? process.env.SEMBLE_MCP_WATCH;
50
41
  return raw !== "0" && raw !== "false";
51
42
  }
52
43
 
53
- /** Returns true when a watch event path should not trigger a re-index. */
44
+ /** Returns true when a watch event path should not trigger an update. */
54
45
  export function shouldIgnoreWatchPath(relativePath: string | null | undefined): boolean {
55
46
  if (!relativePath) {
56
47
  return false;
@@ -66,24 +57,14 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
66
57
 
67
58
  export class IndexCache {
68
59
  private readonly content: ContentType[];
60
+ private readonly defaultRef: string | null;
69
61
  private readonly entries = new Map<string, CacheEntry>();
70
- private readonly reindexTimers = new Map<string, ReturnType<typeof setTimeout>>();
71
62
  private watcher: ReturnType<typeof watch> | null = null;
63
+ private watchedSource: string | null = null;
72
64
 
73
- constructor(content: ContentType[] = ["code"]) {
65
+ constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
74
66
  this.content = content;
75
- }
76
-
77
- evict(source: string, ref?: string | null): void {
78
- this.invalidate(computeSourceCacheKey(source, ref));
79
- }
80
-
81
- private invalidate(cacheKey: string): void {
82
- const entry = this.entries.get(cacheKey);
83
- if (entry) {
84
- entry.generation++;
85
- entry.task = null;
86
- }
67
+ this.defaultRef = defaultRef;
87
68
  }
88
69
 
89
70
  private ensureEntry(cacheKey: string): CacheEntry {
@@ -95,87 +76,182 @@ export class IndexCache {
95
76
  this.clearEntry(oldest);
96
77
  }
97
78
  }
98
- entry = { generation: 0, task: null };
79
+ entry = {
80
+ index: null,
81
+ task: null,
82
+ pendingPaths: new Set(),
83
+ flushQueued: false,
84
+ updateChain: Promise.resolve(),
85
+ };
99
86
  this.entries.set(cacheKey, entry);
100
87
  }
101
88
  return entry;
102
89
  }
103
90
 
104
91
  private clearEntry(cacheKey: string): void {
105
- const timer = this.reindexTimers.get(cacheKey);
106
- if (timer) {
107
- clearTimeout(timer);
108
- this.reindexTimers.delete(cacheKey);
109
- }
110
92
  this.entries.delete(cacheKey);
111
93
  }
112
94
 
113
95
  private startBuild(
114
96
  source: string,
115
97
  ref: string | null | undefined,
116
- entry: CacheEntry,
117
- generationAtStart: number,
98
+ cacheKey: string,
118
99
  ): Promise<MiruIndex> {
119
- return (async () => {
100
+ const task = (async () => {
120
101
  const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
121
- if (entry.generation !== generationAtStart) {
122
- throw new StaleIndexError();
123
- }
124
102
  if (!isGitUrl(source)) {
125
103
  await index.saveToDefaultCache(resolve(source));
126
104
  }
127
105
  return index;
128
106
  })();
107
+
108
+ const entry = this.ensureEntry(cacheKey);
109
+ entry.task = task;
110
+ void task
111
+ .then((index) => {
112
+ entry.index = index;
113
+ this.maybeStartWatcher(source);
114
+ void this.flushFileUpdates(cacheKey, source);
115
+ return index;
116
+ })
117
+ .catch(() => {
118
+ if (entry.task === task) {
119
+ entry.task = null;
120
+ }
121
+ });
122
+
123
+ return task;
129
124
  }
130
125
 
131
126
  async get(source: string, ref?: string | null): Promise<MiruIndex> {
132
- const cacheKey = computeSourceCacheKey(source, ref);
127
+ const resolvedRef = ref ?? this.defaultRef;
128
+ const cacheKey = computeSourceCacheKey(source, resolvedRef);
129
+ const entry = this.ensureEntry(cacheKey);
130
+
131
+ if (!entry.task) {
132
+ this.startBuild(source, resolvedRef, cacheKey);
133
+ }
134
+
135
+ const index = await entry.task;
136
+ if (!index) {
137
+ throw new Error(`Failed to load index for ${source}`);
138
+ }
139
+ await entry.updateChain;
140
+ return index;
141
+ }
142
+
143
+ private scheduleFlush(cacheKey: string, source: string, entry: CacheEntry): void {
144
+ if (!entry.flushQueued) {
145
+ entry.flushQueued = true;
146
+ queueMicrotask(() => {
147
+ this.flushFileUpdates(cacheKey, source);
148
+ });
149
+ }
150
+ }
151
+
152
+ private queueIndexedPaths(source: string, index: MiruIndex): void {
153
+ const cacheKey = computeSourceCacheKey(source);
154
+ const entry = this.ensureEntry(cacheKey);
155
+ for (const chunk of index.chunks) {
156
+ entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
157
+ }
158
+ this.scheduleFlush(cacheKey, source, entry);
159
+ }
160
+
161
+ /** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
162
+ private noteAmbiguousDirectoryChange(source: string): void {
163
+ const cacheKey = computeSourceCacheKey(source);
164
+ const entry = this.ensureEntry(cacheKey);
133
165
 
134
- for (;;) {
135
- const entry = this.ensureEntry(cacheKey);
136
- const generationAtStart = entry.generation;
166
+ if (entry.index) {
167
+ this.queueIndexedPaths(source, entry.index);
168
+ return;
169
+ }
137
170
 
138
- if (!entry.task) {
139
- entry.task = this.startBuild(source, ref, entry, generationAtStart);
171
+ if (entry.task) {
172
+ void entry.task.then((index) => {
173
+ entry.index = index;
174
+ this.queueIndexedPaths(source, index);
175
+ });
176
+ }
177
+ }
178
+
179
+ private noteFileChange(source: string, filename: string | null | undefined): void {
180
+ if (!filename) {
181
+ this.noteAmbiguousDirectoryChange(source);
182
+ return;
183
+ }
184
+ if (shouldIgnoreWatchPath(filename)) {
185
+ return;
186
+ }
187
+
188
+ const cacheKey = computeSourceCacheKey(source);
189
+ const entry = this.ensureEntry(cacheKey);
190
+ const rel = relativePathFromRoot(source, filename);
191
+ if (!rel) {
192
+ return;
193
+ }
194
+ entry.pendingPaths.add(rel);
195
+ this.scheduleFlush(cacheKey, source, entry);
196
+ }
197
+
198
+ private flushFileUpdates(cacheKey: string, source: string): void {
199
+ const entry = this.entries.get(cacheKey);
200
+ if (!entry) {
201
+ return;
202
+ }
203
+ entry.flushQueued = false;
204
+
205
+ const run = async (): Promise<void> => {
206
+ const paths = [...entry.pendingPaths];
207
+ entry.pendingPaths.clear();
208
+ if (paths.length === 0) {
209
+ return;
140
210
  }
141
211
 
142
- const task = entry.task;
143
- try {
144
- const index = await task;
145
- if (entry.generation !== generationAtStart) {
146
- continue;
212
+ let index = entry.index;
213
+ if (!index && entry.task) {
214
+ try {
215
+ index = await entry.task;
216
+ } catch {
217
+ for (const p of paths) {
218
+ entry.pendingPaths.add(p);
219
+ }
220
+ return;
147
221
  }
148
- return index;
149
- } catch (err) {
150
- if (entry.task === task) {
151
- entry.task = null;
222
+ }
223
+ if (!index) {
224
+ for (const p of paths) {
225
+ entry.pendingPaths.add(p);
152
226
  }
153
- if (err instanceof StaleIndexError) {
154
- continue;
227
+ return;
228
+ }
229
+
230
+ try {
231
+ await index.applyFileChanges(paths);
232
+ if (!isGitUrl(source)) {
233
+ await index.persistToCache(resolve(source));
155
234
  }
156
- if (this.entries.get(cacheKey) === entry) {
157
- this.entries.delete(cacheKey);
235
+ } catch {
236
+ for (const p of paths) {
237
+ entry.pendingPaths.add(p);
158
238
  }
159
- throw err;
160
239
  }
161
- }
240
+ };
241
+
242
+ entry.updateChain = entry.updateChain.then(run, run);
162
243
  }
163
244
 
164
- private scheduleReindex(source: string, ref?: string | null): void {
165
- const cacheKey = computeSourceCacheKey(source, ref);
166
- const existing = this.reindexTimers.get(cacheKey);
167
- if (existing) {
168
- clearTimeout(existing);
245
+ private maybeStartWatcher(source: string): void {
246
+ if (!mcpWatchEnabled() || isGitUrl(source)) {
247
+ return;
169
248
  }
170
-
171
- const debounceMs = resolveReindexDebounceMs();
172
- const timer = setTimeout(() => {
173
- this.reindexTimers.delete(cacheKey);
174
- this.invalidate(cacheKey);
175
- void this.get(source, ref).catch(() => undefined);
176
- }, debounceMs);
177
-
178
- this.reindexTimers.set(cacheKey, timer);
249
+ const resolved = resolve(source);
250
+ if (this.watchedSource === resolved) {
251
+ return;
252
+ }
253
+ this.watchedSource = resolved;
254
+ this.startWatcher(resolved);
179
255
  }
180
256
 
181
257
  startWatcher(path: string): void {
@@ -185,47 +261,40 @@ export class IndexCache {
185
261
  }
186
262
 
187
263
  this.watcher = watch(resolved, { recursive: true }, (_event, filename) => {
188
- if (shouldIgnoreWatchPath(filename)) {
189
- return;
190
- }
191
- this.scheduleReindex(resolved);
264
+ this.noteFileChange(resolved, filename);
192
265
  });
193
266
  }
194
267
 
195
268
  close(): void {
196
- for (const timer of this.reindexTimers.values()) {
197
- clearTimeout(timer);
198
- }
199
- this.reindexTimers.clear();
200
269
  this.watcher?.close();
201
270
  this.watcher = null;
271
+ this.watchedSource = null;
272
+ this.entries.clear();
202
273
  }
203
274
  }
204
275
 
205
276
  export async function getIndexForRepo(
206
277
  repo: string | null | undefined,
207
- defaultSource: string | null,
208
278
  cache: IndexCache,
209
279
  ref?: string | null,
210
280
  ): Promise<MiruIndex> {
211
- if (repo && isGitUrl(repo) && !repo.startsWith("https://") && !repo.startsWith("http://")) {
281
+ if (!repo) {
212
282
  throw new Error(
213
- `Only https://, http://, or local directory paths are accepted as \`repo\`. Got: ${repo}`,
283
+ "Pass an https:// or http:// git URL or local directory path as `repo` (project root for local workspaces).",
214
284
  );
215
285
  }
216
286
 
217
- const source = repo ?? defaultSource;
218
- if (!source) {
287
+ if (isGitUrl(repo) && !repo.startsWith("https://") && !repo.startsWith("http://")) {
219
288
  throw new Error(
220
- "No repo specified and no default index. Pass an https:// or http:// git URL or local directory path as `repo`.",
289
+ `Only https://, http://, or local directory paths are accepted as \`repo\`. Got: ${repo}`,
221
290
  );
222
291
  }
223
292
 
224
293
  try {
225
- return await cache.get(source, ref);
294
+ return await cache.get(repo, ref);
226
295
  } catch (err) {
227
296
  const message = err instanceof Error ? err.message : String(err);
228
- throw new Error(`Failed to index ${source}: ${message}`);
297
+ throw new Error(`Failed to index ${repo}: ${message}`);
229
298
  }
230
299
  }
231
300