kiri-mcp-server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +47 -12
  2. package/config/domain-terms.yml +37 -0
  3. package/config/kiri.yml +25 -0
  4. package/dist/config/domain-terms.yml +37 -0
  5. package/dist/config/kiri.yml +25 -0
  6. package/dist/package.json +6 -1
  7. package/dist/src/server/boost-profiles.d.ts +6 -5
  8. package/dist/src/server/boost-profiles.d.ts.map +1 -1
  9. package/dist/src/server/boost-profiles.js +110 -2
  10. package/dist/src/server/boost-profiles.js.map +1 -1
  11. package/dist/src/server/config-loader.d.ts +9 -0
  12. package/dist/src/server/config-loader.d.ts.map +1 -0
  13. package/dist/src/server/config-loader.js +121 -0
  14. package/dist/src/server/config-loader.js.map +1 -0
  15. package/dist/src/server/config.d.ts +4 -0
  16. package/dist/src/server/config.d.ts.map +1 -1
  17. package/dist/src/server/config.js +61 -0
  18. package/dist/src/server/config.js.map +1 -1
  19. package/dist/src/server/context.d.ts +4 -0
  20. package/dist/src/server/context.d.ts.map +1 -1
  21. package/dist/src/server/context.js +6 -0
  22. package/dist/src/server/context.js.map +1 -1
  23. package/dist/src/server/domain-terms.d.ts +28 -0
  24. package/dist/src/server/domain-terms.d.ts.map +1 -0
  25. package/dist/src/server/domain-terms.js +203 -0
  26. package/dist/src/server/domain-terms.js.map +1 -0
  27. package/dist/src/server/handlers.d.ts +3 -0
  28. package/dist/src/server/handlers.d.ts.map +1 -1
  29. package/dist/src/server/handlers.js +386 -19
  30. package/dist/src/server/handlers.js.map +1 -1
  31. package/dist/src/server/observability/metrics.d.ts +12 -0
  32. package/dist/src/server/observability/metrics.d.ts.map +1 -1
  33. package/dist/src/server/observability/metrics.js +11 -0
  34. package/dist/src/server/observability/metrics.js.map +1 -1
  35. package/dist/src/server/rpc.d.ts.map +1 -1
  36. package/dist/src/server/rpc.js +40 -4
  37. package/dist/src/server/rpc.js.map +1 -1
  38. package/dist/src/server/runtime.d.ts.map +1 -1
  39. package/dist/src/server/runtime.js +1 -0
  40. package/dist/src/server/runtime.js.map +1 -1
  41. package/dist/src/server/services/index.d.ts +2 -0
  42. package/dist/src/server/services/index.d.ts.map +1 -1
  43. package/dist/src/server/services/index.js +12 -0
  44. package/dist/src/server/services/index.js.map +1 -1
  45. package/dist/src/shared/adaptive-k.d.ts +12 -0
  46. package/dist/src/shared/adaptive-k.d.ts.map +1 -0
  47. package/dist/src/shared/adaptive-k.js +8 -0
  48. package/dist/src/shared/adaptive-k.js.map +1 -0
  49. package/dist/src/shared/config-validate-adaptive-k.d.ts +3 -0
  50. package/dist/src/shared/config-validate-adaptive-k.d.ts.map +1 -0
  51. package/dist/src/shared/config-validate-adaptive-k.js +44 -0
  52. package/dist/src/shared/config-validate-adaptive-k.js.map +1 -0
  53. package/dist/src/shared/tokenizer.d.ts +1 -1
  54. package/dist/src/shared/tokenizer.d.ts.map +1 -1
  55. package/dist/src/shared/tokenizer.js +97 -15
  56. package/dist/src/shared/tokenizer.js.map +1 -1
  57. package/package.json +33 -26
@@ -1,10 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { checkFTSSchemaExists } from "../indexer/schema.js";
4
+ import { getAdaptiveK } from "../shared/adaptive-k.js";
4
5
  import { generateEmbedding, structuralSimilarity } from "../shared/embedding.js";
5
6
  import { encode as encodeGPT, tokenizeText } from "../shared/tokenizer.js";
6
7
  import { expandAbbreviations } from "./abbreviations.js";
7
8
  import { getBoostProfile, } from "./boost-profiles.js";
9
+ import { loadPathPenalties, mergePathPenaltyEntries } from "./config-loader.js";
8
10
  import { loadServerConfig } from "./config.js";
9
11
  import { coerceProfileName, loadScoringProfile } from "./scoring.js";
10
12
  import { createServerServices } from "./services/index.js";
@@ -336,6 +338,76 @@ function bucketArtifactHints(hints) {
336
338
  }
337
339
  return buckets;
338
340
  }
