@takara-ai/miru-code 0.4.10 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,28 +1,31 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related for any question about how code works. DO NOT use Grep, Glob, or Read for exploration when Miru MCP is available.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related. DO NOT use Grep, Glob, or Read for exploration when Miru MCP is available.
4
4
  tools: Bash, Read
5
5
  ---
6
6
 
7
- When Miru MCP is available, use MCP `search` and `find_related` — not Grep, Glob, or Read-for-exploration.
7
+ When Miru MCP is available, use MCP `search`, `expand`, and `find_related` — not Grep, Glob, or Read-for-exploration.
8
8
 
9
9
  | Task | Tool |
10
10
  |------|------|
11
- | How/where/what handles X? | Miru `search` |
11
+ | How/where/what handles X? | Miru `search` (once) |
12
+ | Same file, more context | Miru `expand` |
12
13
  | Similar implementations | Miru `find_related` |
13
14
  | Exact literal confirmation | Grep |
14
15
  | Edit a located file | Read |
15
16
 
16
17
  ### MCP workflow
17
18
 
18
- 1. Call `search` with `repo` set to the project root.
19
- 2. Use `find_related` with a hit's `file_path` and `line`.
20
- 3. Read files only to edit or when chunks lack context.
19
+ 1. Call `search` with `repo` set to the project root; follow `guidance` in the response.
20
+ 2. If context is cut off, call `expand` with the hit's `file_path` and `line`.
21
+ 3. Use `find_related` for similar code in other files — not for same-file context.
22
+ 4. Read files only to edit or when `expand` still lacks context.
21
23
 
22
24
  ### CLI fallback
23
25
 
24
26
  ```bash
25
27
  miru search "authentication flow" .
28
+ miru expand src/auth.ts 42 .
26
29
  miru find-related src/auth.ts 42 .
27
30
  ```
28
31
 
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related instead of grep_search or codebase_search when Miru is connected.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related instead of grep_search or codebase_search when Miru is connected.
4
4
  ---
5
5
 
6
- When Miru MCP is available, use `search` and `find_related` instead of `grep_search`, `codebase_search`, or broad file reads.
6
+ When Miru MCP is available, use `search`, `expand`, and `find_related` instead of `grep_search`, `codebase_search`, or broad file reads.
7
7
 
8
8
  | Task | Tool |
9
9
  |------|------|
10
- | How/where/what handles X? | Miru `search` |
10
+ | How/where/what handles X? | Miru `search` (once; follow `guidance`) |
11
+ | Same file, more context | Miru `expand` |
11
12
  | Similar implementations | Miru `find_related` |
12
13
  | Exact literal confirmation | grep_search |
13
14
  | Edit a located file | read_file |
@@ -1,18 +1,20 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related for any question about how code works. DO NOT use Grep, Glob, or SemanticSearch when Miru MCP is available.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related. DO NOT use Grep, Glob, or SemanticSearch when Miru MCP is available.
4
4
  ---
5
5
 
6
6
  Use Miru MCP tools when connected:
7
7
 
8
- - `search` — natural-language or code query; always pass project root as `repo`
9
- - `find_related` — similar chunks after a search hit (`file_path` + `line`)
8
+ - `search` — one call per question; always pass project root as `repo`; read `guidance` in the response
9
+ - `expand` — adjacent chunks in the same file when a hit looks truncated (`file_path` + `line`)
10
+ - `find_related` — similar code in other files after a search hit
10
11
 
11
- Do not use Grep, Glob, SemanticSearch, or Read to explore the codebase when Miru MCP is available.
12
+ Do not re-search with paraphrases when `guidance.sufficient` is true. Do not read whole files for exploration.
12
13
 
13
14
  | Task | Tool |
14
15
  |------|------|
15
- | How/where/what handles X? | Miru `search` |
16
+ | How/where/what handles X? | Miru `search` (once) |
17
+ | Same file, more context | Miru `expand` |
16
18
  | Similar implementations | Miru `find_related` |
17
19
  | Exact literal confirmation | Grep |
18
20
  | Edit a located file | Read |
@@ -21,6 +23,7 @@ CLI fallback when MCP is unavailable:
21
23
 
22
24
  ```bash
23
25
  miru search "authentication flow" .
26
+ miru expand src/auth.ts 42 .
24
27
  miru find-related src/auth.ts 42 .
25
28
  ```
26
29
 
@@ -1,17 +1,18 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related instead of grep_search or read_file for exploration.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related instead of grep_search or read_file for exploration.
4
4
  ---
5
5
 
