@takara-ai/miru-code 0.4.12 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "0.4.12",
3
+ "version": "0.5.0",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,9 @@
50
50
  "lint": "biome check .",
51
51
  "lint:fix": "biome check --write .",
52
52
  "prepublishOnly": "bun run typecheck && bun test",
53
+ "benchmark:ab": "bun run scripts/search-ab-local.ts",
54
+ "benchmark:ab-multi": "bun run scripts/search-ab-multi.ts",
55
+ "benchmark:recall": "bun run scripts/benchmark-recall-tokens.ts",
53
56
  "benchmark:quality": "bun run scripts/benchmark-quality.ts",
54
57
  "benchmark:quantization": "bun run scripts/benchmark-quantization.ts"
55
58
  },
@@ -3,30 +3,3 @@ name: miru-code
3
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
-
7
- When Miru MCP is available, use MCP `search`, `expand`, and `find_related` — not Grep, Glob, or Read-for-exploration.
8
-
9
- | Task | Tool |
10
- |------|------|
11
- | How/where/what handles X? | Miru `search` (once) |
12
- | Same file, more context | Miru `expand` |
13
- | Similar implementations | Miru `find_related` |
14
- | Exact literal confirmation | Grep |
15
- | Edit a located file | Read |
16
-
17
- ### MCP workflow
18
-
19
- 1. Call `search` with `repo` set to the project root.
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.
23
-
24
- ### CLI fallback
25
-
26
- ```bash
27
- miru search "authentication flow" .
28
- miru expand src/auth.ts 42 .
29
- miru find-related src/auth.ts 42 .
30
- ```
31
-
32
- If `miru` is not on `$PATH`, use `bunx @takara-ai/miru-code`.
@@ -2,15 +2,3 @@
2
2
  name: miru-code
3
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
-
6
- When Miru MCP is available, use `search`, `expand`, and `find_related` instead of `grep_search`, `codebase_search`, or broad file reads.
7
-
8
- | Task | Tool |
9
- |------|------|
10
- | How/where/what handles X? | Miru `search` (once) |
11
- | Same file, more context | Miru `expand` |
12
- | Similar implementations | Miru `find_related` |
13
- | Exact literal confirmation | grep_search |
14
- | Edit a located file | read_file |
15
-
16
- Pass project root as `repo` on every MCP call.
@@ -2,29 +2,3 @@
2
2
  name: miru-code
3
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
-
6
- Use Miru MCP tools when connected:
7
-
8
- - `search` — one call per question; always pass project root as `repo`
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
11
-
12
- Do not re-search with paraphrases after a good first hit. Do not read whole files for exploration.
13
-
14
- | Task | Tool |
15
- |------|------|
16
- | How/where/what handles X? | Miru `search` (once) |
17
- | Same file, more context | Miru `expand` |
18
- | Similar implementations | Miru `find_related` |
19
- | Exact literal confirmation | Grep |
20
- | Edit a located file | Read |
21
-
22
- CLI fallback when MCP is unavailable:
23
-
24
- ```bash
25
- miru search "authentication flow" .
26
- miru expand src/auth.ts 42 .
27
- miru find-related src/auth.ts 42 .
28
- ```
29
-
30
- If `miru` is not on `$PATH`, use `bunx @takara-ai/miru-code`.
@@ -2,17 +2,3 @@
2
2
  name: miru-code
3
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
-
6
- When Miru MCP is available, use `search`, `expand`, and `find_related` instead of `grep_search`, `glob`, or `read_file` for codebase exploration.
7
-
8
- | Task | Tool |
9
- |------|------|
10
- | How/where/what handles X? | Miru `search` (once) |
11
- | Same file, more context | Miru `expand` |
12
- | Similar implementations | Miru `find_related` |
13
- | Exact literal confirmation | grep_search |
14
- | Edit a located file | read_file |
15
-
16
- Pass project root as `repo` on every MCP call. Do not re-search with paraphrases after a good first hit.
17
-
18
- CLI fallback: `miru search "query" .` or `bunx @takara-ai/miru-code search "query" .`
@@ -2,9 +2,3 @@
2
2
  name: miru-code
