kiri-mcp-server 0.9.9 → 0.10.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 (64) hide show
  1. package/README.md +51 -7
  2. package/dist/package.json +4 -1
  3. package/dist/src/client/proxy.js +81 -12
  4. package/dist/src/client/proxy.js.map +1 -1
  5. package/dist/src/daemon/daemon.js +91 -14
  6. package/dist/src/daemon/daemon.js.map +1 -1
  7. package/dist/src/server/abbreviations.d.ts +47 -0
  8. package/dist/src/server/abbreviations.d.ts.map +1 -0
  9. package/dist/src/server/abbreviations.js +71 -0
  10. package/dist/src/server/abbreviations.js.map +1 -0
  11. package/dist/src/server/boost-profiles.d.ts +63 -0
  12. package/dist/src/server/boost-profiles.d.ts.map +1 -0
  13. package/dist/src/server/boost-profiles.js +86 -0
  14. package/dist/src/server/boost-profiles.js.map +1 -0
  15. package/dist/src/server/handlers.d.ts +3 -2
  16. package/dist/src/server/handlers.d.ts.map +1 -1
  17. package/dist/src/server/handlers.js +457 -94
  18. package/dist/src/server/handlers.js.map +1 -1
  19. package/dist/src/server/main.d.ts.map +1 -1
  20. package/dist/src/server/main.js +112 -30
  21. package/dist/src/server/main.js.map +1 -1
  22. package/dist/src/server/rpc.d.ts.map +1 -1
  23. package/dist/src/server/rpc.js +28 -9
  24. package/dist/src/server/rpc.js.map +1 -1
  25. package/dist/src/server/rrf.d.ts +86 -0
  26. package/dist/src/server/rrf.d.ts.map +1 -0
  27. package/dist/src/server/rrf.js +108 -0
  28. package/dist/src/server/rrf.js.map +1 -0
  29. package/dist/src/server/scoring.d.ts.map +1 -1
  30. package/dist/src/server/scoring.js +19 -0
  31. package/dist/src/server/scoring.js.map +1 -1
  32. package/dist/src/shared/cli/args.d.ts +70 -0
  33. package/dist/src/shared/cli/args.d.ts.map +1 -0
  34. package/dist/src/shared/cli/args.js +84 -0
  35. package/dist/src/shared/cli/args.js.map +1 -0
  36. package/dist/src/shared/embedding/engine.d.ts +38 -0
  37. package/dist/src/shared/embedding/engine.d.ts.map +1 -0
  38. package/dist/src/shared/embedding/engine.js +6 -0
  39. package/dist/src/shared/embedding/engine.js.map +1 -0
  40. package/dist/src/shared/embedding/lsh-engine.d.ts +11 -0
  41. package/dist/src/shared/embedding/lsh-engine.d.ts.map +1 -0
  42. package/dist/src/shared/embedding/lsh-engine.js +14 -0
  43. package/dist/src/shared/embedding/lsh-engine.js.map +1 -0
  44. package/dist/src/shared/embedding/registry.d.ts +25 -0
  45. package/dist/src/shared/embedding/registry.d.ts.map +1 -0
  46. package/dist/src/shared/embedding/registry.js +50 -0
  47. package/dist/src/shared/embedding/registry.js.map +1 -0
  48. package/dist/src/shared/embedding/semantic-engine.d.ts +14 -0
  49. package/dist/src/shared/embedding/semantic-engine.d.ts.map +1 -0
  50. package/dist/src/shared/embedding/semantic-engine.js +50 -0
  51. package/dist/src/shared/embedding/semantic-engine.js.map +1 -0
  52. package/dist/src/shared/models/model-manager.d.ts +38 -0
  53. package/dist/src/shared/models/model-manager.d.ts.map +1 -0
  54. package/dist/src/shared/models/model-manager.js +116 -0
  55. package/dist/src/shared/models/model-manager.js.map +1 -0
  56. package/dist/src/shared/models/model-manifest.d.ts +22 -0
  57. package/dist/src/shared/models/model-manifest.d.ts.map +1 -0
  58. package/dist/src/shared/models/model-manifest.js +24 -0
  59. package/dist/src/shared/models/model-manifest.js.map +1 -0
  60. package/dist/src/shared/utils/validation.d.ts +14 -0
  61. package/dist/src/shared/utils/validation.d.ts.map +1 -0
  62. package/dist/src/shared/utils/validation.js +22 -0
  63. package/dist/src/shared/utils/validation.js.map +1 -0
  64. package/package.json +4 -1
@@ -1,8 +1,11 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { checkFTSSchemaExists } from "../indexer/schema.js";
3
4
  import { generateEmbedding, structuralSimilarity } from "../shared/embedding.js";
4
5
  import { encode as encodeGPT, tokenizeText } from "../shared/tokenizer.js";
5
6
  import { getRepoPathCandidates, normalizeRepoPath } from "../shared/utils/path.js";
7
+ import { expandAbbreviations } from "./abbreviations.js";
8
+ import { getBoostProfile, } from "./boost-profiles.js";
6
9
  import { coerceProfileName, loadScoringProfile } from "./scoring.js";
7
10
  // Configuration file patterns (v0.8.0+: consolidated to avoid duplication)
8
11
  // Comprehensive list covering multiple languages and tools
@@ -225,6 +228,9 @@ const MAX_DEPENDENCY_SEEDS_QUERY_LIMIT = 100; // SQL injection防御用の上限
225
228
  const NEARBY_LIMIT = 6;
226
229
  const FALLBACK_SNIPPET_WINDOW = 40; // Reduced from 120 to optimize token usage
227
230
  const MAX_RERANK_LIMIT = 50;
