@takara-ai/miru-code 0.4.11 → 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 +91 -31
- package/src/mcp/server.ts +36 -27
- 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 +37 -107
package/src/mcp/server.ts
CHANGED
|
@@ -2,14 +2,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import type { ContentType } from "../types.ts";
|
|
4
4
|
import {
|
|
5
|
+
clampMcpTopK,
|
|
5
6
|
DEFAULT_MCP_TOP_K,
|
|
6
7
|
dedupeResultsByFile,
|
|
7
8
|
expandChunksAtLine,
|
|
8
9
|
formatExpandResults,
|
|
9
10
|
formatResults,
|
|
10
11
|
localRepoRoot,
|
|
12
|
+
MAX_MCP_TOP_K,
|
|
11
13
|
resolveChunk,
|
|
12
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";
|
|
13
21
|
import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
|
|
14
22
|
|
|
15
23
|
const REPO_DESCRIPTION =
|
|
@@ -17,14 +25,6 @@ const REPO_DESCRIPTION =
|
|
|
17
25
|
"Pass the project root for local workspaces. " +
|
|
18
26
|
"The index is built on the first tool call and cached for the session.";
|
|
19
27
|
|
|
20
|
-
const SERVER_INSTRUCTIONS =
|
|
21
|
-
"DO NOT use Grep, Glob, SemanticSearch, or Read to explore code when this server is available. " +
|
|
22
|
-
"Workflow: (1) `search` once per question — check `guidance.sufficient` and `suggested_next` in the response. " +
|
|
23
|
-
"(2) If context looks cut off, call `expand` on the hit's file_path + line — do NOT re-search with paraphrases or read the whole file. " +
|
|
24
|
-
"(3) `find_related` traces similar code in other files, not more context in the same file. " +
|
|
25
|
-
"Always pass the project root as `repo`. Local repos return `absolute_path` on each hit — use Read only to edit. " +
|
|
26
|
-
"Native search is only for exact literal confirmation or files outside the indexed repo.";
|
|
27
|
-
|
|
28
28
|
export function createMcpServer(cache: IndexCache): McpServer {
|
|
29
29
|
const server = new McpServer(
|
|
30
30
|
{
|
|
@@ -32,7 +32,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
32
32
|
version: "0.1.0",
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
instructions:
|
|
35
|
+
instructions: MCP_SERVER_INSTRUCTIONS,
|
|
36
36
|
},
|
|
37
37
|
);
|
|
38
38
|
|
|
@@ -40,9 +40,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
40
40
|
"search",
|
|
41
41
|
{
|
|
42
42
|
description:
|
|
43
|
-
|
|
44
|
-
"If a hit looks truncated, call `expand` (same file) instead of re-searching or Read. " +
|
|
45
|
-
"Indexes `repo` on first call; later calls reuse the session cache.",
|
|
43
|
+
`${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
|
|
46
44
|
inputSchema: {
|
|
47
45
|
query: z.string().describe("Natural language or code query."),
|
|
48
46
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
@@ -50,8 +48,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
50
48
|
.number()
|
|
51
49
|
.int()
|
|
52
50
|
.min(1)
|
|
51
|
+
.max(MAX_MCP_TOP_K)
|
|
53
52
|
.optional()
|
|
54
|
-
.describe(`Number of results (default ${DEFAULT_MCP_TOP_K}).`),
|
|
53
|
+
.describe(`Number of results (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
|
|
55
54
|
dedupe_by_file: z
|
|
56
55
|
.boolean()
|
|
57
56
|
.optional()
|
|
@@ -61,7 +60,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
61
60
|
async ({ query, repo, top_k: topK, dedupe_by_file: dedupeByFile }) => {
|
|
62
61
|
try {
|
|
63
62
|
const index = await getIndexForRepo(repo, cache);
|
|
64
|
-
let results = await index.search({ query, topK: topK
|
|
63
|
+
let results = await index.search({ query, topK: clampMcpTopK(topK) });
|
|
65
64
|
if (dedupeByFile !== false) {
|
|
66
65
|
results = dedupeResultsByFile(results);
|
|
67
66
|
}
|
|
@@ -69,7 +68,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
69
68
|
return toolText(JSON.stringify({ error: "No results found." }));
|
|
70
69
|
}
|
|
71
70
|
const repoRoot = localRepoRoot(repo);
|
|
72
|
-
return toolText(JSON.stringify(formatResults(query, results, { repoRoot })));
|
|
71
|
+
return toolText(JSON.stringify(formatResults(query, results, { repoRoot, snippet: true })));
|
|
73
72
|
} catch (err) {
|
|
74
73
|
return toolText(err instanceof Error ? err.message : String(err));
|
|
75
74
|
}
|
|
@@ -79,15 +78,15 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
79
78
|
server.registerTool(
|
|
80
79
|
"expand",
|
|
81
80
|
{
|
|
82
|
-
description:
|
|
83
|
-
"More context in the SAME file as a search hit. Pass file_path + line from a hit; returns adjacent indexed chunks. " +
|
|
84
|
-
"Use when a chunk looks cut off — NOT for finding similar code in other files (use find_related). " +
|
|
85
|
-
"Prefer this over re-searching or reading the whole file.",
|
|
81
|
+
description: MCP_EXPAND_TOOL_DESCRIPTION,
|
|
86
82
|
inputSchema: {
|
|
87
83
|
file_path: z
|
|
88
84
|
.string()
|
|
89
85
|
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
90
|
-
line: z
|
|
86
|
+
line: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.describe("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
|
|
91
90
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
92
91
|
before: z
|
|
93
92
|
.number()
|
|
@@ -140,18 +139,25 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
140
139
|
server.registerTool(
|
|
141
140
|
"find_related",
|
|
142
141
|
{
|
|
143
|
-
description:
|
|
144
|
-
"Find code similar to a file:line in OTHER parts of the codebase. " +
|
|
145
|
-
"For more context in the same file, use `expand` instead.",
|
|
142
|
+
description: MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
146
143
|
inputSchema: {
|
|
147
144
|
file_path: z
|
|
148
145
|
.string()
|
|
149
146
|
.describe(
|
|
150
147
|
"Path from a search hit (`file_path` or `absolute_path` for local repos).",
|
|
151
148
|
),
|
|
152
|
-
line: z
|
|
149
|
+
line: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.describe("Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed)."),
|
|
153
153
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
154
|
-
top_k: z
|
|
154
|
+
top_k: z
|
|
155
|
+
.number()
|
|
156
|
+
.int()
|
|
157
|
+
.min(1)
|
|
158
|
+
.max(MAX_MCP_TOP_K)
|
|
159
|
+
.optional()
|
|
160
|
+
.describe(`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`),
|
|
155
161
|
},
|
|
156
162
|
},
|
|
157
163
|
async ({ file_path: filePath, line, repo, top_k: topK }) => {
|
|
@@ -164,7 +170,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
164
170
|
`No chunk found at ${filePath}:${line}. Make sure the file is indexed and the line number is within a known chunk.`,
|
|
165
171
|
);
|
|
166
172
|
}
|
|
167
|
-
const results = await index.findRelated(chunk, topK
|
|
173
|
+
const results = await index.findRelated(chunk, clampMcpTopK(topK));
|
|
168
174
|
if (results.length === 0) {
|
|
169
175
|
return toolText(
|
|
170
176
|
JSON.stringify({ error: `No related chunks found for ${filePath}:${line}.` }),
|
|
@@ -172,7 +178,10 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
172
178
|
}
|
|
173
179
|
return toolText(
|
|
174
180
|
JSON.stringify(
|
|
175
|
-
formatResults(`Chunks related to ${filePath}:${line}`, results, {
|
|
181
|
+
formatResults(`Chunks related to ${filePath}:${line}`, results, {
|
|
182
|
+
repoRoot,
|
|
183
|
+
snippet: true,
|
|
184
|
+
}),
|
|
176
185
|
),
|
|
177
186
|
);
|
|
178
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
|
+
}
|