mdcontext 0.1.0 → 0.2.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 (251) hide show
  1. package/.changeset/config.json +9 -9
  2. package/.claude/settings.local.json +25 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +85 -0
  5. package/CONTRIBUTING.md +186 -0
  6. package/NOTES/NOTES +44 -0
  7. package/README.md +206 -3
  8. package/biome.json +1 -1
  9. package/dist/chunk-23UPXDNL.js +3044 -0
  10. package/dist/chunk-2W7MO2DL.js +1366 -0
  11. package/dist/chunk-3NUAZGMA.js +1689 -0
  12. package/dist/chunk-7TOWB2XB.js +366 -0
  13. package/dist/chunk-7XOTOADQ.js +3065 -0
  14. package/dist/chunk-AH2PDM2K.js +3042 -0
  15. package/dist/chunk-BNXWSZ63.js +3742 -0
  16. package/dist/chunk-BTL5DJVU.js +3222 -0
  17. package/dist/chunk-HDHYG7E4.js +104 -0
  18. package/dist/chunk-HLR4KZBP.js +3234 -0
  19. package/dist/chunk-IP3FRFEB.js +1045 -0
  20. package/dist/chunk-KHU56VDO.js +3042 -0
  21. package/dist/chunk-KRYIFLQR.js +85 -89
  22. package/dist/chunk-LBSDNLEM.js +287 -0
  23. package/dist/chunk-MNTQ7HCP.js +2643 -0
  24. package/dist/chunk-MUJELQQ6.js +1387 -0
  25. package/dist/chunk-MXJGMSLV.js +2199 -0
  26. package/dist/chunk-N6QJGC3Z.js +2636 -0
  27. package/dist/chunk-OBELGBPM.js +1713 -0
  28. package/dist/chunk-OT7R5XTA.js +3192 -0
  29. package/dist/chunk-P7X4RA2T.js +106 -0
  30. package/dist/chunk-PIDUQNC2.js +3185 -0
  31. package/dist/chunk-POGCDIH4.js +3187 -0
  32. package/dist/chunk-PSIEOQGZ.js +3043 -0
  33. package/dist/chunk-PVRT3IHA.js +3238 -0
  34. package/dist/chunk-QNN4TT23.js +1430 -0
  35. package/dist/chunk-RE3R45RJ.js +3042 -0
  36. package/dist/chunk-S7E6TFX6.js +718 -657
  37. package/dist/chunk-SG6GLU4U.js +1378 -0
  38. package/dist/chunk-SJCDV2ST.js +274 -0
  39. package/dist/chunk-SYE5XLF3.js +104 -0
  40. package/dist/chunk-T5VLYBZD.js +103 -0
  41. package/dist/chunk-TOQB7VWU.js +3238 -0
  42. package/dist/chunk-VFNMZ4ZQ.js +3228 -0
  43. package/dist/chunk-VVTGZNBT.js +1533 -1423
  44. package/dist/chunk-W7Q4RFEV.js +104 -0
  45. package/dist/chunk-XTYYVRLO.js +3190 -0
  46. package/dist/chunk-Y6MDYVJD.js +3063 -0
  47. package/dist/cli/main.js +4072 -629
  48. package/dist/index.d.ts +420 -33
  49. package/dist/index.js +8 -15
  50. package/dist/mcp/server.js +103 -7
  51. package/dist/schema-BAWSG7KY.js +22 -0
  52. package/dist/schema-E3QUPL26.js +20 -0
  53. package/dist/schema-EHL7WUT6.js +20 -0
  54. package/docs/019-USAGE.md +44 -5
  55. package/docs/020-current-implementation.md +8 -8
  56. package/docs/021-DOGFOODING-FINDINGS.md +1 -1
  57. package/docs/CONFIG.md +1123 -0
  58. package/docs/ERRORS.md +383 -0
  59. package/docs/summarization.md +320 -0
  60. package/justfile +40 -0
  61. package/package.json +39 -33
  62. package/research/INDEX.md +315 -0
  63. package/research/code-review/README.md +90 -0
  64. package/research/code-review/cli-error-handling-review.md +979 -0
  65. package/research/code-review/code-review-validation-report.md +464 -0
  66. package/research/code-review/main-ts-review.md +1128 -0
  67. package/research/config-docs/SUMMARY.md +357 -0
  68. package/research/config-docs/TEST-RESULTS.md +776 -0
  69. package/research/config-docs/TODO.md +542 -0
  70. package/research/config-docs/analysis.md +744 -0
  71. package/research/config-docs/fix-validation.md +502 -0
  72. package/research/config-docs/help-audit.md +264 -0
  73. package/research/config-docs/help-system-analysis.md +890 -0
  74. package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
  75. package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
  76. package/research/issue-review.md +603 -0
  77. package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
  78. package/research/llm-summarization/alternative-providers-2026.md +1428 -0
  79. package/research/llm-summarization/anthropic-2026.md +367 -0
  80. package/research/llm-summarization/claude-cli-integration.md +1706 -0
  81. package/research/llm-summarization/cli-integration-patterns.md +3155 -0
  82. package/research/llm-summarization/openai-2026.md +473 -0
  83. package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
  84. package/research/llm-summarization/opencode-cli-integration.md +1552 -0
  85. package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
  86. package/research/llm-summarization/prototype-results.md +56 -0
  87. package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
  88. package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
  89. package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
  90. package/research/mdcontext-pudding/01-index-embed.md +956 -0
  91. package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
  92. package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
  93. package/research/mdcontext-pudding/02-search.md +970 -0
  94. package/research/mdcontext-pudding/03-context.md +779 -0
  95. package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
  96. package/research/mdcontext-pudding/04-tree.md +704 -0
  97. package/research/mdcontext-pudding/05-config.md +1038 -0
  98. package/research/mdcontext-pudding/06-links-summary.txt +87 -0
  99. package/research/mdcontext-pudding/06-links.md +679 -0
  100. package/research/mdcontext-pudding/07-stats.md +693 -0
  101. package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
  102. package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
  103. package/research/mdcontext-pudding/README.md +168 -0
  104. package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
  105. package/research/research-quality-review.md +834 -0
  106. package/research/semantic-search/embedding-text-analysis.md +156 -0
  107. package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
  108. package/research/semantic-search/query-processing-analysis.md +207 -0
  109. package/research/semantic-search/root-cause-and-solution.md +114 -0
  110. package/research/semantic-search/threshold-validation-report.md +69 -0
  111. package/research/semantic-search/vector-search-analysis.md +63 -0
  112. package/research/test-path-issues.md +276 -0
  113. package/review/ALP-76/1-error-type-design.md +962 -0
  114. package/review/ALP-76/2-error-handling-patterns.md +906 -0
  115. package/review/ALP-76/3-error-presentation.md +624 -0
  116. package/review/ALP-76/4-test-coverage.md +625 -0
  117. package/review/ALP-76/5-migration-completeness.md +440 -0
  118. package/review/ALP-76/6-effect-best-practices.md +755 -0
  119. package/scripts/apply-branch-protection.sh +47 -0
  120. package/scripts/branch-protection-templates.json +79 -0
  121. package/scripts/prototype-summarization.ts +346 -0
  122. package/scripts/rebuild-hnswlib.js +32 -37
  123. package/scripts/setup-branch-protection.sh +64 -0
  124. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
  125. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
  126. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
  127. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
  128. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  129. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  130. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
  131. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
  132. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
  133. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
  134. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
  135. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
  136. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
  137. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
  138. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
  139. package/src/cli/argv-preprocessor.test.ts +2 -2
  140. package/src/cli/cli.test.ts +230 -33
  141. package/src/cli/commands/config-cmd.ts +642 -0
  142. package/src/cli/commands/context.ts +97 -9
  143. package/src/cli/commands/duplicates.ts +122 -0
  144. package/src/cli/commands/embeddings.ts +529 -0
  145. package/src/cli/commands/index-cmd.ts +210 -30
  146. package/src/cli/commands/index.ts +3 -0
  147. package/src/cli/commands/search.ts +894 -64
  148. package/src/cli/commands/stats.ts +3 -0
  149. package/src/cli/commands/tree.ts +26 -5
  150. package/src/cli/config-layer.ts +176 -0
  151. package/src/cli/error-handler.test.ts +235 -0
  152. package/src/cli/error-handler.ts +655 -0
  153. package/src/cli/flag-schemas.ts +66 -0
  154. package/src/cli/help.ts +209 -7
  155. package/src/cli/main.ts +348 -58
  156. package/src/cli/options.ts +10 -0
  157. package/src/cli/shared-error-handling.ts +199 -0
  158. package/src/cli/utils.ts +150 -17
  159. package/src/config/file-provider.test.ts +320 -0
  160. package/src/config/file-provider.ts +273 -0
  161. package/src/config/index.ts +72 -0
  162. package/src/config/integration.test.ts +667 -0
  163. package/src/config/precedence.test.ts +277 -0
  164. package/src/config/precedence.ts +451 -0
  165. package/src/config/schema.test.ts +414 -0
  166. package/src/config/schema.ts +603 -0
  167. package/src/config/service.test.ts +320 -0
  168. package/src/config/service.ts +243 -0
  169. package/src/config/testing.test.ts +264 -0
  170. package/src/config/testing.ts +110 -0
  171. package/src/core/types.ts +6 -33
  172. package/src/duplicates/detector.test.ts +183 -0
  173. package/src/duplicates/detector.ts +414 -0
  174. package/src/duplicates/index.ts +18 -0
  175. package/src/embeddings/embedding-namespace.test.ts +300 -0
  176. package/src/embeddings/embedding-namespace.ts +947 -0
  177. package/src/embeddings/heading-boost.test.ts +222 -0
  178. package/src/embeddings/hnsw-build-options.test.ts +198 -0
  179. package/src/embeddings/hyde.test.ts +272 -0
  180. package/src/embeddings/hyde.ts +264 -0
  181. package/src/embeddings/index.ts +2 -0
  182. package/src/embeddings/openai-provider.ts +332 -83
  183. package/src/embeddings/pricing.json +22 -0
  184. package/src/embeddings/provider-constants.ts +204 -0
  185. package/src/embeddings/provider-errors.test.ts +967 -0
  186. package/src/embeddings/provider-errors.ts +565 -0
  187. package/src/embeddings/provider-factory.test.ts +240 -0
  188. package/src/embeddings/provider-factory.ts +225 -0
  189. package/src/embeddings/provider-integration.test.ts +788 -0
  190. package/src/embeddings/query-preprocessing.test.ts +187 -0
  191. package/src/embeddings/semantic-search-threshold.test.ts +508 -0
  192. package/src/embeddings/semantic-search.ts +780 -93
  193. package/src/embeddings/types.ts +293 -16
  194. package/src/embeddings/vector-store.ts +486 -77
  195. package/src/embeddings/voyage-provider.ts +313 -0
  196. package/src/errors/errors.test.ts +845 -0
  197. package/src/errors/index.ts +533 -0
  198. package/src/index/ignore-patterns.test.ts +354 -0
  199. package/src/index/ignore-patterns.ts +305 -0
  200. package/src/index/indexer.ts +286 -48
  201. package/src/index/storage.ts +94 -30
  202. package/src/index/types.ts +40 -2
  203. package/src/index/watcher.ts +67 -9
  204. package/src/index.ts +22 -0
  205. package/src/integration/search-keyword.test.ts +678 -0
  206. package/src/mcp/server.ts +135 -6
  207. package/src/parser/parser.ts +18 -19
  208. package/src/parser/section-filter.test.ts +277 -0
  209. package/src/parser/section-filter.ts +125 -3
  210. package/src/search/__tests__/hybrid-search.test.ts +650 -0
  211. package/src/search/bm25-store.ts +366 -0
  212. package/src/search/cross-encoder.test.ts +253 -0
  213. package/src/search/cross-encoder.ts +406 -0
  214. package/src/search/fuzzy-search.test.ts +419 -0
  215. package/src/search/fuzzy-search.ts +273 -0
  216. package/src/search/hybrid-search.ts +448 -0
  217. package/src/search/path-matcher.test.ts +276 -0
  218. package/src/search/path-matcher.ts +33 -0
  219. package/src/search/searcher.test.ts +99 -1
  220. package/src/search/searcher.ts +189 -67
  221. package/src/search/wink-bm25.d.ts +30 -0
  222. package/src/summarization/cli-providers/claude.ts +202 -0
  223. package/src/summarization/cli-providers/detection.test.ts +273 -0
  224. package/src/summarization/cli-providers/detection.ts +118 -0
  225. package/src/summarization/cli-providers/index.ts +8 -0
  226. package/src/summarization/cost.test.ts +139 -0
  227. package/src/summarization/cost.ts +102 -0
  228. package/src/summarization/error-handler.test.ts +127 -0
  229. package/src/summarization/error-handler.ts +111 -0
  230. package/src/summarization/index.ts +102 -0
  231. package/src/summarization/pipeline.test.ts +498 -0
  232. package/src/summarization/pipeline.ts +231 -0
  233. package/src/summarization/prompts.test.ts +269 -0
  234. package/src/summarization/prompts.ts +133 -0
  235. package/src/summarization/provider-factory.test.ts +396 -0
  236. package/src/summarization/provider-factory.ts +178 -0
  237. package/src/summarization/types.ts +184 -0
  238. package/src/summarize/summarizer.ts +104 -35
  239. package/src/types/huggingface-transformers.d.ts +66 -0
  240. package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
  241. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  242. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  243. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
  244. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
  245. package/tests/integration/embed-index.test.ts +712 -0
  246. package/tests/integration/search-context.test.ts +469 -0
  247. package/tests/integration/search-semantic.test.ts +522 -0
  248. package/vitest.config.ts +1 -6
  249. package/AGENTS.md +0 -46
  250. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  251. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +0 -1264