231
+ // Issue #68: Path/Large File Penalty configuration (環境変数で上書き可能)
232
+ const PATH_MISS_DELTA = parseFloat(process.env.KIRI_PATH_MISS_DELTA || "-0.5");
233
+ const LARGE_FILE_DELTA = parseFloat(process.env.KIRI_LARGE_FILE_DELTA || "-0.8");
228
234
  const MAX_WHY_TAGS = 10;
229
235
  // 項目3: whyタグの優先度マップ(低い数値ほど高優先度)
230
236
  // All actual tag prefixes used in the codebase
@@ -513,6 +519,8 @@ function ensureCandidate(map, filePath) {
513
519
  ext: null,
514
520
  embedding: null,
515
521
  semanticSimilarity: null,
522
+ pathMatchHits: 0, // Issue #68: Track path match count
523
+ penalties: [], // Issue #68: Penalty log for telemetry
516
524
  };
517
525
  map.set(filePath, candidate);
518
526
  }
@@ -677,6 +685,22 @@ function splitQueryWords(query) {
677
685
  const words = query.split(/[\s/\-_]+/).filter((w) => w.length > 2);
678
686
  return words.length > 0 ? words : [query]; // 全て除外された場合は元のクエリを使用
679
687
  }
688
+ /**
689
+ * パス固有のマルチプライヤーを取得(最長プレフィックスマッチ)
690
+ * 配列の順序に依存せず、常に最長一致のプレフィックスを選択
691
+ * @param filePath - ファイルパス
692
+ * @param profileConfig - ブーストプロファイル設定
693
+ * @returns パス固有のマルチプライヤー(マッチなしの場合は1.0)
694
+ */
695
+ function getPathMultiplier(filePath, profileConfig) {
696
+ let bestMatch = { prefix: "", multiplier: 1.0 };
697
+ for (const { prefix, multiplier } of profileConfig.pathMultipliers) {
698
+ if (filePath.startsWith(prefix) && prefix.length > bestMatch.prefix.length) {
699
+ bestMatch = { prefix, multiplier };
700
+ }
701
+ }
702
+ return bestMatch.multiplier;
703
+ }
680
704
  /**
681
705
  * files_search専用のファイルタイプブースト適用(v0.7.0+: 設定可能な乗算的ペナルティ)
682
706
  * context_bundleと同じ乗算的ペナルティロジックを使用
@@ -686,7 +710,7 @@ function splitQueryWords(query) {
686
710
  * @param weights - スコアリングウェイト設定(乗算的ペナルティに使用)
687
711
  * @returns ブースト適用後のスコア
688
712
  */
