@takara-ai/miru-code 1.1.0 → 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/package.json +1 -1
- package/src/installer/prompt.ts +42 -8
- package/src/mcp/index-cache.ts +62 -5
- package/src/miru-index.ts +41 -5
package/package.json
CHANGED
package/src/installer/prompt.ts
CHANGED
|
@@ -91,23 +91,52 @@ function readKey(): Promise<Buffer> {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (code === 0x41) {
|
|
94
|
+
function parseArrowKey(chunk: Buffer, text: string): string | null {
|
|
95
|
+
if (chunk.length >= 2 && (chunk[0] === 0x00 || chunk[0] === 0xe0)) {
|
|
96
|
+
const scanCode = chunk[1];
|
|
97
|
+
if (scanCode === 0x48) {
|
|
99
98
|
return "up";
|
|
100
99
|
}
|
|
101
|
-
if (
|
|
100
|
+
if (scanCode === 0x50) {
|
|
102
101
|
return "down";
|
|
103
102
|
}
|
|
104
|
-
if (
|
|
103
|
+
if (scanCode === 0x4d) {
|
|
105
104
|
return "right";
|
|
106
105
|
}
|
|
107
|
-
if (
|
|
106
|
+
if (scanCode === 0x4b) {
|
|
108
107
|
return "left";
|
|
109
108
|
}
|
|
110
109
|
}
|
|
110
|
+
|
|
111
|
+
// Support both ANSI CSI (ESC [ A) and SS3 (ESC O A) arrow sequences.
|
|
112
|
+
if (text.startsWith(ESC) && text.length >= 3) {
|
|
113
|
+
const introducer = text.charAt(1);
|
|
114
|
+
if (introducer === "[" || introducer === "O") {
|
|
115
|
+
const arrow = text.charAt(text.length - 1);
|
|
116
|
+
if (arrow === "A") {
|
|
117
|
+
return "up";
|
|
118
|
+
}
|
|
119
|
+
if (arrow === "B") {
|
|
120
|
+
return "down";
|
|
121
|
+
}
|
|
122
|
+
if (arrow === "C") {
|
|
123
|
+
return "right";
|
|
124
|
+
}
|
|
125
|
+
if (arrow === "D") {
|
|
126
|
+
return "left";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseKey(chunk: Buffer): string {
|
|
135
|
+
const text = chunk.toString("utf8");
|
|
136
|
+
const arrowKey = parseArrowKey(chunk, text);
|
|
137
|
+
if (arrowKey) {
|
|
138
|
+
return arrowKey;
|
|
139
|
+
}
|
|
111
140
|
if (text === "\r" || text === "\n") {
|
|
112
141
|
return "enter";
|
|
113
142
|
}
|
|
@@ -129,6 +158,11 @@ function parseKey(chunk: Buffer): string {
|
|
|
129
158
|
return text;
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/** Test hook for validating raw key sequence parsing without a TTY. */
|
|
162
|
+
export function parseInstallerKeyForTest(chunk: Buffer): string {
|
|
163
|
+
return parseKey(chunk);
|
|
164
|
+
}
|
|
165
|
+
|
|
132
166
|
async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
|
|
133
167
|
input.setRawMode(true);
|
|
134
168
|
input.resume();
|
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";
|
|
@@ -120,6 +122,11 @@ export class IndexCache {
|
|
|
120
122
|
if (!isGitUrl(source)) {
|
|
121
123
|
await index.saveToCache(resolve(source));
|
|
122
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);
|
|
129
|
+
}
|
|
123
130
|
return index;
|
|
124
131
|
})();
|
|
125
132
|
|
|
@@ -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
|
|
|
@@ -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/miru-index.ts
CHANGED
|
@@ -60,7 +60,7 @@ export class MiruIndex {
|
|
|
60
60
|
* Absolute path to the indexed tree on disk, or `null` for git-only indexes
|
|
61
61
|
* (clone is deleted after indexing; incremental updates need a local root).
|
|
62
62
|
*/
|
|
63
|
-
private readonly
|
|
63
|
+
private readonly _root: string | null;
|
|
64
64
|
|
|
65
65
|
/** Content kinds indexed at build time (e.g. `code`, `markdown`). */
|
|
66
66
|
private readonly content: ContentType[];
|
|
@@ -71,6 +71,9 @@ export class MiruIndex {
|
|
|
71
71
|
/** Language tag → chunk indices, for `filterLanguages` in search. */
|
|
72
72
|
private languageMapping: Map<string, number[]>;
|
|
73
73
|
|
|
74
|
+
/** Stored file mtimes (file path → mtime_ms) for cache freshness checking. */
|
|
75
|
+
private storedFileMtimes: Map<string, number>;
|
|
76
|
+
|
|
74
77
|
/** Read-only view of indexed chunks. */
|
|
75
78
|
get chunks(): Chunk[] {
|
|
76
79
|
return this.chunksInternal;
|
|
@@ -81,6 +84,21 @@ export class MiruIndex {
|
|
|
81
84
|
return this.loadedFromDiskFlag;
|
|
82
85
|
}
|
|
83
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
|
+
|
|
84
102
|
/**
|
|
85
103
|
* Prefer static factories (`fromPath`, `fromGit`, `loadFromDisk`) over calling
|
|
86
104
|
* directly; the constructor is public for tests and advanced composition.
|
|
@@ -94,15 +112,17 @@ export class MiruIndex {
|
|
|
94
112
|
root?: string | null;
|
|
95
113
|
content?: ContentType[];
|
|
96
114
|
loadedFromDisk?: boolean;
|
|
115
|
+
storedFileMtimes?: Record<string, number>;
|
|
97
116
|
}) {
|
|
98
117
|
this.embeddings = options.embeddings;
|
|
99
118
|
this.bm25Index = options.bm25Index;
|
|
100
119
|
this.semanticIndex = options.semanticIndex;
|
|
101
120
|
this.chunksInternal = options.chunks;
|
|
102
121
|
this.embeddingModel = options.embeddingModel;
|
|
103
|
-
this.
|
|
122
|
+
this._root = options.root ?? null;
|
|
104
123
|
this.content = options.content ?? ["code"];
|
|
105
124
|
this.loadedFromDiskFlag = options.loadedFromDisk ?? false;
|
|
125
|
+
this.storedFileMtimes = new Map(Object.entries(options.storedFileMtimes ?? {}));
|
|
106
126
|
this.fileMapping = new Map();
|
|
107
127
|
this.languageMapping = new Map();
|
|
108
128
|
this.rebuildMappings();
|
|
@@ -253,6 +273,7 @@ export class MiruIndex {
|
|
|
253
273
|
const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
|
|
254
274
|
const content = (metadata.content_type as ContentType[]) ?? ["code"];
|
|
255
275
|
const root = metadata.root_path ? String(metadata.root_path) : null;
|
|
276
|
+
const storedFileMtimes = (metadata.file_mtimes as Record<string, number>) ?? {};
|
|
256
277
|
|
|
257
278
|
return new MiruIndex({
|
|
258
279
|
embeddings: getEmbeddingBackend(model),
|
|
@@ -263,6 +284,7 @@ export class MiruIndex {
|
|
|
263
284
|
root,
|
|
264
285
|
content,
|
|
265
286
|
loadedFromDisk: true,
|
|
287
|
+
storedFileMtimes,
|
|
266
288
|
});
|
|
267
289
|
}
|
|
268
290
|
|
|
@@ -275,6 +297,19 @@ export class MiruIndex {
|
|
|
275
297
|
async save(path: string): Promise<void> {
|
|
276
298
|
const paths = persistencePaths(path);
|
|
277
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
|
+
|
|
278
313
|
await saveIndexBundle({
|
|
279
314
|
paths,
|
|
280
315
|
bm25: this.bm25Index,
|
|
@@ -282,7 +317,7 @@ export class MiruIndex {
|
|
|
282
317
|
chunks: this.chunks.map(chunkToDict),
|
|
283
318
|
metadata: {
|
|
284
319
|
index_epoch: indexCacheEpoch(),
|
|
285
|
-
root_path: this.
|
|
320
|
+
root_path: this._root,
|
|
286
321
|
time: Date.now() / 1000,
|
|
287
322
|
embedding_model: this.embeddingModel,
|
|
288
323
|
embedding_dimensions:
|
|
@@ -290,6 +325,7 @@ export class MiruIndex {
|
|
|
290
325
|
embedding_provider: "openai",
|
|
291
326
|
content_type: this.content,
|
|
292
327
|
file_paths: [...this.fileMapping.keys()].sort(),
|
|
328
|
+
file_mtimes: fileMtimes,
|
|
293
329
|
},
|
|
294
330
|
});
|
|
295
331
|
}
|
|
@@ -313,7 +349,7 @@ export class MiruIndex {
|
|
|
313
349
|
* index, and mappings in place; clears `loadedFromDisk` since data changed.
|
|
314
350
|
*/
|
|
315
351
|
async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
|
|
316
|
-
if (!this.
|
|
352
|
+
if (!this._root) {
|
|
317
353
|
throw new Error("Incremental update requires a local index with root_path set.");
|
|
318
354
|
}
|
|
319
355
|
if (relativePaths.length === 0) {
|
|
@@ -321,7 +357,7 @@ export class MiruIndex {
|
|
|
321
357
|
}
|
|
322
358
|
|
|
323
359
|
const updated = await applyIncrementalFileChanges({
|
|
324
|
-
root: this.
|
|
360
|
+
root: this._root,
|
|
325
361
|
content: this.content,
|
|
326
362
|
embeddings: this.embeddings,
|
|
327
363
|
chunks: this.chunksInternal,
|