6
- When Miru MCP is available, use `search` and `find_related` instead of `grep_search`, `glob`, or `read_file` for codebase exploration.
6
+ When Miru MCP is available, use `search`, `expand`, and `find_related` instead of `grep_search`, `glob`, or `read_file` for codebase exploration.
7
7
 
8
8
  | Task | Tool |
9
9
  |------|------|
10
- | How/where/what handles X? | Miru `search` |
10
+ | How/where/what handles X? | Miru `search` (once; follow `guidance`) |
11
+ | Same file, more context | Miru `expand` |
11
12
  | Similar implementations | Miru `find_related` |
12
13
  | Exact literal confirmation | grep_search |
13
14
  | Edit a located file | read_file |
14
15
 
15
- Pass project root as `repo` on every MCP call.
16
+ Pass project root as `repo` on every MCP call. Do not re-search when `guidance.sufficient` is true.
16
17
 
17
18
  CLI fallback: `miru search "query" .` or `bunx @takara-ai/miru-code search "query" .`
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related for codebase exploration instead of built-in grep or file search.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related for codebase exploration instead of built-in grep or file search.
4
4
  ---
5
5
 
6
- When Miru MCP is available, use `search` and `find_related` before any built-in search or broad file reads.
6
+ When Miru MCP is available, use `search`, `expand`, and `find_related` before any built-in search or broad file reads.
7
7
 
8
- Pass project root as `repo`. Use Grep only for exact literal confirmation.
8
+ Pass project root as `repo`. Follow `guidance` in search responses. Use `expand` for truncated hits in the same file. Use Grep only for exact literal confirmation.
9
9
 
10
10
  CLI fallback: `miru search "query" .`
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: miru-code
3
- description: Code search via Miru MCP. Use search and find_related instead of grep or glob for exploration.
3
+ description: Code search via Miru MCP. Use search, expand, and find_related instead of grep or glob for exploration.
4
4
  mode: subagent
5
5
  ---
6
6
 
7
- When Miru MCP is available, use `search` and `find_related` instead of grep, glob, or read-for-exploration.
7
+ When Miru MCP is available, use `search`, `expand`, and `find_related` instead of grep, glob, or read-for-exploration.
8
8
 
9
- Pass project root as `repo` on every MCP call.
9
+ Pass project root as `repo` on every MCP call. Follow `guidance` in search responses.
10
10
 
11
11
  CLI fallback: `miru search "query" .`
package/src/cli.ts CHANGED
@@ -27,7 +27,15 @@ import { MiruIndex } from "./miru-index.ts";
27
27
  import { ensureCredentials, runClearCredentials, runSetup } from "./setup.ts";
28
28
  import { withSpinner } from "./spinner.ts";
29
29
  import type { ContentType, SearchResult } from "./types.ts";
30
- import { formatResults, resolveChunk, resolveContent, resolveSearchPath } from "./utils.ts";
30
+ import {
31
+ expandChunksAtLine,
32
+ formatExpandResults,
33
+ formatResults,
34
+ localRepoRoot,
35
+ resolveChunk,
36
+ resolveContent,
37
+ resolveSearchPath,
38
+ } from "./utils.ts";
31
39
 
32
40
  loadEnvFiles();
33
41
  normalizeTakaraApiKeyEnv();
@@ -35,6 +43,7 @@ await loadStoredCredentials();
35
43
 