689
- function applyFileTypeBoost(path, baseScore, profile = "default", weights) {
713
+ function applyFileTypeBoost(path, baseScore, profileConfig, _weights) {
690
714
  // Blacklisted directories that are almost always irrelevant for code context
691
715
  const blacklistedDirs = [
692
716
  ".cursor/",
@@ -699,58 +723,39 @@ function applyFileTypeBoost(path, baseScore, profile = "default", weights) {
699
723
  ];
700
724
  for (const dir of blacklistedDirs) {
701
725
  if (path.startsWith(dir)) {
702
- // FIX: boost_profile="docs" の場合は docs/ ブラックリストをスキップ
703
- if (profile === "docs" && dir === "docs/") {
726
+ // ✅ Decoupled: Check denylist overrides from profile config
727
+ if (profileConfig.denylistOverrides.includes(dir)) {
704
728
  continue;
705
729
  }
706
730
  return -100; // Effectively remove it
707
731
  }
708
732
  }
709
- if (profile === "none") {
710
- return baseScore;
711
- }
712
- // Extract file extension for type detection
733
+ const fileName = path.split("/").pop() ?? "";
713
734
  const ext = path.includes(".") ? path.substring(path.lastIndexOf(".")) : null;
714
- // ✅ UNIFIED LOGIC: Use same multiplicative penalties as context_bundle
715
- if (profile === "docs") {
716
- // Boost documentation files
717
- if (path.endsWith(".md") || path.endsWith(".yaml") || path.endsWith(".yml")) {
718
- return baseScore * 1.5; // 50% boost (same as context_bundle)
719
- }
720
- // Penalty for implementation files in docs mode
721
- if (path.startsWith("src/") &&
722
- (path.endsWith(".ts") || path.endsWith(".js") || path.endsWith(".tsx"))) {
723
- return baseScore * 0.5; // 50% penalty
724
- }
725
- return baseScore;
726
- }
727
- // Default profile: Use configurable multiplicative penalties
728
735
  let multiplier = 1.0;
729
- const fileName = path.split("/").pop() ?? "";
730
- // ✅ Step 1: Config files get strongest penalty (95% reduction)
736
+ // Step 1: Config files
731
737
  if (isConfigFile(path, fileName)) {
732
- multiplier *= weights.configPenaltyMultiplier; // 0.05 = 95% reduction
738
+ multiplier *= profileConfig.fileTypeMultipliers.config;
733
739
  return baseScore * multiplier;
734
740
  }
735
- // ✅ Step 2: Documentation files get moderate penalty (50% reduction)
741
+ // ✅ Step 2: Documentation files
736
742
  const docExtensions = [".md", ".yaml", ".yml", ".mdc"];
737
743
  if (docExtensions.some((docExt) => path.endsWith(docExt))) {
738
- multiplier *= weights.docPenaltyMultiplier; // 0.5 = 50% reduction
744
+ multiplier *= profileConfig.fileTypeMultipliers.doc;
739
745
  return baseScore * multiplier;
740
746
  }
741
- // ✅ Step 3: Implementation file boosts
742
- if (path.startsWith("src/app/")) {
743
- multiplier *= weights.implBoostMultiplier * 1.4; // Extra boost for app files
744
- }
745
- else if (path.startsWith("src/components/")) {
746
- multiplier *= weights.implBoostMultiplier * 1.3;
747
- }
748
- else if (path.startsWith("src/lib/")) {
749
- multiplier *= weights.implBoostMultiplier * 1.2;
747
+ // ✅ Step 3: Implementation files with path-specific boosts
748
+ const implMultiplier = profileConfig.fileTypeMultipliers.impl;
749
+ // Use longest-prefix-match logic (order-independent)
750
+ const pathBoost = getPathMultiplier(path, profileConfig);
751
+ if (pathBoost !== 1.0) {
752
+ multiplier *= implMultiplier * pathBoost;
753
+ return baseScore * multiplier;
750
754
  }
751
- else if (path.startsWith("src/")) {
755
+ // Fallback for other src/ files
756
+ if (path.startsWith("src/")) {
752
757
  if (ext === ".ts" || ext === ".tsx" || ext === ".js") {
753
- multiplier *= weights.implBoostMultiplier; // Base impl boost
758
+ multiplier *= implMultiplier;
754
759
  }
755
760
  }
756
761
  // Test files: additive penalty (keep strong for files_search)
@@ -767,29 +772,92 @@ function applyPathBasedScoring(candidate, lowerPath, weights, extractedTerms) {
767
772
  if (!extractedTerms || weights.pathMatch <= 0) {
768
773
  return;
769
774
  }
775
+ // hasAddedScore gates additive boosts; pathMatchHits/reasons still track every hit for penalties/debugging.
776
+ let hasAddedScore = false;
770
777
  // フレーズがパスに完全一致する場合(最高の重み)
771
778
  for (const phrase of extractedTerms.phrases) {
772
779
  if (lowerPath.includes(phrase)) {
773
- candidate.score += weights.pathMatch * 1.5; // 1.5倍のブースト
780
+ if (!hasAddedScore) {
781
+ candidate.score += weights.pathMatch * 1.5; // 1.5倍のブースト
782
+ hasAddedScore = true;
783
+ }
774
784
  candidate.reasons.add(`path-phrase:${phrase}`);
775
- return; // 最初のマッチのみ適用
785
+ candidate.pathMatchHits++; // Issue #68: Track path match for penalty calculation
776
786
  }
777
787
  }
778
788
  // パスセグメントがマッチする場合(中程度の重み)
779
789
  const pathParts = lowerPath.split("/");
780
790
  for (const segment of extractedTerms.pathSegments) {
781
791
  if (pathParts.includes(segment)) {
782
- candidate.score += weights.pathMatch;
792
+ if (!hasAddedScore) {
793
+ candidate.score += weights.pathMatch;
794
+ hasAddedScore = true;
795
+ }
783
796
  candidate.reasons.add(`path-segment:${segment}`);
784
- return; // 最初のマッチのみ適用
797
+ candidate.pathMatchHits++; // Issue #68: Track path match for penalty calculation
785
798
  }
786
799
  }
787
800
  // 通常のキーワードがパスに含まれる場合(低い重み)
801
+ const matchedKeywords = new Set();
788
802
  for (const keyword of extractedTerms.keywords) {
789
803
  if (lowerPath.includes(keyword)) {
790
- candidate.score += weights.pathMatch * 0.5; // 0.5倍のブースト
804
+ if (!hasAddedScore) {
805
+ candidate.score += weights.pathMatch * 0.5; // 0.5倍のブースト
806
+ hasAddedScore = true;
807
+ }
791
808
  candidate.reasons.add(`path-keyword:${keyword}`);
792
- return; // 最初のマッチのみ適用
809
+ candidate.pathMatchHits++; // Issue #68: Track path match for penalty calculation
810
+ matchedKeywords.add(keyword); // Track for abbreviation expansion
811
+ }
812
+ }
813
+ // ADR 003: Abbreviation expansion for keywords with zero exact matches
814
+ // Avoid double-counting by only expanding keywords that didn't match exactly
815
+ // Skip abbreviation expansion for files that will be heavily penalized (test/config/lock files)
816
+ const fileName = lowerPath.split("/").pop() ?? "";
817
+ const testPatterns = [".spec.ts", ".spec.js", ".test.ts", ".test.js", ".spec.tsx", ".test.tsx"];
818
+ const lockFiles = [
819
+ "package-lock.json",
820
+ "pnpm-lock.yaml",
821
+ "yarn.lock",
822
+ "bun.lockb",
823
+ "gemfile.lock",
824
+ "cargo.lock",
825
+ "poetry.lock",
826
+ ];
827
+ const configPatterns = [
828
+ "tsconfig.json",
829
+ "vite.config",
830
+ "vitest.config",
831
+ "eslint.config",
832
+ "prettier.config",
833
+ "package.json",
834
+ ".env",
835
+ "dockerfile",
836
+ ];
837
+ const shouldSkipAbbreviation = testPatterns.some((pattern) => lowerPath.endsWith(pattern)) ||
838
+ lockFiles.some((lock) => fileName === lock) ||
839
+ configPatterns.some((cfg) => fileName.includes(cfg));
840
+ if (!shouldSkipAbbreviation) {
841
+ for (const keyword of extractedTerms.keywords) {
842
+ if (matchedKeywords.has(keyword)) {
843
+ continue; // Skip keywords that already matched exactly
844
+ }
845
+ const expandedTerms = expandAbbreviations(keyword);
846
+ // Try each expanded variant (except the original keyword itself)
847
+ for (const term of expandedTerms) {
848
+ if (term === keyword)
849
+ continue; // Skip original to avoid duplicate check
850
+ if (lowerPath.includes(term)) {
851
+ // Lower weight (0.4x) for abbreviation-expanded matches
852
+ if (!hasAddedScore) {
853
+ candidate.score += weights.pathMatch * 0.4;
854
+ hasAddedScore = true;
855
+ }
856
+ candidate.reasons.add(`abbr-path:${keyword}→${term}`);
857
+ candidate.pathMatchHits++; // Count for penalty calculation
858
+ break; // Only count first match per keyword to avoid over-boosting
859
+ }
860
+ }
793
861
  }
794
862
  }
795
863
  }
@@ -799,7 +867,7 @@ function applyPathBasedScoring(candidate, lowerPath, weights, extractedTerms) {
799
867
  * @param profile - boost_profile設定("docs"の場合はdocs/ディレクトリのブラックリストをスキップ)
800
868
  * @returns true if penalty was applied and processing should stop
801
869
  */
802
- function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profile) {
870
+ function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profileConfig) {
803
871
  // Blacklisted directories - effectively remove
804
872
  const blacklistedDirs = [
805
873
  ".cursor/",
@@ -825,10 +893,9 @@ function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profil
825
893
  ];
826
894
  for (const dir of blacklistedDirs) {
827
895
  if (path.startsWith(dir)) {
828
- // ✅ FIX (v0.9.0): boost_profile="docs"の場合はdocs/ブラックリストをスキップ
829
- // これによりドキュメント検索が正しく機能する
830
- if (profile === "docs" && dir === "docs/") {
831
- continue; // このブラックリストエントリをスキップ
896
+ // ✅ Decoupled: Check denylist overrides from profile config
897
+ if (profileConfig.denylistOverrides.includes(dir)) {
898
+ continue; // Skip this blacklisted directory
832
899
  }
833
900
  candidate.score = -100;
834
901
  candidate.reasons.add("penalty:blacklisted-dir");
@@ -857,7 +924,7 @@ function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profil
857
924
  candidate.reasons.add("penalty:lock-file");
858
925
  return true;
859
926
  }
860
- // Configuration files - strong penalty
927
+ // Configuration files - penalty handling depends on profile
861
928
  const configPatterns = [
862
929
  ".config.js",
863
930
  ".config.ts",
@@ -878,6 +945,12 @@ function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profil
878
945
  fileName === "Dockerfile" ||
879
946
  fileName === "docker-compose.yml" ||
880
947
  fileName === "docker-compose.yaml") {
948
+ // ✅ Use explicit flag instead of magic number (0.3) to determine behavior
949
+ // This decouples profile detection from multiplier values
950
+ if (profileConfig.skipConfigAdditivePenalty) {
951
+ return false; // Continue to multiplicative penalty only
952
+ }
953
+ // For other profiles, apply strong additive penalty
881
954
  candidate.score -= 1.5;
882
955
  candidate.reasons.add("penalty:config-file");
883
956
  return true;
@@ -895,54 +968,50 @@ function applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profil
895
968
  * profile="docs": ドキュメントファイルをブースト
896
969
  * profile="default": ドキュメントファイルにペナルティ、実装ファイルをブースト
897
970
  */
898
- function applyFileTypeMultipliers(candidate, path, ext, profile, weights) {
899
- if (profile === "none") {
900
- return;
971
+ function applyFileTypeMultipliers(candidate, path, ext, profileConfig, _weights) {
972
+ const fileName = path.split("/").pop() ?? "";
973
+ // ✅ Step 1: Config files
974
+ if (isConfigFile(path, fileName)) {
975
+ candidate.scoreMultiplier *= profileConfig.fileTypeMultipliers.config;
976
+ candidate.reasons.add("penalty:config-file");
977
+ return; // Don't apply impl boosts to config files
901
978
  }
902
- // ✅ CRITICAL SAFETY: profile="docs" mode boosts docs, skips penalties
903
- if (profile === "docs") {
904
- const docExtensions = [".md", ".yaml", ".yml", ".mdc"];
905
- if (docExtensions.some((docExt) => path.endsWith(docExt))) {
906
- candidate.scoreMultiplier *= 1.5; // 50% boost for docs
979
+ // ✅ Step 2: Documentation files
980
+ const docExtensions = [".md", ".yaml", ".yml", ".mdc"];
981
+ if (docExtensions.some((docExt) => path.endsWith(docExt))) {
982
+ const docMultiplier = profileConfig.fileTypeMultipliers.doc;
983
+ candidate.scoreMultiplier *= docMultiplier;
984
+ if (docMultiplier > 1.0) {
907
985
  candidate.reasons.add("boost:doc-file");
908
986
  }
909
- // No penalty for implementation files in "docs" mode
910
- return;
911
- }
912
- // DEFAULT PROFILE: Use MULTIPLICATIVE penalties for config/docs, MULTIPLICATIVE boosts for impl files
913
- if (profile === "default") {
914
- const fileName = path.split("/").pop() ?? "";
915
- // ✅ Step 1: Config files get strongest penalty (95% reduction)
916
- if (isConfigFile(path, fileName)) {
917
- candidate.scoreMultiplier *= weights.configPenaltyMultiplier; // 0.05 = 95% reduction
918
- candidate.reasons.add("penalty:config-file");
919
- return; // Don't apply impl boosts to config files
920
- }
921
- // ✅ Step 2: Documentation files get moderate penalty (50% reduction)
922
- const docExtensions = [".md", ".yaml", ".yml", ".mdc"];
923
- if (docExtensions.some((docExt) => path.endsWith(docExt))) {
924
- candidate.scoreMultiplier *= weights.docPenaltyMultiplier; // 0.5 = 50% reduction
987
+ else if (docMultiplier < 1.0) {
925
988
  candidate.reasons.add("penalty:doc-file");
926
- return; // Don't apply impl boosts to docs
927
989
  }
928
- // Step 3: Implementation files get multiplicative boost
990
+ return; // Don't apply impl boosts to docs
991
+ }
992
+ // ✅ Step 3: Implementation files with path-specific boosts
993
+ const implMultiplier = profileConfig.fileTypeMultipliers.impl;
994
+ // ✅ Use longest-prefix-match logic (order-independent)
995
+ const pathBoost = getPathMultiplier(path, profileConfig);
996
+ if (pathBoost !== 1.0) {
997
+ candidate.scoreMultiplier *= implMultiplier * pathBoost;
998
+ // Add specific reason based on matched path
929
999
  if (path.startsWith("src/app/")) {
930
- candidate.scoreMultiplier *= weights.implBoostMultiplier * 1.4; // Extra boost for app files
931
1000
  candidate.reasons.add("boost:app-file");
932
1001
  }
933
1002
  else if (path.startsWith("src/components/")) {
934
- candidate.scoreMultiplier *= weights.implBoostMultiplier * 1.3;
935
1003
  candidate.reasons.add("boost:component-file");
936
1004
  }
937
1005
  else if (path.startsWith("src/lib/")) {
938
- candidate.scoreMultiplier *= weights.implBoostMultiplier * 1.2;
939
1006
  candidate.reasons.add("boost:lib-file");
940
1007
  }
941
- else if (path.startsWith("src/")) {
942
- if (ext === ".ts" || ext === ".tsx" || ext === ".js") {
943
- candidate.scoreMultiplier *= weights.implBoostMultiplier;
944
- candidate.reasons.add("boost:impl-file");
945
- }
1008
+ return;
1009
+ }
1010
+ // Fallback for other src/ files
1011
+ if (path.startsWith("src/")) {
1012
+ if (ext === ".ts" || ext === ".tsx" || ext === ".js") {
1013
+ candidate.scoreMultiplier *= implMultiplier;
1014
+ candidate.reasons.add("boost:impl-file");
946
1015
  }
947
1016
  }
948
1017
  }
@@ -958,22 +1027,19 @@ function applyFileTypeMultipliers(candidate, path, ext, profile, weights) {
958
1027
  * 2. profile="docs" skips documentation penalties (allows doc-focused queries)
959
1028
  * 3. Blacklist/test/lock/config files keep additive penalties (already very strong)
960
1029
  */
961
- function applyBoostProfile(candidate, row, profile, weights, extractedTerms) {
962
- if (profile === "none") {
963
- return;
964
- }
1030
+ function applyBoostProfile(candidate, row, profileConfig, weights, extractedTerms) {
965
1031
  const { path, ext } = row;
966
1032
  const lowerPath = path.toLowerCase();
967
1033
  const fileName = path.split("/").pop() ?? "";
968
1034
  // Step 1: パスベースのスコアリング(加算的ブースト)
969
1035
  applyPathBasedScoring(candidate, lowerPath, weights, extractedTerms);
970
1036
  // Step 2: 加算的ペナルティ(ブラックリスト、テスト、lock、設定、マイグレーション)
971
- const shouldStop = applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profile);
1037
+ const shouldStop = applyAdditiveFilePenalties(candidate, path, lowerPath, fileName, profileConfig);
972
1038
  if (shouldStop) {
973
1039
  return; // ペナルティが適用された場合は処理終了
974
1040
  }
975
1041
  // Step 3: ファイルタイプ別の乗算的ペナルティ/ブースト
976
- applyFileTypeMultipliers(candidate, path, ext, profile, weights);
1042
+ applyFileTypeMultipliers(candidate, path, ext, profileConfig, weights);
977
1043
  }
978
1044
  export async function filesSearch(context, params) {
979
1045
  const { db, repoId } = context;
@@ -1058,6 +1124,7 @@ export async function filesSearch(context, params) {
1058
1124
  }
1059
1125
  const rows = await db.all(sql, values);
1060
1126
  const boostProfile = params.boost_profile ?? "default";
1127
+ const profileConfig = getBoostProfile(boostProfile);
1061
1128
  // ✅ v0.7.0+: Load configurable scoring weights for unified boosting logic
1062
1129
  // Note: filesSearch doesn't have a separate profile parameter, uses default weights
1063
1130
  const weights = loadScoringProfile(null);
@@ -1077,7 +1144,9 @@ export async function filesSearch(context, params) {
1077
1144
  matchLine = findFirstMatchLine(row.content ?? "", query);
1078
1145
  }
1079
1146
  const baseScore = row.score ?? 1.0; // FTS時はBM25スコア、ILIKE時は1.0
1080
- const boostedScore = applyFileTypeBoost(row.path, baseScore, boostProfile, weights);
1147
+ const boostedScore = boostProfile === "none"
1148
+ ? baseScore
1149
+ : applyFileTypeBoost(row.path, baseScore, profileConfig, weights);
1081
1150
  const result = {
1082
1151
  path: row.path,
1083
1152
  matchLine,
@@ -1177,6 +1246,265 @@ export async function snippetsGet(context, params) {
1177
1246
  symbolKind,
1178
1247
  };
1179
1248
  }
1249
+ // ============================================================================
1250
+ // Issue #68: Path/Large File Penalty Helper Functions
1251
+ // ============================================================================
1252
+ /**
1253
+ * 環境変数からペナルティ機能フラグを読み取る
1254
+ */
1255
+ function readPenaltyFlags() {
1256
+ return {
1257
+ pathPenalty: process.env.KIRI_PATH_PENALTY === "1",
1258
+ largeFilePenalty: process.env.KIRI_LARGE_FILE_PENALTY === "1",
1259
+ };
1260
+ }
1261
+ /**
1262
+ * クエリ統計を計算(単語数と平均単語長)
1263
+ */
1264
+ function computeQueryStats(goal) {
1265
+ const words = goal
1266
+ .trim()
1267
+ .split(/\s+/)
1268
+ .filter((w) => w.length > 0);
1269
+ const totalLength = words.reduce((sum, w) => sum + w.length, 0);
1270
+ return {
1271
+ wordCount: words.length,
1272
+ avgWordLength: words.length > 0 ? totalLength / words.length : 0,
1273
+ };
1274
+ }
1275
+ /**
1276
+ * Path Miss Penaltyをcandidateに適用(レガシー: Binary penalty)
1277
+ * 条件: wordCount >= 2 AND avgWordLength >= 4 AND pathMatchHits === 0
1278
+ *
1279
+ * @deprecated Use applyGraduatedPenalty() instead (ADR 002)
1280
+ */
1281
+ function applyPathMissPenalty(candidate, queryStats) {
1282
+ if (queryStats.wordCount >= 2 && queryStats.avgWordLength >= 4 && candidate.pathMatchHits === 0) {
1283
+ candidate.score += PATH_MISS_DELTA; // -0.5
1284
+ recordPenaltyEvent(candidate, "path-miss", PATH_MISS_DELTA, {
1285
+ wordCount: queryStats.wordCount,
1286
+ avgWordLength: queryStats.avgWordLength,
1287
+ pathMatchHits: candidate.pathMatchHits,
1288
+ });
1289
+ }
1290
+ }
1291
+ /**
1292
+ * 段階的ペナルティをcandidateに適用(Issue #68: Graduated Penalty)
1293
+ * ADR 002: Graduated Penalty System
1294
+ *
1295
+ * @param candidate Candidate to apply penalty to
1296
+ * @param queryStats Query statistics for eligibility check
1297
+ * @param config Graduated penalty configuration
1298
+ */
1299
+ function applyGraduatedPenalty(candidate, queryStats, config) {
1300
+ const penalty = computeGraduatedPenalty(candidate.pathMatchHits, queryStats, config);
1301
+ if (penalty !== 0) {
1302
+ candidate.score += penalty;
1303
+ recordPenaltyEvent(candidate, "path-miss", penalty, {
1304
+ wordCount: queryStats.wordCount,
1305
+ avgWordLength: queryStats.avgWordLength,
1306
+ pathMatchHits: candidate.pathMatchHits,
1307
+ tier: candidate.pathMatchHits === 0
1308
+ ? "tier0"
1309
+ : candidate.pathMatchHits === 1
1310
+ ? "tier1"
1311
+ : candidate.pathMatchHits === 2
1312
+ ? "tier2"
1313
+ : "no-penalty",
1314
+ });
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Large File Penaltyをcandidateに適用
1319
+ * 条件: totalLines > 500 AND matchLine > 120
1320
+ * TODO(Issue #68): Add "no symbol at match location" check after selectSnippet integration
1321
+ */
1322
+ function applyLargeFilePenalty(candidate) {
1323
+ const { totalLines, matchLine } = candidate;
1324
+ if (totalLines !== null && totalLines > 500 && matchLine !== null && matchLine > 120) {
1325
+ candidate.score += LARGE_FILE_DELTA; // -0.8
1326
+ recordPenaltyEvent(candidate, "large-file", LARGE_FILE_DELTA, {
1327
+ totalLines,
1328
+ matchLine,
1329
+ });
1330
+ }
1331
+ }
1332
+ /**
1333
+ * ペナルティイベントを記録(テレメトリ用)
1334
+ */
1335
+ function recordPenaltyEvent(candidate, kind, delta, details) {
1336
+ candidate.penalties.push({ kind, delta, details });
1337
+ candidate.reasons.add(`penalty:${kind}`);
1338
+ }
1339
+ /**
1340
+ * pathMatchHits分布を計算(Issue #68: Telemetry)
1341
+ * LDE: 純粋関数として実装(副作用なし、イミュータブル)
1342
+ */
1343
+ function computePathMatchDistribution(candidates) {
1344
+ let zero = 0;
1345
+ let one = 0;
1346
+ let two = 0;
1347
+ let three = 0;
1348
+ let fourPlus = 0;
1349
+ for (const candidate of candidates) {
1350
+ const hits = candidate.pathMatchHits;
1351
+ if (hits === 0)
1352
+ zero++;
1353
+ else if (hits === 1)
1354
+ one++;
1355
+ else if (hits === 2)
1356
+ two++;
1357
+ else if (hits === 3)
1358
+ three++;
1359
+ else
1360
+ fourPlus++;
1361
+ }
1362
+ return {
1363
+ zero,
1364
+ one,
1365
+ two,
1366
+ three,
1367
+ fourPlus,
1368
+ total: candidates.length,
1369
+ };
1370
+ }
1371
+ /**
1372
+ * スコア統計を計算(Issue #68: Telemetry)
1373
+ * LDE: 純粋関数として実装(副作用なし、イミュータブル)
1374
+ */
1375
+ function computeScoreStats(candidates) {
1376
+ if (candidates.length === 0) {
1377
+ return { min: 0, max: 0, mean: 0, median: 0 };
1378
+ }
1379
+ const scores = candidates.map((c) => c.score).sort((a, b) => a - b);
1380
+ const sum = scores.reduce((acc, s) => acc + s, 0);
1381
+ const mean = sum / scores.length;
1382
+ const median = scores[Math.floor(scores.length / 2)] ?? 0;
1383
+ return {
1384
+ min: scores[0] ?? 0,
1385
+ max: scores[scores.length - 1] ?? 0,
1386
+ mean,
1387
+ median,
1388
+ };
1389
+ }
1390
+ /**
1391
+ * ペナルティ適用状況を計算(Issue #68: Telemetry)
1392
+ * LDE: 純粋関数として実装(副作用なし、イミュータブル)
1393
+ */
1394
+ function computePenaltyTelemetry(candidates) {
1395
+ let pathMissPenalties = 0;
1396
+ let largeFilePenalties = 0;
1397
+ for (const candidate of candidates) {
1398
+ for (const penalty of candidate.penalties) {
1399
+ if (penalty.kind === "path-miss")
1400
+ pathMissPenalties++;
1401
+ if (penalty.kind === "large-file")
1402
+ largeFilePenalties++;
1403
+ }
1404
+ }
1405
+ return {
1406
+ pathMissPenalties,
1407
+ largeFilePenalties,
1408
+ totalCandidates: candidates.length,
1409
+ pathMatchDistribution: computePathMatchDistribution(candidates),
1410
+ scoreStats: computeScoreStats(candidates),
1411
+ };
1412
+ }
1413
+ /**
1414
+ * テレメトリーをファイル出力(Issue #68: Debug)
1415
+ * LDE: 副作用を分離(I/O操作)
1416
+ *
1417
+ * JSON Lines形式で /tmp/kiri-penalty-telemetry.jsonl に追記
1418
+ */
1419
+ function logPenaltyTelemetry(telemetry, queryStats) {
1420
+ const dist = telemetry.pathMatchDistribution;
1421
+ const scores = telemetry.scoreStats;
1422
+ // JSON Lines形式でテレメトリーデータを記録
1423
+ const telemetryRecord = {
1424
+ timestamp: new Date().toISOString(),
1425
+ query: {
1426
+ wordCount: queryStats.wordCount,
1427
+ avgWordLength: queryStats.avgWordLength,
1428
+ },
1429
+ totalCandidates: telemetry.totalCandidates,
1430
+ pathMissPenalties: telemetry.pathMissPenalties,
1431
+ largeFilePenalties: telemetry.largeFilePenalties,
1432
+ pathMatchDistribution: {
1433
+ zero: dist.zero,
1434
+ one: dist.one,
1435
+ two: dist.two,
1436
+ three: dist.three,
1437
+ fourPlus: dist.fourPlus,
1438
+ total: dist.total,
1439
+ percentages: {
1440
+ zero: ((dist.zero / dist.total) * 100).toFixed(1),
1441
+ one: ((dist.one / dist.total) * 100).toFixed(1),
1442
+ two: ((dist.two / dist.total) * 100).toFixed(1),
1443
+ three: ((dist.three / dist.total) * 100).toFixed(1),
1444
+ fourPlus: ((dist.fourPlus / dist.total) * 100).toFixed(1),
1445
+ },
1446
+ },
1447
+ scoreStats: {
1448
+ min: scores.min.toFixed(2),
1449
+ max: scores.max.toFixed(2),
1450
+ mean: scores.mean.toFixed(2),
1451
+ median: scores.median.toFixed(2),
1452
+ // 最大ペナルティ(-0.8)との比率
1453
+ penaltyRatio: ((0.8 / scores.mean) * 100).toFixed(1) + "%",
1454
+ },
1455
+ };
1456
+ const telemetryFile = "/tmp/kiri-penalty-telemetry.jsonl";
1457
+ fs.appendFileSync(telemetryFile, JSON.stringify(telemetryRecord) + "\n");
1458
+ }
1459
+ /**
1460
+ * 環境変数から段階的ペナルティ設定を読み込む(Issue #68: Graduated Penalty)
1461
+ * LDE: 純粋関数(I/O分離、テスト可能)
1462
+ */
1463
+ function readGraduatedPenaltyConfig() {
1464
+ return {
1465
+ enabled: process.env.KIRI_GRADUATED_PENALTY === "1",
1466
+ minWordCount: parseFloat(process.env.KIRI_PENALTY_MIN_WORD_COUNT || "2"),
1467
+ minAvgWordLength: parseFloat(process.env.KIRI_PENALTY_MIN_AVG_WORD_LENGTH || "4.0"),
1468
+ tier0Delta: parseFloat(process.env.KIRI_PENALTY_TIER_0 || "-0.8"),
1469
+ tier1Delta: parseFloat(process.env.KIRI_PENALTY_TIER_1 || "-0.4"),
1470
+ tier2Delta: parseFloat(process.env.KIRI_PENALTY_TIER_2 || "-0.2"),
1471
+ };
1472
+ }
1473
+ /**
1474
+ * 段階的ペナルティ値を計算(Issue #68: Graduated Penalty)
1475
+ * LDE: 純粋関数(副作用なし、参照透明性)
1476
+ *
1477
+ * ADR 002: Graduated Penalty System
1478
+ * - Tier 0 (pathMatchHits === 0): Strong penalty (no path evidence)
1479
+ * - Tier 1 (pathMatchHits === 1): Medium penalty (weak path evidence)
1480
+ * - Tier 2 (pathMatchHits === 2): Light penalty (moderate path evidence)
1481
+ * - Tier 3+ (pathMatchHits >= 3): No penalty (strong path evidence)
1482
+ *
1483
+ * Invariants:
1484
+ * - Result is always <= 0 (non-positive)
1485
+ * - More path hits → less penalty (monotonicity)
1486
+ * - Query must meet eligibility criteria
1487
+ *
1488
+ * @param pathMatchHits Number of path-based scoring matches
1489
+ * @param queryStats Query word count and average word length
1490
+ * @param config Graduated penalty configuration
1491
+ * @returns Penalty delta (always <= 0)
1492
+ */
1493
+ function computeGraduatedPenalty(pathMatchHits, queryStats, config) {
1494
+ // Early return if query doesn't meet criteria
1495
+ if (queryStats.wordCount < config.minWordCount ||
1496
+ queryStats.avgWordLength < config.minAvgWordLength) {
1497
+ return 0;
1498
+ }
1499
+ // Graduated penalty tiers
1500
+ if (pathMatchHits === 0)
1501
+ return config.tier0Delta;
1502
+ if (pathMatchHits === 1)
1503
+ return config.tier1Delta;
1504
+ if (pathMatchHits === 2)
1505
+ return config.tier2Delta;
1506
+ return 0; // pathMatchHits >= 3: no penalty
1507
+ }
1180
1508
  export async function contextBundle(context, params) {
1181
1509
  context.warningManager.startRequest();
1182
1510
  const { db, repoId } = context;
@@ -1224,6 +1552,9 @@ export async function contextBundle(context, params) {
1224
1552
  const candidates = new Map();
1225
1553
  const stringMatchSeeds = new Set();
1226
1554
  const fileCache = new Map();
1555
+ // ✅ Cache boost profile config to avoid redundant lookups in hot path
1556
+ const boostProfile = params.boost_profile ?? "default";
1557
+ const profileConfig = getBoostProfile(boostProfile);
1227
1558
  // フレーズマッチング(高い重み: textMatch × 2)- 統合クエリでパフォーマンス改善
1228
1559
  if (extractedTerms.phrases.length > 0) {
1229
1560
  const phrasePlaceholders = extractedTerms.phrases
@@ -1242,7 +1573,6 @@ export async function contextBundle(context, params) {
1242
1573
  ORDER BY f.path
1243
1574
  LIMIT ?
1244
1575
  `, [repoId, ...extractedTerms.phrases, MAX_MATCHES_PER_KEYWORD * extractedTerms.phrases.length]);
1245
- const boostProfile = params.boost_profile ?? "default";
1246
1576
  for (const row of rows) {
1247
1577
  if (row.content === null) {
1248
1578
  continue;
@@ -1261,7 +1591,9 @@ export async function contextBundle(context, params) {
1261
1591
  candidate.reasons.add(`phrase:${phrase}`);
1262
1592
  }
1263
1593
  // Apply boost profile once per file
1264
- applyBoostProfile(candidate, row, boostProfile, weights, extractedTerms);
1594
+ if (boostProfile !== "none") {
1595
+ applyBoostProfile(candidate, row, profileConfig, weights, extractedTerms);
1596
+ }
1265
1597
  // Use first matched phrase for preview (guaranteed to exist due to length check above)
1266
1598
  const { line } = buildPreview(row.content, matchedPhrases[0]);
1267
1599
  candidate.matchLine =
@@ -1301,7 +1633,6 @@ export async function contextBundle(context, params) {
1301
1633
  ORDER BY f.path
1302
1634
  LIMIT ?
1303
1635
  `, [repoId, ...extractedTerms.keywords, MAX_MATCHES_PER_KEYWORD * extractedTerms.keywords.length]);
1304
- const boostProfile = params.boost_profile ?? "default";
1305
1636
  for (const row of rows) {
1306
1637
  if (row.content === null) {
1307
1638
  continue;
@@ -1319,7 +1650,9 @@ export async function contextBundle(context, params) {
1319
1650
  candidate.reasons.add(`text:${keyword}`);
1320
1651
  }
1321
1652
  // Apply boost profile once per file
1322
- applyBoostProfile(candidate, row, boostProfile, weights, extractedTerms);
1653
+ if (boostProfile !== "none") {
1654
+ applyBoostProfile(candidate, row, profileConfig, weights, extractedTerms);
1655
+ }
1323
1656
  // Use first matched keyword for preview (guaranteed to exist due to length check above)
1324
1657
  const { line } = buildPreview(row.content, matchedKeywords[0]);
1325
1658
  candidate.matchLine =
@@ -1456,6 +1789,36 @@ export async function contextBundle(context, params) {
1456
1789
  candidate.score *= candidate.scoreMultiplier;
1457
1790
  }
1458
1791
  }
1792
+ // Issue #68: Apply Path-Based Penalties (after multipliers, before sorting)
1793
+ const penaltyFlags = readPenaltyFlags();
1794
+ const queryStats = computeQueryStats(goal); // Always compute for telemetry
1795
+ const graduatedConfig = readGraduatedPenaltyConfig();
1796
+ // ADR 002: Use graduated penalty system if enabled, otherwise use legacy binary penalty
1797
+ if (graduatedConfig.enabled && penaltyFlags.pathPenalty) {
1798
+ for (const candidate of materializedCandidates) {
1799
+ applyGraduatedPenalty(candidate, queryStats, graduatedConfig);
1800
+ }
1801
+ }
1802
+ else if (penaltyFlags.pathPenalty) {
1803
+ // Legacy mode: Binary penalty (pathMatchHits === 0 only)
1804
+ for (const candidate of materializedCandidates) {
1805
+ applyPathMissPenalty(candidate, queryStats);
1806
+ }
1807
+ }
1808
+ // Issue #68: Apply Large File Penalty (after multipliers, before sorting)
1809
+ if (penaltyFlags.largeFilePenalty) {
1810
+ for (const candidate of materializedCandidates) {
1811
+ applyLargeFilePenalty(candidate);
1812
+ }
1813
+ }
1814
+ // Issue #68: Telemetry(デバッグ用、環境変数で制御)
1815
+ // LDE: 純粋関数(計算)と副作用(I/O)を分離
1816
+ const enableTelemetry = process.env.KIRI_PENALTY_TELEMETRY === "1";
1817
+ if (enableTelemetry) {
1818
+ console.error(`[DEBUG] Telemetry enabled. Flags: pathPenalty=${penaltyFlags.pathPenalty}, largeFilePenalty=${penaltyFlags.largeFilePenalty}`);
1819
+ const telemetry = computePenaltyTelemetry(materializedCandidates);
1820
+ logPenaltyTelemetry(telemetry, queryStats);
1821
+ }
1459
1822
  const sortedCandidates = materializedCandidates
1460
1823
  .filter((candidate) => candidate.score > 0) // Filter out candidates with negative or zero scores
1461
1824
  .sort((a, b) => {