@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 +2 -2
- package/package.json +1 -1
- package/src/installer/config.ts +267 -17
- package/src/installer/hooks/install.ts +125 -25
- package/src/installer/installer.ts +4 -2
- package/src/installer/prompt.ts +42 -8
- package/src/mcp/index-cache.ts +74 -7
- package/src/miru-index.ts +41 -5
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.
|
|
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` |
|
|
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
package/src/installer/config.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
185
|
+
delete next[sectionKey];
|
|
143
186
|
} else {
|
|
144
|
-
|
|
187
|
+
next[sectionKey] = section;
|
|
145
188
|
}
|
|
189
|
+
return next;
|
|
190
|
+
}
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
return "
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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"
|
|
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:
|
|
186
|
+
defaultChecked: true,
|
|
185
187
|
planPath: (agent) => agent.hooksPath,
|
|
186
188
|
apply: applyHooks,
|
|
187
189
|
},
|
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";
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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,
|