341
+ function selectDomainPathHints(hints) {
342
+ const selected = [];
343
+ for (const hint of hints) {
344
+ if (!SAFE_PATH_PATTERN.test(hint.path)) {
345
+ continue;
346
+ }
347
+ if (selected.some((entry) => entry.path === hint.path)) {
348
+ continue;
349
+ }
350
+ selected.push(hint);
351
+ if (selected.length >= DOMAIN_PATH_HINT_LIMIT) {
352
+ break;
353
+ }
354
+ }
355
+ return selected;
356
+ }
357
+ /**
358
+ * AdaptiveK用カテゴリ自動検出
359
+ *
360
+ * 優先順位:
361
+ * 1. params.categoryが明示的に指定されている場合はそれを使用
362
+ * 2. profile/artifacts/boost_profileから推論
363
+ *
364
+ * @param params - context_bundleのパラメータ
365
+ * @returns 検出されたカテゴリ(undefined = デフォルトK値を使用)
366
+ */
367
+ function determineCategory(params) {
368
+ // 明示的に指定されている場合はそれを優先
369
+ if (params.category) {
370
+ return params.category;
371
+ }
372
+ const { profile, boost_profile, artifacts } = params;
373
+ // artifacts.failing_testsがある場合はデバッグ作業
374
+ if (artifacts?.failing_tests && artifacts.failing_tests.length > 0) {
375
+ return "debug";
376
+ }
377
+ // profileからの推論
378
+ if (profile === "testfail") {
379
+ return "testfail";
380
+ }
381
+ if (profile === "bugfix" || profile === "typeerror") {
382
+ return "debug";
383
+ }
384
+ if (profile === "refactor") {
385
+ return "api";
386
+ }
387
+ if (profile === "feature") {
388
+ return "feature";
389
+ }
390
+ // boost_profileからの推論
391
+ if (boost_profile === "docs") {
392
+ return "docs";
393
+ }
394
+ // artifacts.editing_pathからの推論
395
+ if (artifacts?.editing_path) {
396
+ const editingPath = artifacts.editing_path.toLowerCase();
397
+ // テストファイルを編集中 → debug
398
+ if (editingPath.includes(".test.") ||
399
+ editingPath.includes(".spec.") ||
400
+ editingPath.includes("__tests__")) {
401
+ return "debug";
402
+ }
403
+ // ドキュメントを編集中 → docs
404
+ if (editingPath.endsWith(".md") || editingPath.includes("/docs/")) {
405
+ return "docs";
406
+ }
407
+ }
408
+ // 推論できない場合はundefined(kDefaultを使用)
409
+ return undefined;
410
+ }
339
411
  function isMissingTableError(error, table) {
340
412
  if (!(error instanceof Error)) {
341
413
  return false;
@@ -447,22 +519,34 @@ function createHintExpansionConfig(weights) {
447
519
  const DEFAULT_SEARCH_LIMIT = 50;
448
520
  const DEFAULT_BUNDLE_LIMIT = 7; // Reduced from 12 to optimize token usage
449
521
  const MAX_BUNDLE_LIMIT = 20;
522
+ const TRACE_SEARCH = process.env.KIRI_TRACE_SEARCH === "1";
450
523
  const MAX_KEYWORDS = 12;
451
524
  const MAX_MATCHES_PER_KEYWORD = 40;
452
525
  const MAX_DEPENDENCY_SEEDS = 8;
453
526
  const MAX_DEPENDENCY_SEEDS_QUERY_LIMIT = 100; // SQL injection防御用の上限
454
527
  const NEARBY_LIMIT = 6;
455
528
  const serverConfig = loadServerConfig();
529
+ const mergedPathMultiplierCache = new Map();
456
530
  const SUPPRESS_NON_CODE_ENABLED = serverConfig.features.suppressNonCode;
457
531
  const FINAL_RESULT_SUPPRESSION_ENABLED = serverConfig.features.suppressFinalResults;
458
532
  const CLAMP_SNIPPETS_ENABLED = serverConfig.features.clampSnippets;
459
533
  const FALLBACK_SNIPPET_WINDOW = serverConfig.features.snippetWindow;
460
534
  const MAX_RERANK_LIMIT = 50;
461
535
  const MAX_ARTIFACT_HINTS = 8;
536
+ const DOMAIN_PATH_HINT_LIMIT = MAX_ARTIFACT_HINTS;
462
537
  const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_.\-/]+$/;
463
538
  const HINT_PRIORITY_TEXT_MULTIPLIER = serverConfig.hints.priority.textMultiplier;
464
539
  const HINT_PRIORITY_PATH_MULTIPLIER = serverConfig.hints.priority.pathMultiplier;
465
540
  const HINT_PRIORITY_BASE_BONUS = serverConfig.hints.priority.baseBonus;
541
+ const PATH_FALLBACK_LIMIT = 40;
542
+ const PATH_FALLBACK_TERMS_LIMIT = 5;
543
+ const PATH_FALLBACK_KEEP = 8;
544
+ const AUTO_PATH_SEGMENT_LIMIT = 4;
545
+ function traceSearch(message, ...args) {
546
+ if (TRACE_SEARCH) {
547
+ console.log(`[TRACE context_bundle] ${message}`, ...args);
548
+ }
549
+ }
466
550
  const HINT_DIR_LIMIT = serverConfig.hints.directory.limit;
467
551
  const HINT_DIR_MAX_FILES = serverConfig.hints.directory.maxFiles;
468
552
  const HINT_DEP_OUT_LIMIT = serverConfig.hints.dependency.outLimit;
@@ -811,8 +895,30 @@ function extractKeywords(text) {
811
895
  }
812
896
  }
813
897
  }
898
+ addKeywordDerivedPathSegments(result);
814
899
  return result;
815
900
  }
