@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 +4 -1
- package/src/agents/claude.md +0 -27
- package/src/agents/copilot.md +0 -12
- package/src/agents/cursor.md +0 -26
- package/src/agents/gemini.md +0 -14
- package/src/agents/kiro.md +0 -6
- package/src/agents/opencode.md +0 -6
- package/src/agents.ts +40 -1
- package/src/index/create.ts +13 -0
- package/src/index/entry-chunks.ts +71 -0
- package/src/installer/hooks/search-guard.ts +3 -4
- package/src/installer/prompt.ts +32 -8
- package/src/installer/search-policy.ts +89 -29
- package/src/mcp/server.ts +23 -23
- package/src/ranking/boosting.ts +11 -0
- package/src/ranking/features.ts +11 -0
- package/src/ranking/location.ts +241 -0
- package/src/search.ts +4 -1
- package/src/snippet.ts +154 -0
- package/src/utils.ts +26 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takara-ai/miru-code",
|
|
3
|
-
"version": "0.
|
|
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
|
},
|
package/src/agents/claude.md
CHANGED
|
@@ -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`.
|
package/src/agents/copilot.md
CHANGED
|
@@ -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.
|
package/src/agents/cursor.md
CHANGED
|
@@ -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`.
|
package/src/agents/gemini.md
CHANGED
|
@@ -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" .`
|
package/src/agents/kiro.md
CHANGED
|
@@ -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" .`
|
package/src/agents/opencode.md
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/index/create.ts
CHANGED
|
@@ -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. */
|
package/src/installer/prompt.ts
CHANGED
|
@@ -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
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
export const SNIPPET_GUIDANCE =
|
|
4
|
+
"Search returns compact snippets (~±15 lines around the best match). " +
|
|
5
|
+
"When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` " +
|
|
6
|
+
"(or `start_line`) — do not re-search or Read the whole file.";
|
|
7
|
+
|
|
8
|
+
export interface NativeToolNames {
|
|
9
|
+
explorationDenied: string;
|
|
10
|
+
grep: string;
|
|
11
|
+
read: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildSearchPolicyTable(native: NativeToolNames): string {
|
|
15
|
+
return `| Task | Use | Not |
|
|
4
16
|
|------|-----|-----|
|
|
5
|
-
| How/where/what handles X? | Miru MCP \`search\` (once) |
|
|
6
|
-
| Same file, more context | Miru MCP \`expand\` | Re-search,
|
|
7
|
-
| Similar code elsewhere | Miru MCP \`find_related\` |
|
|
8
|
-
| Search docs or config | Miru \`search\`
|
|
9
|
-
| Exact literal exists? |
|
|
10
|
-
| Edit a known file:line |
|
|
17
|
+
| How/where/what handles X? | Miru MCP \`search\` (once) | ${native.explorationDenied}, repeat searches |
|
|
18
|
+
| Same file, more context | Miru MCP \`expand\` on \`truncated: true\` | Re-search, ${native.read} whole file |
|
|
19
|
+
| Similar code elsewhere | Miru MCP \`find_related\` | ${native.grep} chains |
|
|
20
|
+
| Search docs or config | Miru \`search\` | ${native.grep} README paths |
|
|
21
|
+
| Exact literal exists? | ${native.grep} | Miru |
|
|
22
|
+
| Edit a known file:line | ${native.read} (after Miru found it) | ${native.read}-before-search |`;
|
|
23
|
+
}
|
|
11
24
|
|
|
12
|
-
export
|
|
25
|
+
export function buildSearchPolicyBody(native: NativeToolNames): string {
|
|
26
|
+
return `DO NOT use ${native.explorationDenied} to explore how code works when Miru MCP is available.
|
|
27
|
+
|
|
28
|
+
${SNIPPET_GUIDANCE}
|
|
13
29
|
|
|
14
30
|
Use Miru MCP tools:
|
|
15
31
|
- \`search\` — one call per question; pass project root as \`repo\`
|
|
16
|
-
- \`expand\` —
|
|
17
|
-
- \`find_related\` — similar code in other files
|
|
32
|
+
- \`expand\` — more context in the same file when \`truncated: true\` (\`file_path\` + \`anchor_line\` or \`start_line\`)
|
|
33
|
+
- \`find_related\` — similar code in other files (hits may also be snippets; use \`expand\` if truncated)
|
|
18
34
|
|
|
19
35
|
Stop rules:
|
|
20
36
|
- Answer from the first \`search\` — do not re-search with paraphrases
|
|
21
|
-
-
|
|
22
|
-
-
|
|
37
|
+
- On \`truncated: true\`, call \`expand\` — not another \`search\` or a full-file ${native.read}
|
|
38
|
+
- ${native.read} is for editing a path Miru already gave you, not for exploration
|
|
23
39
|
|
|
24
40
|
Native tools are allowed ONLY when:
|
|
25
41
|
- confirming an exact literal string (env var name, error code, quoted text)
|
|
26
42
|
- reading a file you already located via Miru, to edit it
|
|
27
43
|
- searching outside the indexed repo
|
|
28
44
|
|
|
29
|
-
${
|
|
45
|
+
${buildSearchPolicyTable(native)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildMcpWorkflow(native: NativeToolNames): string {
|
|
49
|
+
return `### MCP workflow
|
|
50
|
+
|
|
51
|
+
1. Call \`search\` with \`repo\` set to the project root (local path or https:// git URL).
|
|
52
|
+
2. If a hit has \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\` (or \`start_line\`).
|
|
53
|
+
3. Use \`find_related\` to trace similar code in other files — not for more context in the same file.
|
|
54
|
+
4. ${native.read} via \`absolute_path\` only when editing or when \`expand\` still lacks context.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLI_FALLBACK = `### CLI fallback (no MCP in this session)
|
|
58
|
+
|
|
59
|
+
\`\`\`bash
|
|
60
|
+
miru search "authentication flow" .
|
|
61
|
+
miru expand src/auth.ts 42 .
|
|
62
|
+
miru find-related src/auth.ts 42 .
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
66
|
+
|
|
67
|
+
export function buildSubagentBody(native: NativeToolNames): string {
|
|
68
|
+
return `When Miru MCP is available, use MCP \`search\`, \`expand\`, and \`find_related\` — not ${native.explorationDenied} for exploration.
|
|
69
|
+
|
|
70
|
+
${buildSearchPolicyBody(native)}
|
|
71
|
+
|
|
72
|
+
${buildMcpWorkflow(native)}
|
|
73
|
+
|
|
74
|
+
${CLI_FALLBACK}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_NATIVE_TOOLS: NativeToolNames = {
|
|
78
|
+
explorationDenied: "Grep, Glob, SemanticSearch, or Read",
|
|
79
|
+
grep: "Grep",
|
|
80
|
+
read: "Read",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const SEARCH_POLICY_BODY = buildSearchPolicyBody(DEFAULT_NATIVE_TOOLS);
|
|
30
84
|
|
|
31
85
|
export const INSTRUCTIONS_MARKDOWN = `## Miru Code Search
|
|
32
86
|
|
|
@@ -54,25 +108,31 @@ alwaysApply: true
|
|
|
54
108
|
|
|
55
109
|
${SEARCH_POLICY_BODY}
|
|
56
110
|
|
|
57
|
-
When Miru MCP is connected, call \`search\` once per question.
|
|
111
|
+
When Miru MCP is connected, call \`search\` once per question. On \`truncated: true\`, call \`expand\` with \`file_path\` and \`anchor_line\`. Never use Cursor SemanticSearch or Grep for codebase questions unless confirming an exact literal.`;
|
|
58
112
|
|
|
59
|
-
export const SUBAGENT_BODY =
|
|
113
|
+
export const SUBAGENT_BODY = buildSubagentBody(DEFAULT_NATIVE_TOOLS);
|
|
60
114
|
|
|
61
|
-
|
|
115
|
+
export const MCP_SERVER_INSTRUCTIONS =
|
|
116
|
+
"DO NOT use Grep, Glob, SemanticSearch, or Read to explore code when this server is available. " +
|
|
117
|
+
`${SNIPPET_GUIDANCE} ` +
|
|
118
|
+
"Workflow: (1) `search` once per question — do NOT re-search with paraphrases. " +
|
|
119
|
+
"(2) On `truncated: true`, call `expand` with `file_path` and `anchor_line` (or `start_line`) — do NOT read the whole file. " +
|
|
120
|
+
"(3) `find_related` traces similar code in other files, not more context in the same file. " +
|
|
121
|
+
"Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
|
|
122
|
+
"Native search is only for exact literal confirmation or files outside the indexed repo.";
|
|
62
123
|
|
|
63
|
-
|
|
124
|
+
export const MCP_SEARCH_TOOL_DESCRIPTION =
|
|
125
|
+
"DEFAULT code exploration tool. Returns compact snippets (~±15 lines). One call per question is usually enough. " +
|
|
126
|
+
"When a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` (or `start_line`) — not re-search or Read.";
|
|
64
127
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
128
|
+
export const MCP_EXPAND_TOOL_DESCRIPTION =
|
|
129
|
+
"More context in the SAME file as a search hit. Pass `file_path` + `anchor_line` or `start_line` from a truncated hit; " +
|
|
130
|
+
"returns adjacent indexed chunks. Use when `truncated: true` — NOT for similar code in other files (use find_related). " +
|
|
131
|
+
"Prefer this over re-searching or reading the whole file.";
|
|
69
132
|
|
|
70
|
-
|
|
133
|
+
export const MCP_FIND_RELATED_TOOL_DESCRIPTION =
|
|
134
|
+
"Find code similar to a file:line in OTHER parts of the codebase. Results may be snippets; use `expand` when `truncated: true`. " +
|
|
135
|
+
"For more context in the same file, use `expand` instead.";
|
|
71
136
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
miru expand src/auth.ts 42 .
|
|
75
|
-
miru find-related src/auth.ts 42 .
|
|
76
|
-
\`\`\`
|
|
77
|
-
|
|
78
|
-
If \`miru\` is not on \`$PATH\`, use \`bunx @takara-ai/miru-code\`.`;
|
|
137
|
+
export const SEARCH_GUARD_EXPAND_HINT =
|
|
138
|
+
"If a hit has `truncated: true`, call `expand` with `file_path` and `anchor_line` — do not re-search or read the whole file.";
|
package/src/mcp/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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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, {
|
|
181
|
+
formatResults(`Chunks related to ${filePath}:${line}`, results, {
|
|
182
|
+
repoRoot,
|
|
183
|
+
snippet: true,
|
|
184
|
+
}),
|
|
185
185
|
),
|
|
186
186
|
);
|
|
187
187
|
} catch (err) {
|
package/src/ranking/boosting.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|