@takara-ai/miru-code 1.1.0 → 1.1.2

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 CHANGED
@@ -10,7 +10,7 @@ Find code by meaning, not grep. Miru returns the best **chunks** (path, lines, s
10
10
 
11
11
  Miru sends **file contents** to the [Takara inference API](https://takara.ai) when building an index and when embedding search queries. Chunks from your repo are transmitted over HTTPS to generate embeddings. API usage may incur cost depending on your Takara plan.
12
12
 
13
- If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. Use `MIRU_WORKSPACE_ROOT` to restrict MCP indexing to a single workspace directory.
13
+ If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. `MIRU_WORKSPACE_ROOT` is an opt-in boundary for MCP local `repo` paths only, and restricts indexing to a single workspace directory when set.
14
14
 
15
15
  ## Install
16
16
 
@@ -231,7 +231,7 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
231
231
  | `TAKARA_API_KEY` | Required |
232
232
  | `MIRU_OPENAI_BASE_URL` | Default `https://infer.takara.ai/v1` |
233
233
  | `MIRU_OPENAI_EMBEDDING_MODEL` | Default `ds1-miru-int8` |
234
- | `MIRU_WORKSPACE_ROOT` | Restrict MCP local `repo` paths to this directory |
234
+ | `MIRU_WORKSPACE_ROOT` | Optional: restrict MCP local `repo` paths to this directory |
235
235
  | `MIRU_MAX_INDEX_FILES` | Cap files indexed per operation |
236
236
  | `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
237
237
  | `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
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.2",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -107,13 +107,13 @@ export async function mergeJsonMember(
107
107
  if (JSON.stringify(section[memberKey]) === JSON.stringify(value)) {
108
108
  return "unchanged";
109
109
  }
110
-
111
- section[memberKey] = value;
112
- parsed[sectionKey] = section;
113
-
114
- const next = `${JSON.stringify(parsed, null, 2)}\n`;
115
-
116
- await Bun.write(path, next);
110
+ const next = upsertJsonMemberText(text, sectionKey, memberKey, value);
111
+ await Bun.write(
112
+ path,
113
+ ensureTrailingNewline(
114
+ next ?? JSON.stringify(withMergedMember(parsed, sectionKey, memberKey, value), null, 2),
115
+ ),
116
+ );
117
117
  return existed ? "updated" : "created";
118
118
  }
119
119
 
@@ -136,26 +136,276 @@ export async function removeJsonMember(
136
136
  if (section === "error" || !(memberKey in section)) {
137
137
  return "not-found";
138
138
  }
139
+ const nextText = removeJsonMemberText(text, sectionKey, memberKey);
140
+ const nextObject = withRemovedMember(parsed, sectionKey, memberKey);
141
+ const next = nextText ?? JSON.stringify(nextObject, null, 2);
142
+ if (ensureTrailingNewline(next) === ensureTrailingNewline(text)) {
143
+ return "not-found";
144
+ }
145
+ const nextParsed = parseJsonObject(next);
146
+ if (!nextParsed || typeof nextParsed !== "object" || Object.keys(nextParsed).length === 0) {
147
+ await unlink(path);
148
+ return "removed";
149
+ }
150
+ await Bun.write(path, ensureTrailingNewline(next));
151
+ return "removed";
152
+ }
153
+
154
+ function withMergedMember(
155
+ root: Record<string, unknown>,
156
+ sectionKey: string,
157
+ memberKey: string,
158
+ value: Record<string, unknown>,
159
+ ): Record<string, unknown> {
160
+ const next = { ...root };
161
+ const section =
162
+ next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
163
+ ? { ...(next[sectionKey] as Record<string, unknown>) }
164
+ : {};
165
+ section[memberKey] = value;
166
+ next[sectionKey] = section;
167
+ return next;
168
+ }
139
169
 
170
+ function withRemovedMember(
171
+ root: Record<string, unknown>,
172
+ sectionKey: string,
173
+ memberKey: string,
174
+ ): Record<string, unknown> {
175
+ const next = { ...root };
176
+ const section =
177
+ next[sectionKey] && typeof next[sectionKey] === "object" && !Array.isArray(next[sectionKey])
178
+ ? { ...(next[sectionKey] as Record<string, unknown>) }
179
+ : null;
180
+ if (!section || !(memberKey in section)) {
181
+ return next;
182
+ }
140
183
  delete section[memberKey];
141
184
  if (Object.keys(section).length === 0) {
142
- delete parsed[sectionKey];
185
+ delete next[sectionKey];
143
186
  } else {
144
- parsed[sectionKey] = section;
187
+ next[sectionKey] = section;
145
188
  }
189
+ return next;
190
+ }
146
191
 
147
- const next = `${JSON.stringify(parsed, null, 2)}\n`;
148
- if (next === `${text.endsWith("\n") ? text : `${text}\n`}`) {
149
- return "not-found";
192
+ function upsertJsonMemberText(
193
+ text: string,
194
+ sectionKey: string,
195
+ memberKey: string,
196
+ value: Record<string, unknown>,
197
+ ): string | null {
198
+ if (!hasJsoncSyntax(text)) {
199
+ return null;
200
+ }
201
+ const sectionRange = findSectionRange(text, sectionKey);
202
+ if (!sectionRange) {
203
+ return null;
204
+ }
205
+ const { openBrace, closeBrace, indent } = sectionRange;
206
+ const sectionBody = text.slice(openBrace + 1, closeBrace);
207
+ const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
208
+ const valueText = JSON.stringify(value);
209
+ if (memberMatch) {
210
+ const memberKeyStart = openBrace + 1 + memberMatch.index;
211
+ const colonIndex = text.indexOf(":", memberKeyStart);
212
+ const valueStart = skipWhitespace(text, colonIndex + 1);
213
+ const valueEnd = parseJsonValueEnd(text, valueStart);
214
+ if (valueEnd <= valueStart) {
215
+ return null;
216
+ }
217
+ return `${text.slice(0, valueStart)} ${valueText}${text.slice(valueEnd)}`;
150
218
  }
151
219
 
152
- if (Object.keys(parsed).length === 0) {
153
- await unlink(path);
154
- return "removed";
220
+ const memberIndent = `${indent} `;
221
+ const insertion = `${sectionBody.trim().length === 0 ? "" : ","}\n${memberIndent}"${memberKey}": ${valueText}\n${indent}`;
222
+ return `${text.slice(0, closeBrace)}${insertion}${text.slice(closeBrace)}`;
223
+ }
224
+
225
+ function removeJsonMemberText(text: string, sectionKey: string, memberKey: string): string | null {
226
+ if (!hasJsoncSyntax(text)) {
227
+ return null;
228
+ }
229
+ const sectionRange = findSectionRange(text, sectionKey);
230
+ if (!sectionRange) {
231
+ return null;
155
232
  }
233
+ const { openBrace, closeBrace } = sectionRange;
234
+ const sectionBody = text.slice(openBrace + 1, closeBrace);
235
+ const memberMatch = new RegExp(`"(${escapeRegExp(memberKey)})"\\s*:`).exec(sectionBody);
236
+ if (!memberMatch) {
237
+ return null;
238
+ }
239
+ const memberStart = openBrace + 1 + memberMatch.index;
240
+ const colonIndex = text.indexOf(":", memberStart);
241
+ const valueStart = skipWhitespace(text, colonIndex + 1);
242
+ const valueEnd = parseJsonValueEnd(text, valueStart);
243
+ let removeStart = memberStart;
244
+ let removeEnd = valueEnd;
245
+
246
+ const trailing = skipWhitespace(text, valueEnd);
247
+ if (text[trailing] === ",") {
248
+ removeEnd = trailing + 1;
249
+ } else {
250
+ const leadingComma = findLeadingComma(text, memberStart, openBrace + 1);
251
+ if (leadingComma >= 0) {
252
+ removeStart = leadingComma;
253
+ }
254
+ }
255
+ return `${text.slice(0, removeStart)}${text.slice(removeEnd)}`;
256
+ }
156
257
 
157
- await Bun.write(path, next);
158
- return "removed";
258
+ function hasJsoncSyntax(text: string): boolean {
259
+ return text.includes("//") || text.includes("/*");
260
+ }
261
+
262
+ function findSectionRange(
263
+ text: string,
264
+ sectionKey: string,
265
+ ): { openBrace: number; closeBrace: number; indent: string } | null {
266
+ const keyMatch = new RegExp(`(^|\\n)([ \\t]*)"${escapeRegExp(sectionKey)}"\\s*:\\s*\\{`).exec(
267
+ text,
268
+ );
269
+ if (!keyMatch) {
270
+ return null;
271
+ }
272
+ const openBrace = text.indexOf("{", keyMatch.index);
273
+ const closeBrace = findMatchingBrace(text, openBrace);
274
+ if (openBrace < 0 || closeBrace < 0) {
275
+ return null;
276
+ }
277
+ return { openBrace, closeBrace, indent: keyMatch[2] ?? "" };
278
+ }
279
+
280
+ function findMatchingBrace(text: string, openIndex: number): number {
281
+ let depth = 0;
282
+ let inString = false;
283
+ let quote = "";
284
+ let inLineComment = false;
285
+ let inBlockComment = false;
286
+ for (let i = openIndex; i < text.length; i++) {
287
+ const ch = text[i] ?? "";
288
+ const next = text[i + 1] ?? "";
289
+ if (inLineComment) {
290
+ if (ch === "\n") inLineComment = false;
291
+ continue;
292
+ }
293
+ if (inBlockComment) {
294
+ if (ch === "*" && next === "/") {
295
+ inBlockComment = false;
296
+ i++;
297
+ }
298
+ continue;
299
+ }
300
+ if (inString) {
301
+ if (ch === "\\") {
302
+ i++;
303
+ continue;
304
+ }
305
+ if (ch === quote) {
306
+ inString = false;
307
+ }
308
+ continue;
309
+ }
310
+ if (ch === "/" && next === "/") {
311
+ inLineComment = true;
312
+ i++;
313
+ continue;
314
+ }
315
+ if (ch === "/" && next === "*") {
316
+ inBlockComment = true;
317
+ i++;
318
+ continue;
319
+ }
320
+ if (ch === '"' || ch === "'") {
321
+ inString = true;
322
+ quote = ch;
323
+ continue;
324
+ }
325
+ if (ch === "{") depth++;
326
+ if (ch === "}") {
327
+ depth--;
328
+ if (depth === 0) return i;
329
+ }
330
+ }
331
+ return -1;
332
+ }
333
+
334
+ function parseJsonValueEnd(text: string, start: number): number {
335
+ const first = text[start];
336
+ if (!first) return start;
337
+ if (first === "{") return findMatchingBrace(text, start) + 1;
338
+ if (first === "[") return findMatchingBracket(text, start) + 1;
339
+ if (first === '"' || first === "'") return findStringEnd(text, start) + 1;
340
+ let i = start;
341
+ while (i < text.length && ![",", "}", "]", "\n"].includes(text[i] ?? "")) {
342
+ i++;
343
+ }
344
+ return i;
345
+ }
346
+
347
+ function findMatchingBracket(text: string, openIndex: number): number {
348
+ let depth = 0;
349
+ let inString = false;
350
+ let quote = "";
351
+ for (let i = openIndex; i < text.length; i++) {
352
+ const ch = text[i] ?? "";
353
+ if (inString) {
354
+ if (ch === "\\") {
355
+ i++;
356
+ continue;
357
+ }
358
+ if (ch === quote) inString = false;
359
+ continue;
360
+ }
361
+ if (ch === '"' || ch === "'") {
362
+ inString = true;
363
+ quote = ch;
364
+ continue;
365
+ }
366
+ if (ch === "[") depth++;
367
+ if (ch === "]") {
368
+ depth--;
369
+ if (depth === 0) return i;
370
+ }
371
+ }
372
+ return -1;
373
+ }
374
+
375
+ function findStringEnd(text: string, start: number): number {
376
+ const quote = text[start] ?? '"';
377
+ for (let i = start + 1; i < text.length; i++) {
378
+ const ch = text[i] ?? "";
379
+ if (ch === "\\") {
380
+ i++;
381
+ continue;
382
+ }
383
+ if (ch === quote) return i;
384
+ }
385
+ return start;
386
+ }
387
+
388
+ function skipWhitespace(text: string, index: number): number {
389
+ let i = index;
390
+ while (i < text.length && /\s/.test(text[i] ?? "")) i++;
391
+ return i;
392
+ }
393
+
394
+ function findLeadingComma(text: string, index: number, min: number): number {
395
+ for (let i = index - 1; i >= min; i--) {
396
+ const ch = text[i] ?? "";
397
+ if (ch === ",") return i;
398
+ if (!/\s/.test(ch)) break;
399
+ }
400
+ return -1;
401
+ }
402
+
403
+ function escapeRegExp(value: string): string {
404
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
405
+ }
406
+
407
+ function ensureTrailingNewline(text: string): string {
408
+ return text.endsWith("\n") ? text : `${text}\n`;
159
409
  }
160
410
 
161
411
  export async function replaceOrAppendMarked(path: string, content: string): Promise<InstallAction> {
@@ -5,6 +5,8 @@ import { stripJsonComments } from "../config.ts";
5
5
 
6
6
  const HOOK_GUARD_MARKER = "hook-guard";
7
7
  const MIRU_HOOK_MARKER = "miru-search";
8
+ const VSCODE_OWNERS_KEY = "miruOwners";
9
+ const SHARED_VSCODE_HOOK_OWNERS = ["copilot", "vscode", "visualstudio"] as const;
8
10
 
9
11
  const MATCHERS = {
10
12
  cursor: "Grep|Glob|Shell|SemanticSearch",
@@ -304,37 +306,127 @@ export async function removeGeminiHooks(path: string): Promise<InstallAction> {
304
306
  return removeHooksSection(path, ["BeforeTool"], (entry) => hookCommandMatches(entry.command));
305
307
  }
306
308
 
307
- export async function mergeVscodeHooks(path: string): Promise<InstallAction> {
309
+ export async function mergeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
308
310
  const command = resolveHookCommand();
309
- const content = {
310
- hooks: {
311
- PreToolUse: [
312
- {
313
- type: "command",
314
- command,
315
- matcher: MATCHERS.claude,
316
- statusMessage: "Miru search policy",
317
- },
318
- ],
319
- },
320
- };
321
311
  const existed = await Bun.file(path).exists();
322
- if (existed) {
323
- const current = await Bun.file(path).text();
324
- const next = `${JSON.stringify(content, null, 2)}\n`;
325
- if (current === next) {
326
- return "unchanged";
327
- }
312
+ const text = existed ? await Bun.file(path).text() : "";
313
+ const parsed = parseJsonObject(text);
314
+ if (parsed === "error") {
315
+ return "error";
328
316
  }
329
- await Bun.write(path, `${JSON.stringify(content, null, 2)}\n`);
317
+
318
+ const hooksRoot =
319
+ parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
320
+ ? (parsed.hooks as Record<string, unknown>)
321
+ : {};
322
+
323
+ const nextEntry = {
324
+ type: "command",
325
+ command,
326
+ matcher: MATCHERS.claude,
327
+ statusMessage: "Miru search policy",
328
+ };
329
+
330
+ const existingPreToolUse = Array.isArray(hooksRoot.PreToolUse)
331
+ ? (hooksRoot.PreToolUse as unknown[])
332
+ : [];
333
+ const mergedPreToolUse =
334
+ existingPreToolUse.length > 0 &&
335
+ existingPreToolUse.some((entry) =>
336
+ hookCommandMatches((entry as Record<string, unknown>).command),
337
+ )
338
+ ? existingPreToolUse.map((entry) => {
339
+ const hookEntry = entry as Record<string, unknown>;
340
+ if (!hookCommandMatches(hookEntry.command)) {
341
+ return hookEntry;
342
+ }
343
+ return nextEntry;
344
+ })
345
+ : [...existingPreToolUse, nextEntry];
346
+
347
+ const currentOwners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
348
+ ? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
349
+ (item): item is string =>
350
+ typeof item === "string" &&
351
+ SHARED_VSCODE_HOOK_OWNERS.includes(item as (typeof SHARED_VSCODE_HOOK_OWNERS)[number]),
352
+ )
353
+ : [];
354
+ const nextOwners = owner
355
+ ? Array.from(
356
+ new Set([
357
+ ...currentOwners,
358
+ ...(SHARED_VSCODE_HOOK_OWNERS.includes(
359
+ owner as (typeof SHARED_VSCODE_HOOK_OWNERS)[number],
360
+ )
361
+ ? [owner]
362
+ : []),
363
+ ]),
364
+ )
365
+ : currentOwners;
366
+
367
+ const next = {
368
+ ...parsed,
369
+ hooks: { ...hooksRoot, PreToolUse: mergedPreToolUse },
370
+ ...(nextOwners.length > 0 ? { [VSCODE_OWNERS_KEY]: nextOwners } : {}),
371
+ };
372
+ const nextJson = `${JSON.stringify(next, null, 2)}\n`;
373
+ if (nextJson === `${text.endsWith("\n") ? text : `${text}\n`}`) {
374
+ return "unchanged";
375
+ }
376
+ await Bun.write(path, nextJson);
330
377
  return existed ? "updated" : "created";
331
378
  }
332
379
 
333
- export async function removeVscodeHooks(path: string): Promise<InstallAction> {
380
+ export async function removeVscodeHooks(path: string, owner?: string): Promise<InstallAction> {
334
381
  if (!(await Bun.file(path).exists())) {
335
382
  return "not-found";
336
383
  }
337
384
  const text = await Bun.file(path).text();
385
+ const parsed = parseJsonObject(text);
386
+ if (parsed === "error") {
387
+ return "error";
388
+ }
389
+
390
+ const owners = Array.isArray(parsed[VSCODE_OWNERS_KEY])
391
+ ? (parsed[VSCODE_OWNERS_KEY] as unknown[]).filter(
392
+ (item): item is string => typeof item === "string",
393
+ )
394
+ : null;
395
+ if (owners && owner) {
396
+ const remainingOwners = owners.filter((value) => value !== owner);
397
+ if (remainingOwners.length > 0) {
398
+ parsed[VSCODE_OWNERS_KEY] = remainingOwners;
399
+ await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
400
+ return "updated";
401
+ }
402
+ delete parsed[VSCODE_OWNERS_KEY];
403
+ const hooksRoot =
404
+ parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
405
+ ? (parsed.hooks as Record<string, unknown>)
406
+ : {};
407
+ const filtered = Array.isArray(hooksRoot.PreToolUse)
408
+ ? (hooksRoot.PreToolUse as Record<string, unknown>[]).filter(
409
+ (entry) => !hookCommandMatches(entry.command),
410
+ )
411
+ : [];
412
+ if (filtered.length === 0) {
413
+ delete hooksRoot.PreToolUse;
414
+ } else {
415
+ hooksRoot.PreToolUse = filtered;
416
+ }
417
+ if (Object.keys(hooksRoot).length === 0) {
418
+ delete parsed.hooks;
419
+ } else {
420
+ parsed.hooks = hooksRoot;
421
+ }
422
+ if (isEmptyHookConfig(parsed)) {
423
+ await unlink(path);
424
+ return "removed";
425
+ }
426
+ await Bun.write(path, `${JSON.stringify(parsed, null, 2)}\n`);
427
+ return "removed";
428
+ }
429
+
338
430
  if (!text.includes(HOOK_GUARD_MARKER) && !text.includes(MIRU_HOOK_MARKER)) {
339
431
  return "not-found";
340
432
  }
@@ -413,7 +505,11 @@ export async function removeOpenCodePlugin(pluginPath: string): Promise<InstallA
413
505
  return "removed";
414
506
  }
415
507
 
416
- export async function mergeHooks(format: HooksFormat, path: string): Promise<InstallAction> {
508
+ export async function mergeHooks(
509
+ format: HooksFormat,
510
+ path: string,
511
+ owner?: string,
512
+ ): Promise<InstallAction> {
417
513
  switch (format) {
418
514
  case "cursor":
419
515
  return mergeCursorHooks(path);
@@ -422,7 +518,7 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
422
518
  case "gemini":
423
519
  return mergeGeminiHooks(path);
424
520
  case "vscode":
425
- return mergeVscodeHooks(path);
521
+ return mergeVscodeHooks(path, owner);
426
522
  case "kiro":
427
523
  return mergeKiroHooks(path);
428
524
  case "windsurf":
@@ -436,7 +532,11 @@ export async function mergeHooks(format: HooksFormat, path: string): Promise<Ins
436
532
  }
437
533
  }
438
534
 
439
- export async function removeHooks(format: HooksFormat, path: string): Promise<InstallAction> {
535
+ export async function removeHooks(
536
+ format: HooksFormat,
537
+ path: string,
538
+ owner?: string,
539
+ ): Promise<InstallAction> {
440
540
  switch (format) {
441
541
  case "cursor":
442
542
  return removeCursorHooks(path);
@@ -445,7 +545,7 @@ export async function removeHooks(format: HooksFormat, path: string): Promise<In
445
545
  case "gemini":
446
546
  return removeGeminiHooks(path);
447
547
  case "vscode":
448
- return removeVscodeHooks(path);
548
+ return removeVscodeHooks(path, owner);
449
549
  case "kiro":
450
550
  return removeKiroHooks(path);
451
551
  case "windsurf":
@@ -95,7 +95,9 @@ async function applyHooks(agent: AgentTarget, mode: InstallMode): Promise<WriteR
95
95
  }
96
96
 
97
97
  const action =
98
- mode === "install" ? await mergeHooks(format, path) : await removeHooks(format, path);
98
+ mode === "install"
99
+ ? await mergeHooks(format, path, agent.id)
100
+ : await removeHooks(format, path, agent.id);
99
101
 
100
102
  return { path, action };
101
103
  }
@@ -181,7 +183,7 @@ const INTEGRATIONS: Integration[] = [
181
183
  label: "Search hooks",
182
184
  description: "blocks built-in search; routes to Miru MCP",
183
185
  experimental: true,
184
- defaultChecked: false,
186
+ defaultChecked: true,
185
187
  planPath: (agent) => agent.hooksPath,
186
188
  apply: applyHooks,
187
189
  },
@@ -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";
@@ -115,26 +117,34 @@ export class IndexCache {
115
117
  ref: string | null | undefined,
116
118
  cacheKey: string,
117
119
  ): Promise<MiruIndex> {
120
+ const entry = this.ensureEntry(cacheKey, source);
118
121
  const task = (async () => {
119
122
  const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
120
123
  if (!isGitUrl(source)) {
121
124
  await index.saveToCache(resolve(source));
122
125
  }
126
+ // Publish before stale reconciliation so flushFileUpdates can use
127
+ // entry.index instead of awaiting this same task (self-deadlock).
128
+ entry.index = index;
129
+ if (index.loadedFromDisk) {
130
+ // Await reconciliation here (not fire-and-forget) so callers of `get()`
131
+ // never observe an index that's stale relative to what's on disk.
132
+ await this.checkAndQueueStaleFiles(source, index, cacheKey);
133
+ }
123
134
  return index;
124
135
  })();
125
136
 
126
- const entry = this.ensureEntry(cacheKey, source);
127
137
  entry.task = task;
128
138
  void task
129
139
  .then((index) => {
130
140
  entry.index = index;
131
141
  this.maybeStartWatcher(source);
132
- void this.flushFileUpdates(cacheKey, source);
133
142
  return index;
134
143
  })
135
144
  .catch(() => {
136
145
  if (entry.task === task) {
137
146
  entry.task = null;
147
+ entry.index = null;
138
148
  }
139
149
  });
140
150
 
@@ -162,7 +172,7 @@ export class IndexCache {
162
172
  if (!entry.flushQueued) {
163
173
  entry.flushQueued = true;
164
174
  queueMicrotask(() => {
165
- this.flushFileUpdates(cacheKey, source);
175
+ void this.flushFileUpdates(cacheKey, source);
166
176
  });
167
177
  }
168
178
  }
@@ -219,10 +229,14 @@ export class IndexCache {
219
229
  this.scheduleFlush(cacheKey, source, entry);
220
230
  }
221
231
 
222
- private flushFileUpdates(cacheKey: string, source: string): void {
232
+ private flushFileUpdates(
233
+ cacheKey: string,
234
+ source: string,
235
+ knownIndex?: MiruIndex,
236
+ ): Promise<void> {
223
237
  const entry = this.entries.get(cacheKey);
224
238
  if (!entry) {
225
- return;
239
+ return Promise.resolve();
226
240
  }
227
241
  entry.flushQueued = false;
228
242
 
@@ -233,7 +247,9 @@ export class IndexCache {
233
247
  return;
234
248
  }
235
249
 
236
- let index = entry.index;
250
+ // Prefer an index already in hand (or published on the entry). Never await
251
+ // entry.task while that task is itself waiting on this flush — that deadlocks.
252
+ let index = knownIndex ?? entry.index;
237
253
  if (!index && entry.task) {
238
254
  try {
239
255
  index = await entry.task;
@@ -264,6 +280,57 @@ export class IndexCache {
264
280
  };
265
281
 
266
282
  entry.updateChain = entry.updateChain.then(run, run);
283
+ return entry.updateChain;
284
+ }
285
+
286
+ private async checkAndQueueStaleFiles(
287
+ source: string,
288
+ index: MiruIndex,
289
+ cacheKey: string,
290
+ ): Promise<void> {
291
+ if (isGitUrl(source)) {
292
+ return;
293
+ }
294
+
295
+ const root = index.root;
296
+ if (!root) {
297
+ return;
298
+ }
299
+
300
+ const storedMtimes = index.getStoredFileMtimes();
301
+ const entry = this.ensureEntry(cacheKey, source);
302
+
303
+ try {
304
+ await Promise.all(
305
+ [...storedMtimes].map(async ([filePath, storedMtime]) => {
306
+ try {
307
+ const currentStat = await Bun.file(resolve(root, filePath)).stat();
308
+ const currentMtime = Math.floor(currentStat.mtime?.getTime() ?? 0);
309
+
310
+ if (currentMtime !== storedMtime) {
311
+ entry.pendingPaths.add(normalizeRelativePath(filePath));
312
+ }
313
+ } catch {
314
+ entry.pendingPaths.add(normalizeRelativePath(filePath));
315
+ }
316
+ }),
317
+ );
318
+
319
+ // Files created on disk while this index wasn't loaded (or never indexed
320
+ // before) have no entry in `storedMtimes` and are otherwise invisible to
321
+ // the mtime comparison above; a directory walk is the only way to find them.
322
+ const extensions = getExtensions(index.contentTypes);
323
+ for await (const absolutePath of walkFiles(root, extensions)) {
324
+ const relativePath = normalizeRelativePath(relative(root, absolutePath));
325
+ if (!storedMtimes.has(relativePath)) {
326
+ entry.pendingPaths.add(relativePath);
327
+ }
328
+ }
329
+
330
+ if (entry.pendingPaths.size > 0) {
331
+ await this.flushFileUpdates(cacheKey, source, index);
332
+ }
333
+ } catch {}
267
334
  }
268
335
 
269
336
  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,