scythe-context-mcp 0.1.3 → 0.1.4

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,12 @@ This project follows semantic versioning before npm publication where practical.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.4] - 2026-06-14
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+
9
15
  ## [0.1.3] - 2026-06-14
10
16
 
11
17
  ### Changed
package/README.en.md CHANGED
@@ -244,6 +244,8 @@ 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
251
  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.
package/README.md CHANGED
@@ -244,6 +244,8 @@ 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
251
  已完成: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。
package/README.zh-CN.md CHANGED
@@ -244,6 +244,8 @@ 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
251
  已完成: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。
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PACKAGE_VERSION = "0.1.3";
1
+ export const PACKAGE_VERSION = "0.1.4";
2
2
  export function parseCliArgs(args) {
3
3
  if (args.length === 0)
4
4
  return { kind: "serve" };
@@ -65,3 +65,12 @@ export function searchHybrid(options) {
65
65
  });
66
66
  return mergeHybridResults(semanticResults, keywordResults, options.maxResults);
67
67
  }
68
+ export function searchKeywordOnly(options) {
69
+ const keywordResults = searchByKeyword({
70
+ dbPath: options.dbPath,
71
+ query: options.query,
72
+ maxResults: Math.max(options.maxResults * 2, options.maxResults),
73
+ maxSnippetChars: options.maxSnippetChars,
74
+ });
75
+ return mergeHybridResults([], keywordResults, options.maxResults);
76
+ }
@@ -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,
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.4",
4
4
  "description": "Local MCP context engine for Codex with Gemini Embedding 2 support.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",