@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -91,23 +91,52 @@ function readKey(): Promise<Buffer> {
91
91
  });
92
92
  }
93
93
 
94
- function parseKey(chunk: Buffer): string {
95
- const text = chunk.toString("utf8");
96
- if (chunk.length >= 3 && chunk[0] === 0x1b && chunk[1] === 0x5b) {
97
- const code = chunk[2];
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 (code === 0x42) {
100
+ if (scanCode === 0x50) {
102
101
  return "down";
103
102
  }
104
- if (code === 0x43) {
103
+ if (scanCode === 0x4d) {
105
104
  return "right";
106
105
  }
107
- if (code === 0x44) {
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();
@@ -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 root: string | null;
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.root = options.root ?? null;
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.root,
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.root) {
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.root,
360
+ root: this._root,
325
361
  content: this.content,
326
362
  embeddings: this.embeddings,
327
363
  chunks: this.chunksInternal,