3
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
-
6
- When Miru MCP is available, use `search`, `expand`, and `find_related` before any built-in search or broad file reads.
7
-
8
- Pass project root as `repo`. Use `expand` for truncated hits in the same file. Use Grep only for exact literal confirmation.
9
-
10
- CLI fallback: `miru search "query" .`
@@ -3,9 +3,3 @@ name: miru-code
3
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
-
7
- When Miru MCP is available, use `search`, `expand`, and `find_related` instead of grep, glob, or read-for-exploration.
8
-
9
- Pass project root as `repo` on every MCP call.
10
-
11
- CLI fallback: `miru search "query" .`
package/src/agents.ts CHANGED
@@ -1,17 +1,56 @@
1
1
  import { mkdir, readFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import {
4
+ buildSubagentBody,
5
+ type NativeToolNames,
6
+ } from "./installer/search-policy.ts";
3
7
 
4
8
  export type AgentId = "claude" | "copilot" | "cursor" | "gemini" | "kiro" | "opencode";
5
9
 
6
10
  const AGENTS_DIR = join(import.meta.dir, "agents");
7
11
 
12
+ const AGENT_NATIVE_TOOLS: Record<AgentId, NativeToolNames> = {
13
+ claude: {
14
+ explorationDenied: "Grep, Glob, SemanticSearch, or Read",
15
+ grep: "Grep",
16
+ read: "Read",
17
+ },
18
+ cursor: {
19
+ explorationDenied: "Grep, Glob, SemanticSearch, or Read",
20
+ grep: "Grep",
21
+ read: "Read",
22
+ },
23
+ copilot: {
24
+ explorationDenied: "grep_search, codebase_search, glob, or read_file",
25
+ grep: "grep_search",
26
+ read: "read_file",
27
+ },
28
+ gemini: {
29
+ explorationDenied: "grep_search, glob, or read_file",
30
+ grep: "grep_search",
31
+ read: "read_file",
32
+ },
33
+ kiro: {
34
+ explorationDenied: "built-in grep, file search, or broad file reads",
35
+ grep: "Grep",
36
+ read: "Read",
37
+ },
38
+ opencode: {
39
+ explorationDenied: "grep, glob, or read-for-exploration",
40
+ grep: "grep",
41
+ read: "read",
42
+ },
43
+ };
44
+
8
45
  export function agentDestination(agent: AgentId): string {
9
46
  const baseDir = agent === "copilot" ? ".github" : `.${agent}`;
10
47
  return join(baseDir, "agents", "miru-code.md");
11
48
  }
12
49
 
13
50
  export async function loadAgentTemplate(agent: AgentId): Promise<string> {
14
- return readFile(join(AGENTS_DIR, `${agent}.md`), "utf-8");
51
+ const frontmatter = (await readFile(join(AGENTS_DIR, `${agent}.md`), "utf-8")).trim();
52
+ const body = buildSubagentBody(AGENT_NATIVE_TOOLS[agent]);
53
+ return `${frontmatter}\n\n${body}\n`;
15
54
  }
16
55
 
17
56
  export async function writeAgentFile(
@@ -9,8 +9,10 @@ import { BM25Index } from "./bm25.ts";
9
9
  import { walkFiles } from "./file-walker.ts";
10
10
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
11
11
  import type { SemanticIndex } from "./semantic-index.ts";
12
+ import { loadRootEntryChunks } from "./entry-chunks.ts";
12
13
  import { enrichForBm25 } from "./sparse.ts";
13
14
  import { buildSemanticIndex } from "./vector-storage.ts";
15
+ import { searchImprovementsEnabled } from "../ranking/features.ts";
14
16
 
15
17
  const DEFAULT_PIPELINE_EMBED_BATCH = 64;
16
18
  const DEFAULT_PIPELINE_EMBED_INFLIGHT = 8;
@@ -145,6 +147,17 @@ export async function createIndexFromPath(
145
147
  await Promise.race(runningFiles);
146
148
  }
147
149
 
150
+ if (searchImprovementsEnabled()) {
151
+ const entryChunks = await loadRootEntryChunks(
152
+ resolved,
153
+ displayRoot ? root : undefined,
154
+ );
155
+ if (entryChunks.length > 0) {
156
+ readyBySeq.set(fileSeq++, entryChunks);
157
+ flushReadySequentialChunks();
158
+ }
159
+ }
160
+
148
161
  flushReadySequentialChunks();
149
162
  await maybeScheduleEmbed(true);
150
163
  await Promise.all(embedPromises);
@@ -0,0 +1,71 @@
1
+ import { join, relative } from "node:path";
2
+ import type { Chunk } from "../types.ts";
3
+
4
+ function indexedPath(absPath: string, repoRoot: string, displayRoot?: string): string {
5
+ if (displayRoot) {
6
+ return relative(displayRoot, absPath).replace(/\\/g, "/");
7
+ }
8
+ return absPath.replace(/\\/g, "/");
9
+ }
10
+
11
+ async function packageJsonEntryChunks(
12
+ absPath: string,
13
+ repoRoot: string,
14
+ displayRoot?: string,
15
+ ): Promise<Chunk[]> {
16
+ let pkg: Record<string, unknown>;
17
+ try {
18
+ pkg = JSON.parse(await Bun.file(absPath).text()) as Record<string, unknown>;
19
+ } catch {
20
+ return [];
21
+ }
22
+
23
+ const entryLines = ["[package entry]"];
24
+
25
+ const bin = pkg.bin;
26
+ if (typeof bin === "string") {
27
+ entryLines.push(`bin: ${bin}`);
28
+ } else if (bin && typeof bin === "object") {
29
+ for (const [name, target] of Object.entries(bin)) {
30
+ entryLines.push(`bin ${name}: ${String(target)}`);
31
+ }
32
+ }
33
+
34
+ if (typeof pkg.main === "string") {
35
+ entryLines.push(`main: ${pkg.main}`);
36
+ }
37
+
38
+ const types = pkg.types ?? pkg.typings;
39
+ if (typeof types === "string") {
40
+ entryLines.push(`types: ${types}`);
41
+ }
42
+
43
+ if (entryLines.length <= 1) {
44
+ return [];
45
+ }
46
+
47
+ const filePath = indexedPath(absPath, repoRoot, displayRoot);
48
+ const content = `${entryLines.join("\n")}\n`;
49
+
50
+ return [
51
+ {
52
+ content,
53
+ file_path: filePath,
54
+ start_line: 1,
55
+ end_line: entryLines.length,
56
+ language: "json",
57
+ },
58
+ ];
59
+ }
60
+
61
+ /** Synthetic chunks for root config entry points (bin/main) when indexing code repos. */
62
+ export async function loadRootEntryChunks(
63
+ repoRoot: string,
64
+ displayRoot?: string,
65
+ ): Promise<Chunk[]> {
66
+ const pkgPath = join(repoRoot, "package.json");
67
+ if (!(await Bun.file(pkgPath).exists())) {
68
+ return [];
69
+ }
70
+ return packageJsonEntryChunks(pkgPath, repoRoot, displayRoot);
71
+ }
@@ -1,3 +1,5 @@
1
+ import { SEARCH_GUARD_EXPAND_HINT } from "../search-policy.ts";
2
+
1
3
  const REDIRECT_PREFIX =
2
4
  "Use Miru MCP instead of built-in search tools (pass project root as `repo`). " +
3
5
  "Grep/Glob/Shell are only for exact literal confirmation or non-code tasks.";
@@ -119,10 +121,7 @@ export function searchGuardBlockReason(
119
121
  toolInput: Record<string, unknown>,
120
122
  ): string {
121
123
  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
- );
124
+ return `${REDIRECT_PREFIX} Try Miru \`search\` with query "${query}". ${SEARCH_GUARD_EXPAND_HINT}`;
126
125
  }
127
126
 
128
127
  /** Map host-specific stdin JSON into a common evaluation shape. */
@@ -15,11 +15,31 @@ 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 createFrameWriter(): (lines: string[]) => void {
25
+ let previousLineCount = 0;
26
+
27
+ return (lines: string[]) => {
28
+ if (previousLineCount > 0) {
29
+ writeRaw(`${ESC}[${previousLineCount}A${CURSOR_START}`);
30
+ }
31
+ writeRaw(`${CLEAR_LINE}${lines.join(`\n${CLEAR_LINE}`)}\n`);
32
+ const extra = previousLineCount - lines.length;
33
+ if (extra > 0) {
34
+ for (let i = 0; i < extra; i++) {
35
+ writeRaw(`${CLEAR_LINE}\n`);
36
+ }
37
+ writeRaw(`${ESC}[${extra}A${CURSOR_START}`);
38
+ }
39
+ previousLineCount = lines.length;
40
+ };
41
+ }
42
+
23
43
  function readKey(): Promise<Buffer> {
24
44
  return new Promise((resolve, reject) => {
25
45
  const onData = (chunk: Buffer) => {
@@ -90,12 +110,12 @@ async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
90
110
  }
91
111
  }
92
112
 
93
- function renderMultiSelect<T>(
113
+ function buildMultiSelectLines<T>(
94
114
  title: string,
95
115
  items: Array<{ label: string; value: T; checked: boolean }>,
96
116
  cursor: number,
97
117
  footer: string,
98
- ): void {
118
+ ): string[] {
99
119
  const lines: string[] = [];
100
120
  lines.push("");
101
121
  lines.push(bold(title));
@@ -113,7 +133,7 @@ function renderMultiSelect<T>(
113
133
  }
114
134
  lines.push("");
115
135
  lines.push(dim(footer));
116
- writeRaw(`${CLEAR_LINE}${lines.join(`\n${CLEAR_LINE}`)}\n`);
136
+ return lines;
117
137
  }
118
138
 
119
139
  export async function promptMultiSelect<T>(
@@ -130,10 +150,11 @@ export async function promptMultiSelect<T>(
130
150
 
131
151
  const footer = "↑↓ move space toggle a all enter confirm ctrl-c cancel";
132
152
  let cursor = 0;
153
+ const drawFrame = createFrameWriter();
133
154
 
134
155
  return withRawMode(async () => {
135
156
  while (true) {
136
- renderMultiSelect(title, items, cursor, footer);
157
+ drawFrame(buildMultiSelectLines(title, items, cursor, footer));
137
158
  const key = parseKey(await readKey());
138
159
 
139
160
  if (key === "ctrl-c") {
@@ -163,11 +184,13 @@ export async function promptMultiSelect<T>(
163
184
  });
164
185
  }
165
186
 
166
- function renderConfirm(question: string, yesSelected: boolean): void {
187
+ function buildConfirmLines(question: string, yesSelected: boolean): string[] {
167
188
  const yes = yesSelected ? green(bold(" Yes ")) : dim(" Yes ");
168
189
  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`);
190
+ return [
191
+ `${question} ${yes} / ${no}`,
192
+ dim("←→ or y/n to choose enter to confirm ctrl-c to cancel"),
193
+ ];
171
194
  }
172
195
 
173
196
  export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
@@ -186,10 +209,11 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
186
209
  }
187
210
 
188
211
  let yesSelected = defaultYes;
212
+ const drawFrame = createFrameWriter();
189
213
 
190
214
  return withRawMode(async () => {
191
215
  while (true) {
192
- renderConfirm(question, yesSelected);
216
+ drawFrame(buildConfirmLines(question, yesSelected));
193
217
  const key = parseKey(await readKey());
194
218
 
195
219
  if (key === "ctrl-c") {
@@ -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.";
package/src/mcp/server.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  MAX_MCP_TOP_K,
13
13
  resolveChunk,
14
14
  } from "../utils.ts";
15
+ import {
16
+ MCP_EXPAND_TOOL_DESCRIPTION,
17
+ MCP_FIND_RELATED_TOOL_DESCRIPTION,
18
+ MCP_SEARCH_TOOL_DESCRIPTION,
19
+ MCP_SERVER_INSTRUCTIONS,
20
+ } from "../installer/search-policy.ts";
15
21
  import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
16
22
 
17
23
  const REPO_DESCRIPTION =
@@ -19,14 +25,6 @@ const REPO_DESCRIPTION =
19
25
  "Pass the project root for local workspaces. " +
20
26
  "The index is built on the first tool call and cached for the session.";
21
27
 
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
28
  export function createMcpServer(cache: IndexCache): McpServer {
31
29
  const server = new McpServer(
32
30
  {
@@ -34,7 +32,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
34
32
  version: "0.1.0",
35
33
  },
36
34
  {
37
- instructions: SERVER_INSTRUCTIONS,
35
+ instructions: MCP_SERVER_INSTRUCTIONS,
38
36
  },
39
37
  );
40
38
 
@@ -42,9 +40,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
42
40
  "search",
43
41
  {
44
42
  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
+ `${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,15 @@ 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("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
94
90
  repo: z.string().describe(REPO_DESCRIPTION),
95
91
  before: z
96
92
  .number()
@@ -143,16 +139,17 @@ export function createMcpServer(cache: IndexCache): McpServer {
143
139
  server.registerTool(
144
140
  "find_related",
145
141
  {
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.",
142
+ description: MCP_FIND_RELATED_TOOL_DESCRIPTION,
149
143
  inputSchema: {
150
144
  file_path: z
151
145
  .string()
152
146
  .describe(
153
147
  "Path from a search hit (`file_path` or `absolute_path` for local repos).",
154
148
  ),
155
- line: z.number().int().describe("Line number (1-indexed)."),
149
+ line: z
150
+ .number()
151
+ .int()
152
+ .describe("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
156
153
  repo: z.string().describe(REPO_DESCRIPTION),
157
154
  top_k: z
158
155
  .number()
@@ -181,7 +178,10 @@ export function createMcpServer(cache: IndexCache): McpServer {
181
178
  }
182
179
  return toolText(
183
180
  JSON.stringify(
184
- formatResults(`Chunks related to ${filePath}:${line}`, results, { repoRoot }),
181
+ formatResults(`Chunks related to ${filePath}:${line}`, results, {
182
+ repoRoot,
183
+ snippet: true,
184
+ }),
185
185
  ),
186
186
  );
187
187
  } catch (err) {
@@ -2,6 +2,12 @@ import { basename, dirname } from "node:path";
2
2
  import { splitIdentifier } from "../tokens.ts";
3
3
  import type { Chunk } from "../types.ts";
4
4
  import { chunkKey } from "../types.ts";
5
+ import { searchImprovementsEnabled } from "./features.ts";
6
+ import {
7
+ boostExactStemMatches,
8
+ boostLocationSignals,
9
+ penalizeInstallerForLocation,
10
+ } from "./location.ts";
5
11
 
6
12
  const SYMBOL_QUERY_RE =
7
13
  /^(?:(?:[A-Za-z_][A-Za-z0-9_]*(?:(?:::|\\|->|\.)[A-Za-z_][A-Za-z0-9_]*)+)|_(?:[A-Za-z0-9_]*)|(?:[A-Za-z][A-Za-z0-9]*[A-Z_][A-Za-z0-9_]*)|(?:[A-Z][A-Za-z0-9]*))$/;
@@ -165,6 +171,11 @@ export function applyQueryBoost(
165
171
  } else {
166
172
  boostStemMatches(combinedScores, query, maxScore, chunksByKey);
167
173
  boostEmbeddedSymbols(combinedScores, query, maxScore, allChunks, chunksByKey);
174
+ if (searchImprovementsEnabled()) {
175
+ boostExactStemMatches(combinedScores, query, maxScore, chunksByKey);
176
+ boostLocationSignals(combinedScores, query, maxScore, allChunks, chunksByKey);
177
+ penalizeInstallerForLocation(combinedScores, query, chunksByKey);
178
+ }
168
179
  }
169
180
  return combinedScores;
170
181
  }
@@ -0,0 +1,11 @@
1
+ /** Toggle search ranking/indexing improvements (A/B via MIRU_SEARCH_V2=0|1). */
2
+ export function searchImprovementsEnabled(): boolean {
3
+ const value = process.env.MIRU_SEARCH_V2;
4
+ if (value === "0" || value === "false") {
5
+ return false;
6
+ }
7
+ if (value === "1" || value === "true") {
8
+ return true;
9
+ }
10
+ return true;
11
+ }
@@ -0,0 +1,241 @@
1
+ import { basename } from "node:path";
2
+ import type { Chunk } from "../types.ts";
3
+ import { chunkKey } from "../types.ts";
4
+
5
+ const LOCATION_QUERY_RE =
6
+ /\b(where(?:'s| is)?|entry\s*point|bootstrap|starts?|live[s]?|located|defined|wiring)\b/i;
7
+
8
+ const ENTRY_POINT_QUERY_RE = /\b(entry\s*point|bootstrap|starts?|wiring|command\s*line)\b/i;
9
+
10
+ const INTEGRATION_QUERY_RE = /\b(install|uninstall|hook|agent|configure|setup)\b/i;
11
+
12
+ const INSTALLER_PATH_RE = /(?:^|\/)installer(?:\/|$)/;
13
+
14
+ const ENTRY_POINT_CONTENT_RE =
15
+ /#!\/usr\/bin|(?:^|\n)\s*(?:async\s+)?function\s+main\s*\(|(?:^|\n)\s*if\s*\(\s*require\.main\s*===\s*module/;
16
+
17
+ const PACKAGE_ENTRY_RE = /^\[package entry\]/;
18
+
19
+ const LOCATION_BOOST_MULTIPLIER = 2.5;
20
+ const INSTALLER_LOCATION_PENALTY = 0.35;
21
+
22
+ /** Too common as filenames to get exact-stem boosts from incidental query tokens. */
23
+ const IMPLEMENTATION_HINTS = new Set(
24
+ [
25
+ "command",
26
+ "handler",
27
+ "router",
28
+ "routing",
29
+ "middleware",
30
+ "model",
31
+ "channel",
32
+ "request",
33
+ "response",
34
+ "dispatch",
35
+ "pipeline",
36
+ ].map((w) => w.toLowerCase()),
37
+ );
38
+
39
+ const GENERIC_STEM_DENY = new Set(
40
+ [
41
+ "route",
42
+ "routes",
43
+ "router",
44
+ "routing",
45
+ "handler",
46
+ "middleware",
47
+ "channel",
48
+ "model",
49
+ "models",
50
+ "request",
51
+ "response",
52
+ "utils",
53
+ "util",
54
+ "server",
55
+ "client",
56
+ "application",
57
+ "dispatch",
58
+ "core",
59
+ "lib",
60
+ "main",
61
+ "mod",
62
+ "test",
63
+ "tests",
64
+ "schema",
65
+ "config",
66
+ "helper",
67
+ "helpers",
68
+ "engine",
69
+ "pipeline",
70
+ "plug",
71
+ "plugs",
72
+ "command",
73
+ "commands",
74
+ "index",
75
+ ].map((w) => w.toLowerCase()),
76
+ );
77
+
78
+ export function isLocationQuery(query: string): boolean {
79
+ return LOCATION_QUERY_RE.test(query);
80
+ }
81
+
82
+ export function isEntryPointQuery(query: string): boolean {
83
+ return ENTRY_POINT_QUERY_RE.test(query);
84
+ }
85
+
86
+ function fileStemMatchesQuery(filePath: string, query: string): boolean {
87
+ const stem = basename(filePath)
88
+ .replace(/\.[^.]+$/, "")
89
+ .toLowerCase();
90
+ const rawTokens = (query.match(/[a-zA-Z_][a-zA-Z0-9_-]*/g) ?? []).map((t) => t.toLowerCase());
91
+
92
+ if (rawTokens.includes(stem)) {
93
+ return true;
94
+ }
95
+
96
+ for (const token of rawTokens) {
97
+ if (token.includes("-") && token.split("-").includes(stem)) {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ const stemNorm = stem.replace(/[-_]/g, "");
103
+ return rawTokens.some((t) => t.replace(/[-_]/g, "") === stemNorm);
104
+ }
105
+
106
+ export function isIntegrationQuery(query: string): boolean {
107
+ return INTEGRATION_QUERY_RE.test(query);
108
+ }
109
+
110
+ function chunkLooksLikeEntryPoint(chunk: Chunk): boolean {
111
+ if (PACKAGE_ENTRY_RE.test(chunk.content)) {
112
+ return true;
113
+ }
114
+ return ENTRY_POINT_CONTENT_RE.test(chunk.content);
115
+ }
116
+
117
+ export function boostLocationSignals(
118
+ boosted: Map<string, number>,
119
+ query: string,
120
+ maxScore: number,
121
+ allChunks: Chunk[],
122
+ chunksByKey: Map<string, Chunk>,
123
+ ): void {
124
+ if (!isLocationQuery(query)) {
125
+ return;
126
+ }
127
+
128
+ const entryPointQuery = isEntryPointQuery(query);
129
+ const packageBoost = maxScore * (entryPointQuery ? LOCATION_BOOST_MULTIPLIER : 0.75);
130
+ const codeBoost = maxScore * LOCATION_BOOST_MULTIPLIER;
131
+
132
+ for (const chunk of allChunks) {
133
+ if (!chunkLooksLikeEntryPoint(chunk)) {
134
+ continue;
135
+ }
136
+
137
+ const isPackageEntry = PACKAGE_ENTRY_RE.test(chunk.content);
138
+ if (isPackageEntry) {
139
+ const key = chunkKey(chunk);
140
+ boosted.set(key, (boosted.get(key) ?? 0) + packageBoost);
141
+ chunksByKey.set(key, chunk);
142
+ continue;
143
+ }
144
+
145
+ if (!entryPointQuery && !fileStemMatchesQuery(chunk.file_path, query)) {
146
+ continue;
147
+ }
148
+
149
+ const key = chunkKey(chunk);
150
+ boosted.set(key, (boosted.get(key) ?? 0) + codeBoost);
151
+ chunksByKey.set(key, chunk);
152
+ }
153
+ }
154
+
155
+ export function penalizeInstallerForLocation(
156
+ boosted: Map<string, number>,
157
+ query: string,
158
+ chunksByKey: Map<string, Chunk>,
159
+ ): void {
160
+ if (!isLocationQuery(query) || isIntegrationQuery(query)) {
161
+ return;
162
+ }
163
+
164
+ for (const [key, score] of boosted) {
165
+ const chunk = chunksByKey.get(key);
166
+ if (!chunk) {
167
+ continue;
168
+ }
169
+ if (INSTALLER_PATH_RE.test(chunk.file_path.replace(/\\/g, "/"))) {
170
+ boosted.set(key, score * INSTALLER_LOCATION_PENALTY);
171
+ }
172
+ }
173
+ }
174
+
175
+ function hasSpecificImplementationTarget(
176
+ query: string,
177
+ chunksByKey: Map<string, Chunk>,
178
+ ): boolean {
179
+ const lowered = query.toLowerCase();
180
+ for (const hint of IMPLEMENTATION_HINTS) {
181
+ if (!lowered.includes(hint)) {
182
+ continue;
183
+ }
184
+ for (const chunk of chunksByKey.values()) {
185
+ const stem = basename(chunk.file_path)
186
+ .replace(/\.[^.]+$/, "")
187
+ .toLowerCase();
188
+ if (stem === hint) {
189
+ return true;
190
+ }
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+
196
+ export function boostExactStemMatches(
197
+ boosted: Map<string, number>,
198
+ query: string,
199
+ maxScore: number,
200
+ chunksByKey: Map<string, Chunk>,
201
+ ): void {
202
+ const keywords = new Set(
203
+ (query.match(/[a-zA-Z_][a-zA-Z0-9_-]*/g) ?? [])
204
+ .filter((w) => w.length > 2)
205
+ .map((w) => w.toLowerCase()),
206
+ );
207
+ if (keywords.size === 0) {
208
+ return;
209
+ }
210
+
211
+ const boost = maxScore * 0.75;
212
+ const preferImplementation = hasSpecificImplementationTarget(query, chunksByKey);
213
+
214
+ for (const key of [...boosted.keys()]) {
215
+ const chunk = chunksByKey.get(key);
216
+ if (!chunk) {
217
+ continue;
218
+ }
219
+ const stem = basename(chunk.file_path)
220
+ .replace(/\.[^.]+$/, "")
221
+ .toLowerCase();
222
+ const stemNorm = stem.replace(/[-_]/g, "");
223
+ for (const keyword of keywords) {
224
+ if (GENERIC_STEM_DENY.has(keyword)) {
225
+ continue;
226
+ }
227
+ if (preferImplementation && stem === keyword && !IMPLEMENTATION_HINTS.has(stem)) {
228
+ continue;
229
+ }
230
+ const keywordNorm = keyword.replace(/[-_]/g, "");
231
+ if (stem === keyword || stemNorm === keywordNorm) {
232
+ boosted.set(key, (boosted.get(key) ?? 0) + boost);
233
+ break;
234
+ }
235
+ const parts = keyword.split("-").filter((p) => p.length > 2);
236
+ if (parts.length > 1 && parts.some((p) => p === stem)) {
237
+ continue;
238
+ }
239
+ }
240
+ }
241
+ }
package/src/search.ts CHANGED
@@ -4,6 +4,8 @@ import type { SemanticIndex } from "./index/semantic-index.ts";
4
4
  import { selectorToMask } from "./index/sparse.ts";
5
5
  import { selectTopKScoreIndices } from "./index/topk.ts";
6
6
  import { applyQueryBoost, boostMultiChunkFiles } from "./ranking/boosting.ts";
7
+ import { searchImprovementsEnabled } from "./ranking/features.ts";
8
+ import { isLocationQuery } from "./ranking/location.ts";
7
9
  import { rerankTopk } from "./ranking/penalties.ts";
8
10
  import { resolveAlpha } from "./ranking/weighting.ts";
9
11
  import { tokenize } from "./tokens.ts";
@@ -93,7 +95,8 @@ export async function hybridSearch(options: {
93
95
  } = options;
94
96
 
95
97
  const alphaWeight = resolveAlpha(query, alpha);
96
- const candidateCount = topK * 5;
98
+ const candidateCount =
99
+ searchImprovementsEnabled() && isLocationQuery(query) ? topK * 10 : topK * 5;
97
100
  const chunksByKey = new Map(chunks.map((c) => [chunkKey(c), c]));
98
101
 
99
102
  const queryVecPromise = embeddings.embedQuery(query);
package/src/snippet.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { envOptionalInt } from "./env.ts";
2
+ import { tokenize } from "./tokens.ts";
3
+ import type { Chunk, SearchResult } from "./types.ts";
4
+
5
+ const SNIPPET_STOPWORDS = new Set(
6
+ "a an and are as at be by do does for from has have how if in is it not of on or the to was what when where which who why with".split(
7
+ " ",
8
+ ),
9
+ );
10
+
11
+ export interface SnippetMeta {
12
+ truncated: boolean;
13
+ anchor_line: number;
14
+ full_start_line: number;
15
+ full_end_line: number;
16
+ }
17
+
18
+ export interface SnippetResult {
19
+ chunk: Chunk;
20
+ meta: SnippetMeta;
21
+ }
22
+
23
+ export function resolveSnippetLines(): number {
24
+ return envOptionalInt(["MIRU_SNIPPET_LINES"], 3) ?? 15;
25
+ }
26
+
27
+ export function searchSnippetsEnabled(): boolean {
28
+ const value = process.env.MIRU_SEARCH_SNIPPETS;
29
+ if (value === "0" || value === "false") {
30
+ return false;
31
+ }
32
+ if (value === "1" || value === "true") {
33
+ return true;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ function queryMatchTerms(query: string): Set<string> {
39
+ const terms = new Set<string>();
40
+ for (const tok of tokenize(query)) {
41
+ if (tok.length >= 3 && !SNIPPET_STOPWORDS.has(tok)) {
42
+ terms.add(tok);
43
+ }
44
+ }
45
+ for (const word of query.match(/[a-zA-Z_][a-zA-Z0-9_-]*/g) ?? []) {
46
+ const lower = word.toLowerCase();
47
+ if (lower.length >= 3 && !SNIPPET_STOPWORDS.has(lower)) {
48
+ terms.add(lower);
49
+ }
50
+ }
51
+ return terms;
52
+ }
53
+
54
+ function scoreLine(line: string, terms: Set<string>): number {
55
+ const lower = line.toLowerCase();
56
+ let score = 0;
57
+ for (const term of terms) {
58
+ if (lower.includes(term)) {
59
+ score++;
60
+ }
61
+ }
62
+ return score;
63
+ }
64
+
65
+ /** Pick the 0-based line index inside `content` that best matches the query. */
66
+ export function anchorLineOffset(content: string, query: string): number {
67
+ const lines = content.split("\n");
68
+ if (lines.length === 0) {
69
+ return 0;
70
+ }
71
+
72
+ const terms = queryMatchTerms(query);
73
+ let bestIndex = Math.floor(lines.length / 2);
74
+ let bestScore = -1;
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i] ?? "";
78
+ const score = scoreLine(line, terms);
79
+ if (score > bestScore) {
80
+ bestScore = score;
81
+ bestIndex = i;
82
+ }
83
+ }
84
+
85
+ return bestIndex;
86
+ }
87
+
88
+ export function trimChunkToSnippet(
89
+ chunk: Chunk,
90
+ query: string,
91
+ linesEachSide = resolveSnippetLines(),
92
+ ): SnippetResult {
93
+ const lines = chunk.content.split("\n");
94
+ if (lines.length === 0) {
95
+ return {
96
+ chunk,
97
+ meta: {
98
+ truncated: false,
99
+ anchor_line: chunk.start_line,
100
+ full_start_line: chunk.start_line,
101
+ full_end_line: chunk.end_line,
102
+ },
103
+ };
104
+ }
105
+
106
+ const anchorOffset = anchorLineOffset(chunk.content, query);
107
+ const startOffset = Math.max(0, anchorOffset - linesEachSide);
108
+ const endOffset = Math.min(lines.length, anchorOffset + linesEachSide + 1);
109
+ const truncated = startOffset > 0 || endOffset < lines.length;
110
+
111
+ if (!truncated) {
112
+ return {
113
+ chunk,
114
+ meta: {
115
+ truncated: false,
116
+ anchor_line: chunk.start_line + anchorOffset,
117
+ full_start_line: chunk.start_line,
118
+ full_end_line: chunk.end_line,
119
+ },
120
+ };
121
+ }
122
+
123
+ const snippetContent = lines.slice(startOffset, endOffset).join("\n");
124
+ return {
125
+ chunk: {
126
+ ...chunk,
127
+ content: snippetContent,
128
+ start_line: chunk.start_line + startOffset,
129
+ end_line: chunk.start_line + endOffset - 1,
130
+ },
131
+ meta: {
132
+ truncated: true,
133
+ anchor_line: chunk.start_line + anchorOffset,
134
+ full_start_line: chunk.start_line,
135
+ full_end_line: chunk.end_line,
136
+ },
137
+ };
138
+ }
139
+
140
+ export function applySnippetsToResults(
141
+ results: SearchResult[],
142
+ query: string,
143
+ linesEachSide?: number,
144
+ ): Array<{ result: SearchResult; meta: SnippetMeta }> {
145
+ const radius = linesEachSide ?? resolveSnippetLines();
146
+ return results.map((result) => {
147
+ const { chunk, meta } = trimChunkToSnippet(result.chunk, query, radius);
148
+ return { result: { chunk, score: result.score }, meta };
149
+ });
150
+ }
151
+
152
+ export function estimateResultTokens(results: SearchResult[]): number {
153
+ return results.reduce((sum, r) => sum + Math.floor(r.chunk.content.length / 4), 0);
154
+ }
package/src/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join, relative, resolve, sep } from "node:path";
2
+ import { applySnippetsToResults, searchSnippetsEnabled } from "./snippet.ts";
2
3
  import type { Chunk, ContentType, SearchResult } from "./types.ts";
3
4
  import { chunkToDict } from "./types.ts";
4
5
 
@@ -157,15 +158,35 @@ export function expandChunksAtLine(
157
158
  export function formatResults(
158
159
  query: string,
159
160
  results: SearchResult[],
160
- options?: { repoRoot?: string | null },
161
+ options?: { repoRoot?: string | null; snippet?: boolean },
161
162
  ): { query: string; results: Record<string, unknown>[] } {
162
163
  const repoRoot = options?.repoRoot ?? null;
164
+ const useSnippet = options?.snippet ?? searchSnippetsEnabled();
165
+ const payload = useSnippet ? applySnippetsToResults(results, query) : null;
166
+
163
167
  return {
164
168
  query,
165
- results: results.map((result) => ({
166
- chunk: chunkToResponseDict(result.chunk, repoRoot),
167
- score: result.score,
168
- })),
169
+ results: results.map((result, index) => {
170
+ const entry = payload?.[index];
171
+ const chunk = entry?.result.chunk ?? result.chunk;
172
+ const dict = chunkToResponseDict(chunk, repoRoot);
173
+ if (entry?.meta.truncated) {
174
+ return {
175
+ chunk: {
176
+ ...dict,
177
+ truncated: true,
178
+ anchor_line: entry.meta.anchor_line,
179
+ full_start_line: entry.meta.full_start_line,
180
+ full_end_line: entry.meta.full_end_line,
181
+ },
182
+ score: result.score,
183
+ };
184
+ }
185
+ return {
186
+ chunk: dict,
187
+ score: result.score,
188
+ };
189
+ }),
169
190
  };
170
191
  }
171
192