scythe-context-mcp 0.1.3 → 0.1.5
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/CHANGELOG.md +13 -0
- package/README.en.md +5 -2
- package/README.md +5 -2
- package/README.zh-CN.md +5 -2
- package/benchmarks/context-search-cases.json +78 -0
- package/dist/cli.js +1 -1
- package/dist/indexing/codeAwareReranker.js +240 -0
- package/dist/indexing/hybridSearch.js +23 -1
- package/dist/indexing/resultFormat.js +3 -0
- package/dist/tools/registerTools.js +120 -25
- package/docs/benchmark.md +41 -0
- package/package.json +5 -1
- package/scripts/context-benchmark.mjs +474 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ This project follows semantic versioning before npm publication where practical.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.5] - 2026-06-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Add a context-search benchmark comparing `rg`, keyword-only Scythe search, and Gemini-backed hybrid search.
|
|
14
|
+
- Add a local code-aware reranker that uses path, snippet, symbol, import graph, file-role, and source-counterpart signals without extra model/API calls.
|
|
15
|
+
|
|
16
|
+
## [0.1.4] - 2026-06-14
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add keyword-only fallback for hybrid search/context-pack calls when query embedding is unavailable, while preserving explicit `embedding_unavailable` diagnostics for semantic-only mode.
|
|
21
|
+
|
|
9
22
|
## [0.1.3] - 2026-06-14
|
|
10
23
|
|
|
11
24
|
### Changed
|
package/README.en.md
CHANGED
|
@@ -244,11 +244,13 @@ Use `PWD/p` only if you intentionally run a Windows Node process and need WSL to
|
|
|
244
244
|
| `repo_related_files` | Shows symbols, imports, and importedBy for one file. |
|
|
245
245
|
| `gemini_embedding_probe` | Tests Gemini or proxy compatibility and returns endpoint, latency, error classification, and remediation hints. |
|
|
246
246
|
|
|
247
|
+
`repo_context_pack(mode="hybrid")` and `repo_semantic_search(mode="hybrid")` degrade to keyword-only results when query embedding is unavailable, returning `effectiveMode: "keyword"` and `fallback.reason: "embedding_unavailable"`. `mode="semantic"` does not degrade and returns `status: "embedding_unavailable"` because pure semantic search requires query embedding. Use `rg` / direct file reads for exact strings, known paths, or small targeted checks.
|
|
248
|
+
|
|
247
249
|
## Feature Status
|
|
248
250
|
|
|
249
|
-
Implemented: repo scanning, chunking, SQLite metadata, SQLite FTS5, sqlite-vec, Gemini Embedding 2 provider, semantic/keyword/hybrid search, lightweight symbol/dependency graph, related-file lookup, `repo_context_pack`, provider diagnostics, and index freshness diagnostics.
|
|
251
|
+
Implemented: repo scanning, chunking, SQLite metadata, SQLite FTS5, sqlite-vec, Gemini Embedding 2 provider, semantic/keyword/hybrid search, keyword-only fallback when embeddings fail, local code-aware reranker, lightweight symbol/dependency graph, related-file lookup, `repo_context_pack`, provider diagnostics, and index freshness diagnostics.
|
|
250
252
|
|
|
251
|
-
Next: provider capability cache, install/native dependency doctor,
|
|
253
|
+
Next: provider capability cache, install/native dependency doctor, and tree-sitter symbol extraction if needed.
|
|
252
254
|
|
|
253
255
|
## Privacy and Local Files
|
|
254
256
|
|
|
@@ -266,6 +268,7 @@ Do not include API keys, proxy tokens, private source snippets, or index databas
|
|
|
266
268
|
- [Gemini Compatibility](docs/gemini-compatibility.md)
|
|
267
269
|
- [Tech Stack](docs/tech-stack.md)
|
|
268
270
|
- [Codex Integration Review](docs/codex-integration.md)
|
|
271
|
+
- [Context Search Benchmark](docs/benchmark.md)
|
|
269
272
|
|
|
270
273
|
## Development and Publishing Checks
|
|
271
274
|
|
package/README.md
CHANGED
|
@@ -244,11 +244,13 @@ GEMINI_OUTPUT_DIMENSIONALITY = "1536"
|
|
|
244
244
|
| `repo_related_files` | 查看單一檔案的 symbols、imports、importedBy。 |
|
|
245
245
|
| `gemini_embedding_probe` | 測試 Gemini 或 proxy 相容性,回傳 endpoint、latency、錯誤分類與可修復建議。 |
|
|
246
246
|
|
|
247
|
+
`repo_context_pack(mode="hybrid")` 和 `repo_semantic_search(mode="hybrid")` 在 query embedding 不可用時會降級成 keyword-only 結果,並回傳 `effectiveMode: "keyword"` 與 `fallback.reason: "embedding_unavailable"`。`mode="semantic"` 不會降級,會回傳 `status: "embedding_unavailable"`,因為純 semantic search 必須有 query embedding。精確字串、已知路徑或小範圍檢查仍建議直接用 `rg` / 直接讀檔。
|
|
248
|
+
|
|
247
249
|
## 功能狀態
|
|
248
250
|
|
|
249
|
-
已完成:repo 掃描、chunking、SQLite metadata、SQLite FTS5、sqlite-vec、Gemini Embedding 2 provider、semantic/keyword/hybrid search、輕量 symbol/dependency graph、related-file lookup、`repo_context_pack`、provider diagnostics、index freshness diagnostics。
|
|
251
|
+
已完成:repo 掃描、chunking、SQLite metadata、SQLite FTS5、sqlite-vec、Gemini Embedding 2 provider、semantic/keyword/hybrid search、embedding 失敗時的 keyword-only fallback、local code-aware reranker、輕量 symbol/dependency graph、related-file lookup、`repo_context_pack`、provider diagnostics、index freshness diagnostics。
|
|
250
252
|
|
|
251
|
-
下一步:provider capability cache、安裝/原生依賴 doctor
|
|
253
|
+
下一步:provider capability cache、安裝/原生依賴 doctor、必要時加入 tree-sitter symbol extraction。
|
|
252
254
|
|
|
253
255
|
## 隱私與本機檔案
|
|
254
256
|
|
|
@@ -266,6 +268,7 @@ GEMINI_OUTPUT_DIMENSIONALITY = "1536"
|
|
|
266
268
|
- [Gemini 相容性](docs/gemini-compatibility.md)
|
|
267
269
|
- [技術棧](docs/tech-stack.md)
|
|
268
270
|
- [Codex 整合審查](docs/codex-integration.md)
|
|
271
|
+
- [Context search benchmark](docs/benchmark.md)
|
|
269
272
|
|
|
270
273
|
## 開發與發佈檢查
|
|
271
274
|
|
package/README.zh-CN.md
CHANGED
|
@@ -244,11 +244,13 @@ GEMINI_OUTPUT_DIMENSIONALITY = "1536"
|
|
|
244
244
|
| `repo_related_files` | 查看单一文件的 symbols、imports、importedBy。 |
|
|
245
245
|
| `gemini_embedding_probe` | 测试 Gemini 或 proxy 兼容性,返回 endpoint、latency、错误分类与可修复建议。 |
|
|
246
246
|
|
|
247
|
+
`repo_context_pack(mode="hybrid")` 和 `repo_semantic_search(mode="hybrid")` 在 query embedding 不可用时会降级成 keyword-only 结果,并返回 `effectiveMode: "keyword"` 与 `fallback.reason: "embedding_unavailable"`。`mode="semantic"` 不会降级,会返回 `status: "embedding_unavailable"`,因为纯 semantic search 必须有 query embedding。精确字符串、已知路径或小范围检查仍建议直接用 `rg` / 直接读文件。
|
|
248
|
+
|
|
247
249
|
## 功能状态
|
|
248
250
|
|
|
249
|
-
已完成:repo 扫描、chunking、SQLite metadata、SQLite FTS5、sqlite-vec、Gemini Embedding 2 provider、semantic/keyword/hybrid search、轻量 symbol/dependency graph、related-file lookup、`repo_context_pack`、provider diagnostics、index freshness diagnostics。
|
|
251
|
+
已完成:repo 扫描、chunking、SQLite metadata、SQLite FTS5、sqlite-vec、Gemini Embedding 2 provider、semantic/keyword/hybrid search、embedding 失败时的 keyword-only fallback、local code-aware reranker、轻量 symbol/dependency graph、related-file lookup、`repo_context_pack`、provider diagnostics、index freshness diagnostics。
|
|
250
252
|
|
|
251
|
-
下一步:provider capability cache、安装/原生依赖 doctor
|
|
253
|
+
下一步:provider capability cache、安装/原生依赖 doctor、必要时加入 tree-sitter symbol extraction。
|
|
252
254
|
|
|
253
255
|
## 隐私与本地文件
|
|
254
256
|
|
|
@@ -266,6 +268,7 @@ GEMINI_OUTPUT_DIMENSIONALITY = "1536"
|
|
|
266
268
|
- [Gemini 兼容性](docs/gemini-compatibility.md)
|
|
267
269
|
- [技术栈](docs/tech-stack.md)
|
|
268
270
|
- [Codex 集成审查](docs/codex-integration.md)
|
|
271
|
+
- [Context search benchmark](docs/benchmark.md)
|
|
269
272
|
|
|
270
273
|
## 开发与发布检查
|
|
271
274
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "embedding-fallback",
|
|
4
|
+
"query": "embedding unavailable should fall back to keyword-only context pack",
|
|
5
|
+
"expectedPaths": [
|
|
6
|
+
"src/tools/registerTools.ts",
|
|
7
|
+
"src/indexing/hybridSearch.ts"
|
|
8
|
+
],
|
|
9
|
+
"notes": "Task-style query for the hybrid-to-keyword fallback path."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "stable-chunk-ids",
|
|
13
|
+
"query": "preserve stable chunk row ids so embedding cache remains useful after reindex",
|
|
14
|
+
"expectedPaths": [
|
|
15
|
+
"src/indexing/indexWriter.ts",
|
|
16
|
+
"src/storage/schema.ts",
|
|
17
|
+
"src/storage/sqliteVec.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"notes": "Looks for storage and reindex behavior tied to embedding reuse."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "utf8-binary-detection",
|
|
23
|
+
"query": "UTF-8 scanner should not treat a file as binary when prefix ends in multibyte character",
|
|
24
|
+
"expectedPaths": [
|
|
25
|
+
"src/indexing/binary.ts",
|
|
26
|
+
"src/indexing/scanner.test.ts"
|
|
27
|
+
],
|
|
28
|
+
"notes": "Regression coverage for text detection around multibyte boundaries."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "codex-wsl-config",
|
|
32
|
+
"query": "Codex App WSL Windows node setup with PWD and WSLENV",
|
|
33
|
+
"expectedPaths": [
|
|
34
|
+
"README.md",
|
|
35
|
+
"docs/codex-integration.md",
|
|
36
|
+
"src/config.ts"
|
|
37
|
+
],
|
|
38
|
+
"notes": "Documentation and config lookup for the Windows Node plus WSL workspace mode."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "related-file-graph",
|
|
42
|
+
"query": "related files imports reverse imports graph for context pack",
|
|
43
|
+
"expectedPaths": [
|
|
44
|
+
"src/indexing/relatedFiles.ts",
|
|
45
|
+
"src/indexing/contextPack.ts"
|
|
46
|
+
],
|
|
47
|
+
"notes": "Finds symbol and dependency graph code used by repo_context_pack."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "gemini-proxy-url",
|
|
51
|
+
"query": "Gemini v1beta compatible proxy base URL bearer auth output dimensionality",
|
|
52
|
+
"expectedPaths": [
|
|
53
|
+
"src/providers/gemini.ts",
|
|
54
|
+
"src/config.ts",
|
|
55
|
+
"docs/gemini-compatibility.md"
|
|
56
|
+
],
|
|
57
|
+
"notes": "Provider compatibility query with base URL and auth details."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "npm-bin-mode",
|
|
61
|
+
"query": "npm package executable bin mode should be checked before publish",
|
|
62
|
+
"expectedPaths": [
|
|
63
|
+
"scripts/bin-mode.mjs",
|
|
64
|
+
"package.json",
|
|
65
|
+
"src/cli.ts"
|
|
66
|
+
],
|
|
67
|
+
"notes": "Release packaging and executable bit smoke coverage."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "fts-keyword-search",
|
|
71
|
+
"query": "FTS trigram keyword search ranks chunks by bm25 and file path fallback",
|
|
72
|
+
"expectedPaths": [
|
|
73
|
+
"src/indexing/keywordSearch.ts",
|
|
74
|
+
"src/storage/schema.ts"
|
|
75
|
+
],
|
|
76
|
+
"notes": "Keyword search internals and schema lookup."
|
|
77
|
+
}
|
|
78
|
+
]
|
package/dist/cli.js
CHANGED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { keywordTerms } from "./keywordSearch.js";
|
|
3
|
+
import { classifyRelatedPath } from "./relatedFiles.js";
|
|
4
|
+
const sourceExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
5
|
+
function compactSnippet(text, maxChars) {
|
|
6
|
+
const normalized = text.replace(/\s+$/g, "");
|
|
7
|
+
if (normalized.length <= maxChars)
|
|
8
|
+
return normalized;
|
|
9
|
+
return `${normalized.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
10
|
+
}
|
|
11
|
+
function normalizeTerm(value) {
|
|
12
|
+
return value.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
function splitIdentifier(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
17
|
+
.split(/[^A-Za-z0-9_]+|_/g)
|
|
18
|
+
.map((part) => part.toLowerCase())
|
|
19
|
+
.filter((part) => part.length >= 2);
|
|
20
|
+
}
|
|
21
|
+
function queryTerms(query) {
|
|
22
|
+
const terms = new Set();
|
|
23
|
+
for (const term of keywordTerms(query)) {
|
|
24
|
+
terms.add(normalizeTerm(term));
|
|
25
|
+
for (const part of splitIdentifier(term))
|
|
26
|
+
terms.add(part);
|
|
27
|
+
}
|
|
28
|
+
return Array.from(terms).filter((term) => term.length >= 2);
|
|
29
|
+
}
|
|
30
|
+
function isCodeIntent(terms) {
|
|
31
|
+
return terms.some((term) => [
|
|
32
|
+
"function",
|
|
33
|
+
"class",
|
|
34
|
+
"type",
|
|
35
|
+
"interface",
|
|
36
|
+
"handler",
|
|
37
|
+
"provider",
|
|
38
|
+
"schema",
|
|
39
|
+
"index",
|
|
40
|
+
"chunk",
|
|
41
|
+
"row",
|
|
42
|
+
"cache",
|
|
43
|
+
"fallback",
|
|
44
|
+
"rerank",
|
|
45
|
+
"search",
|
|
46
|
+
"embedding",
|
|
47
|
+
"sqlite",
|
|
48
|
+
"storage",
|
|
49
|
+
"scanner",
|
|
50
|
+
"binary",
|
|
51
|
+
].includes(term));
|
|
52
|
+
}
|
|
53
|
+
function isDocsIntent(terms) {
|
|
54
|
+
return terms.some((term) => ["readme", "docs", "documentation", "codex", "wsl", "windows", "setup", "config", "npm", "publish"].includes(term));
|
|
55
|
+
}
|
|
56
|
+
function isTestIntent(terms) {
|
|
57
|
+
return terms.some((term) => ["test", "tests", "spec", "regression", "fixture"].includes(term));
|
|
58
|
+
}
|
|
59
|
+
function sourceCounterparts(testPath, activePaths) {
|
|
60
|
+
const counterparts = [];
|
|
61
|
+
for (const extension of sourceExtensions) {
|
|
62
|
+
const suffixes = [`.test${extension}`, `.spec${extension}`];
|
|
63
|
+
for (const suffix of suffixes) {
|
|
64
|
+
if (!testPath.endsWith(suffix))
|
|
65
|
+
continue;
|
|
66
|
+
const base = testPath.slice(0, -suffix.length);
|
|
67
|
+
for (const sourceExtension of sourceExtensions) {
|
|
68
|
+
const candidate = `${base}${sourceExtension}`;
|
|
69
|
+
if (activePaths.has(candidate))
|
|
70
|
+
counterparts.push(candidate);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return counterparts;
|
|
75
|
+
}
|
|
76
|
+
function readActivePaths(db) {
|
|
77
|
+
const rows = db.prepare("select path from files").all();
|
|
78
|
+
return new Set(rows.map((row) => row.path));
|
|
79
|
+
}
|
|
80
|
+
function readCandidateDetails(db, path) {
|
|
81
|
+
const file = db.prepare("select id from files where path = ?").get(path);
|
|
82
|
+
if (!file)
|
|
83
|
+
return { symbols: [], imports: 0, importedBy: 0 };
|
|
84
|
+
const symbols = db.prepare("select name from file_symbols where file_id = ? limit 80").all(file.id);
|
|
85
|
+
const imports = db.prepare("select count(*) as count from file_dependencies where file_id = ?").get(file.id).count;
|
|
86
|
+
const importedBy = db
|
|
87
|
+
.prepare(`
|
|
88
|
+
select count(*) as count
|
|
89
|
+
from file_dependencies
|
|
90
|
+
join files on files.path = file_dependencies.resolved_path
|
|
91
|
+
where files.id = ?
|
|
92
|
+
`)
|
|
93
|
+
.get(file.id).count;
|
|
94
|
+
return {
|
|
95
|
+
symbols: symbols.flatMap((symbol) => [symbol.name, ...splitIdentifier(symbol.name)]),
|
|
96
|
+
imports,
|
|
97
|
+
importedBy,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function readFirstChunk(db, path, maxSnippetChars) {
|
|
101
|
+
const row = db
|
|
102
|
+
.prepare(`
|
|
103
|
+
select files.path,
|
|
104
|
+
chunks.start_line as startLine,
|
|
105
|
+
chunks.end_line as endLine,
|
|
106
|
+
chunks.text
|
|
107
|
+
from chunks
|
|
108
|
+
join files on files.id = chunks.file_id
|
|
109
|
+
where files.path = ?
|
|
110
|
+
order by chunks.start_line
|
|
111
|
+
limit 1
|
|
112
|
+
`)
|
|
113
|
+
.get(path);
|
|
114
|
+
if (!row)
|
|
115
|
+
return undefined;
|
|
116
|
+
return {
|
|
117
|
+
path: row.path,
|
|
118
|
+
startLine: row.startLine,
|
|
119
|
+
endLine: row.endLine,
|
|
120
|
+
score: 0,
|
|
121
|
+
snippet: compactSnippet(row.text, maxSnippetChars),
|
|
122
|
+
matchTypes: ["local"],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function pathScore(path, terms) {
|
|
126
|
+
const normalizedPath = path.toLowerCase();
|
|
127
|
+
const basename = normalizedPath.split("/").at(-1) ?? normalizedPath;
|
|
128
|
+
let score = 0;
|
|
129
|
+
for (const term of terms) {
|
|
130
|
+
if (normalizedPath.includes(term))
|
|
131
|
+
score += 0.25;
|
|
132
|
+
if (basename.includes(term))
|
|
133
|
+
score += 0.2;
|
|
134
|
+
}
|
|
135
|
+
return Math.min(score, 2.5);
|
|
136
|
+
}
|
|
137
|
+
function snippetScore(snippet, terms) {
|
|
138
|
+
if (!snippet)
|
|
139
|
+
return 0;
|
|
140
|
+
const text = snippet.toLowerCase();
|
|
141
|
+
let score = 0;
|
|
142
|
+
for (const term of terms) {
|
|
143
|
+
if (text.includes(term))
|
|
144
|
+
score += 0.08;
|
|
145
|
+
}
|
|
146
|
+
return Math.min(score, 0.8);
|
|
147
|
+
}
|
|
148
|
+
function symbolScore(details, terms) {
|
|
149
|
+
if (details.symbols.length === 0)
|
|
150
|
+
return 0;
|
|
151
|
+
const symbols = details.symbols.map((symbol) => symbol.toLowerCase());
|
|
152
|
+
let score = 0;
|
|
153
|
+
for (const term of terms) {
|
|
154
|
+
if (symbols.some((symbol) => symbol === term))
|
|
155
|
+
score += 0.7;
|
|
156
|
+
else if (symbols.some((symbol) => symbol.includes(term)))
|
|
157
|
+
score += 0.25;
|
|
158
|
+
}
|
|
159
|
+
return Math.min(score, 2.2);
|
|
160
|
+
}
|
|
161
|
+
function roleScore(path, terms) {
|
|
162
|
+
const role = classifyRelatedPath(path);
|
|
163
|
+
const codeIntent = isCodeIntent(terms);
|
|
164
|
+
const docsIntent = isDocsIntent(terms);
|
|
165
|
+
const testIntent = isTestIntent(terms);
|
|
166
|
+
if (role === "generated")
|
|
167
|
+
return -2;
|
|
168
|
+
if (role === "test")
|
|
169
|
+
return testIntent ? 0.4 : codeIntent ? -0.85 : -0.25;
|
|
170
|
+
if (role === "docs")
|
|
171
|
+
return docsIntent ? 0.45 : codeIntent ? -0.8 : -0.15;
|
|
172
|
+
if (role === "source")
|
|
173
|
+
return codeIntent ? 0.65 : 0.15;
|
|
174
|
+
return -0.25;
|
|
175
|
+
}
|
|
176
|
+
function graphScore(details) {
|
|
177
|
+
return Math.min(0.5, details.imports * 0.04 + details.importedBy * 0.08);
|
|
178
|
+
}
|
|
179
|
+
function baseScore(result) {
|
|
180
|
+
return result.score ?? 0;
|
|
181
|
+
}
|
|
182
|
+
function addCandidate(candidates, candidate) {
|
|
183
|
+
const key = `${candidate.path}:${candidate.startLine}:${candidate.endLine}`;
|
|
184
|
+
const existing = candidates.get(key);
|
|
185
|
+
if (!existing || baseScore(candidate) > baseScore(existing)) {
|
|
186
|
+
candidates.set(key, candidate);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function rerankCodeAware(options) {
|
|
190
|
+
if (!Number.isInteger(options.maxResults) || options.maxResults <= 0) {
|
|
191
|
+
throw new Error("maxResults must be a positive integer");
|
|
192
|
+
}
|
|
193
|
+
const terms = queryTerms(options.query);
|
|
194
|
+
if (terms.length === 0 || options.results.length === 0) {
|
|
195
|
+
return options.results.slice(0, options.maxResults);
|
|
196
|
+
}
|
|
197
|
+
const db = new Database(options.dbPath, { readonly: true });
|
|
198
|
+
try {
|
|
199
|
+
const activePaths = readActivePaths(db);
|
|
200
|
+
const candidates = new Map();
|
|
201
|
+
for (const result of options.results) {
|
|
202
|
+
addCandidate(candidates, result);
|
|
203
|
+
for (const counterpart of sourceCounterparts(result.path, activePaths)) {
|
|
204
|
+
const counterpartResult = readFirstChunk(db, counterpart, options.maxSnippetChars);
|
|
205
|
+
if (counterpartResult) {
|
|
206
|
+
counterpartResult.score = Math.max(0, baseScore(result) * 0.85);
|
|
207
|
+
addCandidate(candidates, counterpartResult);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const detailCache = new Map();
|
|
212
|
+
const detailsFor = (candidatePath) => {
|
|
213
|
+
const existing = detailCache.get(candidatePath);
|
|
214
|
+
if (existing)
|
|
215
|
+
return existing;
|
|
216
|
+
const details = readCandidateDetails(db, candidatePath);
|
|
217
|
+
detailCache.set(candidatePath, details);
|
|
218
|
+
return details;
|
|
219
|
+
};
|
|
220
|
+
return Array.from(candidates.values())
|
|
221
|
+
.map((candidate) => {
|
|
222
|
+
const details = detailsFor(candidate.path);
|
|
223
|
+
const rerankScore = baseScore(candidate) +
|
|
224
|
+
pathScore(candidate.path, terms) +
|
|
225
|
+
snippetScore(candidate.snippet, terms) +
|
|
226
|
+
symbolScore(details, terms) +
|
|
227
|
+
roleScore(candidate.path, terms) +
|
|
228
|
+
graphScore(details);
|
|
229
|
+
return {
|
|
230
|
+
...candidate,
|
|
231
|
+
score: rerankScore,
|
|
232
|
+
};
|
|
233
|
+
})
|
|
234
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.startLine - b.startLine)
|
|
235
|
+
.slice(0, options.maxResults);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
db.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { searchByKeyword } from "./keywordSearch.js";
|
|
2
|
+
import { rerankCodeAware } from "./codeAwareReranker.js";
|
|
2
3
|
import { searchByVector } from "./semanticSearch.js";
|
|
3
4
|
function keyOf(result) {
|
|
4
5
|
return `${result.path}:${result.startLine}:${result.endLine}`;
|
|
@@ -63,5 +64,26 @@ export function searchHybrid(options) {
|
|
|
63
64
|
maxResults: Math.max(options.maxResults * 2, options.maxResults),
|
|
64
65
|
maxSnippetChars: options.maxSnippetChars,
|
|
65
66
|
});
|
|
66
|
-
return
|
|
67
|
+
return rerankCodeAware({
|
|
68
|
+
dbPath: options.dbPath,
|
|
69
|
+
query: options.query,
|
|
70
|
+
results: mergeHybridResults(semanticResults, keywordResults, Math.max(options.maxResults * 3, options.maxResults)),
|
|
71
|
+
maxResults: options.maxResults,
|
|
72
|
+
maxSnippetChars: options.maxSnippetChars,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function searchKeywordOnly(options) {
|
|
76
|
+
const keywordResults = searchByKeyword({
|
|
77
|
+
dbPath: options.dbPath,
|
|
78
|
+
query: options.query,
|
|
79
|
+
maxResults: Math.max(options.maxResults * 2, options.maxResults),
|
|
80
|
+
maxSnippetChars: options.maxSnippetChars,
|
|
81
|
+
});
|
|
82
|
+
return rerankCodeAware({
|
|
83
|
+
dbPath: options.dbPath,
|
|
84
|
+
query: options.query,
|
|
85
|
+
results: mergeHybridResults([], keywordResults, Math.max(options.maxResults * 3, options.maxResults)),
|
|
86
|
+
maxResults: options.maxResults,
|
|
87
|
+
maxSnippetChars: options.maxSnippetChars,
|
|
88
|
+
});
|
|
67
89
|
}
|
|
@@ -15,6 +15,9 @@ export function matchReason(result) {
|
|
|
15
15
|
if (matchTypes.includes("keyword")) {
|
|
16
16
|
return "keyword/path match";
|
|
17
17
|
}
|
|
18
|
+
if (matchTypes.includes("local")) {
|
|
19
|
+
return "local code-aware related file";
|
|
20
|
+
}
|
|
18
21
|
if (typeof result.distance === "number") {
|
|
19
22
|
return `semantic similarity distance ${result.distance.toFixed(4)}`;
|
|
20
23
|
}
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { buildContextPack } from "../indexing/contextPack.js";
|
|
5
5
|
import { reindexDryRun } from "../indexing/dryRun.js";
|
|
6
6
|
import { indexMissingEmbeddings } from "../indexing/embeddingWriter.js";
|
|
7
|
-
import { searchHybrid } from "../indexing/hybridSearch.js";
|
|
7
|
+
import { searchHybrid, searchKeywordOnly } from "../indexing/hybridSearch.js";
|
|
8
8
|
import { readDetailedIndexStatus, readIndexFreshness, recommendedNextActions } from "../indexing/indexStatus.js";
|
|
9
9
|
import { persistentReindexMetadata } from "../indexing/indexWriter.js";
|
|
10
10
|
import { readRelatedFileGraph, readRelatedFiles } from "../indexing/relatedFiles.js";
|
|
@@ -35,6 +35,37 @@ function searchIndexedChunks(options) {
|
|
|
35
35
|
maxSnippetChars: options.maxSnippetChars,
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
function searchKeywordOnlyChunks(options) {
|
|
39
|
+
return searchKeywordOnly({
|
|
40
|
+
dbPath: options.dbPath,
|
|
41
|
+
query: options.query,
|
|
42
|
+
maxResults: options.maxResults,
|
|
43
|
+
maxSnippetChars: options.maxSnippetChars,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function embeddingFailureDetails(error) {
|
|
47
|
+
const geminiError = error instanceof GeminiEmbeddingError ? error : undefined;
|
|
48
|
+
return {
|
|
49
|
+
type: error instanceof Error ? error.name : "UnknownError",
|
|
50
|
+
message: error instanceof Error ? error.message : String(error),
|
|
51
|
+
httpStatus: geminiError?.status,
|
|
52
|
+
retryable: geminiError?.retryable ?? false,
|
|
53
|
+
bodySnippet: geminiError?.bodySnippet,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function embeddingUnavailablePayload(error) {
|
|
57
|
+
return {
|
|
58
|
+
status: "embedding_unavailable",
|
|
59
|
+
fallbackAvailable: "Use mode=hybrid to allow keyword-only fallback, or use rg/direct file reads for exact strings and known paths.",
|
|
60
|
+
error: embeddingFailureDetails(error),
|
|
61
|
+
recommendedNextActions: [
|
|
62
|
+
"Run gemini_embedding_probe with a short test string.",
|
|
63
|
+
"Verify GEMINI_API_KEY, GEMINI_BASE_URL, GEMINI_AUTH_MODE, and GEMINI_OUTPUT_DIMENSIONALITY.",
|
|
64
|
+
"Use repo_context_pack with mode=hybrid for keyword-only degraded results while embeddings are unavailable.",
|
|
65
|
+
"Use rg/direct file reads for exact strings, known paths, or small targeted checks.",
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
38
69
|
function buildGeminiDiagnostics(config, expectedDimensions) {
|
|
39
70
|
const diagnostics = {
|
|
40
71
|
baseUrl: config.baseUrl,
|
|
@@ -223,20 +254,50 @@ export function registerTools(server, config) {
|
|
|
223
254
|
message: "Run repo_reindex with dry_run=false and index_embeddings=true before semantic search.",
|
|
224
255
|
});
|
|
225
256
|
}
|
|
226
|
-
const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
|
|
227
257
|
const dimensions = expectedDimensions;
|
|
228
|
-
|
|
229
|
-
|
|
258
|
+
let effectiveMode = mode;
|
|
259
|
+
let fallback;
|
|
260
|
+
let rawResults;
|
|
261
|
+
try {
|
|
262
|
+
const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
|
|
263
|
+
if (queryEmbedding.dimensions !== dimensions) {
|
|
264
|
+
throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
|
|
265
|
+
}
|
|
266
|
+
rawResults = searchIndexedChunks({
|
|
267
|
+
dbPath,
|
|
268
|
+
query,
|
|
269
|
+
dimensions,
|
|
270
|
+
queryVector: queryEmbedding.vector,
|
|
271
|
+
maxResults: max_results,
|
|
272
|
+
maxSnippetChars: max_snippet_chars,
|
|
273
|
+
mode,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (mode === "semantic") {
|
|
278
|
+
return asJsonText({
|
|
279
|
+
query,
|
|
280
|
+
projectPath,
|
|
281
|
+
dbPath,
|
|
282
|
+
dimensions,
|
|
283
|
+
mode,
|
|
284
|
+
...embeddingUnavailablePayload(error),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
effectiveMode = "keyword";
|
|
288
|
+
fallback = {
|
|
289
|
+
reason: "embedding_unavailable",
|
|
290
|
+
fromMode: mode,
|
|
291
|
+
toMode: "keyword",
|
|
292
|
+
error: embeddingFailureDetails(error),
|
|
293
|
+
};
|
|
294
|
+
rawResults = searchKeywordOnlyChunks({
|
|
295
|
+
dbPath,
|
|
296
|
+
query,
|
|
297
|
+
maxResults: max_results,
|
|
298
|
+
maxSnippetChars: max_snippet_chars,
|
|
299
|
+
});
|
|
230
300
|
}
|
|
231
|
-
const rawResults = searchIndexedChunks({
|
|
232
|
-
dbPath,
|
|
233
|
-
query,
|
|
234
|
-
dimensions,
|
|
235
|
-
queryVector: queryEmbedding.vector,
|
|
236
|
-
maxResults: max_results,
|
|
237
|
-
maxSnippetChars: max_snippet_chars,
|
|
238
|
-
mode,
|
|
239
|
-
});
|
|
240
301
|
const formatted = formatSearchResults(query, rawResults, { maxContextChars: max_context_chars });
|
|
241
302
|
return asJsonText({
|
|
242
303
|
query,
|
|
@@ -244,6 +305,8 @@ export function registerTools(server, config) {
|
|
|
244
305
|
dbPath,
|
|
245
306
|
dimensions,
|
|
246
307
|
mode,
|
|
308
|
+
effectiveMode,
|
|
309
|
+
fallback,
|
|
247
310
|
results: formatted.results,
|
|
248
311
|
context: formatted.summary,
|
|
249
312
|
resultCount: rawResults.length,
|
|
@@ -308,20 +371,50 @@ export function registerTools(server, config) {
|
|
|
308
371
|
message: "Run repo_reindex with dry_run=false and index_embeddings=true before building a context pack.",
|
|
309
372
|
});
|
|
310
373
|
}
|
|
311
|
-
const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
|
|
312
374
|
const dimensions = expectedDimensions;
|
|
313
|
-
|
|
314
|
-
|
|
375
|
+
let effectiveMode = mode;
|
|
376
|
+
let fallback;
|
|
377
|
+
let rawResults;
|
|
378
|
+
try {
|
|
379
|
+
const queryEmbedding = await embeddingProvider.embed({ kind: "query", text: query });
|
|
380
|
+
if (queryEmbedding.dimensions !== dimensions) {
|
|
381
|
+
throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
|
|
382
|
+
}
|
|
383
|
+
rawResults = searchIndexedChunks({
|
|
384
|
+
dbPath,
|
|
385
|
+
query,
|
|
386
|
+
dimensions,
|
|
387
|
+
queryVector: queryEmbedding.vector,
|
|
388
|
+
maxResults: max_results,
|
|
389
|
+
maxSnippetChars: max_snippet_chars,
|
|
390
|
+
mode,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (mode === "semantic") {
|
|
395
|
+
return asJsonText({
|
|
396
|
+
query,
|
|
397
|
+
projectPath,
|
|
398
|
+
dbPath,
|
|
399
|
+
dimensions,
|
|
400
|
+
mode,
|
|
401
|
+
...embeddingUnavailablePayload(error),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
effectiveMode = "keyword";
|
|
405
|
+
fallback = {
|
|
406
|
+
reason: "embedding_unavailable",
|
|
407
|
+
fromMode: mode,
|
|
408
|
+
toMode: "keyword",
|
|
409
|
+
error: embeddingFailureDetails(error),
|
|
410
|
+
};
|
|
411
|
+
rawResults = searchKeywordOnlyChunks({
|
|
412
|
+
dbPath,
|
|
413
|
+
query,
|
|
414
|
+
maxResults: max_results,
|
|
415
|
+
maxSnippetChars: max_snippet_chars,
|
|
416
|
+
});
|
|
315
417
|
}
|
|
316
|
-
const rawResults = searchIndexedChunks({
|
|
317
|
-
dbPath,
|
|
318
|
-
query,
|
|
319
|
-
dimensions,
|
|
320
|
-
queryVector: queryEmbedding.vector,
|
|
321
|
-
maxResults: max_results,
|
|
322
|
-
maxSnippetChars: max_snippet_chars,
|
|
323
|
-
mode,
|
|
324
|
-
});
|
|
325
418
|
const relatedPaths = Array.from(new Set(rawResults.map((result) => result.path))).slice(0, Math.min(max_seed_files, max_related_files));
|
|
326
419
|
const relatedFiles = readRelatedFileGraph({
|
|
327
420
|
dbPath,
|
|
@@ -355,6 +448,8 @@ export function registerTools(server, config) {
|
|
|
355
448
|
dbPath,
|
|
356
449
|
dimensions,
|
|
357
450
|
mode,
|
|
451
|
+
effectiveMode,
|
|
452
|
+
fallback,
|
|
358
453
|
relatedDepth: related_depth,
|
|
359
454
|
relatedSeedCount: relatedPaths.length,
|
|
360
455
|
includeRelatedSnippets: include_related_snippets,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Context Search Benchmark
|
|
2
|
+
|
|
3
|
+
This benchmark compares three lookup modes:
|
|
4
|
+
|
|
5
|
+
- `rg-smart`: local ripgrep baseline without MCP or embeddings.
|
|
6
|
+
- `scythe-keyword`: Scythe metadata, FTS, symbols, dependencies, and context packing without embeddings.
|
|
7
|
+
- `scythe-hybrid`: Scythe hybrid search with Gemini-compatible query embeddings plus keyword results.
|
|
8
|
+
|
|
9
|
+
Run the default no-API baseline:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm run bench:context
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
When running from a source checkout after changing TypeScript files, rebuild first or use:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run bench:context:source
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Run with Gemini-backed hybrid search:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run bench:context -- --include-hybrid
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The benchmark runner loads `.env` the same way the MCP server does. If `GEMINI_API_KEY` is not available to the benchmark process, `scythe-hybrid` is reported as skipped instead of failed.
|
|
28
|
+
|
|
29
|
+
Write a machine-readable report:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm run bench:context -- --json --output local/benchmark/context-search.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The benchmark expects an existing `.scythe-context/index.sqlite` for the target project. Refresh it before measuring:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Through the MCP tool, run repo_reindex with dry_run=false.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The default case file is `benchmarks/context-search-cases.json`. Each case has a natural-language query and one or more expected paths. The summary reports ok/skipped/error counts, hit@1, hit@3, hit@5, MRR, and latency. Use this before and after ranking changes so reranker improvements are measured instead of judged by feel.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scythe-context-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Local MCP context engine for Codex with Gemini Embedding 2 support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,11 +30,15 @@
|
|
|
30
30
|
"README.zh-CN.md",
|
|
31
31
|
"CHANGELOG.md",
|
|
32
32
|
"LICENSE",
|
|
33
|
+
"benchmarks",
|
|
33
34
|
"docs",
|
|
35
|
+
"scripts/context-benchmark.mjs",
|
|
34
36
|
".env.example"
|
|
35
37
|
],
|
|
36
38
|
"scripts": {
|
|
37
39
|
"build": "tsc -p tsconfig.json && node scripts/bin-mode.mjs ensure",
|
|
40
|
+
"bench:context": "node scripts/context-benchmark.mjs",
|
|
41
|
+
"bench:context:source": "npm run build && node scripts/context-benchmark.mjs",
|
|
38
42
|
"dev": "tsx src/index.ts",
|
|
39
43
|
"prepack": "npm run build",
|
|
40
44
|
"smoke": "node dist/index.js --version && node dist/index.js --help",
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CASES_PATH = "benchmarks/context-search-cases.json";
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const args = {
|
|
13
|
+
project: process.cwd(),
|
|
14
|
+
cases: DEFAULT_CASES_PATH,
|
|
15
|
+
maxResults: 8,
|
|
16
|
+
maxSnippetChars: 1200,
|
|
17
|
+
maxContextChars: 16000,
|
|
18
|
+
includeHybrid: false,
|
|
19
|
+
json: false,
|
|
20
|
+
output: undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
24
|
+
const arg = argv[i];
|
|
25
|
+
const next = () => {
|
|
26
|
+
i += 1;
|
|
27
|
+
if (i >= argv.length) throw new Error(`${arg} requires a value`);
|
|
28
|
+
return argv[i];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
switch (arg) {
|
|
32
|
+
case "--project":
|
|
33
|
+
args.project = next();
|
|
34
|
+
break;
|
|
35
|
+
case "--cases":
|
|
36
|
+
args.cases = next();
|
|
37
|
+
break;
|
|
38
|
+
case "--max-results":
|
|
39
|
+
args.maxResults = Number(next());
|
|
40
|
+
break;
|
|
41
|
+
case "--max-snippet-chars":
|
|
42
|
+
args.maxSnippetChars = Number(next());
|
|
43
|
+
break;
|
|
44
|
+
case "--max-context-chars":
|
|
45
|
+
args.maxContextChars = Number(next());
|
|
46
|
+
break;
|
|
47
|
+
case "--include-hybrid":
|
|
48
|
+
args.includeHybrid = true;
|
|
49
|
+
break;
|
|
50
|
+
case "--json":
|
|
51
|
+
args.json = true;
|
|
52
|
+
break;
|
|
53
|
+
case "--output":
|
|
54
|
+
args.output = next();
|
|
55
|
+
break;
|
|
56
|
+
case "--help":
|
|
57
|
+
case "-h":
|
|
58
|
+
printHelp();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
default:
|
|
61
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const key of ["maxResults", "maxSnippetChars", "maxContextChars"]) {
|
|
66
|
+
if (!Number.isInteger(args[key]) || args[key] <= 0) {
|
|
67
|
+
throw new Error(`${key} must be a positive integer`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printHelp() {
|
|
75
|
+
console.log(`Usage: npm run bench:context -- [options]
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--project <path> Project to benchmark. Defaults to cwd.
|
|
79
|
+
--cases <path> JSON case file. Defaults to ${DEFAULT_CASES_PATH}.
|
|
80
|
+
--max-results <n> Ranked results to keep per method. Defaults to 8.
|
|
81
|
+
--include-hybrid Also run Gemini-backed hybrid search.
|
|
82
|
+
--json Print JSON instead of a table.
|
|
83
|
+
--output <path> Write JSON report to a file.
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readCases(casesPath) {
|
|
88
|
+
const raw = fs.readFileSync(casesPath, "utf8");
|
|
89
|
+
const cases = JSON.parse(raw);
|
|
90
|
+
if (!Array.isArray(cases)) throw new Error("Benchmark cases must be a JSON array");
|
|
91
|
+
return cases.map((item, index) => {
|
|
92
|
+
if (!item.id || !item.query || !Array.isArray(item.expectedPaths) || item.expectedPaths.length === 0) {
|
|
93
|
+
throw new Error(`Invalid benchmark case at index ${index}`);
|
|
94
|
+
}
|
|
95
|
+
return item;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function uniquePaths(results) {
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const paths = [];
|
|
102
|
+
for (const result of results) {
|
|
103
|
+
const candidate = typeof result === "string" ? result : result.path;
|
|
104
|
+
if (!candidate) continue;
|
|
105
|
+
const normalized = normalizeRelativePath(candidate);
|
|
106
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
107
|
+
seen.add(normalized);
|
|
108
|
+
paths.push(normalized);
|
|
109
|
+
}
|
|
110
|
+
return paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeRelativePath(value) {
|
|
114
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rankOfExpected(paths, expectedPaths) {
|
|
118
|
+
const expected = new Set(expectedPaths.map(normalizeRelativePath));
|
|
119
|
+
const index = paths.findIndex((candidate) => expected.has(candidate));
|
|
120
|
+
return index >= 0 ? index + 1 : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function metricsFor(paths, expectedPaths) {
|
|
124
|
+
const rank = rankOfExpected(paths, expectedPaths);
|
|
125
|
+
return {
|
|
126
|
+
rank,
|
|
127
|
+
hitAt1: rank === 1,
|
|
128
|
+
hitAt3: rank !== null && rank <= 3,
|
|
129
|
+
hitAt5: rank !== null && rank <= 5,
|
|
130
|
+
reciprocalRank: rank ? 1 / rank : 0,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mean(values) {
|
|
135
|
+
if (values.length === 0) return 0;
|
|
136
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function percentile(values, p) {
|
|
140
|
+
if (values.length === 0) return 0;
|
|
141
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
142
|
+
const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
|
143
|
+
return sorted[index];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function benchmarkMethod(name, cases, runCase) {
|
|
147
|
+
const caseResults = [];
|
|
148
|
+
for (const testCase of cases) {
|
|
149
|
+
const startedAt = performance.now();
|
|
150
|
+
try {
|
|
151
|
+
const result = runCase(testCase);
|
|
152
|
+
const latencyMs = performance.now() - startedAt;
|
|
153
|
+
const paths = uniquePaths(result.paths ?? []);
|
|
154
|
+
caseResults.push({
|
|
155
|
+
id: testCase.id,
|
|
156
|
+
query: testCase.query,
|
|
157
|
+
expectedPaths: testCase.expectedPaths,
|
|
158
|
+
status: result.status ?? "ok",
|
|
159
|
+
latencyMs,
|
|
160
|
+
paths,
|
|
161
|
+
contextPaths: result.contextPaths ? uniquePaths(result.contextPaths) : undefined,
|
|
162
|
+
error: result.error,
|
|
163
|
+
...metricsFor(paths, testCase.expectedPaths),
|
|
164
|
+
context: result.contextPaths ? metricsFor(uniquePaths(result.contextPaths), testCase.expectedPaths) : undefined,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const latencyMs = performance.now() - startedAt;
|
|
168
|
+
caseResults.push({
|
|
169
|
+
id: testCase.id,
|
|
170
|
+
query: testCase.query,
|
|
171
|
+
expectedPaths: testCase.expectedPaths,
|
|
172
|
+
status: "error",
|
|
173
|
+
latencyMs,
|
|
174
|
+
paths: [],
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
...metricsFor([], testCase.expectedPaths),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
method: name,
|
|
183
|
+
summary: summarizeCases(caseResults),
|
|
184
|
+
cases: caseResults,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summarizeCases(caseResults) {
|
|
189
|
+
return {
|
|
190
|
+
cases: caseResults.length,
|
|
191
|
+
ok: caseResults.filter((item) => item.status === "ok").length,
|
|
192
|
+
skipped: caseResults.filter((item) => item.status === "skipped").length,
|
|
193
|
+
errors: caseResults.filter((item) => item.status === "error").length,
|
|
194
|
+
hitAt1: mean(caseResults.map((item) => (item.hitAt1 ? 1 : 0))),
|
|
195
|
+
hitAt3: mean(caseResults.map((item) => (item.hitAt3 ? 1 : 0))),
|
|
196
|
+
hitAt5: mean(caseResults.map((item) => (item.hitAt5 ? 1 : 0))),
|
|
197
|
+
mrr: mean(caseResults.map((item) => item.reciprocalRank)),
|
|
198
|
+
meanLatencyMs: mean(caseResults.map((item) => item.latencyMs)),
|
|
199
|
+
p95LatencyMs: percentile(caseResults.map((item) => item.latencyMs), 95),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function rgAvailable() {
|
|
204
|
+
const result = spawnSync("rg", ["--version"], { encoding: "utf8" });
|
|
205
|
+
return result.status === 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function rgSmartSearch(projectPath, query, maxResults, keywordTerms) {
|
|
209
|
+
if (!rgAvailable()) {
|
|
210
|
+
return { status: "skipped", paths: [], error: "rg is not available on PATH" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const terms = keywordTerms(query)
|
|
214
|
+
.filter((term) => term.length >= 3)
|
|
215
|
+
.slice(0, 10);
|
|
216
|
+
if (terms.length === 0) return { paths: [] };
|
|
217
|
+
|
|
218
|
+
const args = [
|
|
219
|
+
"--json",
|
|
220
|
+
"--ignore-case",
|
|
221
|
+
"--line-number",
|
|
222
|
+
"--glob",
|
|
223
|
+
"!.scythe-context/**",
|
|
224
|
+
"--glob",
|
|
225
|
+
"!local/**",
|
|
226
|
+
"--glob",
|
|
227
|
+
"!node_modules/**",
|
|
228
|
+
"--glob",
|
|
229
|
+
"!dist/**",
|
|
230
|
+
"--glob",
|
|
231
|
+
"!build/**",
|
|
232
|
+
"--glob",
|
|
233
|
+
"!coverage/**",
|
|
234
|
+
"--glob",
|
|
235
|
+
"!package-lock.json",
|
|
236
|
+
"--glob",
|
|
237
|
+
"!pnpm-lock.yaml",
|
|
238
|
+
"--glob",
|
|
239
|
+
"!yarn.lock",
|
|
240
|
+
];
|
|
241
|
+
for (const term of terms) args.push("-e", term);
|
|
242
|
+
args.push(".");
|
|
243
|
+
|
|
244
|
+
const result = spawnSync("rg", args, {
|
|
245
|
+
cwd: projectPath,
|
|
246
|
+
encoding: "utf8",
|
|
247
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
251
|
+
return {
|
|
252
|
+
status: "error",
|
|
253
|
+
paths: [],
|
|
254
|
+
error: result.stderr.trim() || `rg exited with status ${result.status}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const scores = new Map();
|
|
259
|
+
const lines = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
let event;
|
|
262
|
+
try {
|
|
263
|
+
event = JSON.parse(line);
|
|
264
|
+
} catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (event.type !== "match") continue;
|
|
268
|
+
const filePath = event.data?.path?.text;
|
|
269
|
+
if (!filePath) continue;
|
|
270
|
+
const lineText = event.data?.lines?.text ?? "";
|
|
271
|
+
const matches = event.data?.submatches?.length ?? 1;
|
|
272
|
+
const current = scores.get(filePath) ?? { path: filePath, score: 0, firstLine: event.data?.line_number ?? 0 };
|
|
273
|
+
current.score += matches + terms.filter((term) => lineText.toLowerCase().includes(term.toLowerCase())).length * 0.25;
|
|
274
|
+
current.firstLine = Math.min(current.firstLine || Infinity, event.data?.line_number ?? Infinity);
|
|
275
|
+
scores.set(filePath, current);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const paths = Array.from(scores.values())
|
|
279
|
+
.sort((a, b) => b.score - a.score || a.firstLine - b.firstLine || a.path.localeCompare(b.path))
|
|
280
|
+
.slice(0, maxResults)
|
|
281
|
+
.map((item) => item.path);
|
|
282
|
+
|
|
283
|
+
return { paths };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function contextPathsFromResults(rawResults, buildContextPack, readRelatedFileGraph, options) {
|
|
287
|
+
const seedPaths = uniquePaths(rawResults).slice(0, 3);
|
|
288
|
+
const relatedFiles = readRelatedFileGraph({
|
|
289
|
+
dbPath: options.dbPath,
|
|
290
|
+
seedPaths,
|
|
291
|
+
maxDepth: 1,
|
|
292
|
+
maxFiles: 10,
|
|
293
|
+
maxResultsPerFile: 8,
|
|
294
|
+
});
|
|
295
|
+
const pack = buildContextPack(options.query, rawResults, relatedFiles, {
|
|
296
|
+
maxContextChars: options.maxContextChars,
|
|
297
|
+
maxRelatedFiles: 10,
|
|
298
|
+
maxRelatedItems: 8,
|
|
299
|
+
});
|
|
300
|
+
return pack.suggestedPaths;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function main() {
|
|
304
|
+
const args = parseArgs(process.argv.slice(2));
|
|
305
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
306
|
+
const projectPath = path.resolve(args.project);
|
|
307
|
+
const casesPath = path.resolve(args.cases);
|
|
308
|
+
const cases = readCases(casesPath);
|
|
309
|
+
const dbPath = path.join(projectPath, ".scythe-context", "index.sqlite");
|
|
310
|
+
|
|
311
|
+
if (!fs.existsSync(dbPath)) {
|
|
312
|
+
throw new Error(`Index database not found: ${dbPath}. Run repo_reindex first.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const [
|
|
316
|
+
{ keywordTerms },
|
|
317
|
+
{ searchKeywordOnly, searchHybrid },
|
|
318
|
+
{ buildContextPack },
|
|
319
|
+
{ readRelatedFileGraph },
|
|
320
|
+
] = await Promise.all([
|
|
321
|
+
import(path.join(repoRoot, "dist/indexing/keywordSearch.js")),
|
|
322
|
+
import(path.join(repoRoot, "dist/indexing/hybridSearch.js")),
|
|
323
|
+
import(path.join(repoRoot, "dist/indexing/contextPack.js")),
|
|
324
|
+
import(path.join(repoRoot, "dist/indexing/relatedFiles.js")),
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
const methods = [
|
|
328
|
+
benchmarkMethod("rg-smart", cases, (testCase) =>
|
|
329
|
+
rgSmartSearch(projectPath, testCase.query, args.maxResults, keywordTerms),
|
|
330
|
+
),
|
|
331
|
+
benchmarkMethod("scythe-keyword", cases, (testCase) => {
|
|
332
|
+
const rawResults = searchKeywordOnly({
|
|
333
|
+
dbPath,
|
|
334
|
+
query: testCase.query,
|
|
335
|
+
maxResults: args.maxResults,
|
|
336
|
+
maxSnippetChars: args.maxSnippetChars,
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
paths: rawResults,
|
|
340
|
+
contextPaths: contextPathsFromResults(rawResults, buildContextPack, readRelatedFileGraph, {
|
|
341
|
+
dbPath,
|
|
342
|
+
query: testCase.query,
|
|
343
|
+
maxContextChars: args.maxContextChars,
|
|
344
|
+
}),
|
|
345
|
+
};
|
|
346
|
+
}),
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
if (args.includeHybrid) {
|
|
350
|
+
const [{ loadConfig }, { GeminiEmbeddingProvider }] = await Promise.all([
|
|
351
|
+
import(path.join(repoRoot, "dist/config.js")),
|
|
352
|
+
import(path.join(repoRoot, "dist/providers/gemini.js")),
|
|
353
|
+
]);
|
|
354
|
+
const config = loadConfig();
|
|
355
|
+
if (!config.gemini.apiKey) {
|
|
356
|
+
const hybridCases = cases.map((testCase) => ({
|
|
357
|
+
id: testCase.id,
|
|
358
|
+
query: testCase.query,
|
|
359
|
+
expectedPaths: testCase.expectedPaths,
|
|
360
|
+
status: "skipped",
|
|
361
|
+
latencyMs: 0,
|
|
362
|
+
paths: [],
|
|
363
|
+
error: "GEMINI_API_KEY is not set",
|
|
364
|
+
...metricsFor([], testCase.expectedPaths),
|
|
365
|
+
}));
|
|
366
|
+
methods.push({
|
|
367
|
+
method: "scythe-hybrid",
|
|
368
|
+
summary: summarizeCases(hybridCases),
|
|
369
|
+
cases: hybridCases,
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
const provider = new GeminiEmbeddingProvider(config.gemini);
|
|
373
|
+
const dimensions = config.gemini.outputDimensionality ?? 1536;
|
|
374
|
+
|
|
375
|
+
const hybridCases = [];
|
|
376
|
+
for (const testCase of cases) {
|
|
377
|
+
const startedAt = performance.now();
|
|
378
|
+
try {
|
|
379
|
+
const embedding = await provider.embed({ kind: "query", text: testCase.query });
|
|
380
|
+
if (embedding.dimensions !== dimensions) {
|
|
381
|
+
throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${embedding.dimensions}`);
|
|
382
|
+
}
|
|
383
|
+
const rawResults = searchHybrid({
|
|
384
|
+
dbPath,
|
|
385
|
+
query: testCase.query,
|
|
386
|
+
dimensions,
|
|
387
|
+
queryVector: embedding.vector,
|
|
388
|
+
maxResults: args.maxResults,
|
|
389
|
+
maxSnippetChars: args.maxSnippetChars,
|
|
390
|
+
});
|
|
391
|
+
const paths = uniquePaths(rawResults);
|
|
392
|
+
const contextPaths = contextPathsFromResults(rawResults, buildContextPack, readRelatedFileGraph, {
|
|
393
|
+
dbPath,
|
|
394
|
+
query: testCase.query,
|
|
395
|
+
maxContextChars: args.maxContextChars,
|
|
396
|
+
});
|
|
397
|
+
hybridCases.push({
|
|
398
|
+
id: testCase.id,
|
|
399
|
+
query: testCase.query,
|
|
400
|
+
expectedPaths: testCase.expectedPaths,
|
|
401
|
+
status: "ok",
|
|
402
|
+
latencyMs: performance.now() - startedAt,
|
|
403
|
+
paths,
|
|
404
|
+
contextPaths: uniquePaths(contextPaths),
|
|
405
|
+
...metricsFor(paths, testCase.expectedPaths),
|
|
406
|
+
context: metricsFor(uniquePaths(contextPaths), testCase.expectedPaths),
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
hybridCases.push({
|
|
410
|
+
id: testCase.id,
|
|
411
|
+
query: testCase.query,
|
|
412
|
+
expectedPaths: testCase.expectedPaths,
|
|
413
|
+
status: "error",
|
|
414
|
+
latencyMs: performance.now() - startedAt,
|
|
415
|
+
paths: [],
|
|
416
|
+
error: error instanceof Error ? error.message : String(error),
|
|
417
|
+
...metricsFor([], testCase.expectedPaths),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
methods.push({
|
|
422
|
+
method: "scythe-hybrid",
|
|
423
|
+
summary: summarizeCases(hybridCases),
|
|
424
|
+
cases: hybridCases,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const report = {
|
|
430
|
+
generatedAt: new Date().toISOString(),
|
|
431
|
+
projectPath,
|
|
432
|
+
casesPath,
|
|
433
|
+
dbPath,
|
|
434
|
+
maxResults: args.maxResults,
|
|
435
|
+
methods,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (args.output) {
|
|
439
|
+
const outputPath = path.resolve(args.output);
|
|
440
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
441
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (args.json) {
|
|
445
|
+
console.log(JSON.stringify(report, null, 2));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
console.log(`Context search benchmark`);
|
|
450
|
+
console.log(`Project: ${projectPath}`);
|
|
451
|
+
console.log(`Cases: ${cases.length}`);
|
|
452
|
+
console.log("");
|
|
453
|
+
console.log("method ok/skp/err hit@1 hit@3 hit@5 MRR mean ms p95 ms");
|
|
454
|
+
console.log("--------------- ---------- ----- ----- ----- ----- ------- ------");
|
|
455
|
+
for (const method of methods) {
|
|
456
|
+
const summary = method.summary;
|
|
457
|
+
console.log(
|
|
458
|
+
`${method.method.padEnd(15)} ${String(`${summary.ok}/${summary.skipped}/${summary.errors}`).padStart(10)} ${summary.hitAt1.toFixed(2).padStart(5)} ${summary.hitAt3.toFixed(2).padStart(5)} ${summary.hitAt5.toFixed(2).padStart(5)} ${summary.mrr.toFixed(2).padStart(5)} ${summary.meanLatencyMs.toFixed(1).padStart(7)} ${summary.p95LatencyMs.toFixed(1).padStart(6)}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
console.log("");
|
|
463
|
+
console.log("Misses:");
|
|
464
|
+
for (const method of methods) {
|
|
465
|
+
const misses = method.cases.filter((item) => item.status === "ok" && !item.hitAt5);
|
|
466
|
+
if (misses.length === 0) continue;
|
|
467
|
+
console.log(`- ${method.method}: ${misses.map((item) => item.id).join(", ")}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
main().catch((error) => {
|
|
472
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
473
|
+
process.exitCode = 1;
|
|
474
|
+
});
|