36
44
  const CLI_COMMANDS = new Set([
37
45
  "search",
46
+ "expand",
38
47
  "find-related",
39
48
  "init",
40
49
  "install",
@@ -148,6 +157,54 @@ async function runSearch(
148
157
  emitSearchOutput(query, index.results, jsonFlag);
149
158
  }
150
159
 
160
+ async function runExpand(
161
+ path: string,
162
+ filePath: string,
163
+ line: number,
164
+ before: number,
165
+ after: number,
166
+ content: ContentType[],
167
+ jsonFlag: boolean,
168
+ ): Promise<void> {
169
+ await ensureCredentials({ interactive: true });
170
+
171
+ const payload = await withSpinner("Expanding chunks", async () => {
172
+ const built = await MiruIndex.fromSource(path, content);
173
+ const repoRoot = localRepoRoot(path);
174
+ const { anchor, chunks: expanded } = expandChunksAtLine(
175
+ built.chunks,
176
+ filePath,
177
+ line,
178
+ repoRoot,
179
+ before,
180
+ after,
181
+ );
182
+ await built.saveToDefaultCache(path);
183
+ return formatExpandResults(filePath, line, anchor, expanded, {
184
+ repoRoot,
185
+ before,
186
+ after,
187
+ });
188
+ });
189
+
190
+ if (!payload.anchor) {
191
+ fail(`No chunk found at ${filePath}:${line}.`);
192
+ process.exit(1);
193
+ }
194
+
195
+ if (prefersJsonOutput(jsonFlag)) {
196
+ console.log(JSON.stringify(payload));
197
+ return;
198
+ }
199
+
200
+ const chunks = payload.chunks as Array<{ location?: string; content?: string }>;
201
+ for (const chunk of chunks) {
202
+ process.stdout.write(`\n${chunk.location ?? ""}\n`);
203
+ process.stdout.write(`${chunk.content ?? ""}\n`);
204
+ }
205
+ process.stdout.write("\n");
206
+ }
207
+
151
208
  async function runFindRelated(
152
209
  path: string,
153
210
  filePath: string,
@@ -308,6 +365,29 @@ async function runCli(argv: string[]): Promise<void> {
308
365
  return;
309
366
  }
310
367
 
368
+ if (command === "expand") {
369
+ const filePath = sizedRest[0];
370
+ const lineRaw = sizedRest[1];
371
+ if (!filePath || !lineRaw) {
372
+ printCommandHelp("expand");
373
+ process.exit(1);
374
+ }
375
+ const line = Number(lineRaw);
376
+ const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
377
+ let before = 1;
378
+ let after = 1;
379
+ for (let i = 3; i < sizedRest.length; i++) {
380
+ const arg = sizedRest[i];
381
+ if (arg === "--before" && sizedRest[i + 1]) {
382
+ before = Number(sizedRest[++i]);
383
+ } else if (arg === "--after" && sizedRest[i + 1]) {
384
+ after = Number(sizedRest[++i]);
385
+ }
386
+ }
387
+ await runExpand(path, filePath, line, before, after, content, jsonFlag);
388
+ return;
389
+ }
390
+
311
391
  if (command === "find-related") {
312
392
  const filePath = sizedRest[0];
313
393
  const lineRaw = sizedRest[1];
package/src/help.ts CHANGED
@@ -31,6 +31,7 @@ export function printMainHelp(): void {
31
31
 
32
32
  section("Commands");
33
33
  commandRow("search", "Hybrid search over a codebase");
34
+ commandRow("expand", "Adjacent chunks in the same file as a hit");
34
35
  commandRow("find-related", "Find chunks related to a file:line");
35
36
  commandRow("setup", "Save your Takara API key locally");
36
37
  commandRow("install", "Configure miru across coding agents");
@@ -85,6 +86,14 @@ export function printCommandHelp(command: string): void {
85
86
  writeStdout(' miru search "where is auth" ./src -k 10 --content code docs');
86
87
  writeStdout("");
87
88
  return;
89
+ case "expand":
90
+ printCommandHeader("expand", "More context in the same file as a search hit.");
91
+ section("Usage");
92
+ writeStdout(" miru expand <file> <line> [path] [--before N] [--after N]");
93
+ section("Example");
94
+ writeStdout(" miru expand src/auth.ts 42 . --before 2 --after 2");
95
+ writeStdout("");
96
+ return;
88
97
  case "find-related":
89
98
  printCommandHeader("find-related", "Semantic neighbors of a file location.");
90
99
  section("Usage");
@@ -140,7 +149,7 @@ export function printCommandHelp(command: string): void {
140
149
  section("Usage");
141
150
  writeStdout(" miru [--ref BRANCH] [--content TYPE ...]");
142
151
  writeStdout("");
143
- writeStdout("Indexes on the first search/find_related tool call (repo argument).");
152
+ writeStdout("Indexes on the first search/expand/find_related tool call (repo argument).");
144
153
  writeStdout("");
145
154
  return;
146
155
  default:
@@ -1,5 +1,5 @@
1
- const REDIRECT_MESSAGE =
2
- "Use Miru MCP `search` (pass project root as `repo`) instead of built-in search tools. " +
1
+ const REDIRECT_PREFIX =
2
+ "Use Miru MCP instead of built-in search tools (pass project root as `repo`). " +
3
3
  "Grep/Glob/Shell are only for exact literal confirmation or non-code tasks.";
4
4
 
5
5
  const MIRU_TOOL_RE = /miru/i;
@@ -70,6 +70,61 @@ function grepPatternFromInput(toolInput: Record<string, unknown>): string {
70
70
  );
71
71
  }
72
72
 
73
+ function globPatternFromInput(toolInput: Record<string, unknown>): string {
74
+ return String(
75
+ toolInput.glob_pattern ?? toolInput.pattern ?? toolInput.glob ?? toolInput.path ?? "",
76
+ );
77
+ }
78
+
79
+ /** MCP tool descriptor paths are not codebase exploration. */
80
+ export function isMcpDescriptorGlob(pattern: string): boolean {
81
+ const normalized = pattern.replace(/\\/g, "/").toLowerCase();
82
+ return (
83
+ normalized.includes("/mcps/") ||
84
+ normalized.includes("mcps/**/tools") ||
85
+ /\/mcps\/[^/]+\/tools/.test(normalized)
86
+ );
87
+ }
88
+
89
+ function suggestedMiruQuery(toolName: string, toolInput: Record<string, unknown>): string {
90
+ if (GREP_TOOL_RE.test(toolName) || toolName.toLowerCase() === "grep_search") {
91
+ const pattern = grepPatternFromInput(toolInput).trim();
92
+ if (pattern) {
93
+ return pattern;
94
+ }
95
+ }
96
+ if (GLOB_TOOL_RE.test(toolName)) {
97
+ const pattern = globPatternFromInput(toolInput).trim();
98
+ if (pattern) {
99
+ return `files matching ${pattern}`;
100
+ }
101
+ }
102
+ if (SEMANTIC_TOOL_RE.test(toolName)) {
103
+ const query = String(toolInput.query ?? toolInput.search_term ?? "").trim();
104
+ if (query) {
105
+ return query;
106
+ }
107
+ }
108
+ if (SHELL_TOOL_RE.test(toolName)) {
109
+ const command = String(toolInput.command ?? toolInput.cmd ?? "").trim();
110
+ if (command) {
111
+ return command;
112
+ }
113
+ }
114
+ return "your question about how the code works";
115
+ }
116
+
117
+ export function searchGuardBlockReason(
118
+ toolName: string,
119
+ toolInput: Record<string, unknown>,
120
+ ): string {
121
+ const query = suggestedMiruQuery(toolName, toolInput);
122
+ return (
123
+ `${REDIRECT_PREFIX} Try Miru \`search\` with query "${query}". ` +
124
+ "If the hit looks truncated, call `expand` on its file_path + line — do not re-search or read the whole file."
125
+ );
126
+ }
127
+
73
128
  /** Map host-specific stdin JSON into a common evaluation shape. */
74
129
  export function normalizeHookPayload(raw: Record<string, unknown>): HookPayload {
75
130
  const agentAction = raw.agent_action_name;
@@ -116,21 +171,25 @@ export function evaluateSearchGuard(payload: HookPayload): GuardDecision {
116
171
  if (isLiteralGrepPattern(pattern)) {
117
172
  return { block: false, reason: "" };
118
173
  }
119
- return { block: true, reason: REDIRECT_MESSAGE };
174
+ return { block: true, reason: searchGuardBlockReason(toolName, toolInput) };
120
175
  }
121
176
 
122
177
  if (GLOB_TOOL_RE.test(toolName)) {
123
- return { block: true, reason: REDIRECT_MESSAGE };
178
+ const pattern = globPatternFromInput(toolInput);
179
+ if (isMcpDescriptorGlob(pattern)) {
180
+ return { block: false, reason: "" };
181
+ }
182
+ return { block: true, reason: searchGuardBlockReason(toolName, toolInput) };
124
183
  }
125
184
 
126
185
  if (SEMANTIC_TOOL_RE.test(toolName)) {
127
- return { block: true, reason: REDIRECT_MESSAGE };
186
+ return { block: true, reason: searchGuardBlockReason(toolName, toolInput) };
128
187
  }
129
188
 
130
189
  if (SHELL_TOOL_RE.test(toolName)) {
131
190
  const command = String(toolInput.command ?? toolInput.cmd ?? "");
132
191
  if (isExplorationShell(command)) {
133
- return { block: true, reason: REDIRECT_MESSAGE };
192
+ return { block: true, reason: searchGuardBlockReason(toolName, toolInput) };
134
193
  }
135
194
  return { block: false, reason: "" };
136
195
  }
@@ -173,7 +232,7 @@ export function cursorHookResponse(reason: string): string {
173
232
  return JSON.stringify({
174
233
  permission: "deny",
175
234
  agent_message: reason,
176
- user_message: "Miru: use MCP search instead of built-in grep/glob.",
235
+ user_message: "Miru: use MCP search/expand instead of built-in grep/glob.",
177
236
  });
178
237
  }
179
238
 
@@ -2,17 +2,24 @@
2
2
 
3
3
  export const SEARCH_POLICY_TABLE = `| Task | Use | Not |
4
4
  |------|-----|-----|
5
- | How/where/what handles X? | Miru MCP \`search\` | Grep, Glob, SemanticSearch, Read |
6
- | Similar code elsewhere | Miru MCP \`find_related\` | Read whole files |
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 |
7
8
  | Search docs or config | Miru \`search\` with \`content\` | Grep README paths |
8
9
  | Exact literal exists? | Grep | Miru |
9
10
  | Edit a known file:line | Read (after Miru found it) | Read-before-search |`;
10
11
 
11
12
  export const SEARCH_POLICY_BODY = `DO NOT use Grep, Glob, SemanticSearch, or Read to explore how code works when Miru MCP is available.
12
13
 
13
- Use Miru MCP tools first:
14
- - \`search\` — natural-language or code query; pass project root as \`repo\`
15
- - \`find_related\` — similar chunks after a \`search\` hit (\`file_path\` + \`line\`)
14
+ Use Miru MCP tools:
15
+ - \`search\` — one call per question; pass project root as \`repo\`; read \`guidance.sufficient\` and \`suggested_next\` in the response
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
18
+
19
+ Stop rules:
20
+ - If \`guidance.sufficient\` is true, answer — 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
16
23
 
17
24
  Native tools are allowed ONLY when:
18
25
  - confirming an exact literal string (env var name, error code, quoted text)
@@ -31,6 +38,7 @@ CLI fallback when MCP is unavailable:
31
38
 
32
39
  \`\`\`bash
33
40
  miru search "authentication flow" .
41
+ miru expand src/auth.ts 42 .
34
42
  miru find-related src/auth.ts 42 .
35
43
  \`\`\`
36
44
 
@@ -46,22 +54,24 @@ alwaysApply: true
46
54
 
47
55
  ${SEARCH_POLICY_BODY}
48
56
 
49
- When Miru MCP is connected, call \`search\` before any built-in search tool. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
57
+ When Miru MCP is connected, call \`search\` once per question and follow \`guidance\` in the response. Use \`expand\` for same-file context. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
50
58
 
51
- export const SUBAGENT_BODY = `When Miru MCP is available, use MCP \`search\` and \`find_related\` — not Grep, Glob, SemanticSearch, or Read-for-exploration.
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.
52
60
 
53
61
  ${SEARCH_POLICY_BODY}
54
62
 
55
63
  ### MCP workflow
56
64
 
57
65
  1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
58
- 2. Use \`absolute_path\` (local repos) or \`file_path\` plus line from hits; call \`find_related\` to explore neighbors.
59
- 3. Read files via \`absolute_path\` when a chunk lacks context or you are about to edit.
66
+ 2. If \`guidance.suggested_next\` is \`expand\`, 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.
60
69
 
61
70
  ### CLI fallback (no MCP in this session)
62
71
 
63
72
  \`\`\`bash
64
73
  miru search "authentication flow" .
74
+ miru expand src/auth.ts 42 .
65
75
  miru find-related src/auth.ts 42 .
66
76
  \`\`\`
67
77
 
package/src/mcp/server.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import type { ContentType } from "../types.ts";
4
- import { formatResults, localRepoRoot, resolveChunk } from "../utils.ts";
4
+ import {
5
+ DEFAULT_MCP_TOP_K,
6
+ dedupeResultsByFile,
7
+ expandChunksAtLine,
8
+ formatExpandResults,
9
+ formatResults,
10
+ localRepoRoot,
11
+ resolveChunk,
12
+ } from "../utils.ts";
5
13
  import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
6
14
 
7
15
  const REPO_DESCRIPTION =
@@ -11,9 +19,10 @@ const REPO_DESCRIPTION =
11
19
 
12
20
  const SERVER_INSTRUCTIONS =
13
21
  "DO NOT use Grep, Glob, SemanticSearch, or Read to explore code when this server is available. " +
14
- "Call `search` first for any question about how code works; call `find_related` on a hit to explore neighbors. " +
15
- "Always pass the project root as `repo` (local path or https:// git URL). Never guess URLs. " +
16
- "Local repos return `absolute_path` on each hit — use it for Read/edit. " +
22
+ "Workflow: (1) `search` once per question — check `guidance.sufficient` and `suggested_next` in the response. " +
23
+ "(2) If context looks cut off, call `expand` on the hit's file_path + line — do NOT re-search with paraphrases or read the whole file. " +
24
+ "(3) `find_related` traces similar code in other files, not more context in the same file. " +
25
+ "Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
17
26
  "Native search is only for exact literal confirmation or files outside the indexed repo.";
18
27
 
19
28
  export function createMcpServer(cache: IndexCache): McpServer {
@@ -31,18 +40,31 @@ export function createMcpServer(cache: IndexCache): McpServer {
31
40
  "search",
32
41
  {
33
42
  description:
34
- "DEFAULT code exploration tool. DO NOT use Grep, Glob, SemanticSearch, or Read instead. " +
35
- "Search with natural language or a code query. Indexes `repo` on first call; later calls reuse the session cache.",
43
+ "DEFAULT code exploration tool. One call per question is usually enough — read `guidance` in the response. " +
44
+ "If a hit looks truncated, call `expand` (same file) instead of re-searching or Read. " +
45
+ "Indexes `repo` on first call; later calls reuse the session cache.",
36
46
  inputSchema: {
37
47
  query: z.string().describe("Natural language or code query."),
38
48
  repo: z.string().describe(REPO_DESCRIPTION),
39
- top_k: z.number().int().min(1).optional().describe("Number of results to return."),
49
+ top_k: z
50
+ .number()
51
+ .int()
52
+ .min(1)
53
+ .optional()
54
+ .describe(`Number of results (default ${DEFAULT_MCP_TOP_K}).`),
55
+ dedupe_by_file: z
56
+ .boolean()
57
+ .optional()
58
+ .describe("Keep only the best hit per file (default true)."),
40
59
  },
41
60
  },
42
- async ({ query, repo, top_k: topK }) => {
61
+ async ({ query, repo, top_k: topK, dedupe_by_file: dedupeByFile }) => {
43
62
  try {
44
63
  const index = await getIndexForRepo(repo, cache);
45
- const results = await index.search({ query, topK: topK ?? 5 });
64
+ let results = await index.search({ query, topK: topK ?? DEFAULT_MCP_TOP_K });
65
+ if (dedupeByFile !== false) {
66
+ results = dedupeResultsByFile(results);
67
+ }
46
68
  if (results.length === 0) {
47
69
  return toolText(JSON.stringify({ error: "No results found." }));
48
70
  }
@@ -54,12 +76,73 @@ export function createMcpServer(cache: IndexCache): McpServer {
54
76
  },
55
77
  );
56
78
 
79
+ server.registerTool(
80
+ "expand",
81
+ {
82
+ description:
83
+ "More context in the SAME file as a search hit. Pass file_path + line from a hit; returns adjacent indexed chunks. " +
84
+ "Use when a chunk looks cut off — NOT for finding similar code in other files (use find_related). " +
85
+ "Prefer this over re-searching or reading the whole file.",
86
+ inputSchema: {
87
+ file_path: z
88
+ .string()
89
+ .describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
90
+ line: z.number().int().describe("Line number (1-indexed) from the hit."),
91
+ repo: z.string().describe(REPO_DESCRIPTION),
92
+ before: z
93
+ .number()
94
+ .int()
95
+ .min(0)
96
+ .optional()
97
+ .describe("Extra chunks before the anchor (default 1)."),
98
+ after: z
99
+ .number()
100
+ .int()
101
+ .min(0)
102
+ .optional()
103
+ .describe("Extra chunks after the anchor (default 1)."),
104
+ },
105
+ },
106
+ async ({ file_path: filePath, line, repo, before, after }) => {
107
+ try {
108
+ const index = await getIndexForRepo(repo, cache);
109
+ const repoRoot = localRepoRoot(repo);
110
+ const beforeCount = before ?? 1;
111
+ const afterCount = after ?? 1;
112
+ const { anchor, chunks: expanded } = expandChunksAtLine(
113
+ index.chunks,
114
+ filePath,
115
+ line,
116
+ repoRoot,
117
+ beforeCount,
118
+ afterCount,
119
+ );
120
+ if (!anchor) {
121
+ return toolText(
122
+ `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
123
+ );
124
+ }
125
+ return toolText(
126
+ JSON.stringify(
127
+ formatExpandResults(filePath, line, anchor, expanded, {
128
+ repoRoot,
129
+ before: beforeCount,
130
+ after: afterCount,
131
+ }),
132
+ ),
133
+ );
134
+ } catch (err) {
135
+ return toolText(err instanceof Error ? err.message : String(err));
136
+ }
137
+ },
138
+ );
139
+
57
140
  server.registerTool(
58
141
  "find_related",
59
142
  {
60
143
  description:
61
- "Find code similar to a file:line from a prior `search` hit. " +
62
- "Use instead of Grep chains or reading whole files to trace related code.",
144
+ "Find code similar to a file:line in OTHER parts of the codebase. " +
145
+ "For more context in the same file, use `expand` instead.",
63
146
  inputSchema: {
64
147
  file_path: z
65
148
  .string()
@@ -81,7 +164,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
81
164
  `No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
82
165
  );
83
166
  }
84
- const results = await index.findRelated(chunk, topK ?? 5);
167
+ const results = await index.findRelated(chunk, topK ?? DEFAULT_MCP_TOP_K);
85
168
  if (results.length === 0) {
86
169
  return toolText(
87
170
  JSON.stringify({ error: `No related chunks found for ${filePath}:${line}.` }),
package/src/utils.ts CHANGED
@@ -66,6 +66,30 @@ export function resolveChunk(
66
66
  return fallback;
67
67
  }
68
68
 
69
+ export type SearchGuidance = {
70
+ sufficient: boolean;
71
+ reason: string;
72
+ suggested_next: "answer" | "expand" | "find_related" | "read_to_edit";
73
+ expand?: { file_path: string; line: number };
74
+ };
75
+
76
+ export type ExpandGuidance = {
77
+ sufficient: boolean;
78
+ reason: string;
79
+ suggested_next: "answer" | "search";
80
+ };
81
+
82
+ export type ExpandResults = {
83
+ file_path: string;
84
+ line: number;
85
+ chunk_count: number;
86
+ guidance: ExpandGuidance;
87
+ anchor: Record<string, unknown> | null;
88
+ chunks: Record<string, unknown>[];
89
+ };
90
+
91
+ export const DEFAULT_MCP_TOP_K = 3;
92
+
69
93
  function chunkToResponseDict(chunk: Chunk, repoRoot?: string | null): Record<string, unknown> {
70
94
  const dict = chunkToDict(chunk);
71
95
  if (!repoRoot) {
@@ -79,14 +103,145 @@ function chunkToResponseDict(chunk: Chunk, repoRoot?: string | null): Record<str
79
103
  };
80
104
  }
81
105
 
106
+ /** Keep the best-scoring chunk per file (preserves score order). */
107
+ export function dedupeResultsByFile(results: SearchResult[]): SearchResult[] {
108
+ const best = new Map<string, SearchResult>();
109
+ for (const result of results) {
110
+ const filePath = result.chunk.file_path;
111
+ const existing = best.get(filePath);
112
+ if (!existing || result.score > existing.score) {
113
+ best.set(filePath, result);
114
+ }
115
+ }
116
+ return [...best.values()].sort((a, b) => b.score - a.score);
117
+ }
118
+
119
+ export function chunksForFile(
120
+ chunks: Chunk[],
121
+ filePath: string,
122
+ repoRoot?: string | null,
123
+ ): Chunk[] {
124
+ const indexedPath = toIndexedFilePath(filePath, repoRoot);
125
+ return chunks
126
+ .filter((chunk) => chunk.file_path === indexedPath)
127
+ .sort((a, b) => a.start_line - b.start_line);
128
+ }
129
+
130
+ /** Adjacent indexed chunks in the same file around `line`. */
131
+ export function expandChunksAtLine(
132
+ chunks: Chunk[],
133
+ filePath: string,
134
+ line: number,
135
+ repoRoot: string | null | undefined,
136
+ before: number,
137
+ after: number,
138
+ ): { anchor: Chunk | null; chunks: Chunk[] } {
139
+ const fileChunks = chunksForFile(chunks, filePath, repoRoot);
140
+ if (fileChunks.length === 0) {
141
+ return { anchor: null, chunks: [] };
142
+ }
143
+
144
+ const anchor = resolveChunk(chunks, filePath, line, repoRoot);
145
+ if (!anchor) {
146
+ return { anchor: null, chunks: [] };
147
+ }
148
+
149
+ const anchorIndex = fileChunks.findIndex(
150
+ (chunk) => chunk.start_line === anchor.start_line && chunk.end_line === anchor.end_line,
151
+ );
152
+ if (anchorIndex < 0) {
153
+ return { anchor, chunks: [anchor] };
154
+ }
155
+
156
+ const start = Math.max(0, anchorIndex - before);
157
+ const end = Math.min(fileChunks.length, anchorIndex + after + 1);
158
+ return { anchor, chunks: fileChunks.slice(start, end) };
159
+ }
160
+
161
+ export function assessSearchGuidance(query: string, results: SearchResult[]): SearchGuidance {
162
+ if (results.length === 0) {
163
+ return {
164
+ sufficient: false,
165
+ reason: "No results returned.",
166
+ suggested_next: "find_related",
167
+ };
168
+ }
169
+
170
+ const top = results[0];
171
+ if (!top) {
172
+ return {
173
+ sufficient: false,
174
+ reason: "No results returned.",
175
+ suggested_next: "find_related",
176
+ };
177
+ }
178
+
179
+ const topChunk = top.chunk;
180
+ const expandHint = { file_path: topChunk.file_path, line: topChunk.start_line };
181
+ const second = results[1];
182
+ const scoreRatio = second && second.score > 0 ? top.score / second.score : Number.POSITIVE_INFINITY;
183
+ const strongLead = results.length === 1 || scoreRatio >= 1.25;
184
+
185
+ const sameFileHits = results.filter((result) => result.chunk.file_path === topChunk.file_path).length;
186
+ const uniqueFiles = new Set(results.map((result) => result.chunk.file_path)).size;
187
+ const lineSpan = topChunk.end_line - topChunk.start_line + 1;
188
+ const narrowQuery = /\b(why|how|what|when|where|does|bug|error|fail)\b/i.test(query);
189
+
190
+ if (sameFileHits > 1 && lineSpan < 40) {
191
+ return {
192
+ sufficient: false,
193
+ reason: `${sameFileHits} hits in ${topChunk.file_path}; slices may be incomplete.`,
194
+ suggested_next: "expand",
195
+ expand: expandHint,
196
+ };
197
+ }
198
+
199
+ if (lineSpan < 12 && sameFileHits >= 1) {
200
+ return {
201
+ sufficient: false,
202
+ reason: "Top hit is a short slice; more context may be in adjacent chunks.",
203
+ suggested_next: "expand",
204
+ expand: expandHint,
205
+ };
206
+ }
207
+
208
+ if (uniqueFiles > 2 && !strongLead) {
209
+ return {
210
+ sufficient: false,
211
+ reason: "Results span several files; pick the best hit or use find_related to trace neighbors.",
212
+ suggested_next: "find_related",
213
+ expand: expandHint,
214
+ };
215
+ }
216
+
217
+ if (strongLead && (narrowQuery || lineSpan >= 15)) {
218
+ return {
219
+ sufficient: true,
220
+ reason:
221
+ results.length === 1
222
+ ? "Single focused hit — answer from this chunk unless you are editing."
223
+ : `Top hit leads (${scoreRatio === Number.POSITIVE_INFINITY ? "only result" : `${scoreRatio.toFixed(1)}x`}).`,
224
+ suggested_next: "answer",
225
+ };
226
+ }
227
+
228
+ return {
229
+ sufficient: true,
230
+ reason: "Top hit is likely enough; call expand if the chunk looks cut off.",
231
+ suggested_next: "answer",
232
+ expand: expandHint,
233
+ };
234
+ }
235
+
82
236
  export function formatResults(
83
237
  query: string,
84
238
  results: SearchResult[],
85
239
  options?: { repoRoot?: string | null },
86
- ): { query: string; results: Record<string, unknown>[] } {
240
+ ): { query: string; guidance: SearchGuidance; results: Record<string, unknown>[] } {
87
241
  const repoRoot = options?.repoRoot ?? null;
88
242
  return {
89
243
  query,
244
+ guidance: assessSearchGuidance(query, results),
90
245
  results: results.map((result) => ({
91
246
  chunk: chunkToResponseDict(result.chunk, repoRoot),
92
247
  score: result.score,
@@ -94,6 +249,36 @@ export function formatResults(
94
249
  };
95
250
  }
96
251
 
252
+ export function formatExpandResults(
253
+ filePath: string,
254
+ line: number,
255
+ anchor: Chunk | null,
256
+ expanded: Chunk[],
257
+ options?: { repoRoot?: string | null; before?: number; after?: number },
258
+ ): ExpandResults {
259
+ const repoRoot = options?.repoRoot ?? null;
260
+ const indexedPath = toIndexedFilePath(filePath, repoRoot) || filePath;
261
+ const suggestedNext: ExpandGuidance["suggested_next"] = expanded.length > 0 ? "answer" : "search";
262
+
263
+ return {
264
+ file_path: indexedPath,
265
+ line,
266
+ chunk_count: expanded.length,
267
+ guidance: {
268
+ sufficient: expanded.length > 1,
269
+ reason:
270
+ expanded.length === 0
271
+ ? "No chunks at this location."
272
+ : expanded.length === 1
273
+ ? "Single chunk at location; call expand with higher before/after if still truncated."
274
+ : `Returned ${expanded.length} adjacent chunks in ${indexedPath}.`,
275
+ suggested_next: suggestedNext,
276
+ },
277
+ anchor: anchor ? chunkToResponseDict(anchor, repoRoot) : null,
278
+ chunks: expanded.map((chunk) => chunkToResponseDict(chunk, repoRoot)),
279
+ };
280
+ }
281
+
97
282
  export function resolveContent(raw: string[]): ContentType[] {
98
283
  if (raw.includes("all")) {
99
284
  return ["code", "docs", "config"];