@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.
- package/README.md +68 -13
- package/package.json +1 -1
- package/src/agents.ts +2 -4
- package/src/brand-banner.ts +151 -0
- package/src/cache.ts +30 -2
- package/src/cli-ui.ts +40 -61
- package/src/cli.ts +4 -4
- package/src/concurrency.ts +36 -29
- package/src/credentials.ts +4 -7
- package/src/embeddings/openai.ts +10 -7
- package/src/env-files.ts +10 -7
- package/src/help.ts +18 -22
- package/src/index/chunk-selector.ts +57 -0
- package/src/index/dense.ts +75 -54
- package/src/index/entry-chunks.ts +1 -1
- package/src/index/int8-dot.ts +55 -0
- package/src/index/persistence.ts +0 -2
- package/src/index/quantize.ts +52 -64
- package/src/index/semantic-index.ts +1 -1
- package/src/index/sparse.ts +1 -1
- package/src/index/topk.ts +78 -1
- package/src/index/vector-storage.ts +6 -1
- package/src/index.ts +23 -1
- package/src/installer/config.ts +8 -12
- package/src/installer/hooks/install.ts +6 -9
- package/src/installer/installer.ts +3 -6
- package/src/installer/prompt.ts +42 -8
- package/src/installer/search-policy.ts +6 -7
- package/src/mcp/index-cache.ts +64 -7
- package/src/mcp/server.ts +13 -17
- package/src/miru-index.ts +157 -24
- package/src/search.ts +4 -4
- package/src/setup.ts +8 -2
- package/src/terminal.ts +171 -0
- package/src/version.ts +21 -10
package/src/mcp/index-cache.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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}:${
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
171
|
+
const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
|
|
176
172
|
if (!chunk) {
|
|
177
173
|
return toolText(
|
|
178
|
-
`No chunk found at ${filePath}:${
|
|
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}:${
|
|
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}:${
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 {
|
|
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
|
-
|
|
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.",
|