@takara-ai/miru-code 0.4.12 → 0.6.0
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 +9 -0
- package/package.json +6 -1
- package/src/agents/claude.md +0 -27
- package/src/agents/copilot.md +0 -12
- package/src/agents/cursor.md +0 -26
- package/src/agents/gemini.md +0 -14
- package/src/agents/kiro.md +0 -6
- package/src/agents/opencode.md +0 -6
- package/src/agents.ts +37 -1
- package/src/cli-ui.ts +4 -1
- package/src/cli.ts +1 -9
- package/src/concurrency.ts +1 -5
- package/src/embeddings/openai.ts +60 -30
- package/src/git.ts +1 -1
- package/src/index/create.ts +24 -4
- package/src/index/entry-chunks.ts +71 -0
- package/src/index/vector-storage.ts +1 -1
- package/src/installer/hooks/search-guard.ts +3 -4
- package/src/installer/prompt.ts +118 -18
- package/src/installer/search-policy.ts +89 -29
- package/src/mcp/index-cache.ts +57 -24
- package/src/mcp/server.ts +31 -27
- package/src/ranking/boosting.ts +11 -0
- package/src/ranking/features.ts +11 -0
- package/src/ranking/location.ts +237 -0
- package/src/search.ts +4 -1
- package/src/setup.ts +1 -1
- package/src/snippet.ts +154 -0
- package/src/spinner.ts +1 -1
- package/src/utils.ts +63 -6
package/src/installer/prompt.ts
CHANGED
|
@@ -15,11 +15,63 @@ const ESC = "\x1b";
|
|
|
15
15
|
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
16
16
|
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
17
17
|
const CLEAR_LINE = `${ESC}[2K`;
|
|
18
|
+
const CURSOR_START = `${ESC}[G`;
|
|
18
19
|
|
|
19
20
|
function writeRaw(text: string): void {
|
|
20
21
|
output.write(text);
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function moveCursorUp(lines: number): void {
|
|
25
|
+
if (lines > 0) {
|
|
26
|
+
writeRaw(`${ESC}[${lines}A`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function moveCursorDown(lines: number): void {
|
|
31
|
+
if (lines > 0) {
|
|
32
|
+
writeRaw(`${ESC}[${lines}B`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rewriteLineAtOffsetFromBottom(offsetFromBottom: number, content: string): void {
|
|
37
|
+
moveCursorUp(offsetFromBottom);
|
|
38
|
+
writeRaw(`${CURSOR_START}${CLEAR_LINE}${content}`);
|
|
39
|
+
moveCursorDown(offsetFromBottom);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MULTI_SELECT_HEADER_LINES = 3;
|
|
43
|
+
const MULTI_SELECT_TRAILER_LINES = 2;
|
|
44
|
+
|
|
45
|
+
function multiSelectTotalLines(itemCount: number): number {
|
|
46
|
+
return MULTI_SELECT_HEADER_LINES + itemCount + MULTI_SELECT_TRAILER_LINES;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function multiSelectItemOffsetFromBottom(itemIndex: number, itemCount: number): number {
|
|
50
|
+
return multiSelectTotalLines(itemCount) - (MULTI_SELECT_HEADER_LINES + itemIndex);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatMultiSelectItemLine(
|
|
54
|
+
item: { label: string; checked: boolean },
|
|
55
|
+
selected: boolean,
|
|
56
|
+
): string {
|
|
57
|
+
const pointer = selected ? cyan("›") : " ";
|
|
58
|
+
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
59
|
+
const label = selected ? bold(item.label) : item.label;
|
|
60
|
+
return ` ${pointer} ${mark} ${label}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateMultiSelectItemRow(
|
|
64
|
+
item: { label: string; checked: boolean },
|
|
65
|
+
itemIndex: number,
|
|
66
|
+
itemCount: number,
|
|
67
|
+
selected: boolean,
|
|
68
|
+
): void {
|
|
69
|
+
rewriteLineAtOffsetFromBottom(
|
|
70
|
+
multiSelectItemOffsetFromBottom(itemIndex, itemCount),
|
|
71
|
+
formatMultiSelectItemLine(item, selected),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
23
75
|
function readKey(): Promise<Buffer> {
|
|
24
76
|
return new Promise((resolve, reject) => {
|
|
25
77
|
const onData = (chunk: Buffer) => {
|
|
@@ -90,12 +142,12 @@ async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
90
142
|
}
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
function
|
|
145
|
+
function buildMultiSelectLines<T>(
|
|
94
146
|
title: string,
|
|
95
147
|
items: Array<{ label: string; value: T; checked: boolean }>,
|
|
96
148
|
cursor: number,
|
|
97
149
|
footer: string,
|
|
98
|
-
):
|
|
150
|
+
): string[] {
|
|
99
151
|
const lines: string[] = [];
|
|
100
152
|
lines.push("");
|
|
101
153
|
lines.push(bold(title));
|
|
@@ -105,15 +157,11 @@ function renderMultiSelect<T>(
|
|
|
105
157
|
if (!item) {
|
|
106
158
|
continue;
|
|
107
159
|
}
|
|
108
|
-
|
|
109
|
-
const pointer = selected ? cyan("›") : " ";
|
|
110
|
-
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
111
|
-
const label = selected ? bold(item.label) : item.label;
|
|
112
|
-
lines.push(` ${pointer} ${mark} ${label}`);
|
|
160
|
+
lines.push(formatMultiSelectItemLine(item, i === cursor));
|
|
113
161
|
}
|
|
114
162
|
lines.push("");
|
|
115
163
|
lines.push(dim(footer));
|
|
116
|
-
|
|
164
|
+
return lines;
|
|
117
165
|
}
|
|
118
166
|
|
|
119
167
|
export async function promptMultiSelect<T>(
|
|
@@ -130,10 +178,12 @@ export async function promptMultiSelect<T>(
|
|
|
130
178
|
|
|
131
179
|
const footer = "↑↓ move space toggle a all enter confirm ctrl-c cancel";
|
|
132
180
|
let cursor = 0;
|
|
181
|
+
const itemCount = items.length;
|
|
133
182
|
|
|
134
183
|
return withRawMode(async () => {
|
|
184
|
+
writeRaw(`${buildMultiSelectLines(title, items, cursor, footer).join("\n")}\n`);
|
|
185
|
+
|
|
135
186
|
while (true) {
|
|
136
|
-
renderMultiSelect(title, items, cursor, footer);
|
|
137
187
|
const key = parseKey(await readKey());
|
|
138
188
|
|
|
139
189
|
if (key === "ctrl-c") {
|
|
@@ -145,29 +195,72 @@ export async function promptMultiSelect<T>(
|
|
|
145
195
|
return items.filter((item) => item.checked).map((item) => item.value);
|
|
146
196
|
}
|
|
147
197
|
if (key === "up") {
|
|
148
|
-
|
|
198
|
+
if (cursor > 0) {
|
|
199
|
+
const previous = cursor;
|
|
200
|
+
cursor--;
|
|
201
|
+
const previousItem = items[previous];
|
|
202
|
+
const currentItem = items[cursor];
|
|
203
|
+
if (previousItem) {
|
|
204
|
+
updateMultiSelectItemRow(previousItem, previous, itemCount, false);
|
|
205
|
+
}
|
|
206
|
+
if (currentItem) {
|
|
207
|
+
updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
149
210
|
} else if (key === "down") {
|
|
150
|
-
cursor
|
|
211
|
+
if (cursor < items.length - 1) {
|
|
212
|
+
const previous = cursor;
|
|
213
|
+
cursor++;
|
|
214
|
+
const previousItem = items[previous];
|
|
215
|
+
const currentItem = items[cursor];
|
|
216
|
+
if (previousItem) {
|
|
217
|
+
updateMultiSelectItemRow(previousItem, previous, itemCount, false);
|
|
218
|
+
}
|
|
219
|
+
if (currentItem) {
|
|
220
|
+
updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
151
223
|
} else if (key === "space") {
|
|
152
224
|
const item = items[cursor];
|
|
153
225
|
if (item) {
|
|
154
226
|
item.checked = !item.checked;
|
|
227
|
+
updateMultiSelectItemRow(item, cursor, itemCount, true);
|
|
155
228
|
}
|
|
156
229
|
} else if (key === "all") {
|
|
157
230
|
const allChecked = items.every((item) => item.checked);
|
|
158
|
-
for (
|
|
231
|
+
for (let i = 0; i < items.length; i++) {
|
|
232
|
+
const item = items[i];
|
|
233
|
+
if (!item) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
159
236
|
item.checked = !allChecked;
|
|
237
|
+
updateMultiSelectItemRow(item, i, itemCount, i === cursor);
|
|
160
238
|
}
|
|
161
239
|
}
|
|
162
240
|
}
|
|
163
241
|
});
|
|
164
242
|
}
|
|
165
243
|
|
|
166
|
-
function
|
|
244
|
+
function buildConfirmPromptLine(question: string, yesSelected: boolean): string {
|
|
167
245
|
const yes = yesSelected ? green(bold(" Yes ")) : dim(" Yes ");
|
|
168
246
|
const no = yesSelected ? dim(" No ") : green(bold(" No "));
|
|
169
|
-
|
|
170
|
-
|
|
247
|
+
return `${question} ${yes} / ${no}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildConfirmLines(question: string, yesSelected: boolean): string[] {
|
|
251
|
+
return [
|
|
252
|
+
buildConfirmPromptLine(question, yesSelected),
|
|
253
|
+
dim("←→ or y/n to choose enter to confirm ctrl-c to cancel"),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const CONFIRM_PROMPT_LINES = 2;
|
|
258
|
+
|
|
259
|
+
function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
|
|
260
|
+
rewriteLineAtOffsetFromBottom(
|
|
261
|
+
CONFIRM_PROMPT_LINES,
|
|
262
|
+
buildConfirmPromptLine(question, yesSelected),
|
|
263
|
+
);
|
|
171
264
|
}
|
|
172
265
|
|
|
173
266
|
export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
|
|
@@ -188,8 +281,9 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
188
281
|
let yesSelected = defaultYes;
|
|
189
282
|
|
|
190
283
|
return withRawMode(async () => {
|
|
284
|
+
writeRaw(`${buildConfirmLines(question, yesSelected).join("\n")}\n`);
|
|
285
|
+
|
|
191
286
|
while (true) {
|
|
192
|
-
renderConfirm(question, yesSelected);
|
|
193
287
|
const key = parseKey(await readKey());
|
|
194
288
|
|
|
195
289
|
if (key === "ctrl-c") {
|
|
@@ -201,9 +295,15 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
201
295
|
return yesSelected;
|
|
202
296
|
}
|
|
203
297
|
if (key === "left" || key === "no") {
|
|
204
|
-
yesSelected
|
|
298
|
+
if (yesSelected) {
|
|
299
|
+
yesSelected = false;
|
|
300
|
+
updateConfirmPromptLine(question, yesSelected);
|
|
301
|
+
}
|
|
205
302
|
} else if (key === "right" || key === "yes") {
|
|
206
|
-
yesSelected
|
|
303
|
+
if (!yesSelected) {
|
|
304
|
+
yesSelected = true;
|
|
305
|
+
updateConfirmPromptLine(question, yesSelected);
|
|
306
|
+
}
|
|
207
307
|
}
|
|
208
308
|
}
|
|
209
309
|
});
|
|
@@ -1,32 +1,86 @@
|
|
|
1
1
|
/** Shared code-search policy text for instructions, Cursor rules, and sub-agents. */
|
|
2
2
|
|
|
3
|
-
export const
|
|
3
|
+
export const SNIPPET_GUIDANCE =
|
|
4
|
+
"Search returns compact snippets (~±15 lines around the best match). " +
|
|
5
|
+
"When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` " +
|
|
6
|
+
"(or `start_line`) — do not re-search or Read the whole file.";
|
|
7
|
+
|
|
8
|
+
export interface NativeToolNames {
|
|
9
|
+
explorationDenied: string;
|
|
10
|
+
grep: string;
|
|
11
|
+
read: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildSearchPolicyTable(native: NativeToolNames): string {
|
|
15
|
+
return `| Task | Use | Not |
|
|
4
16
|
|------|-----|-----|
|
|
5
|
-
| How/where/what handles X? | Miru MCP \`search\` (once) |
|
|
6
|
-
| Same file, more context | Miru MCP \`expand\` | Re-search,
|
|
7
|
-
| Similar code elsewhere | Miru MCP \`find_related\` |
|
|
8
|
-
| Search docs or config | Miru \`search\`
|
|
9
|
-
| Exact literal exists? |
|
|
10
|
-
| Edit a known file:line |
|
|
17
|
+
| How/where/what handles X? | Miru MCP \`search\` (once) | ${native.explorationDenied}, repeat searches |
|
|
18
|
+
| Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
|
|
19
|
+
| Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
|
|
20
|
+
| Search docs or config | Miru \`search\` | ${native.grep} README paths |
|
|
21
|
+
| Exact literal exists? | ${native.grep} | Miru |
|
|
22
|
+
| Edit a known file:line | ${native.read} (after Miru found it) | ${native.read}-before-search |`;
|
|
23
|
+
}
|
|
11
24
|
|
|
12
|
-
export
|
|
25
|
+
export function buildSearchPolicyBody(native: NativeToolNames): string {
|
|
26
|
+
return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
|
|
27
|
+
|
|
28
|
+
${SNIPPET_GUIDANCE}
|
|
13
29
|
|
|
14
30
|
Use Miru MCP tools:
|
|
15
31
|
- \`search\` — one call per question; pass project root as \`repo\`
|
|
16
|
-
- \`expand\` —
|
|
17
|
-
- \`find_related\` — similar code in other files
|
|
32
|
+
- \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\` or \`start_line\`)
|
|
33
|
+
- \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
|
|
18
34
|
|
|
19
35
|
Stop rules:
|
|
20
36
|
- Answer from the first \`search\` — do not re-search with paraphrases
|
|
21
|
-
-
|
|
22
|
-
-
|
|
37
|
+
- On \`truncated: true\`, call \`expand\` — not another \`search\` or a full-file ${native.read}
|
|
38
|
+
- ${native.read} is for editing a path Miru already gave you, not for exploration
|
|
23
39
|
|
|
24
40
|
Native tools are allowed ONLY when:
|
|
25
41
|
- confirming an exact literal string (env var name, error code, quoted text)
|
|
26
42
|
- reading a file you already located via Miru, to edit it
|
|
27
43
|
- searching outside the indexed repo
|
|
28
44
|
|
|
29
|
-
${
|
|
45
|
+
${buildSearchPolicyTable(native)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildMcpWorkflow(native: NativeToolNames): string {
|
|
49
|
+
return `### MCP workflow
|
|
50
|
+
|
|
51
|
+
1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
|
|
52
|
+
2. If a hit has \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\` (or \`start_line\`).
|
|
53
|
+
3. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
|
|
54
|
+
4. ${native.read} via \`absolute_path\` only when editing or when \`expand\` still lacks context.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLI_FALLBACK = `### CLI fallback (no MCP in this session)
|
|
58
|
+
|
|
59
|
+
\`\`\`bash
|
|
60
|
+
miru search "authentication flow" .
|
|
61
|
+
miru expand src/auth.ts 42 .
|
|
62
|
+
miru find-related src/auth.ts 42 .
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
66
|
+
|
|
67
|
+
export function buildSubagentBody(native: NativeToolNames): string {
|
|
68
|
+
return `When Miru MCP is available, use MCP \`search\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
|
|
69
|
+
|
|
70
|
+
${buildSearchPolicyBody(native)}
|
|
71
|
+
|
|
72
|
+
${buildMcpWorkflow(native)}
|
|
73
|
+
|
|
74
|
+
${CLI_FALLBACK}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_NATIVE_TOOLS: NativeToolNames = {
|
|
78
|
+
explorationDenied: "Grep, Glob, SemanticSearch, or Read",
|
|
79
|
+
grep: "Grep",
|
|
80
|
+
read: "Read",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const SEARCH_POLICY_BODY = buildSearchPolicyBody(DEFAULT_NATIVE_TOOLS);
|
|
30
84
|
|
|
31
85
|
export const INSTRUCTIONS_MARKDOWN = `## Miru Code Search
|
|
32
86
|
|
|
@@ -54,25 +108,31 @@ alwaysApply: true
|
|
|
54
108
|
|
|
55
109
|
${SEARCH_POLICY_BODY}
|
|
56
110
|
|
|
57
|
-
When Miru MCP is connected, call \`search\` once per question.
|
|
111
|
+
When Miru MCP is connected, call \`search\` once per question. On \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\`. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
|
|
58
112
|
|
|
59
|
-
export const SUBAGENT_BODY =
|
|
113
|
+
export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
|
|
60
114
|
|
|
61
|
-
|
|
115
|
+
export const MCP_SERVER_INSTRUCTIONS =
|
|
116
|
+
"DO NOT use Grep, Glob, SemanticSearch, or Read to explore code when this server is available. " +
|
|
117
|
+
`${SNIPPET_GUIDANCE} ` +
|
|
118
|
+
"Workflow: (1) `search` once per question — do NOT re-search with paraphrases. " +
|
|
119
|
+
"(2) On `truncated: true`, call `expand` with `file_path` and `anchor_line` (or `start_line`) — do NOT read the whole file. " +
|
|
120
|
+
"(3) `find_related` traces similar code in other files, not more context in the same file. " +
|
|
121
|
+
"Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
|
|
122
|
+
"Native search is only for exact literal confirmation or files outside the indexed repo.";
|
|
62
123
|
|
|
63
|
-
|
|
124
|
+
export const MCP_SEARCH_TOOL_DESCRIPTION =
|
|
125
|
+
"DEFAULT code exploration tool. Returns compact snippets (~±15 lines). One call per question is usually enough. " +
|
|
126
|
+
"When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` (or `start_line`) — not re-search or Read.";
|
|
64
127
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
128
|
+
export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
129
|
+
"More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` or `start_line` from a truncated hit; " +
|
|
130
|
+
"returns adjacent indexed chunks. Use when `truncated: true` — NOT for similar code in other files (use find_related). " +
|
|
131
|
+
"Prefer this over re-searching or reading the whole file.";
|
|
69
132
|
|
|
70
|
-
|
|
133
|
+
export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
|
|
134
|
+
"Find code similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
|
|
135
|
+
"For more context in the same file, use `expand` instead.";
|
|
71
136
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
miru expand src/auth.ts 42 .
|
|
75
|
-
miru find-related src/auth.ts 42 .
|
|
76
|
-
\`\`\`
|
|
77
|
-
|
|
78
|
-
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
137
|
+
export const SEARCH_GUARD_EXPAND_HINT =
|
|
138
|
+
"If a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` — do not re-search or read the whole file.";
|
package/src/mcp/index-cache.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
|
|
4
4
|
import { MiruIndex } from "../miru-index.ts";
|
|
5
5
|
import type { ContentType } from "../types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
computeSourceCacheKey,
|
|
8
|
+
isAllowedRepoSource,
|
|
9
|
+
isGitUrl,
|
|
10
|
+
validateLocalRepoPath,
|
|
11
|
+
} from "../utils.ts";
|
|
7
12
|
|
|
8
13
|
const CACHE_MAX_SIZE = 10;
|
|
9
14
|
|
|
@@ -28,7 +33,10 @@ const WATCH_IGNORED_DIR_NAMES = new Set([
|
|
|
28
33
|
".eggs",
|
|
29
34
|
]);
|
|
30
35
|
|
|
36
|
+
type WatcherHandle = ReturnType<typeof watch>;
|
|
37
|
+
|
|
31
38
|
type CacheEntry = {
|
|
39
|
+
source: string;
|
|
32
40
|
index: MiruIndex | null;
|
|
33
41
|
task: Promise<MiruIndex> | null;
|
|
34
42
|
pendingPaths: Set<string>;
|
|
@@ -37,7 +45,7 @@ type CacheEntry = {
|
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export function mcpWatchEnabled(): boolean {
|
|
40
|
-
const raw = process.env.MIRU_MCP_WATCH
|
|
48
|
+
const raw = process.env.MIRU_MCP_WATCH;
|
|
41
49
|
return raw !== "0" && raw !== "false";
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -59,15 +67,14 @@ export class IndexCache {
|
|
|
59
67
|
private readonly content: ContentType[];
|
|
60
68
|
private readonly defaultRef: string | null;
|
|
61
69
|
private readonly entries = new Map<string, CacheEntry>();
|
|
62
|
-
|
|
63
|
-
private watchedSource: string | null = null;
|
|
70
|
+
readonly watchers = new Map<string, WatcherHandle>();
|
|
64
71
|
|
|
65
72
|
constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
|
|
66
73
|
this.content = content;
|
|
67
74
|
this.defaultRef = defaultRef;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
private ensureEntry(cacheKey: string): CacheEntry {
|
|
77
|
+
private ensureEntry(cacheKey: string, source: string): CacheEntry {
|
|
71
78
|
let entry = this.entries.get(cacheKey);
|
|
72
79
|
if (!entry) {
|
|
73
80
|
if (this.entries.size >= CACHE_MAX_SIZE) {
|
|
@@ -77,6 +84,7 @@ export class IndexCache {
|
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
entry = {
|
|
87
|
+
source,
|
|
80
88
|
index: null,
|
|
81
89
|
task: null,
|
|
82
90
|
pendingPaths: new Set(),
|
|
@@ -89,9 +97,19 @@ export class IndexCache {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
private clearEntry(cacheKey: string): void {
|
|
100
|
+
const entry = this.entries.get(cacheKey);
|
|
101
|
+
if (entry && !isGitUrl(entry.source)) {
|
|
102
|
+
this.stopWatcher(resolve(entry.source));
|
|
103
|
+
}
|
|
92
104
|
this.entries.delete(cacheKey);
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
private stopWatcher(resolvedPath: string): void {
|
|
108
|
+
const watcher = this.watchers.get(resolvedPath);
|
|
109
|
+
watcher?.close();
|
|
110
|
+
this.watchers.delete(resolvedPath);
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
private startBuild(
|
|
96
114
|
source: string,
|
|
97
115
|
ref: string | null | undefined,
|
|
@@ -105,7 +123,7 @@ export class IndexCache {
|
|
|
105
123
|
return index;
|
|
106
124
|
})();
|
|
107
125
|
|
|
108
|
-
const entry = this.ensureEntry(cacheKey);
|
|
126
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
109
127
|
entry.task = task;
|
|
110
128
|
void task
|
|
111
129
|
.then((index) => {
|
|
@@ -126,7 +144,7 @@ export class IndexCache {
|
|
|
126
144
|
async get(source: string, ref?: string | null): Promise<MiruIndex> {
|
|
127
145
|
const resolvedRef = ref ?? this.defaultRef;
|
|
128
146
|
const cacheKey = computeSourceCacheKey(source, resolvedRef);
|
|
129
|
-
const entry = this.ensureEntry(cacheKey);
|
|
147
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
130
148
|
|
|
131
149
|
if (!entry.task) {
|
|
132
150
|
this.startBuild(source, resolvedRef, cacheKey);
|
|
@@ -151,7 +169,7 @@ export class IndexCache {
|
|
|
151
169
|
|
|
152
170
|
private queueIndexedPaths(source: string, index: MiruIndex): void {
|
|
153
171
|
const cacheKey = computeSourceCacheKey(source);
|
|
154
|
-
const entry = this.ensureEntry(cacheKey);
|
|
172
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
155
173
|
for (const chunk of index.chunks) {
|
|
156
174
|
entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
|
|
157
175
|
}
|
|
@@ -161,7 +179,10 @@ export class IndexCache {
|
|
|
161
179
|
/** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
|
|
162
180
|
private noteAmbiguousDirectoryChange(source: string): void {
|
|
163
181
|
const cacheKey = computeSourceCacheKey(source);
|
|
164
|
-
const entry = this.
|
|
182
|
+
const entry = this.entries.get(cacheKey);
|
|
183
|
+
if (!entry) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
165
186
|
|
|
166
187
|
if (entry.index) {
|
|
167
188
|
this.queueIndexedPaths(source, entry.index);
|
|
@@ -186,7 +207,10 @@ export class IndexCache {
|
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
const cacheKey = computeSourceCacheKey(source);
|
|
189
|
-
const entry = this.
|
|
210
|
+
const entry = this.entries.get(cacheKey);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
190
214
|
const rel = relativePathFromRoot(source, filename);
|
|
191
215
|
if (!rel) {
|
|
192
216
|
return;
|
|
@@ -246,29 +270,31 @@ export class IndexCache {
|
|
|
246
270
|
if (!mcpWatchEnabled() || isGitUrl(source)) {
|
|
247
271
|
return;
|
|
248
272
|
}
|
|
249
|
-
|
|
250
|
-
if (this.watchedSource === resolved) {
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
this.watchedSource = resolved;
|
|
254
|
-
this.startWatcher(resolved);
|
|
273
|
+
this.startWatcher(source);
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
startWatcher(path: string): void {
|
|
258
277
|
const resolved = resolve(path);
|
|
259
|
-
if (this.
|
|
260
|
-
|
|
278
|
+
if (this.watchers.has(resolved)) {
|
|
279
|
+
return;
|
|
261
280
|
}
|
|
262
281
|
|
|
263
|
-
|
|
282
|
+
const watcher = watch(resolved, { recursive: true }, (_event, filename) => {
|
|
264
283
|
this.noteFileChange(resolved, filename);
|
|
265
284
|
});
|
|
285
|
+
this.watchers.set(resolved, watcher);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
get watcher(): WatcherHandle | null {
|
|
289
|
+
const handles = [...this.watchers.values()];
|
|
290
|
+
return handles[handles.length - 1] ?? null;
|
|
266
291
|
}
|
|
267
292
|
|
|
268
293
|
close(): void {
|
|
269
|
-
this.
|
|
270
|
-
|
|
271
|
-
|
|
294
|
+
for (const watcher of this.watchers.values()) {
|
|
295
|
+
watcher.close();
|
|
296
|
+
}
|
|
297
|
+
this.watchers.clear();
|
|
272
298
|
this.entries.clear();
|
|
273
299
|
}
|
|
274
300
|
}
|
|
@@ -284,12 +310,19 @@ export async function getIndexForRepo(
|
|
|
284
310
|
);
|
|
285
311
|
}
|
|
286
312
|
|
|
287
|
-
if (isGitUrl(repo) && !
|
|
313
|
+
if (isGitUrl(repo) && !isAllowedRepoSource(repo)) {
|
|
314
|
+
if (repo.startsWith("http://")) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"Plain http:// git URLs are disabled by default. Set MIRU_ALLOW_HTTP_GIT=1 to opt in.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
288
319
|
throw new Error(
|
|
289
|
-
`Only https
|
|
320
|
+
`Only https:// git URLs or local directory paths are accepted as \`repo\`. Got: ${repo}`,
|
|
290
321
|
);
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
validateLocalRepoPath(repo);
|
|
325
|
+
|
|
293
326
|
try {
|
|
294
327
|
return await cache.get(repo, ref);
|
|
295
328
|
} catch (err) {
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import packageJson from "../../package.json";
|
|
4
|
+
import {
|
|
5
|
+
MCP_EXPAND_TOOL_DESCRIPTION,
|
|
6
|
+
MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
7
|
+
MCP_SEARCH_TOOL_DESCRIPTION,
|
|
8
|
+
MCP_SERVER_INSTRUCTIONS,
|
|
9
|
+
} from "../installer/search-policy.ts";
|
|
3
10
|
import type { ContentType } from "../types.ts";
|
|
4
11
|
import {
|
|
5
12
|
clampMcpTopK,
|
|
@@ -19,32 +26,21 @@ const REPO_DESCRIPTION =
|
|
|
19
26
|
"Pass the project root for local workspaces. " +
|
|
20
27
|
"The index is built on the first tool call and cached for the session.";
|
|
21
28
|
|
|
22
|
-
const SERVER_INSTRUCTIONS =
|
|
23
|
-
"DO NOT use Grep, Glob, SemanticSearch, or Read to explore code when this server is available. " +
|
|
24
|
-
"Workflow: (1) `search` once per question — do NOT re-search with paraphrases. " +
|
|
25
|
-
"(2) If context looks cut off, call `expand` on the hit's file_path + line — do NOT read the whole file. " +
|
|
26
|
-
"(3) `find_related` traces similar code in other files, not more context in the same file. " +
|
|
27
|
-
"Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
|
|
28
|
-
"Native search is only for exact literal confirmation or files outside the indexed repo.";
|
|
29
|
-
|
|
30
29
|
export function createMcpServer(cache: IndexCache): McpServer {
|
|
31
30
|
const server = new McpServer(
|
|
32
31
|
{
|
|
33
32
|
name: "miru",
|
|
34
|
-
version:
|
|
33
|
+
version: packageJson.version,
|
|
35
34
|
},
|
|
36
35
|
{
|
|
37
|
-
instructions:
|
|
36
|
+
instructions: MCP_SERVER_INSTRUCTIONS,
|
|
38
37
|
},
|
|
39
38
|
);
|
|
40
39
|
|
|
41
40
|
server.registerTool(
|
|
42
41
|
"search",
|
|
43
42
|
{
|
|
44
|
-
description:
|
|
45
|
-
"DEFAULT code exploration tool. One call per question is usually enough. " +
|
|
46
|
-
"If a hit looks truncated, call `expand` (same file) instead of re-searching or Read. " +
|
|
47
|
-
"Indexes `repo` on first call; later calls reuse the session cache.",
|
|
43
|
+
description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
|
|
48
44
|
inputSchema: {
|
|
49
45
|
query: z.string().describe("Natural language or code query."),
|
|
50
46
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
@@ -72,7 +68,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
72
68
|
return toolText(JSON.stringify({ error: "No results found." }));
|
|
73
69
|
}
|
|
74
70
|
const repoRoot = localRepoRoot(repo);
|
|
75
|
-
return toolText(JSON.stringify(formatResults(query, results, { repoRoot })));
|
|
71
|
+
return toolText(JSON.stringify(formatResults(query, results, { repoRoot, snippet: true })));
|
|
76
72
|
} catch (err) {
|
|
77
73
|
return toolText(err instanceof Error ? err.message : String(err));
|
|
78
74
|
}
|
|
@@ -82,15 +78,17 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
82
78
|
server.registerTool(
|
|
83
79
|
"expand",
|
|
84
80
|
{
|
|
85
|
-
description:
|
|
86
|
-
"More context in the SAME file as a search hit. Pass file_path + line from a hit; returns adjacent indexed chunks. " +
|
|
87
|
-
"Use when a chunk looks cut off — NOT for finding similar code in other files (use find_related). " +
|
|
88
|
-
"Prefer this over re-searching or reading the whole file.",
|
|
81
|
+
description: MCP_EXPAND_TOOL_DESCRIPTION,
|
|
89
82
|
inputSchema: {
|
|
90
83
|
file_path: z
|
|
91
84
|
.string()
|
|
92
85
|
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
93
|
-
line: z
|
|
86
|
+
line: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.describe(
|
|
90
|
+
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
91
|
+
),
|
|
94
92
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
95
93
|
before: z
|
|
96
94
|
.number()
|
|
@@ -143,16 +141,17 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
143
141
|
server.registerTool(
|
|
144
142
|
"find_related",
|
|
145
143
|
{
|
|
146
|
-
description:
|
|
147
|
-
"Find code similar to a file:line in OTHER parts of the codebase. " +
|
|
148
|
-
"For more context in the same file, use `expand` instead.",
|
|
144
|
+
description: MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
149
145
|
inputSchema: {
|
|
150
146
|
file_path: z
|
|
151
147
|
.string()
|
|
148
|
+
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
149
|
+
line: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
152
|
.describe(
|
|
153
|
-
"
|
|
153
|
+
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
154
154
|
),
|
|
155
|
-
line: z.number().int().describe("Line number (1-indexed)."),
|
|
156
155
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
157
156
|
top_k: z
|
|
158
157
|
.number()
|
|
@@ -160,7 +159,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
160
159
|
.min(1)
|
|
161
160
|
.max(MAX_MCP_TOP_K)
|
|
162
161
|
.optional()
|
|
163
|
-
.describe(
|
|
162
|
+
.describe(
|
|
163
|
+
`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
|
|
164
|
+
),
|
|
164
165
|
},
|
|
165
166
|
},
|
|
166
167
|
async ({ file_path: filePath, line, repo, top_k: topK }) => {
|
|
@@ -181,7 +182,10 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
181
182
|
}
|
|
182
183
|
return toolText(
|
|
183
184
|
JSON.stringify(
|
|
184
|
-
formatResults(`Chunks related to ${filePath}:${line}`, results, {
|
|
185
|
+
formatResults(`Chunks related to ${filePath}:${line}`, results, {
|
|
186
|
+
repoRoot,
|
|
187
|
+
snippet: true,
|
|
188
|
+
}),
|
|
185
189
|
),
|
|
186
190
|
);
|
|
187
191
|
} catch (err) {
|