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,446 @@
1
+ /**
2
+ * Indexer service for building and updating indexes
3
+ */
4
+
5
+ import * as fs from 'node:fs/promises'
6
+ import * as path from 'node:path'
7
+ import { Effect } from 'effect'
8
+ import type { MdSection } from '../core/types.js'
9
+ import { parse } from '../parser/parser.js'
10
+ import {
11
+ computeHash,
12
+ createEmptyDocumentIndex,
13
+ createEmptyLinkIndex,
14
+ createEmptySectionIndex,
15
+ createStorage,
16
+ initializeIndex,
17
+ loadDocumentIndex,
18
+ loadLinkIndex,
19
+ loadSectionIndex,
20
+ saveDocumentIndex,
21
+ saveLinkIndex,
22
+ saveSectionIndex,
23
+ } from './storage.js'
24
+ import type {
25
+ DocumentEntry,
26
+ DocumentIndex,
27
+ IndexBuildError,
28
+ IndexResult,
29
+ SectionEntry,
30
+ } from './types.js'
31
+
32
+ // ============================================================================
33
+ // File Discovery
34
+ // ============================================================================
35
+
36
+ const isMarkdownFile = (filename: string): boolean =>
37
+ filename.endsWith('.md') || filename.endsWith('.mdx')
38
+
39
+ const shouldExclude = (
40
+ filePath: string,
41
+ exclude: readonly string[],
42
+ ): boolean => {
43
+ const normalized = filePath.toLowerCase()
44
+ for (const pattern of exclude) {
45
+ if (
46
+ pattern.includes('node_modules') &&
47
+ normalized.includes('node_modules')
48
+ ) {
49
+ return true
50
+ }
51
+ if (pattern.startsWith('**/.*') && normalized.includes('/.')) {
52
+ return true
53
+ }
54
+ }
55
+ return false
56
+ }
57
+
58
+ const walkDirectory = async (
59
+ dir: string,
60
+ exclude: readonly string[],
61
+ ): Promise<string[]> => {
62
+ const files: string[] = []
63
+ const entries = await fs.readdir(dir, { withFileTypes: true })
64
+
65
+ for (const entry of entries) {
66
+ const fullPath = path.join(dir, entry.name)
67
+
68
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
69
+ continue
70
+ }
71
+
72
+ if (shouldExclude(fullPath, exclude)) {
73
+ continue
74
+ }
75
+
76
+ if (entry.isDirectory()) {
77
+ const subFiles = await walkDirectory(fullPath, exclude)
78
+ files.push(...subFiles)
79
+ } else if (entry.isFile() && isMarkdownFile(entry.name)) {
80
+ files.push(fullPath)
81
+ }
82
+ }
83
+
84
+ return files
85
+ }
86
+
87
+ // ============================================================================
88
+ // Section Flattening
89
+ // ============================================================================
90
+
91
+ const flattenSections = (
92
+ sections: readonly MdSection[],
93
+ docId: string,
94
+ docPath: string,
95
+ ): SectionEntry[] => {
96
+ const result: SectionEntry[] = []
97
+
98
+ const traverse = (section: MdSection): void => {
99
+ result.push({
100
+ id: section.id,
101
+ documentId: docId,
102
+ documentPath: docPath,
103
+ heading: section.heading,
104
+ level: section.level,
105
+ startLine: section.startLine,
106
+ endLine: section.endLine,
107
+ tokenCount: section.metadata.tokenCount,
108
+ hasCode: section.metadata.hasCode,
109
+ hasList: section.metadata.hasList,
110
+ hasTable: section.metadata.hasTable,
111
+ })
112
+
113
+ for (const child of section.children) {
114
+ traverse(child)
115
+ }
116
+ }
117
+
118
+ for (const section of sections) {
119
+ traverse(section)
120
+ }
121
+
122
+ return result
123
+ }
124
+
125
+ // ============================================================================
126
+ // Link Resolution
127
+ // ============================================================================
128
+
129
+ const resolveInternalLink = (
130
+ href: string,
131
+ fromPath: string,
132
+ rootPath: string,
133
+ ): string | null => {
134
+ if (href.startsWith('#')) {
135
+ return fromPath
136
+ }
137
+
138
+ if (href.startsWith('http://') || href.startsWith('https://')) {
139
+ return null
140
+ }
141
+
142
+ const linkPath = href.split('#')[0] ?? ''
143
+ if (!linkPath) return null
144
+
145
+ const fromDir = path.dirname(fromPath)
146
+ const resolved = path.resolve(fromDir, linkPath)
147
+
148
+ if (!resolved.startsWith(rootPath)) {
149
+ return null
150
+ }
151
+
152
+ return path.relative(rootPath, resolved)
153
+ }
154
+
155
+ // ============================================================================
156
+ // Index Building
157
+ // ============================================================================
158
+
159
+ export interface IndexOptions {
160
+ readonly force?: boolean
161
+ readonly exclude?: readonly string[]
162
+ }
163
+
164
+ export const buildIndex = (
165
+ rootPath: string,
166
+ options: IndexOptions = {},
167
+ ): Effect.Effect<IndexResult, Error> =>
168
+ Effect.gen(function* () {
169
+ const startTime = Date.now()
170
+ const storage = createStorage(rootPath)
171
+ const errors: IndexBuildError[] = []
172
+
173
+ // Initialize storage
174
+ yield* initializeIndex(storage)
175
+
176
+ // Load existing indexes or create empty ones
177
+ const existingDocIndex = yield* loadDocumentIndex(storage)
178
+ const docIndex: DocumentIndex =
179
+ options.force || !existingDocIndex
180
+ ? createEmptyDocumentIndex(storage.rootPath)
181
+ : existingDocIndex
182
+
183
+ // Load existing section and link indexes to preserve data for unchanged files
184
+ const existingSectionIndex = yield* loadSectionIndex(storage)
185
+ const existingLinkIndex = yield* loadLinkIndex(storage)
186
+ const sectionIndex = existingSectionIndex ?? createEmptySectionIndex()
187
+ const linkIndex = existingLinkIndex ?? createEmptyLinkIndex()
188
+
189
+ // Discover files
190
+ const exclude = options.exclude ?? ['**/node_modules/**', '**/.*/**']
191
+ const files = yield* Effect.tryPromise({
192
+ try: () => walkDirectory(storage.rootPath, exclude),
193
+ catch: (e) => new Error(`Failed to walk directory: ${e}`),
194
+ })
195
+
196
+ // Process each file
197
+ let documentsIndexed = 0
198
+ let sectionsIndexed = 0
199
+ let linksIndexed = 0
200
+
201
+ const mutableDocuments: Record<string, DocumentEntry> = {
202
+ ...docIndex.documents,
203
+ }
204
+ // Initialize with existing data to preserve sections/links for unchanged files
205
+ const mutableSections: Record<string, SectionEntry> = {
206
+ ...sectionIndex.sections,
207
+ }
208
+ const mutableByHeading: Record<string, string[]> = Object.fromEntries(
209
+ Object.entries(sectionIndex.byHeading).map(([k, v]) => [k, [...v]]),
210
+ )
211
+ const mutableByDocument: Record<string, string[]> = Object.fromEntries(
212
+ Object.entries(sectionIndex.byDocument).map(([k, v]) => [k, [...v]]),
213
+ )
214
+ const mutableForward: Record<string, string[]> = Object.fromEntries(
215
+ Object.entries(linkIndex.forward).map(([k, v]) => [k, [...v]]),
216
+ )
217
+ const mutableBackward: Record<string, string[]> = Object.fromEntries(
218
+ Object.entries(linkIndex.backward).map(([k, v]) => [k, [...v]]),
219
+ )
220
+ const brokenLinks: string[] = [...linkIndex.broken]
221
+
222
+ for (const filePath of files) {
223
+ const relativePath = path.relative(storage.rootPath, filePath)
224
+
225
+ // Process each file, collecting errors instead of failing
226
+ const processFile = Effect.gen(function* () {
227
+ // Read file content and stats
228
+ const [content, stats] = yield* Effect.promise(() =>
229
+ Promise.all([fs.readFile(filePath, 'utf-8'), fs.stat(filePath)]),
230
+ )
231
+
232
+ const hash = computeHash(content)
233
+ const existingEntry = mutableDocuments[relativePath]
234
+
235
+ // Skip if unchanged
236
+ if (
237
+ !options.force &&
238
+ existingEntry &&
239
+ existingEntry.hash === hash &&
240
+ existingEntry.mtime === stats.mtime.getTime()
241
+ ) {
242
+ return // File unchanged, skip processing
243
+ }
244
+
245
+ // Parse document
246
+ const doc = yield* parse(content, {
247
+ path: relativePath,
248
+ lastModified: stats.mtime,
249
+ }).pipe(
250
+ Effect.mapError(
251
+ (e) => new Error(`Parse error in ${relativePath}: ${e.message}`),
252
+ ),
253
+ )
254
+
255
+ // Clean up old sections for this document before adding new ones
256
+ if (existingEntry) {
257
+ const oldSectionIds = mutableByDocument[existingEntry.id] ?? []
258
+ for (const sectionId of oldSectionIds) {
259
+ const oldSection = mutableSections[sectionId]
260
+ if (oldSection) {
261
+ // Remove from byHeading
262
+ const headingKey = oldSection.heading.toLowerCase()
263
+ const headingList = mutableByHeading[headingKey]
264
+ if (headingList) {
265
+ const idx = headingList.indexOf(sectionId)
266
+ if (idx !== -1) headingList.splice(idx, 1)
267
+ }
268
+ }
269
+ delete mutableSections[sectionId]
270
+ }
271
+ delete mutableByDocument[existingEntry.id]
272
+
273
+ // Clean up old links
274
+ delete mutableForward[relativePath]
275
+ }
276
+
277
+ // Update document index
278
+ mutableDocuments[relativePath] = {
279
+ id: doc.id,
280
+ path: relativePath,
281
+ title: doc.title,
282
+ mtime: stats.mtime.getTime(),
283
+ hash,
284
+ tokenCount: doc.metadata.tokenCount,
285
+ sectionCount: doc.metadata.headingCount,
286
+ }
287
+
288
+ documentsIndexed++
289
+
290
+ // Update section index
291
+ const sections = flattenSections(doc.sections, doc.id, relativePath)
292
+ mutableByDocument[doc.id] = []
293
+
294
+ for (const section of sections) {
295
+ mutableSections[section.id] = section
296
+ mutableByDocument[doc.id]?.push(section.id)
297
+
298
+ // Index by heading
299
+ const headingKey = section.heading.toLowerCase()
300
+ if (!mutableByHeading[headingKey]) {
301
+ mutableByHeading[headingKey] = []
302
+ }
303
+ mutableByHeading[headingKey]?.push(section.id)
304
+
305
+ sectionsIndexed++
306
+ }
307
+
308
+ // Update link index
309
+ const internalLinks = doc.links.filter((l) => l.type === 'internal')
310
+ const outgoingLinks: string[] = []
311
+
312
+ for (const link of internalLinks) {
313
+ const target = resolveInternalLink(
314
+ link.href,
315
+ filePath,
316
+ storage.rootPath,
317
+ )
318
+
319
+ if (target) {
320
+ outgoingLinks.push(target)
321
+
322
+ // Add to backward links
323
+ if (!mutableBackward[target]) {
324
+ mutableBackward[target] = []
325
+ }
326
+ if (!mutableBackward[target]?.includes(relativePath)) {
327
+ mutableBackward[target]?.push(relativePath)
328
+ }
329
+
330
+ linksIndexed++
331
+ }
332
+ }
333
+
334
+ mutableForward[relativePath] = outgoingLinks
335
+ }).pipe(
336
+ Effect.catchAll((error) => {
337
+ errors.push({
338
+ path: relativePath,
339
+ message: error instanceof Error ? error.message : String(error),
340
+ })
341
+ return Effect.void
342
+ }),
343
+ )
344
+
345
+ yield* processFile
346
+ }
347
+
348
+ // Check for broken links
349
+ for (const [_from, targets] of Object.entries(mutableForward)) {
350
+ for (const target of targets) {
351
+ if (!mutableDocuments[target] && !brokenLinks.includes(target)) {
352
+ brokenLinks.push(target)
353
+ }
354
+ }
355
+ }
356
+
357
+ // Save indexes
358
+ yield* saveDocumentIndex(storage, {
359
+ version: docIndex.version,
360
+ rootPath: storage.rootPath,
361
+ documents: mutableDocuments,
362
+ })
363
+
364
+ yield* saveSectionIndex(storage, {
365
+ version: sectionIndex.version,
366
+ sections: mutableSections,
367
+ byHeading: mutableByHeading,
368
+ byDocument: mutableByDocument,
369
+ })
370
+
371
+ yield* saveLinkIndex(storage, {
372
+ version: linkIndex.version,
373
+ forward: mutableForward,
374
+ backward: mutableBackward,
375
+ broken: brokenLinks,
376
+ })
377
+
378
+ const duration = Date.now() - startTime
379
+
380
+ // Calculate totals for all links across all forward entries
381
+ const totalLinks = Object.values(mutableForward).reduce(
382
+ (sum, links) => sum + links.length,
383
+ 0,
384
+ )
385
+
386
+ return {
387
+ documentsIndexed,
388
+ sectionsIndexed,
389
+ linksIndexed,
390
+ totalDocuments: Object.keys(mutableDocuments).length,
391
+ totalSections: Object.keys(mutableSections).length,
392
+ totalLinks,
393
+ duration,
394
+ errors,
395
+ }
396
+ })
397
+
398
+ // ============================================================================
399
+ // Link Queries
400
+ // ============================================================================
401
+
402
+ export const getOutgoingLinks = (
403
+ rootPath: string,
404
+ filePath: string,
405
+ ): Effect.Effect<readonly string[], Error> =>
406
+ Effect.gen(function* () {
407
+ const storage = createStorage(rootPath)
408
+ const linkIndex = yield* loadLinkIndex(storage)
409
+
410
+ if (!linkIndex) {
411
+ return []
412
+ }
413
+
414
+ const relativePath = path.relative(storage.rootPath, path.resolve(filePath))
415
+ return linkIndex.forward[relativePath] ?? []
416
+ })
417
+
418
+ export const getIncomingLinks = (
419
+ rootPath: string,
420
+ filePath: string,
421
+ ): Effect.Effect<readonly string[], Error> =>
422
+ Effect.gen(function* () {
423
+ const storage = createStorage(rootPath)
424
+ const linkIndex = yield* loadLinkIndex(storage)
425
+
426
+ if (!linkIndex) {
427
+ return []
428
+ }
429
+
430
+ const relativePath = path.relative(storage.rootPath, path.resolve(filePath))
431
+ return linkIndex.backward[relativePath] ?? []
432
+ })
433
+
434
+ export const getBrokenLinks = (
435
+ rootPath: string,
436
+ ): Effect.Effect<readonly string[], Error> =>
437
+ Effect.gen(function* () {
438
+ const storage = createStorage(rootPath)
439
+ const linkIndex = yield* loadLinkIndex(storage)
440
+
441
+ if (!linkIndex) {
442
+ return []
443
+ }
444
+
445
+ return linkIndex.broken
446
+ })
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Index storage operations
3
+ */
4
+
5
+ import * as crypto from 'node:crypto'
6
+ import * as fs from 'node:fs/promises'
7
+ import * as path from 'node:path'
8
+ import { Effect } from 'effect'
9
+
10
+ import type {
11
+ DocumentIndex,
12
+ IndexConfig,
13
+ LinkIndex,
14
+ SectionIndex,
15
+ } from './types.js'
16
+ import { getIndexPaths, INDEX_VERSION } from './types.js'
17
+
18
+ // ============================================================================
19
+ // File System Helpers
20
+ // ============================================================================
21
+
22
+ const ensureDir = (dirPath: string): Effect.Effect<void, Error> =>
23
+ Effect.tryPromise({
24
+ try: () => fs.mkdir(dirPath, { recursive: true }),
25
+ catch: (e) => new Error(`Failed to create directory ${dirPath}: ${e}`),
26
+ }).pipe(Effect.map(() => undefined))
27
+
28
+ const readJsonFile = <T>(filePath: string): Effect.Effect<T | null, Error> =>
29
+ Effect.tryPromise({
30
+ try: async () => {
31
+ try {
32
+ const content = await fs.readFile(filePath, 'utf-8')
33
+ return JSON.parse(content) as T
34
+ } catch {
35
+ return null
36
+ }
37
+ },
38
+ catch: (e) => new Error(`Failed to read ${filePath}: ${e}`),
39
+ })
40
+
41
+ const writeJsonFile = <T>(
42
+ filePath: string,
43
+ data: T,
44
+ ): Effect.Effect<void, Error> =>
45
+ Effect.gen(function* () {
46
+ const dir = path.dirname(filePath)
47
+ yield* ensureDir(dir)
48
+ yield* Effect.tryPromise({
49
+ try: () => fs.writeFile(filePath, JSON.stringify(data, null, 2)),
50
+ catch: (e) => new Error(`Failed to write ${filePath}: ${e}`),
51
+ })
52
+ })
53
+
54
+ // ============================================================================
55
+ // Hash Computation
56
+ // ============================================================================
57
+
58
+ export const computeHash = (content: string): string => {
59
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
60
+ }
61
+
62
+ // ============================================================================
63
+ // Index Storage Operations
64
+ // ============================================================================
65
+
66
+ export interface IndexStorage {
67
+ readonly rootPath: string
68
+ readonly paths: ReturnType<typeof getIndexPaths>
69
+ }
70
+
71
+ export const createStorage = (rootPath: string): IndexStorage => ({
72
+ rootPath: path.resolve(rootPath),
73
+ paths: getIndexPaths(path.resolve(rootPath)),
74
+ })
75
+
76
+ export const initializeIndex = (
77
+ storage: IndexStorage,
78
+ ): Effect.Effect<void, Error> =>
79
+ Effect.gen(function* () {
80
+ yield* ensureDir(storage.paths.root)
81
+ yield* ensureDir(storage.paths.parsed)
82
+ yield* ensureDir(path.dirname(storage.paths.documents))
83
+
84
+ // Create default config if it doesn't exist
85
+ const existingConfig = yield* loadConfig(storage)
86
+ if (!existingConfig) {
87
+ const config: IndexConfig = {
88
+ version: INDEX_VERSION,
89
+ rootPath: storage.rootPath,
90
+ include: ['**/*.md', '**/*.mdx'],
91
+ exclude: ['**/node_modules/**', '**/.*/**'],
92
+ createdAt: new Date().toISOString(),
93
+ updatedAt: new Date().toISOString(),
94
+ }
95
+ yield* saveConfig(storage, config)
96
+ }
97
+ })
98
+
99
+ // ============================================================================
100
+ // Config Operations
101
+ // ============================================================================
102
+
103
+ export const loadConfig = (
104
+ storage: IndexStorage,
105
+ ): Effect.Effect<IndexConfig | null, Error> =>
106
+ readJsonFile<IndexConfig>(storage.paths.config)
107
+
108
+ export const saveConfig = (
109
+ storage: IndexStorage,
110
+ config: IndexConfig,
111
+ ): Effect.Effect<void, Error> =>
112
+ writeJsonFile(storage.paths.config, {
113
+ ...config,
114
+ updatedAt: new Date().toISOString(),
115
+ })
116
+
117
+ // ============================================================================
118
+ // Document Index Operations
119
+ // ============================================================================
120
+
121
+ export const loadDocumentIndex = (
122
+ storage: IndexStorage,
123
+ ): Effect.Effect<DocumentIndex | null, Error> =>
124
+ readJsonFile<DocumentIndex>(storage.paths.documents)
125
+
126
+ export const saveDocumentIndex = (
127
+ storage: IndexStorage,
128
+ index: DocumentIndex,
129
+ ): Effect.Effect<void, Error> => writeJsonFile(storage.paths.documents, index)
130
+
131
+ export const createEmptyDocumentIndex = (rootPath: string): DocumentIndex => ({
132
+ version: INDEX_VERSION,
133
+ rootPath,
134
+ documents: {},
135
+ })
136
+
137
+ // ============================================================================
138
+ // Section Index Operations
139
+ // ============================================================================
140
+
141
+ export const loadSectionIndex = (
142
+ storage: IndexStorage,
143
+ ): Effect.Effect<SectionIndex | null, Error> =>
144
+ readJsonFile<SectionIndex>(storage.paths.sections)
145
+
146
+ export const saveSectionIndex = (
147
+ storage: IndexStorage,
148
+ index: SectionIndex,
149
+ ): Effect.Effect<void, Error> => writeJsonFile(storage.paths.sections, index)
150
+
151
+ export const createEmptySectionIndex = (): SectionIndex => ({
152
+ version: INDEX_VERSION,
153
+ sections: {},
154
+ byHeading: {},
155
+ byDocument: {},
156
+ })
157
+
158
+ // ============================================================================
159
+ // Link Index Operations
160
+ // ============================================================================
161
+
162
+ export const loadLinkIndex = (
163
+ storage: IndexStorage,
164
+ ): Effect.Effect<LinkIndex | null, Error> =>
165
+ readJsonFile<LinkIndex>(storage.paths.links)
166
+
167
+ export const saveLinkIndex = (
168
+ storage: IndexStorage,
169
+ index: LinkIndex,
170
+ ): Effect.Effect<void, Error> => writeJsonFile(storage.paths.links, index)
171
+
172
+ export const createEmptyLinkIndex = (): LinkIndex => ({
173
+ version: INDEX_VERSION,
174
+ forward: {},
175
+ backward: {},
176
+ broken: [],
177
+ })
178
+
179
+ // ============================================================================
180
+ // Index Existence Check
181
+ // ============================================================================
182
+
183
+ export const indexExists = (
184
+ storage: IndexStorage,
185
+ ): Effect.Effect<boolean, Error> =>
186
+ Effect.tryPromise({
187
+ try: async () => {
188
+ try {
189
+ await fs.access(storage.paths.config)
190
+ return true
191
+ } catch {
192
+ return false
193
+ }
194
+ },
195
+ catch: (e) => new Error(`Failed to check index existence: ${e}`),
196
+ })