@@ -4,23 +4,142 @@
4
4
  * Search markdown content by meaning or heading pattern.
5
5
  */
6
6
 
7
+ import * as fs from 'node:fs/promises'
7
8
  import * as path from 'node:path'
8
9
  import * as readline from 'node:readline'
9
10
  import { Args, Command, Options } from '@effect/cli'
10
11
  import { Console, Effect, Option } from 'effect'
11
- import { handleApiKeyError } from '../../embeddings/openai-provider.js'
12
+ import { ConfigService, defaultConfig } from '../../config/index.js'
13
+ import type {
14
+ BuildEmbeddingsResult,
15
+ EmbeddingEstimate,
16
+ } from '../../embeddings/semantic-search.js'
12
17
  import {
13
18
  buildEmbeddings,
14
19
  estimateEmbeddingCost,
15
- semanticSearch,
20
+ semanticSearchWithStats,
16
21
  } from '../../embeddings/semantic-search.js'
22
+ import type { SearchQuality } from '../../embeddings/types.js'
23
+ import { createStorage, loadSectionIndex } from '../../index/storage.js'
24
+ import { INDEX_DIR } from '../../index/types.js'
25
+ import { initializeReranker } from '../../search/cross-encoder.js'
26
+ import {
27
+ detectSearchModes,
28
+ hybridSearch,
29
+ type SearchMode,
30
+ } from '../../search/hybrid-search.js'
17
31
  import { isAdvancedQuery } from '../../search/query-parser.js'
