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,319 @@
1
+ /**
2
+ * Query Parser for mdcontext search
3
+ *
4
+ * Supports:
5
+ * - Boolean operators: AND, OR, NOT (case-insensitive)
6
+ * - Quoted phrases: "exact phrase"
7
+ * - Grouping: (term1 OR term2) AND term3
8
+ * - Precedence: NOT > AND > OR
9
+ */
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export type QueryNode =
16
+ | { type: 'term'; value: string }
17
+ | { type: 'phrase'; value: string }
18
+ | { type: 'and'; left: QueryNode; right: QueryNode }
19
+ | { type: 'or'; left: QueryNode; right: QueryNode }
20
+ | { type: 'not'; operand: QueryNode }
21
+
22
+ export interface ParsedQuery {
23
+ readonly ast: QueryNode
24
+ readonly terms: readonly string[]
25
+ readonly phrases: readonly string[]
26
+ }
27
+
28
+ // ============================================================================
29
+ // Tokenizer
30
+ // ============================================================================
31
+
32
+ type TokenType = 'AND' | 'OR' | 'NOT' | 'LPAREN' | 'RPAREN' | 'PHRASE' | 'TERM'
33
+
34
+ interface Token {
35
+ type: TokenType
36
+ value: string
37
+ }
38
+
39
+ /**
40
+ * Tokenize query string into tokens
41
+ */
42
+ const tokenize = (query: string): Token[] => {
43
+ const tokens: Token[] = []
44
+ let i = 0
45
+
46
+ while (i < query.length) {
47
+ // Skip whitespace
48
+ if (/\s/.test(query[i]!)) {
49
+ i++
50
+ continue
51
+ }
52
+
53
+ // Quoted phrase
54
+ if (query[i] === '"') {
55
+ const start = i + 1
56
+ i++
57
+ while (i < query.length && query[i] !== '"') {
58
+ i++
59
+ }
60
+ const value = query.slice(start, i)
61
+ tokens.push({ type: 'PHRASE', value })
62
+ i++ // Skip closing quote
63
+ continue
64
+ }
65
+
66
+ // Parentheses
67
+ if (query[i] === '(') {
68
+ tokens.push({ type: 'LPAREN', value: '(' })
69
+ i++
70
+ continue
71
+ }
72
+ if (query[i] === ')') {
73
+ tokens.push({ type: 'RPAREN', value: ')' })
74
+ i++
75
+ continue
76
+ }
77
+
78
+ // Words (operators or terms)
79
+ const wordMatch = query.slice(i).match(/^[^\s()"]+/)
80
+ if (wordMatch) {
81
+ const word = wordMatch[0]
82
+ const upperWord = word.toUpperCase()
83
+
84
+ if (upperWord === 'AND') {
85
+ tokens.push({ type: 'AND', value: 'AND' })
86
+ } else if (upperWord === 'OR') {
87
+ tokens.push({ type: 'OR', value: 'OR' })
88
+ } else if (upperWord === 'NOT') {
89
+ tokens.push({ type: 'NOT', value: 'NOT' })
90
+ } else {
91
+ tokens.push({ type: 'TERM', value: word })
92
+ }
93
+ i += word.length
94
+ continue
95
+ }
96
+
97
+ // Unknown character, skip
98
+ i++
99
+ }
100
+
101
+ return tokens
102
+ }
103
+
104
+ // ============================================================================
105
+ // Parser (Recursive Descent)
106
+ // ============================================================================
107
+
108
+ /**
109
+ * Parser for boolean query expressions.
110
+ * Grammar:
111
+ * expr -> andExpr (OR andExpr)*
112
+ * andExpr -> notExpr (AND notExpr)*
113
+ * notExpr -> NOT notExpr | primary
114
+ * primary -> TERM | PHRASE | LPAREN expr RPAREN
115
+ */
116
+ class Parser {
117
+ private tokens: Token[]
118
+ private pos: number = 0
119
+ readonly terms: string[] = []
120
+ readonly phrases: string[] = []
121
+
122
+ constructor(tokens: Token[]) {
123
+ this.tokens = tokens
124
+ }
125
+
126
+ private current(): Token | undefined {
127
+ return this.tokens[this.pos]
128
+ }
129
+
130
+ private advance(): Token | undefined {
131
+ return this.tokens[this.pos++]
132
+ }
133
+
134
+ private match(type: TokenType): boolean {
135
+ if (this.current()?.type === type) {
136
+ this.advance()
137
+ return true
138
+ }
139
+ return false
140
+ }
141
+
142
+ parse(): QueryNode | null {
143
+ if (this.tokens.length === 0) {
144
+ return null
145
+ }
146
+ return this.parseExpr()
147
+ }
148
+
149
+ private parseExpr(): QueryNode {
150
+ let left = this.parseAndExpr()
151
+
152
+ while (this.match('OR')) {
153
+ const right = this.parseAndExpr()
154
+ left = { type: 'or', left, right }
155
+ }
156
+
157
+ return left
158
+ }
159
+
160
+ private parseAndExpr(): QueryNode {
161
+ let left = this.parseNotExpr()
162
+
163
+ // Handle implicit AND (terms without explicit AND between them)
164
+ while (this.match('AND') || this.isImplicitAnd()) {
165
+ const right = this.parseNotExpr()
166
+ left = { type: 'and', left, right }
167
+ }
168
+
169
+ return left
170
+ }
171
+
172
+ private isImplicitAnd(): boolean {
173
+ const tok = this.current()
174
+ // If next token is a TERM, PHRASE, NOT, or LPAREN, treat as implicit AND
175
+ return (
176
+ tok?.type === 'TERM' ||
177
+ tok?.type === 'PHRASE' ||
178
+ tok?.type === 'NOT' ||
179
+ tok?.type === 'LPAREN'
180
+ )
181
+ }
182
+
183
+ private parseNotExpr(): QueryNode {
184
+ if (this.match('NOT')) {
185
+ const operand = this.parseNotExpr()
186
+ return { type: 'not', operand }
187
+ }
188
+ return this.parsePrimary()
189
+ }
190
+
191
+ private parsePrimary(): QueryNode {
192
+ const tok = this.current()
193
+
194
+ if (this.match('LPAREN')) {
195
+ const expr = this.parseExpr()
196
+ this.match('RPAREN') // Consume closing paren (ignore if missing)
197
+ return expr
198
+ }
199
+
200
+ if (tok?.type === 'PHRASE') {
201
+ this.advance()
202
+ this.phrases.push(tok.value)
203
+ return { type: 'phrase', value: tok.value }
204
+ }
205
+
206
+ if (tok?.type === 'TERM') {
207
+ this.advance()
208
+ this.terms.push(tok.value)
209
+ return { type: 'term', value: tok.value }
210
+ }
211
+
212
+ // Unexpected token, return empty term
213
+ return { type: 'term', value: '' }
214
+ }
215
+ }
216
+
217
+ // ============================================================================
218
+ // Public API
219
+ // ============================================================================
220
+
221
+ /**
222
+ * Parse a search query into an AST
223
+ */
224
+ export const parseQuery = (query: string): ParsedQuery | null => {
225
+ const tokens = tokenize(query)
226
+ if (tokens.length === 0) {
227
+ return null
228
+ }
229
+
230
+ const parser = new Parser(tokens)
231
+ const ast = parser.parse()
232
+
233
+ if (!ast) {
234
+ return null
235
+ }
236
+
237
+ return {
238
+ ast,
239
+ terms: parser.terms,
240
+ phrases: parser.phrases,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Check if a query contains boolean operators or phrases
246
+ */
247
+ export const isAdvancedQuery = (query: string): boolean => {
248
+ const tokens = tokenize(query)
249
+ return tokens.some(
250
+ (t) =>
251
+ t.type === 'AND' ||
252
+ t.type === 'OR' ||
253
+ t.type === 'NOT' ||
254
+ t.type === 'PHRASE' ||
255
+ t.type === 'LPAREN',
256
+ )
257
+ }
258
+
259
+ /**
260
+ * Evaluate a parsed query against text content
261
+ * Returns true if the text matches the query
262
+ */
263
+ export const evaluateQuery = (ast: QueryNode, text: string): boolean => {
264
+ const lowerText = text.toLowerCase()
265
+
266
+ const evaluate = (node: QueryNode): boolean => {
267
+ switch (node.type) {
268
+ case 'term': {
269
+ // Empty term matches anything
270
+ if (!node.value) return true
271
+ return lowerText.includes(node.value.toLowerCase())
272
+ }
273
+ case 'phrase': {
274
+ // Phrase must match exactly (case-insensitive)
275
+ return lowerText.includes(node.value.toLowerCase())
276
+ }
277
+ case 'and': {
278
+ return evaluate(node.left) && evaluate(node.right)
279
+ }
280
+ case 'or': {
281
+ return evaluate(node.left) || evaluate(node.right)
282
+ }
283
+ case 'not': {
284
+ return !evaluate(node.operand)
285
+ }
286
+ }
287
+ }
288
+
289
+ return evaluate(ast)
290
+ }
291
+
292
+ /**
293
+ * Build a regex pattern from a parsed query for highlighting matches
294
+ * This creates a pattern that matches any of the terms/phrases
295
+ */
296
+ export const buildHighlightPattern = (parsed: ParsedQuery): RegExp => {
297
+ const patterns: string[] = []
298
+
299
+ // Escape special regex chars
300
+ const escapeChars = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
301
+
302
+ for (const term of parsed.terms) {
303
+ if (term) {
304
+ patterns.push(`\\b${escapeChars(term)}\\b`)
305
+ }
306
+ }
307
+
308
+ for (const phrase of parsed.phrases) {
309
+ if (phrase) {
310
+ patterns.push(escapeChars(phrase))
311
+ }
312
+ }
313
+
314
+ if (patterns.length === 0) {
315
+ return /.^/ // Match nothing
316
+ }
317
+
318
+ return new RegExp(patterns.join('|'), 'gi')
319
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Tests for keyword search
3
+ */
4
+
5
+ import * as fs from 'node:fs/promises'
6
+ import * as path from 'node:path'
7
+ import { Effect } from 'effect'
8
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
9
+ import { buildIndex } from '../index/indexer.js'
10
+ import { formatContextForLLM, getContext, search } from './searcher.js'
11
+
12
+ // Test fixture directory
13
+ const TEST_DIR = path.join(process.cwd(), 'tests', 'fixtures', 'search')
14
+
15
+ // Helper to run Effect
16
+ const runEffect = <A, E>(effect: Effect.Effect<A, E>) =>
17
+ Effect.runPromise(effect)
18
+
19
+ describe('search', () => {
20
+ beforeAll(async () => {
21
+ // Create test fixtures
22
+ await fs.mkdir(TEST_DIR, { recursive: true })
23
+
24
+ // Create test markdown files
25
+ await fs.writeFile(
26
+ path.join(TEST_DIR, 'doc1.md'),
27
+ `# Document One
28
+
29
+ ## Introduction
30
+
31
+ This is the introduction section.
32
+
33
+ ## Code Example
34
+
35
+ Here's some code:
36
+
37
+ \`\`\`typescript
38
+ const x = 1;
39
+ \`\`\`
40
+
41
+ ## Summary
42
+
43
+ A brief summary.
44
+ `,
45
+ )
46
+
47
+ await fs.writeFile(
48
+ path.join(TEST_DIR, 'doc2.md'),
49
+ `# Document Two
50
+
51
+ ## Overview
52
+
53
+ An overview of the document.
54
+
55
+ ## Data Table
56
+
57
+ | Column A | Column B |
58
+ |----------|----------|
59
+ | Value 1 | Value 2 |
60
+
61
+ ## Tasks
62
+
63
+ - Task 1
64
+ - Task 2
65
+ - Task 3
66
+ `,
67
+ )
68
+
69
+ // Build index
70
+ await runEffect(buildIndex(TEST_DIR, { force: true }))
71
+ })
72
+
73
+ afterAll(async () => {
74
+ // Clean up
75
+ await fs.rm(TEST_DIR, { recursive: true, force: true })
76
+ })
77
+
78
+ describe('search()', () => {
79
+ it('should return all sections without filters', async () => {
80
+ const results = await runEffect(search(TEST_DIR))
81
+ expect(results.length).toBeGreaterThan(0)
82
+ })
83
+
84
+ it('should filter by heading pattern', async () => {
85
+ const results = await runEffect(
86
+ search(TEST_DIR, { heading: 'Introduction|Overview' }),
87
+ )
88
+ expect(results.length).toBe(2)
89
+ expect(results.map((r) => r.section.heading)).toContain('Introduction')
90
+ expect(results.map((r) => r.section.heading)).toContain('Overview')
91
+ })
92
+
93
+ it('should filter by path pattern', async () => {
94
+ const results = await runEffect(
95
+ search(TEST_DIR, { pathPattern: 'doc1*' }),
96
+ )
97
+ expect(results.length).toBeGreaterThan(0)
98
+ for (const result of results) {
99
+ expect(result.section.documentPath).toMatch(/doc1/)
100
+ }
101
+ })
102
+
103
+ it('should filter by hasCode', async () => {
104
+ const results = await runEffect(search(TEST_DIR, { hasCode: true }))
105
+ expect(results.length).toBeGreaterThan(0)
106
+ for (const result of results) {
107
+ expect(result.section.hasCode).toBe(true)
108
+ }
109
+ })
110
+
111
+ it('should filter by hasTable', async () => {
112
+ const results = await runEffect(search(TEST_DIR, { hasTable: true }))
113
+ expect(results.length).toBeGreaterThan(0)
114
+ for (const result of results) {
115
+ expect(result.section.hasTable).toBe(true)
116
+ }
117
+ })
118
+
119
+ it('should filter by hasList', async () => {
120
+ const results = await runEffect(search(TEST_DIR, { hasList: true }))
121
+ expect(results.length).toBeGreaterThan(0)
122
+ for (const result of results) {
123
+ expect(result.section.hasList).toBe(true)
124
+ }
125
+ })
126
+
127
+ it('should respect limit', async () => {
128
+ const results = await runEffect(search(TEST_DIR, { limit: 2 }))
129
+ expect(results.length).toBe(2)
130
+ })
131
+ })
132
+
133
+ describe('getContext()', () => {
134
+ it('should return document context', async () => {
135
+ const context = await runEffect(
136
+ getContext(TEST_DIR, path.join(TEST_DIR, 'doc1.md')),
137
+ )
138
+ expect(context.title).toBe('Document One')
139
+ expect(context.sections.length).toBeGreaterThan(0)
140
+ })
141
+
142
+ it('should respect maxTokens', async () => {
143
+ const fullContext = await runEffect(
144
+ getContext(TEST_DIR, path.join(TEST_DIR, 'doc1.md')),
145
+ )
146
+ // Use a limit that's definitely smaller than the full document
147
+ const limitTokens = Math.max(10, Math.floor(fullContext.totalTokens / 2))
148
+ const limitedContext = await runEffect(
149
+ getContext(TEST_DIR, path.join(TEST_DIR, 'doc1.md'), {
150
+ maxTokens: limitTokens,
151
+ }),
152
+ )
153
+ expect(limitedContext.includedTokens).toBeLessThanOrEqual(limitTokens)
154
+ // Only check for reduction if the full context exceeds the limit
155
+ if (fullContext.totalTokens > limitTokens) {
156
+ expect(limitedContext.includedTokens).toBeLessThan(
157
+ fullContext.totalTokens,
158
+ )
159
+ }
160
+ })
161
+ })
162
+
163
+ describe('formatContextForLLM()', () => {
164
+ it('should format context as readable text', async () => {
165
+ const context = await runEffect(
166
+ getContext(TEST_DIR, path.join(TEST_DIR, 'doc1.md')),
167
+ )
168
+ const formatted = formatContextForLLM(context)
169
+ expect(formatted).toContain('# Document One')
170
+ expect(formatted).toContain('Path: doc1.md')
171
+ expect(formatted).toContain('tokens')
172
+ })
173
+
174
+ it('should include content metadata markers', async () => {
175
+ const context = await runEffect(
176
+ getContext(TEST_DIR, path.join(TEST_DIR, 'doc1.md')),
177
+ )
178
+ const formatted = formatContextForLLM(context)
179
+ expect(formatted).toContain('[code]')
180
+ })
181
+ })
182
+ })