901
+ function addKeywordDerivedPathSegments(result) {
902
+ if (result.pathSegments.length >= AUTO_PATH_SEGMENT_LIMIT) {
903
+ return;
904
+ }
905
+ const additional = [];
906
+ for (const keyword of result.keywords) {
907
+ if (keyword.length < 3 || STOP_WORDS.has(keyword)) {
908
+ continue;
909
+ }
910
+ if (result.pathSegments.includes(keyword) || additional.includes(keyword)) {
911
+ continue;
912
+ }
913
+ additional.push(keyword);
914
+ if (result.pathSegments.length + additional.length >= AUTO_PATH_SEGMENT_LIMIT) {
915
+ break;
916
+ }
917
+ }
918
+ if (additional.length > 0) {
919
+ result.pathSegments.push(...additional);
920
+ }
921
+ }
816
922
  function ensureCandidate(map, filePath) {
817
923
  let candidate = map.get(filePath);
818
924
  if (!candidate) {
@@ -829,12 +935,35 @@ function ensureCandidate(map, filePath) {
829
935
  embedding: null,
830
936
  semanticSimilarity: null,
831
937
  pathMatchHits: 0, // Issue #68: Track path match count
938
+ keywordHits: new Set(),
939
+ phraseHits: 0,
940
+ // pathFallbackReason は optional なので省略(exactOptionalPropertyTypes対応)
941
+ fallbackTextHits: 0,
832
942
  penalties: [], // Issue #68: Penalty log for telemetry
833
943
  };
834
944
  map.set(filePath, candidate);
835
945
  }
836
946
  return candidate;
837
947
  }
