@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.
@@ -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 renderMultiSelect<T>(
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
- ): void {
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
- const selected = i === cursor;
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
- writeRaw(`${CLEAR_LINE}${lines.join(`\n${CLEAR_LINE}`)}\n`);
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
- cursor = Math.max(0, cursor - 1);
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 = Math.min(items.length - 1, cursor + 1);
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 (const item of items) {
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 renderConfirm(question: string, yesSelected: boolean): void {
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
- writeRaw(`${CLEAR_LINE}${question} ${yes} / ${no}\n`);
170
- writeRaw(`${CLEAR_LINE}${dim("←→ or y/n to choose enter to confirm ctrl-c to cancel")}\n`);
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 = false;
298
+ if (yesSelected) {
299
+ yesSelected = false;
300
+ updateConfirmPromptLine(question, yesSelected);
301
+ }
205
302
  } else if (key === "right" || key === "yes") {
206
- yesSelected = true;
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 SEARCH_POLICY_TABLE = `| Task | Use | Not |
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) | Grep, Glob, SemanticSearch, repeat searches |
6
- | Same file, more context | Miru MCP \`expand\` | Re-search, Read whole file |
7
- | Similar code elsewhere | Miru MCP \`find_related\` | Grep chains |
8
- | Search docs or config | Miru \`search\` with \`content\` | Grep README paths |
9
- | Exact literal exists? | Grep | Miru |
10
- | Edit a known file:line | Read (after Miru found it) | Read-before-search |`;
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 const SEARCH_POLICY_BODY = `DO NOT use Grep, Glob, SemanticSearch, or Read to explore how code works when Miru MCP is available.
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\` — adjacent chunks in the same file when a hit looks truncated (\`file_path\` + \`line\` from the hit)
17
- - \`find_related\` — similar code in other files after a \`search\` hit
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
- - If context is cut off in one file, call \`expand\` — not another \`search\` or a full-file Read
22
- - Read is for editing a path Miru already gave you, not for exploration
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
- ${SEARCH_POLICY_TABLE}`;
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. Use \`expand\` for same-file context. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
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 = `When Miru MCP is available, use MCP \`search\`, \`expand\`, and \`find_related\` — not Grep, Glob, SemanticSearch, or Read-for-exploration.
113
+ export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
60
114
 
61
- ${SEARCH_POLICY_BODY}
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
- ### MCP workflow
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
- 1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
66
- 2. If a hit looks truncated, call \`expand\` with the hit's \`file_path\` and \`line\`.
67
- 3. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
68
- 4. Read via \`absolute_path\` only when editing or when \`expand\` still lacks context.
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
- ### CLI fallback (no MCP in this session)
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
- \`\`\`bash
73
- miru search "authentication flow" .
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.";
@@ -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 { computeSourceCacheKey, isGitUrl } from "../utils.ts";
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 ?? process.env.SEMBLE_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
- private watcher: ReturnType<typeof watch> | null = null;
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.ensureEntry(cacheKey);
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.ensureEntry(cacheKey);
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
- const resolved = resolve(source);
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.watcher) {
260
- this.watcher.close();
278
+ if (this.watchers.has(resolved)) {
279
+ return;
261
280
  }
262
281
 
263
- this.watcher = watch(resolved, { recursive: true }, (_event, filename) => {
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.watcher?.close();
270
- this.watcher = null;
271
- this.watchedSource = null;
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) && !repo.startsWith("https://") && !repo.startsWith("http://")) {
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://, http://, or local directory paths are accepted as \`repo\`. Got: ${repo}`,
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: "0.1.0",
33
+ version: packageJson.version,
35
34
  },
36
35
  {
37
- instructions: SERVER_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.number().int().describe("Line number (1-indexed) from the hit."),
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
- "Path from a search hit (`file_path` or `absolute_path` for local repos).",
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(`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
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, { repoRoot }),
185
+ formatResults(`Chunks related to ${filePath}:${line}`, results, {
186
+ repoRoot,
187
+ snippet: true,
188
+ }),
185
189
  ),
186
190
  );
187
191
  } catch (err) {