18
32
  import { search, searchContent } from '../../search/searcher.js'
33
+ import {
34
+ type APIProviderName,
35
+ buildPrompt,
36
+ type CLIProviderName,
37
+ displaySummarizationError,
38
+ estimateSummaryCost,
39
+ formatResultsForSummary,
40
+ getBestAvailableSummarizer,
41
+ type SummarizableResult,
42
+ } from '../../summarization/index.js'
19
43
  import { jsonOption, prettyOption } from '../options.js'
44
+ import {
45
+ createCostEstimateErrorHandler,
46
+ createEmbeddingErrorHandler,
47
+ } from '../shared-error-handling.js'
20
48
  import { formatJson, getIndexInfo, isRegexPattern } from '../utils.js'
21
49
 
22
- // Auto-index threshold in seconds
23
- const AUTO_INDEX_THRESHOLD_SECONDS = 10
50
+ // Auto-index threshold is now configurable via search.autoIndexThreshold
51
+
52
+ /**
53
+ * Check if content contains all the refine terms (case-insensitive).
54
+ */
55
+ const contentMatchesAllTerms = (
56
+ content: string,
57
+ terms: readonly string[],
58
+ ): boolean => {
59
+ const lowerContent = content.toLowerCase()
60
+ return terms.every((term) => lowerContent.includes(term.toLowerCase()))
61
+ }
62
+
63
+ /**
64
+ * Section info for refine filtering.
65
+ */
66
+ interface SectionInfo {
67
+ readonly documentPath: string
68
+ readonly startLine: number
69
+ readonly endLine: number
70
+ }
71
+
72
+ /**
73
+ * Filter search results by refine terms using parallel file loading.
74
+ * Uses a file cache and concurrency limit for performance.
75
+ *
76
+ * @param rootPath - Root path for file loading
77
+ * @param results - Search results to filter
78
+ * @param refineTerms - Terms that must all be present in section content
79
+ * @param limit - Maximum results to return
80
+ * @param getSectionInfo - Function to extract section info from a result
81
+ */
82
+ const filterResultsByRefineTerms = <T>(
83
+ rootPath: string,
84
+ results: readonly T[],
85
+ refineTerms: readonly string[],
86
+ limit: number,
87
+ getSectionInfo: (result: T) => SectionInfo | null,
88
+ ): Effect.Effect<T[], never> =>
89
+ Effect.gen(function* () {
90
+ if (refineTerms.length === 0 || results.length === 0) {
91
+ return results.slice(0, limit) as T[]
92
+ }
93
+
94
+ // Cache for file contents to avoid re-reading files
95
+ const fileCache = new Map<string, string | null>()
96
+
97
+ const getFileContent = (
98
+ documentPath: string,
99
+ ): Effect.Effect<string | null, never> =>
100
+ Effect.gen(function* () {
101
+ if (fileCache.has(documentPath)) {
102
+ return fileCache.get(documentPath)!
103
+ }
104
+ const content = yield* Effect.promise(async () => {
105
+ try {
106
+ const filePath = path.join(rootPath, documentPath)
107
+ return await fs.readFile(filePath, 'utf-8')
108
+ } catch {
109
+ return null
110
+ }
111
+ })
112
+ fileCache.set(documentPath, content)
113
+ return content
114
+ })
115
+
116
+ // Check each result in parallel with concurrency limit
117
+ const checkedResults = yield* Effect.all(
118
+ results.map((result) =>
119
+ Effect.gen(function* () {
120
+ const info = getSectionInfo(result)
121
+ if (!info) return null
122
+
123
+ const fileContent = yield* getFileContent(info.documentPath)
124
+ if (!fileContent) return null
125
+
126
+ const lines = fileContent.split('\n')
127
+ const sectionContent = lines
128
+ .slice(info.startLine - 1, info.endLine)
129
+ .join('\n')
130
+
131
+ if (contentMatchesAllTerms(sectionContent, refineTerms)) {
132
+ return result
133
+ }
134
+ return null
135
+ }),
136
+ ),
137
+ { concurrency: 10 },
138
+ )
139
+
140
+ // Filter nulls and limit results
141
+ return checkedResults.filter((r): r is T => r !== null).slice(0, limit)
142
+ })
24
143
 
