mdcontext 0.0.1 → 0.1.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 (140) hide show
  1. package/.changeset/README.md +28 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/ci.yml +83 -0
  4. package/.github/workflows/release.yml +113 -0
  5. package/.tldrignore +112 -0
  6. package/AGENTS.md +46 -0
  7. package/BACKLOG.md +338 -0
  8. package/README.md +231 -11
  9. package/biome.json +36 -0
  10. package/cspell.config.yaml +14 -0
  11. package/dist/chunk-KRYIFLQR.js +92 -0
  12. package/dist/chunk-S7E6TFX6.js +742 -0
  13. package/dist/chunk-VVTGZNBT.js +1519 -0
  14. package/dist/cli/main.d.ts +1 -0
  15. package/dist/cli/main.js +2015 -0
  16. package/dist/index.d.ts +266 -0
  17. package/dist/index.js +86 -0
  18. package/dist/mcp/server.d.ts +1 -0
  19. package/dist/mcp/server.js +376 -0
  20. package/docs/019-USAGE.md +586 -0
  21. package/docs/020-current-implementation.md +364 -0
  22. package/docs/021-DOGFOODING-FINDINGS.md +175 -0
  23. package/docs/BACKLOG.md +80 -0
  24. package/docs/DESIGN.md +439 -0
  25. package/docs/PROJECT.md +88 -0
  26. package/docs/ROADMAP.md +407 -0
  27. package/docs/test-links.md +9 -0
  28. package/package.json +69 -10
  29. package/pnpm-workspace.yaml +5 -0
  30. package/research/config-analysis/01-current-implementation.md +470 -0
  31. package/research/config-analysis/02-strategy-recommendation.md +428 -0
  32. package/research/config-analysis/03-task-candidates.md +715 -0
  33. package/research/config-analysis/033-research-configuration-management.md +828 -0
  34. package/research/config-analysis/034-research-effect-cli-config.md +1504 -0
  35. package/research/config-analysis/04-consolidated-task-candidates.md +277 -0
  36. package/research/dogfood/consolidated-tool-evaluation.md +373 -0
  37. package/research/dogfood/strategy-a/a-synthesis.md +184 -0
  38. package/research/dogfood/strategy-a/a1-docs.md +226 -0
  39. package/research/dogfood/strategy-a/a2-amorphic.md +156 -0
  40. package/research/dogfood/strategy-a/a3-llm.md +164 -0
  41. package/research/dogfood/strategy-b/b-synthesis.md +228 -0
  42. package/research/dogfood/strategy-b/b1-architecture.md +207 -0
  43. package/research/dogfood/strategy-b/b2-gaps.md +258 -0
  44. package/research/dogfood/strategy-b/b3-workflows.md +250 -0
  45. package/research/dogfood/strategy-c/c-synthesis.md +451 -0
  46. package/research/dogfood/strategy-c/c1-explorer.md +192 -0
  47. package/research/dogfood/strategy-c/c2-diver-memory.md +145 -0
  48. package/research/dogfood/strategy-c/c3-diver-control.md +148 -0
  49. package/research/dogfood/strategy-c/c4-diver-failure.md +151 -0
  50. package/research/dogfood/strategy-c/c5-diver-execution.md +221 -0
  51. package/research/dogfood/strategy-c/c6-diver-org.md +221 -0
  52. package/research/effect-cli-error-handling.md +845 -0
  53. package/research/effect-errors-as-values.md +943 -0
  54. package/research/errors-task-analysis/00-consolidated-tasks.md +207 -0
  55. package/research/errors-task-analysis/cli-commands-analysis.md +909 -0
  56. package/research/errors-task-analysis/embeddings-analysis.md +709 -0
  57. package/research/errors-task-analysis/index-search-analysis.md +812 -0
  58. package/research/mdcontext-error-analysis.md +521 -0
  59. package/research/npm_publish/011-npm-workflow-research-agent2.md +792 -0
  60. package/research/npm_publish/012-npm-workflow-research-agent1.md +530 -0
  61. package/research/npm_publish/013-npm-workflow-research-agent3.md +722 -0
  62. package/research/npm_publish/014-npm-workflow-synthesis.md +556 -0
  63. package/research/npm_publish/031-npm-workflow-task-analysis.md +134 -0
  64. package/research/semantic-search/002-research-embedding-models.md +490 -0
  65. package/research/semantic-search/003-research-rag-alternatives.md +523 -0
  66. package/research/semantic-search/004-research-vector-search.md +841 -0
  67. package/research/semantic-search/032-research-semantic-search.md +427 -0
  68. package/research/task-management-2026/00-synthesis-recommendations.md +295 -0
  69. package/research/task-management-2026/01-ai-workflow-tools.md +416 -0
  70. package/research/task-management-2026/02-agent-framework-patterns.md +476 -0
  71. package/research/task-management-2026/03-lightweight-file-based.md +567 -0
  72. package/research/task-management-2026/04-established-tools-ai-features.md +541 -0
  73. package/research/task-management-2026/linear/01-core-features-workflow.md +771 -0
  74. package/research/task-management-2026/linear/02-api-integrations.md +930 -0
  75. package/research/task-management-2026/linear/03-ai-features.md +368 -0
  76. package/research/task-management-2026/linear/04-pricing-setup.md +205 -0
  77. package/research/task-management-2026/linear/05-usage-patterns-best-practices.md +605 -0
  78. package/scripts/rebuild-hnswlib.js +63 -0
  79. package/src/cli/argv-preprocessor.test.ts +210 -0
  80. package/src/cli/argv-preprocessor.ts +202 -0
  81. package/src/cli/cli.test.ts +430 -0
  82. package/src/cli/commands/backlinks.ts +54 -0
  83. package/src/cli/commands/context.ts +197 -0
  84. package/src/cli/commands/index-cmd.ts +300 -0
  85. package/src/cli/commands/index.ts +13 -0
  86. package/src/cli/commands/links.ts +52 -0
  87. package/src/cli/commands/search.ts +451 -0
  88. package/src/cli/commands/stats.ts +146 -0
  89. package/src/cli/commands/tree.ts +107 -0
  90. package/src/cli/flag-schemas.ts +275 -0
  91. package/src/cli/help.ts +386 -0
  92. package/src/cli/index.ts +9 -0
  93. package/src/cli/main.ts +145 -0
  94. package/src/cli/options.ts +31 -0
  95. package/src/cli/typo-suggester.test.ts +105 -0
  96. package/src/cli/typo-suggester.ts +130 -0
  97. package/src/cli/utils.ts +126 -0
  98. package/src/core/index.ts +1 -0
  99. package/src/core/types.ts +140 -0
  100. package/src/embeddings/index.ts +8 -0
  101. package/src/embeddings/openai-provider.ts +165 -0
  102. package/src/embeddings/semantic-search.ts +583 -0
  103. package/src/embeddings/types.ts +82 -0
  104. package/src/embeddings/vector-store.ts +299 -0
  105. package/src/index/index.ts +4 -0
  106. package/src/index/indexer.ts +446 -0
  107. package/src/index/storage.ts +196 -0
  108. package/src/index/types.ts +109 -0
  109. package/src/index/watcher.ts +131 -0
  110. package/src/index.ts +8 -0
  111. package/src/mcp/server.ts +483 -0
  112. package/src/parser/index.ts +1 -0
  113. package/src/parser/parser.test.ts +291 -0
  114. package/src/parser/parser.ts +395 -0
  115. package/src/parser/section-filter.ts +270 -0
  116. package/src/search/query-parser.test.ts +260 -0
  117. package/src/search/query-parser.ts +319 -0
  118. package/src/search/searcher.test.ts +182 -0
  119. package/src/search/searcher.ts +602 -0
  120. package/src/summarize/budget-bugs.test.ts +620 -0
  121. package/src/summarize/formatters.ts +419 -0
  122. package/src/summarize/index.ts +20 -0
  123. package/src/summarize/summarizer.test.ts +275 -0
  124. package/src/summarize/summarizer.ts +528 -0
  125. package/src/summarize/verify-bugs.test.ts +238 -0
  126. package/src/utils/index.ts +1 -0
  127. package/src/utils/tokens.test.ts +142 -0
  128. package/src/utils/tokens.ts +186 -0
  129. package/tests/fixtures/cli/.mdcontext/config.json +8 -0
  130. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +33 -0
  131. package/tests/fixtures/cli/.mdcontext/indexes/links.json +12 -0
  132. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +233 -0
  133. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  134. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +1264 -0
  135. package/tests/fixtures/cli/README.md +9 -0
  136. package/tests/fixtures/cli/api-reference.md +11 -0
  137. package/tests/fixtures/cli/getting-started.md +11 -0
  138. package/tsconfig.json +26 -0
  139. package/vitest.config.ts +21 -0
  140. package/vitest.setup.ts +12 -0
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Keyword search for mdcontext
3
+ */
4
+
5
+ import * as fs from 'node:fs/promises'
6
+ import * as path from 'node:path'
7
+ import { Effect } from 'effect'
8
+
9
+ import {
10
+ createStorage,
11
+ loadDocumentIndex,
12
+ loadSectionIndex,
13
+ } from '../index/storage.js'
14
+ import type { DocumentEntry, SectionEntry } from '../index/types.js'
15
+ import {
16
+ buildHighlightPattern,
17
+ evaluateQuery,
18
+ isAdvancedQuery,
19
+ type ParsedQuery,
20
+ parseQuery,
21
+ } from './query-parser.js'
22
+
23
+ // ============================================================================
24
+ // Search Options
25
+ // ============================================================================
26
+
27
+ export interface SearchOptions {
28
+ /** Filter by heading pattern (regex) */
29
+ readonly heading?: string | undefined
30
+ /** Search within section content (regex) */
31
+ readonly content?: string | undefined
32
+ /** Filter by file path pattern (glob-like) */
33
+ readonly pathPattern?: string | undefined
34
+ /** Only sections with code blocks */
35
+ readonly hasCode?: boolean | undefined
36
+ /** Only sections with lists */
37
+ readonly hasList?: boolean | undefined
38
+ /** Only sections with tables */
39
+ readonly hasTable?: boolean | undefined
40
+ /** Minimum heading level */
41
+ readonly minLevel?: number | undefined
42
+ /** Maximum heading level */
43
+ readonly maxLevel?: number | undefined
44
+ /** Maximum results */
45
+ readonly limit?: number | undefined
46
+ /** Lines of context before matches */
47
+ readonly contextBefore?: number | undefined
48
+ /** Lines of context after matches */
49
+ readonly contextAfter?: number | undefined
50
+ }
51
+
52
+ export interface ContentMatch {
53
+ /** The line number where match was found (1-based) */
54
+ readonly lineNumber: number
55
+ /** The matching line text */
56
+ readonly line: string
57
+ /** Snippet showing match context (lines before and after) */
58
+ readonly snippet: string
59
+ /** Context lines with their line numbers (for JSON output) */
60
+ readonly contextLines?: readonly ContextLine[]
61
+ }
62
+
63
+ export interface ContextLine {
64
+ /** The line number (1-based) */
65
+ readonly lineNumber: number
66
+ /** The line text */
67
+ readonly line: string
68
+ /** Whether this is the matching line */
69
+ readonly isMatch: boolean
70
+ }
71
+
72
+ export interface SearchResult {
73
+ readonly section: SectionEntry
74
+ readonly document: DocumentEntry
75
+ readonly sectionContent?: string
76
+ /** Matches found within the content (when content search is used) */
77
+ readonly matches?: readonly ContentMatch[]
78
+ }
79
+
80
+ // ============================================================================
81
+ // Path Matching
82
+ // ============================================================================
83
+
84
+ const matchPath = (filePath: string, pattern: string): boolean => {
85
+ // Simple glob-like matching
86
+ const regexPattern = pattern
87
+ .replace(/\./g, '\\.')
88
+ .replace(/\*/g, '.*')
89
+ .replace(/\?/g, '.')
90
+
91
+ const regex = new RegExp(`^${regexPattern}$`, 'i')
92
+ return regex.test(filePath)
93
+ }
94
+
95
+ // ============================================================================
96
+ // Search Implementation
97
+ // ============================================================================
98
+
99
+ export const search = (
100
+ rootPath: string,
101
+ options: SearchOptions = {},
102
+ ): Effect.Effect<readonly SearchResult[], Error> =>
103
+ Effect.gen(function* () {
104
+ const storage = createStorage(rootPath)
105
+
106
+ const docIndex = yield* loadDocumentIndex(storage)
107
+ const sectionIndex = yield* loadSectionIndex(storage)
108
+
109
+ if (!docIndex || !sectionIndex) {
110
+ return []
111
+ }
112
+
113
+ const results: SearchResult[] = []
114
+ const headingRegex = options.heading
115
+ ? new RegExp(options.heading, 'i')
116
+ : null
117
+
118
+ for (const section of Object.values(sectionIndex.sections)) {
119
+ // Filter by heading pattern
120
+ if (headingRegex && !headingRegex.test(section.heading)) {
121
+ continue
122
+ }
123
+
124
+ // Filter by path pattern
125
+ if (
126
+ options.pathPattern &&
127
+ !matchPath(section.documentPath, options.pathPattern)
128
+ ) {
129
+ continue
130
+ }
131
+
132
+ // Filter by code blocks
133
+ if (
134
+ options.hasCode !== undefined &&
135
+ section.hasCode !== options.hasCode
136
+ ) {
137
+ continue
138
+ }
139
+
140
+ // Filter by lists
141
+ if (
142
+ options.hasList !== undefined &&
143
+ section.hasList !== options.hasList
144
+ ) {
145
+ continue
146
+ }
147
+
148
+ // Filter by tables
149
+ if (
150
+ options.hasTable !== undefined &&
151
+ section.hasTable !== options.hasTable
152
+ ) {
153
+ continue
154
+ }
155
+
156
+ // Filter by level range
157
+ if (options.minLevel !== undefined && section.level < options.minLevel) {
158
+ continue
159
+ }
160
+
161
+ if (options.maxLevel !== undefined && section.level > options.maxLevel) {
162
+ continue
163
+ }
164
+
165
+ const document = docIndex.documents[section.documentPath]
166
+ if (document) {
167
+ results.push({ section, document })
168
+ }
169
+
170
+ // Check limit
171
+ if (options.limit !== undefined && results.length >= options.limit) {
172
+ break
173
+ }
174
+ }
175
+
176
+ return results
177
+ })
178
+
179
+ // ============================================================================
180
+ // Content Search Implementation
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Search within section content.
185
+ * Supports boolean operators (AND, OR, NOT) and quoted phrases.
186
+ * Falls back to regex for simple patterns.
187
+ */
188
+ export const searchContent = (
189
+ rootPath: string,
190
+ options: SearchOptions = {},
191
+ ): Effect.Effect<readonly SearchResult[], Error> =>
192
+ Effect.gen(function* () {
193
+ const storage = createStorage(rootPath)
194
+
195
+ const docIndex = yield* loadDocumentIndex(storage)
196
+ const sectionIndex = yield* loadSectionIndex(storage)
197
+
198
+ if (!docIndex || !sectionIndex) {
199
+ return []
200
+ }
201
+
202
+ // Parse content query - use boolean parser if advanced, else regex
203
+ let parsedQuery: ParsedQuery | null = null
204
+ let contentRegex: RegExp | null = null
205
+ let highlightRegex: RegExp | null = null
206
+
207
+ if (options.content) {
208
+ if (isAdvancedQuery(options.content)) {
209
+ parsedQuery = parseQuery(options.content)
210
+ if (parsedQuery) {
211
+ highlightRegex = buildHighlightPattern(parsedQuery)
212
+ }
213
+ } else {
214
+ // Simple search - use as regex
215
+ contentRegex = new RegExp(options.content, 'gi')
216
+ highlightRegex = contentRegex
217
+ }
218
+ }
219
+
220
+ const headingRegex = options.heading
221
+ ? new RegExp(options.heading, 'i')
222
+ : null
223
+
224
+ const results: SearchResult[] = []
225
+
226
+ // Group sections by document for efficient file reading
227
+ const sectionsByDoc: Record<string, SectionEntry[]> = {}
228
+ for (const section of Object.values(sectionIndex.sections)) {
229
+ const docSections = sectionsByDoc[section.documentPath]
230
+ if (docSections) {
231
+ docSections.push(section)
232
+ } else {
233
+ sectionsByDoc[section.documentPath] = [section]
234
+ }
235
+ }
236
+
237
+ // Process each document
238
+ for (const [docPath, sections] of Object.entries(sectionsByDoc)) {
239
+ // Apply path filter early
240
+ if (options.pathPattern && !matchPath(docPath, options.pathPattern)) {
241
+ continue
242
+ }
243
+
244
+ const document = docIndex.documents[docPath]
245
+ if (!document) continue
246
+
247
+ // Load file content for content search
248
+ let fileContent: string | null = null
249
+ let fileLines: string[] = []
250
+
251
+ if (parsedQuery || contentRegex) {
252
+ const filePath = path.join(storage.rootPath, docPath)
253
+ try {
254
+ fileContent = yield* Effect.promise(() =>
255
+ fs.readFile(filePath, 'utf-8'),
256
+ )
257
+ fileLines = fileContent.split('\n')
258
+ } catch {
259
+ continue // Skip files that can't be read
260
+ }
261
+ }
262
+
263
+ for (const section of sections) {
264
+ // Apply heading filter
265
+ if (headingRegex && !headingRegex.test(section.heading)) {
266
+ continue
267
+ }
268
+
269
+ // Apply other filters
270
+ if (
271
+ options.hasCode !== undefined &&
272
+ section.hasCode !== options.hasCode
273
+ ) {
274
+ continue
275
+ }
276
+ if (
277
+ options.hasList !== undefined &&
278
+ section.hasList !== options.hasList
279
+ ) {
280
+ continue
281
+ }
282
+ if (
283
+ options.hasTable !== undefined &&
284
+ section.hasTable !== options.hasTable
285
+ ) {
286
+ continue
287
+ }
288
+ if (
289
+ options.minLevel !== undefined &&
290
+ section.level < options.minLevel
291
+ ) {
292
+ continue
293
+ }
294
+ if (
295
+ options.maxLevel !== undefined &&
296
+ section.level > options.maxLevel
297
+ ) {
298
+ continue
299
+ }
300
+
301
+ // Content search
302
+ if ((parsedQuery || contentRegex) && fileContent) {
303
+ const sectionLines = fileLines.slice(
304
+ section.startLine - 1,
305
+ section.endLine,
306
+ )
307
+ const sectionContent = sectionLines.join('\n')
308
+
309
+ // For boolean queries, evaluate against entire section content
310
+ if (parsedQuery) {
311
+ if (!evaluateQuery(parsedQuery.ast, sectionContent)) {
312
+ continue // Section doesn't match query
313
+ }
314
+ }
315
+
316
+ // Find individual line matches for highlighting
317
+ const matches: ContentMatch[] = []
318
+ const searchRegex = contentRegex || highlightRegex
319
+
320
+ // Use configurable context lines (default to 1 if not specified)
321
+ const contextBefore = options.contextBefore ?? 1
322
+ const contextAfter = options.contextAfter ?? 1
323
+
324
+ if (searchRegex) {
325
+ for (let i = 0; i < sectionLines.length; i++) {
326
+ const line = sectionLines[i]
327
+ if (line && searchRegex.test(line)) {
328
+ // Reset regex lastIndex for next test
329
+ searchRegex.lastIndex = 0
330
+
331
+ const absoluteLineNum = section.startLine + i
332
+
333
+ // Create snippet with configurable context
334
+ const snippetStart = Math.max(0, i - contextBefore)
335
+ const snippetEnd = Math.min(
336
+ sectionLines.length,
337
+ i + contextAfter + 1,
338
+ )
339
+ const snippetLines = sectionLines.slice(
340
+ snippetStart,
341
+ snippetEnd,
342
+ )
343
+ const snippet = snippetLines.join('\n')
344
+
345
+ // Build context lines array for JSON output
346
+ const contextLines: ContextLine[] = []
347
+ for (let j = snippetStart; j < snippetEnd; j++) {
348
+ const ctxLine = sectionLines[j]
349
+ if (ctxLine !== undefined) {
350
+ contextLines.push({
351
+ lineNumber: section.startLine + j,
352
+ line: ctxLine,
353
+ isMatch: j === i,
354
+ })
355
+ }
356
+ }
357
+
358
+ matches.push({
359
+ lineNumber: absoluteLineNum,
360
+ line: line,
361
+ snippet,
362
+ contextLines,
363
+ })
364
+ }
365
+ }
366
+ }
367
+
368
+ // For boolean queries, include section even without line-level matches
369
+ // (the section matched as a whole)
370
+ if (parsedQuery || matches.length > 0) {
371
+ const result: SearchResult = {
372
+ section,
373
+ document,
374
+ sectionContent,
375
+ }
376
+ if (matches.length > 0) {
377
+ results.push({ ...result, matches })
378
+ } else {
379
+ results.push(result)
380
+ }
381
+
382
+ if (
383
+ options.limit !== undefined &&
384
+ results.length >= options.limit
385
+ ) {
386
+ return results
387
+ }
388
+ }
389
+ } else if (!parsedQuery && !contentRegex) {
390
+ // No content search, heading-only search
391
+ results.push({ section, document })
392
+
393
+ if (options.limit !== undefined && results.length >= options.limit) {
394
+ return results
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ return results
401
+ })
402
+
403
+ // ============================================================================
404
+ // Search with Content (legacy, uses heading-only search)
405
+ // ============================================================================
406
+
407
+ export const searchWithContent = (
408
+ rootPath: string,
409
+ options: SearchOptions = {},
410
+ ): Effect.Effect<readonly SearchResult[], Error> =>
411
+ Effect.gen(function* () {
412
+ const storage = createStorage(rootPath)
413
+ const results = yield* search(rootPath, options)
414
+
415
+ const resultsWithContent: SearchResult[] = []
416
+
417
+ for (const result of results) {
418
+ const filePath = path.join(storage.rootPath, result.section.documentPath)
419
+
420
+ try {
421
+ const fileContent = yield* Effect.promise(() =>
422
+ fs.readFile(filePath, 'utf-8'),
423
+ )
424
+
425
+ const lines = fileContent.split('\n')
426
+ const sectionContent = lines
427
+ .slice(result.section.startLine - 1, result.section.endLine)
428
+ .join('\n')
429
+
430
+ resultsWithContent.push({
431
+ ...result,
432
+ sectionContent,
433
+ })
434
+ } catch {
435
+ // If file can't be read, include result without content
436
+ resultsWithContent.push(result)
437
+ }
438
+ }
439
+
440
+ return resultsWithContent
441
+ })
442
+
443
+ // ============================================================================
444
+ // Context Generation
445
+ // ============================================================================
446
+
447
+ export interface ContextOptions {
448
+ /** Maximum tokens to include */
449
+ readonly maxTokens?: number | undefined
450
+ /** Include section content */
451
+ readonly includeContent?: boolean | undefined
452
+ /** Compression level: brief, summary, full */
453
+ readonly level?: 'brief' | 'summary' | 'full' | undefined
454
+ }
455
+
456
+ export interface DocumentContext {
457
+ readonly path: string
458
+ readonly title: string
459
+ readonly totalTokens: number
460
+ readonly includedTokens: number
461
+ readonly sections: readonly SectionContext[]
462
+ }
463
+
464
+ export interface SectionContext {
465
+ readonly heading: string
466
+ readonly level: number
467
+ readonly tokens: number
468
+ readonly content?: string | undefined
469
+ readonly hasCode: boolean
470
+ readonly hasList: boolean
471
+ readonly hasTable: boolean
472
+ }
473
+
474
+ export const getContext = (
475
+ rootPath: string,
476
+ filePath: string,
477
+ options: ContextOptions = {},
478
+ ): Effect.Effect<DocumentContext, Error> =>
479
+ Effect.gen(function* () {
480
+ const storage = createStorage(rootPath)
481
+ const resolvedFile = path.resolve(filePath)
482
+ const relativePath = path.relative(storage.rootPath, resolvedFile)
483
+
484
+ const docIndex = yield* loadDocumentIndex(storage)
485
+ const sectionIndex = yield* loadSectionIndex(storage)
486
+
487
+ if (!docIndex || !sectionIndex) {
488
+ return yield* Effect.fail(
489
+ new Error("Index not found. Run 'mdcontext index' first."),
490
+ )
491
+ }
492
+
493
+ const document = docIndex.documents[relativePath]
494
+ if (!document) {
495
+ return yield* Effect.fail(
496
+ new Error(`Document not found in index: ${relativePath}`),
497
+ )
498
+ }
499
+
500
+ // Get sections for this document
501
+ const sectionIds = sectionIndex.byDocument[document.id] ?? []
502
+ const sections: SectionContext[] = []
503
+ let includedTokens = 0
504
+ const maxTokens = options.maxTokens ?? Infinity
505
+ const includeContent = options.includeContent ?? options.level === 'full'
506
+
507
+ // Read file content if needed
508
+ let fileContent: string | null = null
509
+ if (includeContent) {
510
+ try {
511
+ fileContent = yield* Effect.promise(() =>
512
+ fs.readFile(resolvedFile, 'utf-8'),
513
+ )
514
+ } catch {
515
+ // Continue without content
516
+ }
517
+ }
518
+
519
+ const fileLines = fileContent?.split('\n') ?? []
520
+
521
+ for (const sectionId of sectionIds) {
522
+ const section = sectionIndex.sections[sectionId]
523
+ if (!section) continue
524
+
525
+ // Check token budget
526
+ if (includedTokens + section.tokenCount > maxTokens) {
527
+ // Include brief info only if we're over budget
528
+ if (options.level === 'brief') continue
529
+
530
+ sections.push({
531
+ heading: section.heading,
532
+ level: section.level,
533
+ tokens: section.tokenCount,
534
+ hasCode: section.hasCode,
535
+ hasList: section.hasList,
536
+ hasTable: section.hasTable,
537
+ })
538
+ continue
539
+ }
540
+
541
+ includedTokens += section.tokenCount
542
+
543
+ let content: string | undefined
544
+ if (includeContent && fileContent) {
545
+ content = fileLines
546
+ .slice(section.startLine - 1, section.endLine)
547
+ .join('\n')
548
+ }
549
+
550
+ sections.push({
551
+ heading: section.heading,
552
+ level: section.level,
553
+ tokens: section.tokenCount,
554
+ content,
555
+ hasCode: section.hasCode,
556
+ hasList: section.hasList,
557
+ hasTable: section.hasTable,
558
+ })
559
+ }
560
+
561
+ return {
562
+ path: relativePath,
563
+ title: document.title,
564
+ totalTokens: document.tokenCount,
565
+ includedTokens,
566
+ sections,
567
+ }
568
+ })
569
+
570
+ // ============================================================================
571
+ // LLM-Ready Output
572
+ // ============================================================================
573
+
574
+ export const formatContextForLLM = (context: DocumentContext): string => {
575
+ const lines: string[] = []
576
+
577
+ lines.push(`# ${context.title}`)
578
+ lines.push(`Path: ${context.path}`)
579
+ lines.push(`Tokens: ${context.includedTokens}/${context.totalTokens}`)
580
+ lines.push('')
581
+
582
+ for (const section of context.sections) {
583
+ const prefix = '#'.repeat(section.level)
584
+ const meta: string[] = []
585
+ if (section.hasCode) meta.push('code')
586
+ if (section.hasList) meta.push('list')
587
+ if (section.hasTable) meta.push('table')
588
+
589
+ const metaStr = meta.length > 0 ? ` [${meta.join(', ')}]` : ''
590
+ lines.push(
591
+ `${prefix} ${section.heading}${metaStr} (${section.tokens} tokens)`,
592
+ )
593
+
594
+ if (section.content) {
595
+ lines.push('')
596
+ lines.push(section.content)
597
+ lines.push('')
598
+ }
599
+ }
600
+
601
+ return lines.join('\n')
602
+ }