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 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, keyword-only fallback when embeddings fail, and tree-sitter symbol extraction if needed.
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、embedding 失敗時的 keyword-only fallback、必要時加入 tree-sitter symbol extraction。
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、embedding 失败时的 keyword-only fallback、必要时加入 tree-sitter symbol extraction。
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
@@ -1,4 +1,4 @@
1
- export const PACKAGE_VERSION = "0.1.3";
1
+ export const PACKAGE_VERSION = "0.1.5";
2
2
  export function parseCliArgs(args) {
3
3
  if (args.length === 0)
4
4
  return { kind: "serve" };
@@ -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 mergeHybridResults(semanticResults, keywordResults, options.maxResults);
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
- if (queryEmbedding.dimensions !== dimensions) {
229
- throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
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
- if (queryEmbedding.dimensions !== dimensions) {
314
- throw new Error(`Query embedding dimensions mismatch: expected ${dimensions}, got ${queryEmbedding.dimensions}`);
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",
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
+ });