25
144
  const promptUser = (message: string): Promise<string> => {
26
145
  return new Promise((resolve) => {
@@ -55,9 +174,11 @@ export const searchCommand = Command.make(
55
174
  Options.withDescription('Search headings only (not content)'),
56
175
  Options.withDefault(false),
57
176
  ),
58
- mode: Options.choice('mode', ['semantic', 'keyword']).pipe(
177
+ mode: Options.choice('mode', ['hybrid', 'semantic', 'keyword']).pipe(
59
178
  Options.withAlias('m'),
60
- Options.withDescription('Force search mode: semantic or keyword'),
179
+ Options.withDescription(
180
+ 'Search mode: hybrid (BM25+semantic), semantic, or keyword',
181
+ ),
61
182
  Options.optional,
62
183
  ),
63
184
  limit: Options.integer('limit').pipe(
@@ -67,7 +188,7 @@ export const searchCommand = Command.make(
67
188
  ),
68
189
  threshold: Options.float('threshold').pipe(
69
190
  Options.withDescription('Similarity threshold for semantic search (0-1)'),
70
- Options.withDefault(0.45),
191
+ Options.withDefault(0.35),
71
192
  ),
72
193
  context: Options.integer('context').pipe(
73
194
  Options.withAlias('C'),
@@ -88,10 +209,93 @@ export const searchCommand = Command.make(
88
209
  Options.withDescription(
89
210
  'Auto-create semantic index if estimated time is under this threshold (seconds)',
90
211
  ),
91
- Options.withDefault(AUTO_INDEX_THRESHOLD_SECONDS),
212
+ Options.optional,
213
+ ),
214
+ provider: Options.choice('provider', [
215
+ 'openai',
216
+ 'ollama',
217
+ 'lm-studio',
218
+ 'openrouter',
219
+ 'voyage',
220
+ ]).pipe(
221
+ Options.withDescription(
222
+ 'Embedding provider for semantic search: openai, ollama, lm-studio, openrouter, or voyage',
223
+ ),
224
+ Options.optional,
225
+ ),
226
+ rerank: Options.boolean('rerank').pipe(
227
+ Options.withAlias('r'),
228
+ Options.withDescription(
229
+ 'Re-rank results using cross-encoder for improved precision. Downloads ~90MB model on first use. Requires @huggingface/transformers.',
230
+ ),
231
+ Options.withDefault(false),
232
+ ),
233
+ quality: Options.choice('quality', ['fast', 'balanced', 'thorough']).pipe(
234
+ Options.withAlias('q'),
235
+ Options.withDescription(
236
+ 'Search quality mode: fast (quicker, lower recall), balanced (default), thorough (slower, better recall)',
237
+ ),
238
+ Options.optional,
239
+ ),
240
+ hyde: Options.boolean('hyde').pipe(
241
+ Options.withDescription(
242
+ 'Use HyDE (Hypothetical Document Embeddings) for complex queries. Generates a hypothetical answer with LLM, then searches using that embedding. Improves recall 10-30% on complex/ambiguous queries at cost of ~1-2s latency and LLM API usage.',
243
+ ),
244
+ Options.withDefault(false),
245
+ ),
246
+ rerankInit: Options.boolean('rerank-init').pipe(
247
+ Options.withDescription(
248
+ 'Pre-download the cross-encoder model (~90MB) for re-ranking. Use this before first search to avoid latency.',
249
+ ),
250
+ Options.withDefault(false),
251
+ ),
252
+ timeout: Options.integer('timeout').pipe(
253
+ Options.withDescription(
254
+ 'Request timeout in milliseconds for embedding API calls (default: 30000)',
255
+ ),
256
+ Options.optional,
92
257
  ),
93
258
  json: jsonOption,
94
259
  pretty: prettyOption,
260
+ summarize: Options.boolean('summarize').pipe(
261
+ Options.withAlias('s'),
262
+ Options.withDescription('Generate AI summary of search results'),
263
+ Options.withDefault(false),
264
+ ),
265
+ yes: Options.boolean('yes').pipe(
266
+ Options.withAlias('y'),
267
+ Options.withDescription('Skip cost confirmation for paid AI providers'),
268
+ Options.withDefault(false),
269
+ ),
270
+ stream: Options.boolean('stream').pipe(
271
+ Options.withDescription('Stream AI summary output in real-time'),
272
+ Options.withDefault(false),
273
+ ),
274
+ fuzzy: Options.boolean('fuzzy').pipe(
275
+ Options.withAlias('f'),
276
+ Options.withDescription(
277
+ 'Enable fuzzy matching for typo tolerance (e.g., "configration" matches "configuration")',
278
+ ),
279
+ Options.withDefault(false),
280
+ ),
281
+ stem: Options.boolean('stem').pipe(
282
+ Options.withDescription(
283
+ 'Enable word stemming (e.g., "fail" matches "failure", "failed", "failing")',
284
+ ),
285
+ Options.withDefault(false),
286
+ ),
287
+ fuzzyDistance: Options.integer('fuzzy-distance').pipe(
288
+ Options.withDescription(
289
+ 'Max edit distance for fuzzy matching (default: 2)',
290
+ ),
291
+ Options.optional,
292
+ ),
293
+ refine: Options.text('refine').pipe(
294
+ Options.withDescription(
295
+ 'Additional filter terms to narrow results (can be used multiple times)',
296
+ ),
297
+ Options.repeated,
298
+ ),
95
299
  },
96
300
  ({
97
301
  query,
@@ -105,12 +309,81 @@ export const searchCommand = Command.make(
105
309
  beforeContext,
106
310
  afterContext,
107
311
  autoIndexThreshold,
312
+ provider,
313
+ rerank,
314
+ quality,
315
+ hyde,
316
+ rerankInit,
317
+ timeout,
108
318
  json,
109
319
  pretty,
320
+ summarize,
321
+ yes,
322
+ stream,
323
+ fuzzy,
324
+ stem,
325
+ fuzzyDistance,
326
+ refine,
110
327
  }) =>
111
328
  Effect.gen(function* () {
112
329
  const resolvedDir = path.resolve(dirPath)
113
330
 
331
+ // Handle --rerank-init: pre-download model and exit
332
+ if (rerankInit) {
333
+ yield* Console.log(
334
+ 'Initializing cross-encoder model (~90MB download)...',
335
+ )
336
+
337
+ const cacheDir = path.join(resolvedDir, INDEX_DIR, 'models')
338
+
339
+ const result = yield* initializeReranker(cacheDir, (progress) => {
340
+ if (progress.status === 'loading' && progress.file) {
341
+ const pct = progress.progress
342
+ ? ` (${Math.round(progress.progress)}%)`
343
+ : ''
344
+ process.stdout.write(`\r Downloading: ${progress.file}${pct}`)
345
+ }
346
+ }).pipe(
347
+ Effect.map(() => true),
348
+ Effect.catchTag('RerankerError', (e) => {
349
+ if (e.reason === 'DependencyMissing') {
350
+ return Effect.succeed(false)
351
+ }
352
+ return Effect.fail(e)
353
+ }),
354
+ )
355
+
356
+ if (!result) {
357
+ yield* Console.log('')
358
+ yield* Console.log('Error: @huggingface/transformers not installed.')
359
+ yield* Console.log(
360
+ 'Install with: npm install @huggingface/transformers',
361
+ )
362
+ return
363
+ }
364
+
365
+ yield* Console.log('')
366
+ yield* Console.log('Cross-encoder model initialized successfully.')
367
+ yield* Console.log('Use --rerank on searches for improved precision.')
368
+ return
369
+ }
370
+
371
+ // Get configuration (with fallback to defaults if not available)
372
+ const config = yield* Effect.serviceOption(ConfigService).pipe(
373
+ Effect.map(Option.getOrElse(() => defaultConfig)),
374
+ )
375
+ const searchConfig = config.search
376
+
377
+ // Apply config-based defaults when CLI options use their static defaults
378
+ // Note: CLI options have static defaults for help text; config overrides those defaults
379
+ const effectiveLimit = limit === 10 ? searchConfig.defaultLimit : limit
380
+ const effectiveThreshold =
381
+ threshold === 0.35 ? searchConfig.minSimilarity : threshold
382
+ const effectiveAutoIndexThreshold = Option.getOrElse(
383
+ autoIndexThreshold,
384
+ () => searchConfig.autoIndexThreshold,
385
+ )
386
+
114
387
  // Get index info for display
115
388
  const indexInfo = yield* Effect.promise(() => getIndexInfo(resolvedDir))
116
389
 
@@ -123,54 +396,75 @@ export const searchCommand = Command.make(
123
396
  return
124
397
  }
125
398
 
126
- // Check for embeddings
127
- let embedsExist = indexInfo.embeddingsExist
399
+ // Determine the actual index root (may be a parent directory)
400
+ const indexRoot = indexInfo.indexRoot ?? resolvedDir
401
+
402
+ // Calculate path filter for scoped search
403
+ // If searching a subdirectory, filter results to that path
404
+ let scopedPathPattern: string | undefined
405
+ if (indexInfo.indexRoot && indexInfo.indexRoot !== resolvedDir) {
406
+ // Get relative path from index root to search dir
407
+ const relativePath = path.relative(indexRoot, resolvedDir)
408
+ // Create pattern to match files in this directory and subdirectories
409
+ scopedPathPattern = `${relativePath}/*`
410
+ if (!json) {
411
+ yield* Console.log(`Searching within: ${relativePath}/`)
412
+ yield* Console.log('')
413
+ }
414
+ }
415
+
416
+ // Check available search modes
417
+ const searchModes = yield* detectSearchModes(indexRoot)
418
+ let embedsExist = searchModes.hasEmbeddings
128
419
 
129
420
  // Determine search mode
130
- // Priority: --mode flag > --keyword flag > regex pattern > embeddings availability
131
- let useKeyword: boolean
421
+ // Priority: --mode flag > --keyword flag > advanced query > auto-detect
422
+ let effectiveMode: SearchMode
132
423
  let modeReason: string
133
424
 
134
425
  const modeValue = Option.getOrUndefined(mode)
135
426
 
136
- if (modeValue === 'semantic') {
137
- // User explicitly requested semantic search
427
+ if (modeValue === 'hybrid') {
428
+ effectiveMode = 'hybrid'
429
+ modeReason = '--mode hybrid'
430
+ } else if (modeValue === 'semantic') {
138
431
  if (!embedsExist) {
139
- // Try to auto-create index
140
432
  embedsExist = yield* handleMissingEmbeddings(
141
- resolvedDir,
142
- autoIndexThreshold,
433
+ indexRoot,
434
+ effectiveAutoIndexThreshold,
143
435
  json,
144
436
  )
145
437
  if (!embedsExist) {
146
- // User declined or error
147
438
  return
148
439
  }
149
440
  }
150
- useKeyword = false
441
+ effectiveMode = 'semantic'
151
442
  modeReason = '--mode semantic'
152
443
  } else if (modeValue === 'keyword') {
153
- useKeyword = true
444
+ effectiveMode = 'keyword'
154
445
  modeReason = '--mode keyword'
155
446
  } else if (keyword) {
156
- useKeyword = true
447
+ effectiveMode = 'keyword'
157
448
  modeReason = '--keyword flag'
158
449
  } else if (isAdvancedQuery(query)) {
159
- // Detect quoted phrases and boolean operators (AND, OR, NOT)
160
- useKeyword = true
450
+ effectiveMode = 'keyword'
161
451
  modeReason = 'boolean/phrase pattern detected'
162
452
  } else if (isRegexPattern(query)) {
163
- useKeyword = true
453
+ effectiveMode = 'keyword'
164
454
  modeReason = 'regex pattern detected'
165
- } else if (!embedsExist) {
166
- useKeyword = true
167
- modeReason = 'no embeddings'
168
455
  } else {
169
- useKeyword = false
170
- modeReason = 'embeddings available'
456
+ // Auto-detect best mode based on available indexes
457
+ effectiveMode = searchModes.recommendedMode
458
+ if (effectiveMode === 'hybrid') {
459
+ modeReason = 'both indexes available'
460
+ } else if (effectiveMode === 'semantic') {
461
+ modeReason = 'embeddings available'
462
+ } else {
463
+ modeReason = 'no embeddings'
464
+ }
171
465
  }
172
466
 
173
- const modeIndicator = useKeyword ? '[keyword]' : '[semantic]'
467
+ const modeIndicator = `[${effectiveMode}]`
174
468
 
175
469
  // Show index info (non-JSON mode)
176
470
  if (!json && indexInfo.lastUpdated) {
@@ -199,20 +493,202 @@ export const searchCommand = Command.make(
199
493
  const beforeValue = Option.getOrUndefined(beforeContext)
200
494
  const afterValue = Option.getOrUndefined(afterContext)
201
495
 
202
- const contextBefore = beforeValue ?? contextValue ?? 1
203
- const contextAfter = afterValue ?? contextValue ?? 1
496
+ const contextBefore = beforeValue ?? contextValue
497
+ const contextAfter = afterValue ?? contextValue
498
+
499
+ if (effectiveMode === 'hybrid') {
500
+ // Hybrid search - combines BM25 and semantic with RRF
501
+ const effectiveQuality = Option.getOrUndefined(quality) as
502
+ | SearchQuality
503
+ | undefined
504
+ // Get more results if refinement is needed (we'll filter down later)
505
+ const refineTerms = refine.length > 0 ? refine : []
506
+ const fetchLimit =
507
+ refineTerms.length > 0 ? effectiveLimit * 5 : effectiveLimit
508
+
509
+ const { results: rawResults, stats } = yield* hybridSearch(
510
+ indexRoot,
511
+ query,
512
+ {
513
+ limit: fetchLimit,
514
+ threshold: effectiveThreshold,
515
+ mode: 'hybrid',
516
+ rerank,
517
+ quality: effectiveQuality,
518
+ contextBefore,
519
+ contextAfter,
520
+ ...(scopedPathPattern && { pathPattern: scopedPathPattern }),
521
+ },
522
+ )
523
+
524
+ // Apply refine filtering if terms provided (parallel with caching)
525
+ let results = rawResults
526
+ if (refineTerms.length > 0) {
527
+ const storage = createStorage(indexRoot)
528
+ const sectionIndex = yield* loadSectionIndex(storage)
529
+
530
+ if (sectionIndex) {
531
+ results = yield* filterResultsByRefineTerms(
532
+ indexRoot,
533
+ rawResults,
534
+ refineTerms,
535
+ effectiveLimit,
536
+ (result) => {
537
+ const section = sectionIndex.sections[result.sectionId]
538
+ return section
539
+ ? {
540
+ documentPath: result.documentPath,
541
+ startLine: section.startLine,
542
+ endLine: section.endLine,
543
+ }
544
+ : null
545
+ },
546
+ )
547
+ }
548
+ }
549
+
550
+ // Warn if reranking was requested but not applied
551
+ if (rerank && !stats.reranked && !json) {
552
+ yield* Console.log(
553
+ 'Note: --rerank requested but @huggingface/transformers not installed',
554
+ )
555
+ yield* Console.log(
556
+ ' Install with: npm install @huggingface/transformers',
557
+ )
558
+ yield* Console.log('')
559
+ }
560
+
561
+ if (json) {
562
+ const moreAvailable =
563
+ stats.totalAvailable !== undefined &&
564
+ stats.totalAvailable > results.length
565
+ ? stats.totalAvailable - results.length
566
+ : undefined
567
+ const output = {
568
+ mode: 'hybrid',
569
+ modeReason,
570
+ query,
571
+ stats,
572
+ moreAvailable,
573
+ results: results.map((r) => ({
574
+ path: r.documentPath,
575
+ heading: r.heading,
576
+ score: r.score,
577
+ similarity: r.similarity,
578
+ bm25Score: r.bm25Score,
579
+ sources: r.sources,
580
+ ...(r.contextLines && { contextLines: r.contextLines }),
581
+ })),
582
+ }
583
+ yield* Console.log(formatJson(output, pretty))
584
+ } else {
585
+ const showReason = !modeReason.startsWith('--mode')
586
+ const modeStr = showReason
587
+ ? `${modeIndicator} (${modeReason})`
588
+ : modeIndicator
589
+ yield* Console.log(`${modeStr} Searching: "${query}"`)
590
+
591
+ // Show results count with "more available" indicator if results were limited
592
+ const moreAvailable =
593
+ stats.totalAvailable !== undefined &&
594
+ stats.totalAvailable > results.length
595
+ ? stats.totalAvailable - results.length
596
+ : 0
597
+ if (moreAvailable > 0) {
598
+ yield* Console.log(
599
+ `Results: ${results.length} (${moreAvailable} more available, use --limit to see more)`,
600
+ )
601
+ } else {
602
+ yield* Console.log(`Results: ${results.length}`)
603
+ }
604
+ yield* Console.log('')
605
+
606
+ for (const result of results) {
607
+ const sources = result.sources.join('+')
608
+ const score = (result.score * 100).toFixed(1)
609
+ yield* Console.log(` ${result.documentPath}`)
610
+ yield* Console.log(
611
+ ` ${result.heading} (${score} RRF, ${sources})`,
612
+ )
613
+
614
+ if (result.contextLines && result.contextLines.length > 0) {
615
+ yield* Console.log('')
616
+ for (const ctxLine of result.contextLines) {
617
+ const marker = ctxLine.isMatch ? '>' : ' '
618
+ yield* Console.log(
619
+ ` ${marker} ${ctxLine.lineNumber}: ${ctxLine.line}`,
620
+ )
621
+ }
622
+ }
623
+
624
+ yield* Console.log('')
625
+ }
626
+ }
204
627
 
205
- if (useKeyword) {
628
+ // Summarization for hybrid search
629
+ if (summarize && results.length > 0) {
630
+ const summarizableResults: SummarizableResult[] = results.map(
631
+ (r) => ({
632
+ documentPath: r.documentPath,
633
+ heading: r.heading,
634
+ score: r.score,
635
+ ...(r.similarity !== undefined && { similarity: r.similarity }),
636
+ }),
637
+ )
638
+ yield* runSummarization({
639
+ results: summarizableResults,
640
+ query,
641
+ searchMode: 'hybrid',
642
+ json,
643
+ yes,
644
+ stream,
645
+ config: {
646
+ mode: config.aiSummarization.mode,
647
+ provider: config.aiSummarization.provider,
648
+ },
649
+ })
650
+ }
651
+ } else if (effectiveMode === 'keyword') {
206
652
  // Keyword search - content by default, heading-only if flag set
207
- const results = headingOnly
208
- ? yield* search(resolvedDir, { heading: query, limit })
209
- : yield* searchContent(resolvedDir, {
653
+ const effectiveFuzzyDistance = Option.getOrUndefined(fuzzyDistance)
654
+ const refineTerms = refine.length > 0 ? refine : []
655
+ const fetchLimit =
656
+ refineTerms.length > 0 ? effectiveLimit * 5 : effectiveLimit
657
+
658
+ let results = headingOnly
659
+ ? yield* search(indexRoot, {
660
+ heading: query,
661
+ limit: fetchLimit,
662
+ ...(scopedPathPattern && { pathPattern: scopedPathPattern }),
663
+ })
664
+ : yield* searchContent(indexRoot, {
210
665
  content: query,
211
- limit,
666
+ limit: fetchLimit,
212
667
  contextBefore,
213
668
  contextAfter,
669
+ fuzzy,
670
+ stem,
671
+ ...(effectiveFuzzyDistance !== undefined && {
672
+ fuzzyDistance: effectiveFuzzyDistance,
673
+ }),
674
+ ...(scopedPathPattern && { pathPattern: scopedPathPattern }),
214
675
  })
215
676
 
677
+ // Apply refine filtering if terms provided (parallel with caching)
678
+ if (refineTerms.length > 0) {
679
+ results = yield* filterResultsByRefineTerms(
680
+ indexRoot,
681
+ results,
682
+ refineTerms,
683
+ effectiveLimit,
684
+ (result) => ({
685
+ documentPath: result.section.documentPath,
686
+ startLine: result.section.startLine,
687
+ endLine: result.section.endLine,
688
+ }),
689
+ )
690
+ }
691
+
216
692
  if (json) {
217
693
  const output = {
218
694
  mode: 'keyword',
@@ -220,6 +696,11 @@ export const searchCommand = Command.make(
220
696
  query,
221
697
  contextBefore,
222
698
  contextAfter,
699
+ fuzzy,
700
+ stem,
701
+ ...(effectiveFuzzyDistance !== undefined && {
702
+ fuzzyDistance: effectiveFuzzyDistance,
703
+ }),
223
704
  results: results.map((r) => ({
224
705
  path: r.section.documentPath,
225
706
  heading: r.section.heading,
@@ -236,13 +717,20 @@ export const searchCommand = Command.make(
236
717
  yield* Console.log(formatJson(output, pretty))
237
718
  } else {
238
719
  const searchType = headingOnly ? 'Heading' : 'Content'
239
- // Show mode with explanation for auto-detected modes
240
720
  const showReason =
241
721
  modeReason !== '--mode keyword' && modeReason !== '--keyword flag'
242
722
  const modeStr = showReason
243
723
  ? `${modeIndicator} (${modeReason})`
244
724
  : modeIndicator
245
- yield* Console.log(`${modeStr} ${searchType} search: "${query}"`)
725
+ // Build fuzzy/stem indicator
726
+ const fuzzyIndicators: string[] = []
727
+ if (fuzzy) fuzzyIndicators.push('fuzzy')
728
+ if (stem) fuzzyIndicators.push('stem')
729
+ const fuzzyStr =
730
+ fuzzyIndicators.length > 0 ? ` [${fuzzyIndicators.join('+')}]` : ''
731
+ yield* Console.log(
732
+ `${modeStr}${fuzzyStr} ${searchType} search: "${query}"`,
733
+ )
246
734
  yield* Console.log(`Results: ${results.length}`)
247
735
  yield* Console.log('')
248
736
 
@@ -255,12 +743,9 @@ export const searchCommand = Command.make(
255
743
  ` ${levelMarker} ${result.section.heading} (${result.section.tokenCount} tokens)`,
256
744
  )
257
745
 
258
- // Show match snippets with line numbers
259
746
  if (result.matches && result.matches.length > 0) {
260
747
  yield* Console.log('')
261
748
  for (const match of result.matches.slice(0, 3)) {
262
- // Show first 3 matches per section
263
- // Use contextLines for formatted output with line numbers
264
749
  if (match.contextLines && match.contextLines.length > 0) {
265
750
  for (const ctxLine of match.contextLines) {
266
751
  const marker = ctxLine.isMatch ? '>' : ' '
@@ -269,7 +754,6 @@ export const searchCommand = Command.make(
269
754
  )
270
755
  }
271
756
  } else {
272
- // Fallback to simple snippet display
273
757
  yield* Console.log(` Line ${match.lineNumber}:`)
274
758
  const snippetLines = match.snippet.split('\n')
275
759
  for (const line of snippetLines) {
@@ -287,52 +771,395 @@ export const searchCommand = Command.make(
287
771
  yield* Console.log('')
288
772
  }
289
773
 
290
- // Show tip for enabling semantic search if no embeddings
291
774
  if (!indexInfo.embeddingsExist) {
292
775
  yield* Console.log(
293
776
  "Tip: Run 'mdcontext index --embed' to enable semantic search",
294
777
  )
295
778
  }
296
779
  }
780
+
781
+ // Summarization for keyword search
782
+ if (summarize && results.length > 0) {
783
+ const summarizableResults: SummarizableResult[] = results.map(
784
+ (r) => ({
785
+ documentPath: r.section.documentPath,
786
+ heading: r.section.heading,
787
+ }),
788
+ )
789
+ yield* runSummarization({
790
+ results: summarizableResults,
791
+ query,
792
+ searchMode: 'keyword',
793
+ json,
794
+ yes,
795
+ stream,
796
+ config: {
797
+ mode: config.aiSummarization.mode,
798
+ provider: config.aiSummarization.provider,
799
+ },
800
+ })
801
+ }
297
802
  } else {
298
- // Semantic search
299
- const results = yield* semanticSearch(resolvedDir, query, {
300
- limit,
301
- threshold,
302
- }).pipe(handleApiKeyError)
803
+ // Build provider config from CLI flag if specified
804
+ const cliTimeout = Option.getOrUndefined(timeout)
805
+ const providerConfig = Option.isSome(provider)
806
+ ? {
807
+ provider: provider.value as
808
+ | 'openai'
809
+ | 'ollama'
810
+ | 'lm-studio'
811
+ | 'openrouter'
812
+ | 'voyage',
813
+ timeout: cliTimeout,
814
+ }
815
+ : cliTimeout !== undefined
816
+ ? { provider: 'openai' as const, timeout: cliTimeout }
817
+ : undefined
818
+
819
+ // Semantic search with stats for below-threshold feedback
820
+ const refineTerms = refine.length > 0 ? refine : []
821
+ const fetchLimit =
822
+ refineTerms.length > 0 ? effectiveLimit * 5 : effectiveLimit
823
+
824
+ const semanticQuality = Option.getOrUndefined(quality) as
825
+ | SearchQuality
826
+ | undefined
827
+ const searchResult = yield* semanticSearchWithStats(indexRoot, query, {
828
+ limit: fetchLimit,
829
+ threshold: effectiveThreshold,
830
+ providerConfig,
831
+ quality: semanticQuality,
832
+ hyde,
833
+ contextBefore,
834
+ contextAfter,
835
+ ...(scopedPathPattern && { pathPattern: scopedPathPattern }),
836
+ })
837
+ let {
838
+ results,
839
+ belowThresholdCount,
840
+ belowThresholdHighest,
841
+ totalAvailable,
842
+ } = searchResult
843
+
844
+ // Apply refine filtering if terms provided (parallel with caching)
845
+ if (refineTerms.length > 0) {
846
+ const storage = createStorage(indexRoot)
847
+ const sectionIndex = yield* loadSectionIndex(storage)
848
+
849
+ if (sectionIndex) {
850
+ results = yield* filterResultsByRefineTerms(
851
+ indexRoot,
852
+ results,
853
+ refineTerms,
854
+ effectiveLimit,
855
+ (result) => {
856
+ const section = sectionIndex.sections[result.sectionId]
857
+ return section
858
+ ? {
859
+ documentPath: result.documentPath,
860
+ startLine: section.startLine,
861
+ endLine: section.endLine,
862
+ }
863
+ : null
864
+ },
865
+ )
866
+ }
867
+ }
303
868
 
304
869
  if (json) {
870
+ const moreAvailableSemantic =
871
+ totalAvailable !== undefined && totalAvailable > results.length
872
+ ? totalAvailable - results.length
873
+ : undefined
305
874
  const output = {
306
875
  mode: 'semantic',
307
876
  modeReason,
308
877
  query,
878
+ hyde,
309
879
  results,
880
+ belowThresholdCount,
881
+ belowThresholdHighest,
882
+ moreAvailable: moreAvailableSemantic,
310
883
  }
311
884
  yield* Console.log(formatJson(output, pretty))
312
885
  } else {
313
- // Show mode with explanation for auto-detected modes
314
886
  const showSemanticReason = modeReason !== '--mode semantic'
315
887
  const semanticModeStr = showSemanticReason
316
888
  ? `${modeIndicator} (${modeReason})`
317
889
  : modeIndicator
318
- yield* Console.log(`${semanticModeStr} Semantic search: "${query}"`)
319
- yield* Console.log(`Results: ${results.length}`)
890
+ const hydeIndicator = hyde ? ' [HyDE]' : ''
891
+ yield* Console.log(
892
+ `${semanticModeStr}${hydeIndicator} Semantic search: "${query}"`,
893
+ )
894
+
895
+ // Show results count with "more available" indicator if results were limited
896
+ const moreAvailableSemantic =
897
+ totalAvailable !== undefined && totalAvailable > results.length
898
+ ? totalAvailable - results.length
899
+ : 0
900
+ if (moreAvailableSemantic > 0) {
901
+ yield* Console.log(
902
+ `Results: ${results.length} (${moreAvailableSemantic} more available, use --limit to see more)`,
903
+ )
904
+ } else {
905
+ yield* Console.log(`Results: ${results.length}`)
906
+ }
320
907
  yield* Console.log('')
321
908
 
322
909
  for (const result of results) {
323
910
  const similarity = (result.similarity * 100).toFixed(1)
324
911
  yield* Console.log(` ${result.documentPath}`)
325
912
  yield* Console.log(` ${result.heading} (${similarity}% match)`)
913
+
914
+ if (result.contextLines && result.contextLines.length > 0) {
915
+ yield* Console.log('')
916
+ for (const ctxLine of result.contextLines) {
917
+ const marker = ctxLine.isMatch ? '>' : ' '
918
+ yield* Console.log(
919
+ ` ${marker} ${ctxLine.lineNumber}: ${ctxLine.line}`,
920
+ )
921
+ }
922
+ }
923
+
924
+ yield* Console.log('')
925
+ }
926
+
927
+ // Show below-threshold feedback when 0 results but content exists
928
+ if (
929
+ results.length === 0 &&
930
+ belowThresholdCount !== undefined &&
931
+ belowThresholdCount > 0 &&
932
+ belowThresholdHighest !== undefined
933
+ ) {
934
+ const highestPct = (belowThresholdHighest * 100).toFixed(1)
935
+ const suggestedThreshold = Math.max(
936
+ 0.1,
937
+ belowThresholdHighest - 0.05,
938
+ ).toFixed(2)
939
+ yield* Console.log(
940
+ `Note: ${belowThresholdCount} results found below ${(effectiveThreshold * 100).toFixed(0)}% threshold (highest: ${highestPct}%)`,
941
+ )
942
+ yield* Console.log(
943
+ `Tip: Use --threshold ${suggestedThreshold} to see more results`,
944
+ )
326
945
  yield* Console.log('')
327
946
  }
328
947
 
329
- // Show tip for keyword search alternative
330
948
  yield* Console.log('Tip: Use --mode keyword for exact text matching')
331
949
  }
950
+
951
+ // Summarization for semantic search
952
+ if (summarize && results.length > 0) {
953
+ const summarizableResults: SummarizableResult[] = results.map(
954
+ (r) => ({
955
+ documentPath: r.documentPath,
956
+ heading: r.heading,
957
+ similarity: r.similarity,
958
+ }),
959
+ )
960
+ yield* runSummarization({
961
+ results: summarizableResults,
962
+ query,
963
+ searchMode: 'semantic',
964
+ json,
965
+ yes,
966
+ stream,
967
+ config: {
968
+ mode: config.aiSummarization.mode,
969
+ provider: config.aiSummarization.provider,
970
+ },
971
+ })
972
+ }
332
973
  }
333
974
  }),
334
975
  ).pipe(Command.withDescription('Search by meaning or structure'))
335
976
 
977
+ /**
978
+ * Options for running AI summarization
979
+ */
980
+ interface SummarizationOptions {
981
+ readonly results: readonly SummarizableResult[]
982
+ readonly query: string
983
+ readonly searchMode: 'hybrid' | 'semantic' | 'keyword'
984
+ readonly json: boolean
985
+ readonly yes: boolean
986
+ readonly stream: boolean
987
+ readonly config: {
988
+ readonly mode: 'cli' | 'api'
989
+ readonly provider: CLIProviderName | APIProviderName
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Run AI summarization on search results.
995
+ * Handles cost estimation, user consent, and output formatting.
996
+ *
997
+ * GRACEFUL DEGRADATION: This function never fails - on error, it displays
998
+ * an error message and returns, allowing search results to still be shown.
999
+ */
1000
+ const runSummarization = (
1001
+ options: SummarizationOptions,
1002
+ ): Effect.Effect<void, never> =>
1003
+ runSummarizationUnsafe(options).pipe(
1004
+ Effect.catchAll((error) =>
1005
+ Effect.sync(() => {
1006
+ if (!options.json) {
1007
+ displaySummarizationError(error)
1008
+ }
1009
+ }),
1010
+ ),
1011
+ )
1012
+
1013
+ /**
1014
+ * Internal implementation that may fail.
1015
+ * Wrapped by runSummarization for graceful error handling.
1016
+ */
1017
+ const runSummarizationUnsafe = (
1018
+ options: SummarizationOptions,
1019
+ ): Effect.Effect<void, Error> =>
1020
+ Effect.gen(function* () {
1021
+ const { results, query, searchMode, json, yes, stream, config } = options
1022
+
1023
+ if (results.length === 0) {
1024
+ if (!json) {
1025
+ yield* Console.log('No results to summarize.')
1026
+ }
1027
+ return
1028
+ }
1029
+
1030
+ // Get summarizer
1031
+ const summarizerData = yield* Effect.tryPromise({
1032
+ try: async () => {
1033
+ const result = await getBestAvailableSummarizer({
1034
+ mode: config.mode,
1035
+ provider: config.provider,
1036
+ })
1037
+ if (!result) {
1038
+ throw new Error('No summarization providers available')
1039
+ }
1040
+ return result
1041
+ },
1042
+ catch: (e) => new Error(`Failed to get summarizer: ${e}`),
1043
+ })
1044
+
1045
+ const { summarizer, config: resolvedConfig } = summarizerData
1046
+
1047
+ // Format results for summary input
1048
+ const resultsText = formatResultsForSummary(results)
1049
+
1050
+ // Estimate cost
1051
+ const costEstimate = estimateSummaryCost(
1052
+ resultsText,
1053
+ resolvedConfig.mode,
1054
+ resolvedConfig.provider,
1055
+ )
1056
+
1057
+ // Display cost info
1058
+ if (!json) {
1059
+ if (costEstimate.isPaid) {
1060
+ yield* Console.log('')
1061
+ yield* Console.log('Cost Estimate:')
1062
+ yield* Console.log(` Provider: ${costEstimate.provider}`)
1063
+ yield* Console.log(
1064
+ ` Input tokens: ~${costEstimate.inputTokens.toLocaleString()}`,
1065
+ )
1066
+ yield* Console.log(
1067
+ ` Output tokens: ~${costEstimate.outputTokens.toLocaleString()}`,
1068
+ )
1069
+ yield* Console.log(` Estimated cost: ${costEstimate.formattedCost}`)
1070
+
1071
+ // Get user consent if needed
1072
+ if (!yes) {
1073
+ const answer = yield* Effect.promise(() =>
1074
+ promptUser('Continue with summarization? [Y/n]: '),
1075
+ )
1076
+ if (answer === 'n' || answer === 'no') {
1077
+ yield* Console.log('Summarization cancelled.')
1078
+ return
1079
+ }
1080
+ }
1081
+ } else {
1082
+ yield* Console.log('')
1083
+ yield* Console.log(
1084
+ `Using ${resolvedConfig.provider} (subscription - FREE)`,
1085
+ )
1086
+ }
1087
+ }
1088
+
1089
+ // Build prompt
1090
+ const prompt = buildPrompt({
1091
+ query,
1092
+ resultCount: results.length,
1093
+ searchMode,
1094
+ })
1095
+
1096
+ // Generate summary
1097
+ if (!json) {
1098
+ yield* Console.log('')
1099
+ yield* Console.log('--- AI Summary ---')
1100
+ yield* Console.log('')
1101
+ }
1102
+
1103
+ const startTime = Date.now()
1104
+
1105
+ if (stream && 'summarizeStream' in summarizer) {
1106
+ // Streaming output
1107
+ yield* Effect.tryPromise({
1108
+ try: () =>
1109
+ (
1110
+ summarizer as {
1111
+ summarizeStream: (
1112
+ input: string,
1113
+ prompt: string,
1114
+ options: { onChunk: (chunk: string) => void },
1115
+ ) => Promise<void>
1116
+ }
1117
+ ).summarizeStream(resultsText, prompt, {
1118
+ onChunk: (chunk) => {
1119
+ process.stdout.write(chunk)
1120
+ },
1121
+ }),
1122
+ catch: (e) => new Error(`Summarization failed: ${e}`),
1123
+ })
1124
+ if (!json) {
1125
+ yield* Console.log('') // Final newline
1126
+ }
1127
+ } else {
1128
+ // Non-streaming output
1129
+ const summaryResult = yield* Effect.tryPromise({
1130
+ try: () => summarizer.summarize(resultsText, prompt),
1131
+ catch: (e) => new Error(`Summarization failed: ${e}`),
1132
+ })
1133
+
1134
+ if (json) {
1135
+ yield* Console.log(
1136
+ JSON.stringify(
1137
+ {
1138
+ summary: summaryResult.summary,
1139
+ provider: summaryResult.provider,
1140
+ mode: summaryResult.mode,
1141
+ durationMs: summaryResult.durationMs,
1142
+ cost: costEstimate.isPaid ? costEstimate.formattedCost : 'FREE',
1143
+ },
1144
+ null,
1145
+ 2,
1146
+ ),
1147
+ )
1148
+ } else {
1149
+ yield* Console.log(summaryResult.summary)
1150
+ }
1151
+ }
1152
+
1153
+ const durationMs = Date.now() - startTime
1154
+ if (!json) {
1155
+ yield* Console.log('')
1156
+ yield* Console.log('------------------')
1157
+ yield* Console.log(
1158
+ `Generated in ${(durationMs / 1000).toFixed(1)}s | ${costEstimate.isPaid ? costEstimate.formattedCost : 'FREE'}`,
1159
+ )
1160
+ }
1161
+ })
1162
+
336
1163
  /**
337
1164
  * Handle the case when embeddings don't exist.
338
1165
  * Returns true if embeddings were created (or already exist), false to fall back to keyword search.
@@ -344,8 +1171,11 @@ const handleMissingEmbeddings = (
344
1171
  ): Effect.Effect<boolean, Error> =>
345
1172
  Effect.gen(function* () {
346
1173
  // Get cost estimate
1174
+ // Note: We gracefully handle errors since this is an optional auto-index feature.
1175
+ // IndexNotFoundError is expected if index doesn't exist.
347
1176
  const estimate = yield* estimateEmbeddingCost(resolvedDir).pipe(
348
- Effect.catchAll(() => Effect.succeed(null)),
1177
+ Effect.map((r): EmbeddingEstimate | null => r),
1178
+ Effect.catchTags(createCostEstimateErrorHandler()),
349
1179
  )
350
1180
 
351
1181
  if (!estimate) {
@@ -364,18 +1194,19 @@ const handleMissingEmbeddings = (
364
1194
  )
365
1195
  }
366
1196
 
1197
+ // Note: Graceful degradation - embedding errors fall back to keyword search
367
1198
  const result = yield* buildEmbeddings(resolvedDir, {
368
1199
  force: false,
369
1200
  onFileProgress: (progress) => {
370
1201
  if (!json) {
371
- process.stdout.write(
372
- `\r [${progress.fileIndex}/${progress.totalFiles}] ${progress.filePath}...`,
1202
+ console.log(
1203
+ ` [${progress.fileIndex}/${progress.totalFiles}] ${progress.filePath}`,
373
1204
  )
374
1205
  }
375
1206
  },
376
1207
  }).pipe(
377
- handleApiKeyError,
378
- Effect.catchAll(() => Effect.succeed(null)),
1208
+ Effect.map((r): BuildEmbeddingsResult | null => r),
1209
+ Effect.catchTags(createEmbeddingErrorHandler({ silent: json })),
379
1210
  )
380
1211
 
381
1212
  if (!result) {
@@ -383,7 +1214,6 @@ const handleMissingEmbeddings = (
383
1214
  }
384
1215
 
385
1216
  if (!json) {
386
- process.stdout.write(`\r${' '.repeat(80)}\r`)
387
1217
  yield* Console.log(
388
1218
  `Index created (${result.sectionsEmbedded} sections, $${result.cost.toFixed(6)})`,
389
1219
  )
@@ -415,18 +1245,19 @@ const handleMissingEmbeddings = (
415
1245
  yield* Console.log('Building embeddings...')
416
1246
  }
417
1247
 
1248
+ // Note: Graceful degradation - embedding errors fall back to keyword search
418
1249
  const result = yield* buildEmbeddings(resolvedDir, {
419
1250
  force: false,
420
1251
  onFileProgress: (progress) => {
421
1252
  if (!json) {
422
- process.stdout.write(
423
- `\r [${progress.fileIndex}/${progress.totalFiles}] ${progress.filePath}...`,
1253
+ console.log(
1254
+ ` [${progress.fileIndex}/${progress.totalFiles}] ${progress.filePath}`,
424
1255
  )
425
1256
  }
426
1257
  },
427
1258
  }).pipe(
428
- handleApiKeyError,
429
- Effect.catchAll(() => Effect.succeed(null)),
1259
+ Effect.map((r): BuildEmbeddingsResult | null => r),
1260
+ Effect.catchTags(createEmbeddingErrorHandler({ silent: json })),
430
1261
  )
431
1262
 
432
1263
  if (!result) {
@@ -434,7 +1265,6 @@ const handleMissingEmbeddings = (
434
1265
  }
435
1266
 
436
1267
  if (!json) {
437
- process.stdout.write(`\r${' '.repeat(80)}\r`)
438
1268
  yield* Console.log(
439
1269
  `Index created (${result.sectionsEmbedded} sections, $${result.cost.toFixed(6)})`,
440
1270
  )