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
@@ -0,0 +1,788 @@
1
+ /**
2
+ * Provider Switching Integration Tests
3
+ *
4
+ * Tests for provider switching, configuration precedence, and cross-provider
5
+ * compatibility. These tests verify the full integration of embedding providers
6
+ * with the configuration system.
7
+ */
8
+
9
+ import * as fs from 'node:fs'
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
12
+ import { Effect, Option } from 'effect'
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14
+ import {
15
+ createConfigProvider,
16
+ createConfigProviderSync,
17
+ } from '../config/index.js'
18
+ import { MdContextConfig } from '../config/schema.js'
19
+ import {
20
+ createEmbeddingProviderDirect,
21
+ getProviderBaseURL,
22
+ PROVIDER_BASE_URLS,
23
+ } from './provider-factory.js'
24
+ import type { VectorIndex } from './types.js'
25
+
26
+ // ============================================================================
27
+ // Test Setup
28
+ // ============================================================================
29
+
30
+ describe('Provider Integration Tests', () => {
31
+ let tempDir: string
32
+ const savedEnv: Record<string, string | undefined> = {}
33
+
34
+ beforeEach(() => {
35
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mdcontext-provider-int-'))
36
+
37
+ // Save and clear relevant env vars
38
+ const envKeys = [
39
+ 'MDCONTEXT_EMBEDDINGS_PROVIDER',
40
+ 'MDCONTEXT_EMBEDDINGS_BASEURL',
41
+ 'MDCONTEXT_EMBEDDINGS_MODEL',
42
+ 'OPENAI_API_KEY',
43
+ 'OPENROUTER_API_KEY',
44
+ ]
45
+ for (const key of envKeys) {
46
+ savedEnv[key] = process.env[key]
47
+ delete process.env[key]
48
+ }
49
+ })
50
+
51
+ afterEach(() => {
52
+ fs.rmSync(tempDir, { recursive: true, force: true })
53
+
54
+ // Restore env vars
55
+ for (const [key, value] of Object.entries(savedEnv)) {
56
+ if (value !== undefined) {
57
+ process.env[key] = value
58
+ } else {
59
+ delete process.env[key]
60
+ }
61
+ }
62
+ vi.restoreAllMocks()
63
+ })
64
+
65
+ // ==========================================================================
66
+ // Configuration Precedence Tests
67
+ // ==========================================================================
68
+
69
+ describe('Configuration Precedence (CLI > Env > File > Defaults)', () => {
70
+ it('uses default provider (openai) when nothing specified', async () => {
71
+ const provider = createConfigProviderSync({
72
+ skipConfigFile: true,
73
+ skipEnv: true,
74
+ })
75
+
76
+ const result = await Effect.runPromise(
77
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
78
+ )
79
+
80
+ expect(result.embeddings.provider).toBe('openai')
81
+ expect(result.embeddings.model).toBe('text-embedding-3-small')
82
+ })
83
+
84
+ it('config file overrides defaults', async () => {
85
+ const fileConfig = {
86
+ embeddings: { provider: 'ollama', model: 'nomic-embed-text' },
87
+ }
88
+ fs.writeFileSync(
89
+ path.join(tempDir, 'mdcontext.config.json'),
90
+ JSON.stringify(fileConfig),
91
+ )
92
+
93
+ const provider = await Effect.runPromise(
94
+ createConfigProvider({
95
+ workingDir: tempDir,
96
+ skipEnv: true,
97
+ }),
98
+ )
99
+
100
+ const result = await Effect.runPromise(
101
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
102
+ )
103
+
104
+ expect(result.embeddings.provider).toBe('ollama')
105
+ expect(result.embeddings.model).toBe('nomic-embed-text')
106
+ })
107
+
108
+ it('environment variable overrides config file', async () => {
109
+ // Config file says openai
110
+ const fileConfig = {
111
+ embeddings: { provider: 'openai', model: 'text-embedding-3-small' },
112
+ }
113
+ fs.writeFileSync(
114
+ path.join(tempDir, 'mdcontext.config.json'),
115
+ JSON.stringify(fileConfig),
116
+ )
117
+
118
+ // Env says ollama
119
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'ollama'
120
+
121
+ const provider = await Effect.runPromise(
122
+ createConfigProvider({
123
+ workingDir: tempDir,
124
+ skipEnv: false,
125
+ }),
126
+ )
127
+
128
+ const result = await Effect.runPromise(
129
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
130
+ )
131
+
132
+ expect(result.embeddings.provider).toBe('ollama')
133
+ })
134
+
135
+ it('CLI flag overrides environment variable', async () => {
136
+ // Env says openai
137
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'openai'
138
+
139
+ // CLI says ollama
140
+ const provider = createConfigProviderSync({
141
+ skipConfigFile: true,
142
+ skipEnv: false,
143
+ cliOverrides: { embeddings: { provider: 'ollama' } },
144
+ })
145
+
146
+ const result = await Effect.runPromise(
147
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
148
+ )
149
+
150
+ // CLI wins
151
+ expect(result.embeddings.provider).toBe('ollama')
152
+ })
153
+
154
+ it('CLI baseURL overrides provider default', async () => {
155
+ const customURL = 'http://custom:9999/v1'
156
+
157
+ // Note: CLI overrides use plain strings, which get flattened to config keys.
158
+ // The flattenConfig function converts any value to a string, so this works at runtime
159
+ // even though the type expects Option<string>. Using 'as never' for test simplicity.
160
+ const provider = createConfigProviderSync({
161
+ skipConfigFile: true,
162
+ skipEnv: true,
163
+ cliOverrides: {
164
+ embeddings: {
165
+ provider: 'ollama',
166
+ baseURL: customURL as never, // Type workaround for flattened config
167
+ },
168
+ },
169
+ })
170
+
171
+ const result = await Effect.runPromise(
172
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
173
+ )
174
+
175
+ expect(result.embeddings.provider).toBe('ollama')
176
+ expect(Option.getOrNull(result.embeddings.baseURL)).toBe(customURL)
177
+ })
178
+
179
+ it('complete precedence chain works correctly', async () => {
180
+ // Config file: provider=openai, model=text-embedding-3-large
181
+ const fileConfig = {
182
+ embeddings: {
183
+ provider: 'openai',
184
+ model: 'text-embedding-3-large',
185
+ batchSize: 50,
186
+ },
187
+ }
188
+ fs.writeFileSync(
189
+ path.join(tempDir, 'mdcontext.config.json'),
190
+ JSON.stringify(fileConfig),
191
+ )
192
+
193
+ // Env: provider=ollama
194
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'ollama'
195
+
196
+ // CLI: provider=openrouter
197
+ const provider = await Effect.runPromise(
198
+ createConfigProvider({
199
+ workingDir: tempDir,
200
+ skipEnv: false,
201
+ cliOverrides: { embeddings: { provider: 'openrouter' } },
202
+ }),
203
+ )
204
+
205
+ const result = await Effect.runPromise(
206
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
207
+ )
208
+
209
+ // CLI wins for provider
210
+ expect(result.embeddings.provider).toBe('openrouter')
211
+ // File config used for unoverridden values
212
+ expect(result.embeddings.model).toBe('text-embedding-3-large')
213
+ expect(result.embeddings.batchSize).toBe(50)
214
+ })
215
+ })
216
+
217
+ // ==========================================================================
218
+ // Provider BaseURL Resolution Tests
219
+ // ==========================================================================
220
+
221
+ describe('Provider BaseURL Resolution', () => {
222
+ it('returns undefined for openai (uses SDK default)', () => {
223
+ const result = getProviderBaseURL('openai', Option.none())
224
+ expect(result).toBeUndefined()
225
+ })
226
+
227
+ it('returns correct default for ollama', () => {
228
+ const result = getProviderBaseURL('ollama', Option.none())
229
+ expect(result).toBe('http://localhost:11434/v1')
230
+ })
231
+
232
+ it('returns correct default for lm-studio', () => {
233
+ const result = getProviderBaseURL('lm-studio', Option.none())
234
+ expect(result).toBe('http://localhost:1234/v1')
235
+ })
236
+
237
+ it('returns correct default for openrouter', () => {
238
+ const result = getProviderBaseURL('openrouter', Option.none())
239
+ expect(result).toBe('https://openrouter.ai/api/v1')
240
+ })
241
+
242
+ it('config baseURL overrides provider default', () => {
243
+ const customURL = 'http://custom-ollama:11434/v1'
244
+ const result = getProviderBaseURL('ollama', Option.some(customURL))
245
+ expect(result).toBe(customURL)
246
+ })
247
+
248
+ it('config baseURL works for openai (custom proxy)', () => {
249
+ const proxyURL = 'https://openai-proxy.example.com/v1'
250
+ const result = getProviderBaseURL('openai', Option.some(proxyURL))
251
+ expect(result).toBe(proxyURL)
252
+ })
253
+ })
254
+
255
+ // ==========================================================================
256
+ // Provider Factory Tests
257
+ // ==========================================================================
258
+
259
+ describe('Provider Factory', () => {
260
+ it('creates provider with ollama configuration', async () => {
261
+ const program = createEmbeddingProviderDirect({
262
+ provider: 'ollama',
263
+ model: 'nomic-embed-text',
264
+ apiKey: 'dummy-key', // Ollama doesn't require API key but we pass one for testing
265
+ })
266
+
267
+ const provider = await Effect.runPromise(program)
268
+
269
+ expect(provider.name).toContain('ollama')
270
+ expect(provider.name).toContain('nomic-embed-text')
271
+ })
272
+
273
+ it('creates provider with lm-studio configuration', async () => {
274
+ const program = createEmbeddingProviderDirect({
275
+ provider: 'lm-studio',
276
+ apiKey: 'dummy-key',
277
+ })
278
+
279
+ const provider = await Effect.runPromise(program)
280
+
281
+ expect(provider.name).toContain('lm-studio')
282
+ })
283
+
284
+ it('creates provider with openrouter configuration', async () => {
285
+ const program = createEmbeddingProviderDirect({
286
+ provider: 'openrouter',
287
+ model: 'text-embedding-3-small',
288
+ apiKey: 'sk-or-test-key',
289
+ })
290
+
291
+ const provider = await Effect.runPromise(program)
292
+
293
+ expect(provider.name).toContain('openrouter')
294
+ })
295
+
296
+ it('creates provider with custom baseURL', async () => {
297
+ const customURL = 'https://custom-api.example.com/v1'
298
+
299
+ const program = createEmbeddingProviderDirect({
300
+ provider: 'openai',
301
+ baseURL: customURL,
302
+ apiKey: 'test-key',
303
+ })
304
+
305
+ const provider = await Effect.runPromise(program)
306
+
307
+ expect(provider).toBeDefined()
308
+ expect(provider.name).toContain('openai')
309
+ })
310
+
311
+ it('accepts baseURL as Option.some', async () => {
312
+ const customURL = 'https://custom-api.example.com/v1'
313
+
314
+ const program = createEmbeddingProviderDirect({
315
+ provider: 'openai',
316
+ baseURL: Option.some(customURL),
317
+ apiKey: 'test-key',
318
+ })
319
+
320
+ const provider = await Effect.runPromise(program)
321
+ expect(provider).toBeDefined()
322
+ })
323
+
324
+ it('uses provider default when baseURL is Option.none', async () => {
325
+ const program = createEmbeddingProviderDirect({
326
+ provider: 'ollama',
327
+ baseURL: Option.none(),
328
+ apiKey: 'test-key',
329
+ })
330
+
331
+ const provider = await Effect.runPromise(program)
332
+ expect(provider.name).toContain('ollama')
333
+ })
334
+ })
335
+
336
+ // ==========================================================================
337
+ // Provider Metadata Tests
338
+ // ==========================================================================
339
+
340
+ describe('Provider Metadata in Index', () => {
341
+ it('VectorIndex type includes provider fields', () => {
342
+ const index: VectorIndex = {
343
+ version: 1,
344
+ provider: 'ollama',
345
+ providerModel: 'nomic-embed-text',
346
+ providerBaseURL: 'http://localhost:11434/v1',
347
+ dimensions: 768,
348
+ entries: {},
349
+ totalCost: 0,
350
+ totalTokens: 0,
351
+ createdAt: new Date().toISOString(),
352
+ updatedAt: new Date().toISOString(),
353
+ }
354
+
355
+ expect(index.provider).toBe('ollama')
356
+ expect(index.providerModel).toBe('nomic-embed-text')
357
+ expect(index.providerBaseURL).toBe('http://localhost:11434/v1')
358
+ })
359
+
360
+ it('VectorIndex supports optional provider fields', () => {
361
+ const index: VectorIndex = {
362
+ version: 1,
363
+ provider: 'openai',
364
+ dimensions: 512,
365
+ entries: {},
366
+ totalCost: 0.005,
367
+ totalTokens: 10000,
368
+ createdAt: new Date().toISOString(),
369
+ updatedAt: new Date().toISOString(),
370
+ }
371
+
372
+ expect(index.provider).toBe('openai')
373
+ expect(index.providerModel).toBeUndefined()
374
+ expect(index.providerBaseURL).toBeUndefined()
375
+ })
376
+
377
+ it('simulates reading index metadata for provider mismatch detection', () => {
378
+ // Simulate index created with Ollama
379
+ const indexMeta: VectorIndex = {
380
+ version: 1,
381
+ provider: 'ollama',
382
+ providerModel: 'nomic-embed-text',
383
+ dimensions: 768,
384
+ entries: {},
385
+ totalCost: 0,
386
+ totalTokens: 1000,
387
+ createdAt: '2024-01-01T00:00:00Z',
388
+ updatedAt: '2024-01-01T00:00:00Z',
389
+ }
390
+
391
+ // Function to check provider mismatch
392
+ const checkProviderMismatch = (
393
+ indexProvider: string,
394
+ queryProvider: string,
395
+ ): boolean => {
396
+ return indexProvider !== queryProvider
397
+ }
398
+
399
+ // Querying with different provider should warn
400
+ expect(checkProviderMismatch(indexMeta.provider, 'openai')).toBe(true)
401
+ expect(checkProviderMismatch(indexMeta.provider, 'ollama')).toBe(false)
402
+ })
403
+ })
404
+
405
+ // ==========================================================================
406
+ // All Provider Types Test
407
+ // ==========================================================================
408
+
409
+ describe('All Provider Types', () => {
410
+ const providers = [
411
+ 'openai',
412
+ 'ollama',
413
+ 'lm-studio',
414
+ 'openrouter',
415
+ 'voyage',
416
+ ] as const
417
+
418
+ for (const providerType of providers) {
419
+ it(`${providerType} provider can be created with factory`, async () => {
420
+ const program = createEmbeddingProviderDirect({
421
+ provider: providerType,
422
+ apiKey: 'test-key',
423
+ })
424
+
425
+ const provider = await Effect.runPromise(program)
426
+
427
+ expect(provider).toBeDefined()
428
+ expect(provider.name).toContain(providerType)
429
+ expect(typeof provider.dimensions).toBe('number')
430
+ expect(typeof provider.embed).toBe('function')
431
+ })
432
+
433
+ it(`${providerType} has correct default baseURL`, () => {
434
+ const expectedURL = PROVIDER_BASE_URLS[providerType]
435
+ const actualURL = getProviderBaseURL(providerType, Option.none())
436
+
437
+ expect(actualURL).toBe(expectedURL)
438
+ })
439
+ }
440
+ })
441
+
442
+ // ==========================================================================
443
+ // Config File Provider Selection Tests
444
+ // ==========================================================================
445
+
446
+ describe('Config File Provider Selection', () => {
447
+ it('supports provider: "ollama" in config file', async () => {
448
+ const fileConfig = {
449
+ embeddings: { provider: 'ollama' },
450
+ }
451
+ fs.writeFileSync(
452
+ path.join(tempDir, 'mdcontext.config.json'),
453
+ JSON.stringify(fileConfig),
454
+ )
455
+
456
+ const provider = await Effect.runPromise(
457
+ createConfigProvider({
458
+ workingDir: tempDir,
459
+ skipEnv: true,
460
+ }),
461
+ )
462
+
463
+ const result = await Effect.runPromise(
464
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
465
+ )
466
+
467
+ expect(result.embeddings.provider).toBe('ollama')
468
+ })
469
+
470
+ it('supports provider: "lm-studio" in config file', async () => {
471
+ const fileConfig = {
472
+ embeddings: { provider: 'lm-studio' },
473
+ }
474
+ fs.writeFileSync(
475
+ path.join(tempDir, 'mdcontext.config.json'),
476
+ JSON.stringify(fileConfig),
477
+ )
478
+
479
+ const provider = await Effect.runPromise(
480
+ createConfigProvider({
481
+ workingDir: tempDir,
482
+ skipEnv: true,
483
+ }),
484
+ )
485
+
486
+ const result = await Effect.runPromise(
487
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
488
+ )
489
+
490
+ expect(result.embeddings.provider).toBe('lm-studio')
491
+ })
492
+
493
+ it('supports provider: "openrouter" in config file', async () => {
494
+ const fileConfig = {
495
+ embeddings: { provider: 'openrouter' },
496
+ }
497
+ fs.writeFileSync(
498
+ path.join(tempDir, 'mdcontext.config.json'),
499
+ JSON.stringify(fileConfig),
500
+ )
501
+
502
+ const provider = await Effect.runPromise(
503
+ createConfigProvider({
504
+ workingDir: tempDir,
505
+ skipEnv: true,
506
+ }),
507
+ )
508
+
509
+ const result = await Effect.runPromise(
510
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
511
+ )
512
+
513
+ expect(result.embeddings.provider).toBe('openrouter')
514
+ })
515
+
516
+ it('supports custom baseURL in config file', async () => {
517
+ const customURL = 'http://custom:8080/v1'
518
+ const fileConfig = {
519
+ embeddings: {
520
+ provider: 'ollama',
521
+ baseURL: customURL,
522
+ },
523
+ }
524
+ fs.writeFileSync(
525
+ path.join(tempDir, 'mdcontext.config.json'),
526
+ JSON.stringify(fileConfig),
527
+ )
528
+
529
+ const provider = await Effect.runPromise(
530
+ createConfigProvider({
531
+ workingDir: tempDir,
532
+ skipEnv: true,
533
+ }),
534
+ )
535
+
536
+ const result = await Effect.runPromise(
537
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
538
+ )
539
+
540
+ expect(result.embeddings.provider).toBe('ollama')
541
+ expect(Option.getOrNull(result.embeddings.baseURL)).toBe(customURL)
542
+ })
543
+ })
544
+
545
+ // ==========================================================================
546
+ // Environment Variable Tests
547
+ // ==========================================================================
548
+
549
+ describe('Environment Variable Provider Selection', () => {
550
+ it('MDCONTEXT_EMBEDDINGS_PROVIDER=ollama works', async () => {
551
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'ollama'
552
+
553
+ const provider = createConfigProviderSync({
554
+ skipConfigFile: true,
555
+ skipEnv: false,
556
+ })
557
+
558
+ const result = await Effect.runPromise(
559
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
560
+ )
561
+
562
+ expect(result.embeddings.provider).toBe('ollama')
563
+ })
564
+
565
+ it('MDCONTEXT_EMBEDDINGS_PROVIDER=lm-studio works', async () => {
566
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'lm-studio'
567
+
568
+ const provider = createConfigProviderSync({
569
+ skipConfigFile: true,
570
+ skipEnv: false,
571
+ })
572
+
573
+ const result = await Effect.runPromise(
574
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
575
+ )
576
+
577
+ expect(result.embeddings.provider).toBe('lm-studio')
578
+ })
579
+
580
+ it('MDCONTEXT_EMBEDDINGS_PROVIDER=openrouter works', async () => {
581
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'openrouter'
582
+
583
+ const provider = createConfigProviderSync({
584
+ skipConfigFile: true,
585
+ skipEnv: false,
586
+ })
587
+
588
+ const result = await Effect.runPromise(
589
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
590
+ )
591
+
592
+ expect(result.embeddings.provider).toBe('openrouter')
593
+ })
594
+
595
+ it('MDCONTEXT_EMBEDDINGS_MODEL works', async () => {
596
+ process.env.MDCONTEXT_EMBEDDINGS_PROVIDER = 'ollama'
597
+ process.env.MDCONTEXT_EMBEDDINGS_MODEL = 'mxbai-embed-large'
598
+
599
+ const provider = createConfigProviderSync({
600
+ skipConfigFile: true,
601
+ skipEnv: false,
602
+ })
603
+
604
+ const result = await Effect.runPromise(
605
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
606
+ )
607
+
608
+ expect(result.embeddings.provider).toBe('ollama')
609
+ expect(result.embeddings.model).toBe('mxbai-embed-large')
610
+ })
611
+ })
612
+
613
+ // ==========================================================================
614
+ // Provider Switching Scenarios
615
+ // ==========================================================================
616
+
617
+ describe('Provider Switching Scenarios', () => {
618
+ it('simulates switching from OpenAI to Ollama config', async () => {
619
+ // Start with OpenAI config
620
+ const openaiConfig = {
621
+ embeddings: {
622
+ provider: 'openai',
623
+ model: 'text-embedding-3-small',
624
+ },
625
+ }
626
+
627
+ fs.writeFileSync(
628
+ path.join(tempDir, 'mdcontext.config.json'),
629
+ JSON.stringify(openaiConfig),
630
+ )
631
+
632
+ let provider = await Effect.runPromise(
633
+ createConfigProvider({ workingDir: tempDir, skipEnv: true }),
634
+ )
635
+
636
+ let result = await Effect.runPromise(
637
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
638
+ )
639
+
640
+ expect(result.embeddings.provider).toBe('openai')
641
+
642
+ // Switch to Ollama config
643
+ const ollamaConfig = {
644
+ embeddings: {
645
+ provider: 'ollama',
646
+ model: 'nomic-embed-text',
647
+ },
648
+ }
649
+
650
+ fs.writeFileSync(
651
+ path.join(tempDir, 'mdcontext.config.json'),
652
+ JSON.stringify(ollamaConfig),
653
+ )
654
+
655
+ provider = await Effect.runPromise(
656
+ createConfigProvider({ workingDir: tempDir, skipEnv: true }),
657
+ )
658
+
659
+ result = await Effect.runPromise(
660
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
661
+ )
662
+
663
+ expect(result.embeddings.provider).toBe('ollama')
664
+ expect(result.embeddings.model).toBe('nomic-embed-text')
665
+ })
666
+
667
+ it('simulates temporary CLI override without changing config', async () => {
668
+ // Persistent config uses OpenAI
669
+ const fileConfig = {
670
+ embeddings: {
671
+ provider: 'openai',
672
+ model: 'text-embedding-3-small',
673
+ },
674
+ }
675
+
676
+ fs.writeFileSync(
677
+ path.join(tempDir, 'mdcontext.config.json'),
678
+ JSON.stringify(fileConfig),
679
+ )
680
+
681
+ // One-off CLI override to use Ollama
682
+ const provider = await Effect.runPromise(
683
+ createConfigProvider({
684
+ workingDir: tempDir,
685
+ skipEnv: true,
686
+ cliOverrides: {
687
+ embeddings: {
688
+ provider: 'ollama',
689
+ model: 'nomic-embed-text',
690
+ },
691
+ },
692
+ }),
693
+ )
694
+
695
+ const result = await Effect.runPromise(
696
+ MdContextConfig.pipe(Effect.withConfigProvider(provider)),
697
+ )
698
+
699
+ // CLI override active
700
+ expect(result.embeddings.provider).toBe('ollama')
701
+ expect(result.embeddings.model).toBe('nomic-embed-text')
702
+
703
+ // Verify config file unchanged
704
+ const fileContent = fs.readFileSync(
705
+ path.join(tempDir, 'mdcontext.config.json'),
706
+ 'utf-8',
707
+ )
708
+ const savedConfig = JSON.parse(fileContent)
709
+ expect(savedConfig.embeddings.provider).toBe('openai')
710
+ })
711
+ })
712
+
713
+ // ==========================================================================
714
+ // Cross-Provider Compatibility Tests
715
+ // ==========================================================================
716
+
717
+ describe('Cross-Provider Compatibility', () => {
718
+ it('detects provider mismatch between index and query config', () => {
719
+ // Simulate checking index metadata against query config
720
+ const indexMetadata: Pick<
721
+ VectorIndex,
722
+ 'provider' | 'providerModel' | 'dimensions'
723
+ > = {
724
+ provider: 'ollama',
725
+ providerModel: 'nomic-embed-text',
726
+ dimensions: 768,
727
+ }
728
+
729
+ const queryConfig = {
730
+ provider: 'openai' as const,
731
+ model: 'text-embedding-3-small',
732
+ }
733
+
734
+ // Check for mismatch
735
+ const isMismatch = indexMetadata.provider !== queryConfig.provider
736
+
737
+ expect(isMismatch).toBe(true)
738
+
739
+ // Generate warning message
740
+ const warningMessage = `Index was created with ${indexMetadata.provider} (${indexMetadata.providerModel}), but querying with ${queryConfig.provider} (${queryConfig.model}). Results may be inconsistent. Consider re-indexing.`
741
+
742
+ expect(warningMessage).toContain('Index was created with ollama')
743
+ expect(warningMessage).toContain('querying with openai')
744
+ expect(warningMessage).toContain('re-indexing')
745
+ })
746
+
747
+ it('no warning when provider matches', () => {
748
+ const indexMetadata = {
749
+ provider: 'openai',
750
+ providerModel: 'text-embedding-3-small',
751
+ dimensions: 512,
752
+ }
753
+
754
+ const queryConfig = {
755
+ provider: 'openai' as const,
756
+ model: 'text-embedding-3-small',
757
+ }
758
+
759
+ const isMismatch = indexMetadata.provider !== queryConfig.provider
760
+
761
+ expect(isMismatch).toBe(false)
762
+ })
763
+
764
+ it('different models on same provider should still warn', () => {
765
+ const indexMetadata = {
766
+ provider: 'openai',
767
+ providerModel: 'text-embedding-3-small',
768
+ dimensions: 512,
769
+ }
770
+
771
+ const queryConfig = {
772
+ provider: 'openai' as const,
773
+ model: 'text-embedding-3-large', // Different model
774
+ }
775
+
776
+ // Provider matches but model differs
777
+ const providerMatch = indexMetadata.provider === queryConfig.provider
778
+ const modelMatch = indexMetadata.providerModel === queryConfig.model
779
+
780
+ expect(providerMatch).toBe(true)
781
+ expect(modelMatch).toBe(false)
782
+
783
+ // Should still warn about model mismatch
784
+ const shouldWarn = !modelMatch
785
+ expect(shouldWarn).toBe(true)
786
+ })
787
+ })
788
+ })