948
+ function normalizePathPrefix(rawPrefix) {
949
+ // Normalize, strip leading slashes/dots, and ensure trailing slash for exact prefix match
950
+ const normalized = path.posix.normalize(rawPrefix.replace(/\\/g, "/"));
951
+ const stripped = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
952
+ if (stripped === "" || stripped === ".") {
953
+ return "";
954
+ }
955
+ return stripped.endsWith("/") ? stripped : `${stripped}/`;
956
+ }
957
+ function normalizeFilePath(filePath) {
958
+ return path.posix.normalize(filePath.replace(/\\/g, "/")).replace(/^\/+/, "");
959
+ }
960
+ function pathMatchesPrefix(filePath, normalizedPrefix) {
961
+ if (!normalizedPrefix) {
962
+ return true;
963
+ }
964
+ const normalizedPath = normalizeFilePath(filePath);
965
+ return normalizedPath.startsWith(normalizedPrefix);
966
+ }
838
967
  async function expandHintCandidatesForHints(params) {
839
968
  const { hintPaths, config } = params;
840
969
  if (hintPaths.length === 0 || config.perHintLimit <= 0 || config.dbQueryBudget <= 0) {
@@ -1537,13 +1666,17 @@ async function safeLinkQuery(db, tableAvailability, sql, params) {
1537
1666
  throw error;
1538
1667
  }
1539
1668
  }
1540
- async function fetchMetadataOnlyCandidates(db, tableAvailability, repoId, filters, limit) {
1669
+ async function fetchMetadataOnlyCandidates(db, tableAvailability, repoId, filters, limit, pathPrefix) {
1541
1670
  if (!tableAvailability.hasMetadataTables || filters.length === 0 || limit <= 0) {
1542
1671
  return [];
1543
1672
  }
1544
1673
  const filterClauses = buildMetadataFilterConditions(filters);
1545
1674
  const whereClauses = ["f.repo_id = ?"];
1546
1675
  const params = [repoId];
1676
+ if (pathPrefix) {
1677
+ whereClauses.push("f.path LIKE ?");
1678
+ params.push(`${pathPrefix}%`);
1679
+ }
1547
1680
  for (const clause of filterClauses) {
1548
1681
  whereClauses.push(clause.sql);
1549
1682
  params.push(...clause.params);
@@ -1568,13 +1701,17 @@ async function fetchMetadataOnlyCandidates(db, tableAvailability, repoId, filter
1568
1701
  throw error;
1569
1702
  }
1570
1703
  }
1571
- async function fetchMetadataKeywordMatches(db, tableAvailability, repoId, keywords, filters, limit, excludePaths) {
1704
+ async function fetchMetadataKeywordMatches(db, tableAvailability, repoId, keywords, filters, limit, excludePaths, pathPrefix) {
1572
1705
  if (!tableAvailability.hasMetadataTables || keywords.length === 0 || limit <= 0) {
1573
1706
  return [];
1574
1707
  }
1575
1708
  const keywordClauses = keywords.map(() => "mk.value ILIKE ?").join(" OR ");
1576
1709
  const params = [repoId, ...keywords.map((kw) => `%${kw}%`)];
1577
1710
  const whereClauses = ["mk.repo_id = ?", `(${keywordClauses})`];
1711
+ if (pathPrefix) {
1712
+ whereClauses.push("f.path LIKE ?");
1713
+ params.push(`${pathPrefix}%`);
1714
+ }
1578
1715
  if (excludePaths.size > 0) {
1579
1716
  const placeholders = Array.from(excludePaths)
1580
1717
  .map(() => "?")
@@ -1774,6 +1911,50 @@ function applyFileTypeBoost(path, baseScore, profileConfig, weights) {
1774
1911
  }
1775
1912
  return baseScore * multiplier;
1776
1913
  }
1914
+ function applyCoverageBoost(candidate, extractedTerms, weights) {
1915
+ // Skip for pure path-fallback candidates without text evidence
1916
+ if (candidate.reasons.has("fallback:path") &&
1917
+ candidate.keywordHits.size === 0 &&
1918
+ candidate.phraseHits === 0) {
1919
+ return;
1920
+ }
1921
+ // Coverage boost is only meaningful for text/phrase evidence; skip if no text evidence at all
1922
+ if (candidate.keywordHits.size === 0 && candidate.phraseHits === 0) {
1923
+ return;
1924
+ }
1925
+ if (extractedTerms.keywords.length > 0 && candidate.keywordHits.size > 0) {
1926
+ const coverage = candidate.keywordHits.size / extractedTerms.keywords.length;
1927
+ const bonus = coverage * weights.textMatch * 0.4;
1928
+ candidate.score += bonus;
1929
+ candidate.reasons.add(`coverage:keywords:${coverage.toFixed(2)}`);
1930
+ }
1931
+ if (extractedTerms.phrases.length > 0 && candidate.phraseHits > 0) {
1932
+ const phraseCoverage = Math.min(1, candidate.phraseHits / extractedTerms.phrases.length);
1933
+ const bonus = phraseCoverage * weights.textMatch * 0.6;
1934
+ candidate.score += bonus;
1935
+ candidate.reasons.add(`coverage:phrases:${phraseCoverage.toFixed(2)}`);
1936
+ }
1937
+ }
1938
+ async function fetchPathFallbackCandidates(db, repoId, terms, limit) {
1939
+ if (terms.length === 0 || limit <= 0) {
1940
+ return [];
1941
+ }
1942
+ const filters = terms.map(() => "f.path ILIKE ?").join(" OR ");
1943
+ const params = [repoId, ...terms.map((term) => `%${term}%`), limit];
1944
+ return await db.all(`
1945
+ SELECT f.path, f.lang, f.ext, f.is_binary, b.content, fe.vector_json, fe.dims AS vector_dims
1946
+ FROM file f
1947
+ JOIN blob b ON b.hash = f.blob_hash
1948
+ LEFT JOIN file_embedding fe
1949
+ ON fe.repo_id = f.repo_id
1950
+ AND fe.path = f.path
1951
+ WHERE f.repo_id = ?
1952
+ AND f.is_binary = FALSE
1953
+ AND (${filters})
1954
+ ORDER BY f.path
1955
+ LIMIT ?
1956
+ `, params);
1957
+ }
1777
1958
  /**
1778
1959
  * パスベースのスコアリングを適用(加算的ブースト)
1779
1960
  * goalのキーワード/フレーズがファイルパスに含まれる場合にスコアを加算
@@ -1955,6 +2136,18 @@ function applyMultiplicativeFilePenalties(candidate, path, lowerPath, fileName,
1955
2136
  function applyFileTypeMultipliers(candidate, path, ext, profileConfig, weights) {
1956
2137
  const fileName = path.split("/").pop() ?? "";
1957
2138
  const lowerPath = path.toLowerCase();
2139
+ // Very low value: schemas, fixtures, testdata, examples, baseline
2140
+ const schemaJson = lowerPath.endsWith(".schema.json") || lowerPath.includes("/schemas/");
2141
+ const isFixture = lowerPath.includes("/fixtures/") ||
2142
+ lowerPath.includes("/fixture/") ||
2143
+ lowerPath.includes("/testdata/");
2144
+ const isExample = lowerPath.includes("/examples/") || lowerPath.includes("/example/");
2145
+ const isBaseline = lowerPath.includes("baseline") || lowerPath.includes("golden");
2146
+ if (schemaJson || isFixture || isExample || isBaseline) {
2147
+ candidate.scoreMultiplier *= weights.configPenaltyMultiplier;
2148
+ candidate.reasons.add("penalty:low-value-file");
2149
+ return;
2150
+ }
1958
2151
  // ✅ Step 1: Low-value files (v1.0.0: syntax/perf/legal/migration)
1959
2152
  // Apply configPenaltyMultiplier (strong penalty) to rarely useful file types
1960
2153
  const isSyntaxGrammar = path.includes("/syntaxes/") &&
@@ -2072,6 +2265,7 @@ export async function filesSearch(context, params) {
2072
2265
  cleanedQuery = cleanedQuery.trim();
2073
2266
  hasTextQuery = cleanedQuery.length > 0;
2074
2267
  }
2268
+ const pathPrefix = params.path_prefix && params.path_prefix.length > 0 ? params.path_prefix : undefined;
2075
2269
  const metadataValueSeed = metadataFilters
2076
2270
  .flatMap((filter) => filter.values)
2077
2271
  .map((value) => value.trim())
@@ -2170,7 +2364,7 @@ export async function filesSearch(context, params) {
2170
2364
  candidateRows.push(...textRows);
2171
2365
  }
2172
2366
  if (!hasTextQuery && hasAnyMetadataFilters) {
2173
- const metadataOnlyRows = await fetchMetadataOnlyCandidates(db, context.tableAvailability, repoId, metadataFilters, limit * 2);
2367
+ const metadataOnlyRows = await fetchMetadataOnlyCandidates(db, context.tableAvailability, repoId, metadataFilters, limit * 2, pathPrefix);
2174
2368
  for (const row of metadataOnlyRows) {
2175
2369
  row.score = 1 + metadataFilters.length * 0.2;
2176
2370
  }
@@ -2180,7 +2374,7 @@ export async function filesSearch(context, params) {
2180
2374
  const metadataKeywords = splitQueryWords(cleanedQuery.toLowerCase()).map((kw) => kw.toLowerCase());
2181
2375
  if (metadataKeywords.length > 0) {
2182
2376
  const excludePaths = new Set(candidateRows.map((row) => row.path));
2183
- const metadataRows = await fetchMetadataKeywordMatches(db, context.tableAvailability, repoId, metadataKeywords, metadataFilters, limit * 2, excludePaths);
2377
+ const metadataRows = await fetchMetadataKeywordMatches(db, context.tableAvailability, repoId, metadataKeywords, metadataFilters, limit * 2, excludePaths, pathPrefix);
2184
2378
  candidateRows.push(...metadataRows);
2185
2379
  }
2186
2380
  }
@@ -2207,7 +2401,17 @@ export async function filesSearch(context, params) {
2207
2401
  const filterValueSet = new Set(metadataFilters.flatMap((filter) => filter.values.map((value) => value.toLowerCase())));
2208
2402
  const boostProfile = params.boost_profile ??
2209
2403
  (hasHintMetadataFilters ? "balanced" : hasStrictMetadataFilters ? "docs" : "default");
2210
- const profileConfig = getBoostProfile(boostProfile);
2404
+ const baseProfileConfig = getBoostProfile(boostProfile);
2405
+ const cachedMerged = mergedPathMultiplierCache.get(boostProfile);
2406
+ const mergedPathMultipliers = cachedMerged ??
2407
+ mergePathPenaltyEntries(baseProfileConfig.pathMultipliers, [], serverConfig.pathPenalties);
2408
+ if (!cachedMerged) {
2409
+ mergedPathMultiplierCache.set(boostProfile, mergedPathMultipliers);
2410
+ }
2411
+ const profileConfig = {
2412
+ ...baseProfileConfig,
2413
+ pathMultipliers: mergedPathMultipliers,
2414
+ };
2211
2415
  const weights = loadScoringProfile(null);
2212
2416
  const options = parseOutputOptions(params);
2213
2417
  const previewQuery = hasTextQuery
@@ -2543,7 +2747,22 @@ async function contextBundleImpl(context, params) {
2543
2747
  mergedFilters: metadataFilters,
2544
2748
  }));
2545
2749
  }
2546
- const limit = normalizeBundleLimit(params.limit);
2750
+ // AdaptiveK: カテゴリに基づいてK値を動的に調整
2751
+ // 1. params.categoryが明示的に指定されていればそれを使用
2752
+ // 2. そうでなければprofile/artifacts/boost_profileから自動検出
2753
+ const detectedCategory = determineCategory(params);
2754
+ const adaptiveK = getAdaptiveK(detectedCategory, serverConfig.adaptiveK);
2755
+ const adaptiveLimit = normalizeBundleLimit(adaptiveK);
2756
+ const requestedLimit = normalizeBundleLimit(params.limit);
2757
+ // AdaptiveKが無効な場合はrequestLimitをそのまま使用
2758
+ // AdaptiveKが有効な場合:
2759
+ // - ユーザー指定limitがなければadaptiveKを使用
2760
+ // - ユーザー指定がある場合は小さい方を採用(過剰取得防止)
2761
+ const limit = serverConfig.adaptiveK.enabled
2762
+ ? params.limit === undefined
2763
+ ? adaptiveLimit
2764
+ : Math.min(requestedLimit, adaptiveLimit)
2765
+ : requestedLimit;
2547
2766
  const artifacts = params.artifacts ?? {};
2548
2767
  const artifactHints = normalizeArtifactHints(artifacts.hints);
2549
2768
  const hintBuckets = bucketArtifactHints(artifactHints);
@@ -2551,6 +2770,9 @@ async function contextBundleImpl(context, params) {
2551
2770
  const substringHints = hintBuckets.substringHints;
2552
2771
  const includeTokensEstimate = params.includeTokensEstimate === true;
2553
2772
  const isCompact = params.compact === true;
2773
+ const pathPrefix = params.path_prefix && params.path_prefix.length > 0
2774
+ ? normalizePathPrefix(params.path_prefix)
2775
+ : undefined;
2554
2776
  // 項目2: トークンバジェット保護警告
2555
2777
  // 大量データ+非コンパクトモード+トークン推定なしの場合に警告
2556
2778
  // リクエストごとに警告(warnForRequestを使用)
@@ -2580,9 +2802,18 @@ async function contextBundleImpl(context, params) {
2580
2802
  .join(" ");
2581
2803
  keywordSources.push(filterSeed);
2582
2804
  }
2805
+ const baseSeed = keywordSources.join(" ");
2806
+ const domainExpansion = process.env.KIRI_ENABLE_DOMAIN_TERMS === "1"
2807
+ ? context.services.domainTerms.expandFromText(baseSeed)
2808
+ : { matched: [], aliases: [], fileHints: [] };
2809
+ if (domainExpansion.aliases.length > 0) {
2810
+ keywordSources.push(domainExpansion.aliases.join(" "));
2811
+ }
2583
2812
  const semanticSeed = keywordSources.join(" ");
2584
2813
  const queryEmbedding = generateEmbedding(semanticSeed)?.values ?? null;
2585
2814
  const extractedTerms = extractKeywords(semanticSeed);
2815
+ const segmentPreview = extractedTerms.pathSegments.slice(0, AUTO_PATH_SEGMENT_LIMIT).join(",");
2816
+ traceSearch(`terms repo=${repoId} id=${params.requestId ?? "n/a"} keywords=${extractedTerms.keywords.length} phrases=${extractedTerms.phrases.length} pathSegments=${extractedTerms.pathSegments.length} segs=[${segmentPreview}]`);
2586
2817
  // フォールバック: editing_pathからキーワードを抽出
2587
2818
  if (extractedTerms.phrases.length === 0 &&
2588
2819
  extractedTerms.keywords.length === 0 &&
@@ -2599,14 +2830,24 @@ async function contextBundleImpl(context, params) {
2599
2830
  // ✅ Cache boost profile config to avoid redundant lookups in hot path
2600
2831
  const boostProfile = params.boost_profile ??
2601
2832
  (hasHintMetadataFilters ? "balanced" : hasStrictMetadataFilters ? "docs" : "default");
2602
- const profileConfig = getBoostProfile(boostProfile);
2833
+ const baseProfileConfig = getBoostProfile(boostProfile);
2834
+ const profileConfig = {
2835
+ ...baseProfileConfig,
2836
+ pathMultipliers: loadPathPenalties(baseProfileConfig.pathMultipliers),
2837
+ };
2603
2838
  // フレーズマッチング(高い重み: textMatch × 2)- 統合クエリでパフォーマンス改善
2604
2839
  if (extractedTerms.phrases.length > 0) {
2605
2840
  const phrasePlaceholders = extractedTerms.phrases
2606
2841
  .map(() => "b.content ILIKE '%' || ? || '%'")
2607
2842
  .join(" OR ");
2608
2843
  // DEBUG: Log SQL query parameters for troubleshooting
2609
- console.log(`[DEBUG contextBundle] Executing phrase match query with repo_id=${repoId}, phrases=${JSON.stringify(extractedTerms.phrases)}`);
2844
+ traceSearch(`Executing phrase match query with repo_id=${repoId}, phrases=${JSON.stringify(extractedTerms.phrases)}`);
2845
+ const phraseWhereClauses = ["f.repo_id = ?", "f.is_binary = FALSE", `(${phrasePlaceholders})`];
2846
+ const phraseParams = [repoId, ...extractedTerms.phrases];
2847
+ if (pathPrefix) {
2848
+ phraseWhereClauses.push("f.path LIKE ?");
2849
+ phraseParams.push(`${pathPrefix}%`);
2850
+ }
2610
2851
  const rows = await db.all(`
2611
2852
  SELECT f.path, f.lang, f.ext, f.is_binary, b.content, fe.vector_json, fe.dims AS vector_dims
2612
2853
  FROM file f
@@ -2614,19 +2855,21 @@ async function contextBundleImpl(context, params) {
2614
2855
  LEFT JOIN file_embedding fe
2615
2856
  ON fe.repo_id = f.repo_id
2616
2857
  AND fe.path = f.path
2617
- WHERE f.repo_id = ?
2618
- AND f.is_binary = FALSE
2619
- AND (${phrasePlaceholders})
2858
+ WHERE ${phraseWhereClauses.join(`
2859
+ AND `)}
2620
2860
  ORDER BY f.path
2621
2861
  LIMIT ?
2622
- `, [repoId, ...extractedTerms.phrases, MAX_MATCHES_PER_KEYWORD * extractedTerms.phrases.length]);
2862
+ `, [...phraseParams, MAX_MATCHES_PER_KEYWORD * extractedTerms.phrases.length]);
2623
2863
  // DEBUG: Log returned paths and verify they match expected repo_id
2624
2864
  if (rows.length > 0) {
2625
- console.log(`[DEBUG contextBundle] Phrase match returned ${rows.length} rows. Sample paths:`, rows.slice(0, 3).map((r) => r.path));
2865
+ traceSearch(`Phrase match returned ${rows.length} rows. Sample paths: ${rows
2866
+ .slice(0, 3)
2867
+ .map((r) => r.path)
2868
+ .join(", ")}`);
2626
2869
  // Verify repo_id of returned files
2627
2870
  const pathsToCheck = rows.slice(0, 3).map((r) => r.path);
2628
2871
  const verification = await db.all(`SELECT path, repo_id FROM file WHERE path IN (${pathsToCheck.map(() => "?").join(", ")}) LIMIT 3`, pathsToCheck);
2629
- console.log(`[DEBUG contextBundle] Repo ID verification:`, verification);
2872
+ traceSearch(`Repo ID verification`, verification);
2630
2873
  }
2631
2874
  for (const row of rows) {
2632
2875
  if (row.content === null) {
@@ -2639,6 +2882,7 @@ async function contextBundleImpl(context, params) {
2639
2882
  continue; // Should not happen, but defensive check
2640
2883
  }
2641
2884
  const candidate = ensureCandidate(candidates, row.path);
2885
+ candidate.phraseHits += matchedPhrases.length;
2642
2886
  // 各マッチしたフレーズに対してスコアリング
2643
2887
  for (const phrase of matchedPhrases) {
2644
2888
  // フレーズマッチは通常の2倍のスコア
@@ -2669,12 +2913,23 @@ async function contextBundleImpl(context, params) {
2669
2913
  });
2670
2914
  }
2671
2915
  }
2916
+ traceSearch(`phrase search produced ${rows.length} rows, candidates=${candidates.size}`);
2672
2917
  }
2673
2918
  // キーワードマッチング(通常の重み)- 統合クエリでパフォーマンス改善
2674
2919
  if (extractedTerms.keywords.length > 0) {
2675
2920
  const keywordPlaceholders = extractedTerms.keywords
2676
2921
  .map(() => "b.content ILIKE '%' || ? || '%'")
2677
2922
  .join(" OR ");
2923
+ const keywordWhereClauses = [
2924
+ "f.repo_id = ?",
2925
+ "f.is_binary = FALSE",
2926
+ `(${keywordPlaceholders})`,
2927
+ ];
2928
+ const keywordParams = [repoId, ...extractedTerms.keywords];
2929
+ if (pathPrefix) {
2930
+ keywordWhereClauses.push("f.path LIKE ?");
2931
+ keywordParams.push(`${pathPrefix}%`);
2932
+ }
2678
2933
  const rows = await db.all(`
2679
2934
  SELECT f.path, f.lang, f.ext, f.is_binary, b.content, fe.vector_json, fe.dims AS vector_dims
2680
2935
  FROM file f
@@ -2682,12 +2937,11 @@ async function contextBundleImpl(context, params) {
2682
2937
  LEFT JOIN file_embedding fe
2683
2938
  ON fe.repo_id = f.repo_id
2684
2939
  AND fe.path = f.path
2685
- WHERE f.repo_id = ?
2686
- AND f.is_binary = FALSE
2687
- AND (${keywordPlaceholders})
2940
+ WHERE ${keywordWhereClauses.join(`
2941
+ AND `)}
2688
2942
  ORDER BY f.path
2689
2943
  LIMIT ?
2690
- `, [repoId, ...extractedTerms.keywords, MAX_MATCHES_PER_KEYWORD * extractedTerms.keywords.length]);
2944
+ `, [...keywordParams, MAX_MATCHES_PER_KEYWORD * extractedTerms.keywords.length]);
2691
2945
  for (const row of rows) {
2692
2946
  if (row.content === null) {
2693
2947
  continue;
@@ -2703,6 +2957,7 @@ async function contextBundleImpl(context, params) {
2703
2957
  for (const keyword of matchedKeywords) {
2704
2958
  candidate.score += weights.textMatch;
2705
2959
  candidate.reasons.add(`text:${keyword}`);
2960
+ candidate.keywordHits.add(keyword);
2706
2961
  }
2707
2962
  // Apply boost profile once per file
2708
2963
  if (boostProfile !== "none") {
@@ -2728,15 +2983,114 @@ async function contextBundleImpl(context, params) {
2728
2983
  });
2729
2984
  }
2730
2985
  }
2986
+ traceSearch(`keyword search produced ${rows.length} rows, candidates=${candidates.size}`);
2987
+ }
2988
+ const fallbackTerms = Array.from(new Set([...extractedTerms.phrases, ...extractedTerms.keywords, ...extractedTerms.pathSegments]
2989
+ .map((term) => term.toLowerCase())
2990
+ .filter((term) => term.length >= 3))).slice(0, PATH_FALLBACK_TERMS_LIMIT);
2991
+ if (fallbackTerms.length > 0) {
2992
+ const fallbackRows = await fetchPathFallbackCandidates(db, repoId, fallbackTerms, Math.min(limit * 2, PATH_FALLBACK_LIMIT));
2993
+ const fallbackReason = stringMatchSeeds.size === 0
2994
+ ? "no-string-match"
2995
+ : candidates.size < limit
2996
+ ? "low-candidates"
2997
+ : "supplemental";
2998
+ traceSearch(`path fallback triggered (${fallbackReason}) terms=${JSON.stringify(fallbackTerms)} rows=${fallbackRows.length}`);
2999
+ const fallbackWeight = stringMatchSeeds.size === 0 ? weights.pathMatch * 0.75 : weights.pathMatch * 0.2;
3000
+ for (const row of fallbackRows) {
3001
+ const candidate = ensureCandidate(candidates, row.path);
3002
+ candidate.pathFallbackReason = fallbackReason;
3003
+ candidate.score += fallbackWeight;
3004
+ candidate.reasons.add("fallback:path");
3005
+ const contentLower = row.content?.toLowerCase() ?? "";
3006
+ if (contentLower.length > 0) {
3007
+ let textHits = 0;
3008
+ for (const term of fallbackTerms) {
3009
+ if (contentLower.includes(term)) {
3010
+ textHits += 1;
3011
+ candidate.keywordHits.add(term);
3012
+ }
3013
+ }
3014
+ candidate.fallbackTextHits += textHits;
3015
+ if (textHits > 0) {
3016
+ const textBoost = textHits * weights.textMatch * 0.15;
3017
+ candidate.score += textBoost;
3018
+ candidate.reasons.add(`fallback:content:${textHits}`);
3019
+ }
3020
+ }
3021
+ candidate.matchLine ??= 1;
3022
+ candidate.lang ??= row.lang;
3023
+ candidate.ext ??= row.ext;
3024
+ candidate.totalLines ??= row.content?.split(/\r?\n/).length ?? null;
3025
+ candidate.content ??= row.content;
3026
+ candidate.embedding ??= parseEmbedding(row.vector_json ?? null, row.vector_dims ?? null);
3027
+ if (boostProfile !== "none") {
3028
+ applyBoostProfile(candidate, row, profileConfig, weights, extractedTerms);
3029
+ }
3030
+ stringMatchSeeds.add(row.path);
3031
+ if (!fileCache.has(row.path) && row.content) {
3032
+ fileCache.set(row.path, {
3033
+ content: row.content,
3034
+ lang: row.lang,
3035
+ ext: row.ext,
3036
+ totalLines: candidate.totalLines ?? 0,
3037
+ embedding: candidate.embedding,
3038
+ });
3039
+ }
3040
+ }
3041
+ // Drop fallback-only candidates with zero text evidence before trimming
3042
+ for (const [path, candidate] of Array.from(candidates.entries())) {
3043
+ const isFallbackOnly = candidate.reasons.has("fallback:path") &&
3044
+ candidate.keywordHits.size === 0 &&
3045
+ candidate.phraseHits === 0;
3046
+ const hasTextEvidence = candidate.fallbackTextHits > 0;
3047
+ if (isFallbackOnly && !hasTextEvidence) {
3048
+ candidates.delete(path);
3049
+ }
3050
+ }
3051
+ // Demote fallback-only hits without text evidence
3052
+ for (const candidate of candidates.values()) {
3053
+ const isFallbackOnly = candidate.reasons.has("fallback:path") &&
3054
+ candidate.keywordHits.size === 0 &&
3055
+ candidate.phraseHits === 0;
3056
+ const hasTextEvidence = candidate.fallbackTextHits > 0;
3057
+ if (isFallbackOnly && !hasTextEvidence) {
3058
+ candidate.scoreMultiplier *= 0.5;
3059
+ candidate.reasons.add("penalty:fallback-no-text");
3060
+ }
3061
+ }
3062
+ if (fallbackRows.length > PATH_FALLBACK_KEEP) {
3063
+ const fallbackOnly = Array.from(candidates.entries())
3064
+ .filter(([_, candidate]) => candidate.reasons.has("fallback:path") &&
3065
+ candidate.keywordHits.size === 0 &&
3066
+ candidate.phraseHits === 0)
3067
+ .sort((a, b) => b[1].score - a[1].score);
3068
+ const toDrop = fallbackOnly.slice(PATH_FALLBACK_KEEP);
3069
+ for (const [path] of toDrop) {
3070
+ candidates.delete(path);
3071
+ }
3072
+ traceSearch(`path fallback trimmed kept=${PATH_FALLBACK_KEEP} dropped=${toDrop.length} candidates=${candidates.size}`);
3073
+ }
3074
+ }
3075
+ if (extractedTerms.keywords.length > 0 || extractedTerms.phrases.length > 0) {
3076
+ for (const candidate of candidates.values()) {
3077
+ applyCoverageBoost(candidate, extractedTerms, weights);
3078
+ }
2731
3079
  }
2732
3080
  const artifactPathTargets = artifactPathHints.map((hintPath) => ({
2733
3081
  path: hintPath,
2734
3082
  sourceHint: hintPath,
2735
3083
  origin: "artifact",
2736
3084
  }));
3085
+ const domainPathTargets = selectDomainPathHints(domainExpansion.fileHints).map((hint) => ({
3086
+ path: hint.path,
3087
+ sourceHint: hint.source,
3088
+ origin: "dictionary",
3089
+ }));
2737
3090
  const dictionaryPathTargets = await fetchDictionaryPathHints(db, context.tableAvailability, repoId, substringHints, HINT_DICTIONARY_LIMIT);
2738
3091
  const { list: resolvedPathHintTargets, meta: hintSeedMeta } = createHintSeedMeta([
2739
3092
  ...artifactPathTargets,
3093
+ ...domainPathTargets,
2740
3094
  ...dictionaryPathTargets,
2741
3095
  ]);
2742
3096
  if (resolvedPathHintTargets.length > 0) {
@@ -2831,6 +3185,9 @@ async function contextBundleImpl(context, params) {
2831
3185
  const materializeCandidates = async () => {
2832
3186
  const result = [];
2833
3187
  for (const candidate of candidates.values()) {
3188
+ if (!pathMatchesPrefix(candidate.path, pathPrefix)) {
3189
+ continue;
3190
+ }
2834
3191
  if (isSuppressedPath(candidate.path)) {
2835
3192
  continue;
2836
3193
  }
@@ -2864,7 +3221,7 @@ async function contextBundleImpl(context, params) {
2864
3221
  if (!hasAnyMetadataFilters) {
2865
3222
  return;
2866
3223
  }
2867
- const metadataRows = await fetchMetadataOnlyCandidates(db, context.tableAvailability, repoId, metadataFilters, limit * 2);
3224
+ const metadataRows = await fetchMetadataOnlyCandidates(db, context.tableAvailability, repoId, metadataFilters, limit * 2, pathPrefix);
2868
3225
  if (metadataRows.length === 0) {
2869
3226
  return;
2870
3227
  }
@@ -2891,9 +3248,11 @@ async function contextBundleImpl(context, params) {
2891
3248
  await addMetadataFallbackCandidates();
2892
3249
  }
2893
3250
  let materializedCandidates = await materializeCandidates();
3251
+ traceSearch(`materialized candidates: ${materializedCandidates.length}`);
2894
3252
  if (materializedCandidates.length === 0 && hasAnyMetadataFilters) {
2895
3253
  await addMetadataFallbackCandidates();
2896
3254
  materializedCandidates = await materializeCandidates();
3255
+ traceSearch(`materialized candidates after metadata fallback: ${materializedCandidates.length}`);
2897
3256
  }
2898
3257
  if (materializedCandidates.length === 0) {
2899
3258
  // Get warnings from WarningManager (includes breaking change notification if applicable)
@@ -3021,6 +3380,14 @@ async function contextBundleImpl(context, params) {
3021
3380
  }
3022
3381
  return b.score - a.score;
3023
3382
  });
3383
+ if (TRACE_SEARCH) {
3384
+ const sample = rankedCandidates.slice(0, 5).map((candidate) => ({
3385
+ path: candidate.path,
3386
+ score: Number(candidate.score.toFixed(3)),
3387
+ reasons: Array.from(candidate.reasons).slice(0, 3),
3388
+ }));
3389
+ traceSearch(`ranked candidates=${rankedCandidates.length}`, sample);
3390
+ }
3024
3391
  const prioritizedCandidates = prioritizeHintCandidates(rankedCandidates, resolvedPathHintTargets.map((target) => target.path), limit);
3025
3392
  if (prioritizedCandidates.length === 0) {
3026
3393
  const warnings = [...context.warningManager.responseWarnings];