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.
- package/.changeset/README.md +28 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/ci.yml +83 -0
- package/.github/workflows/release.yml +113 -0
- package/.tldrignore +112 -0
- package/AGENTS.md +46 -0
- package/BACKLOG.md +338 -0
- package/README.md +231 -11
- package/biome.json +36 -0
- package/cspell.config.yaml +14 -0
- package/dist/chunk-KRYIFLQR.js +92 -0
- package/dist/chunk-S7E6TFX6.js +742 -0
- package/dist/chunk-VVTGZNBT.js +1519 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +2015 -0
- package/dist/index.d.ts +266 -0
- package/dist/index.js +86 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +376 -0
- package/docs/019-USAGE.md +586 -0
- package/docs/020-current-implementation.md +364 -0
- package/docs/021-DOGFOODING-FINDINGS.md +175 -0
- package/docs/BACKLOG.md +80 -0
- package/docs/DESIGN.md +439 -0
- package/docs/PROJECT.md +88 -0
- package/docs/ROADMAP.md +407 -0
- package/docs/test-links.md +9 -0
- package/package.json +69 -10
- package/pnpm-workspace.yaml +5 -0
- package/research/config-analysis/01-current-implementation.md +470 -0
- package/research/config-analysis/02-strategy-recommendation.md +428 -0
- package/research/config-analysis/03-task-candidates.md +715 -0
- package/research/config-analysis/033-research-configuration-management.md +828 -0
- package/research/config-analysis/034-research-effect-cli-config.md +1504 -0
- package/research/config-analysis/04-consolidated-task-candidates.md +277 -0
- package/research/dogfood/consolidated-tool-evaluation.md +373 -0
- package/research/dogfood/strategy-a/a-synthesis.md +184 -0
- package/research/dogfood/strategy-a/a1-docs.md +226 -0
- package/research/dogfood/strategy-a/a2-amorphic.md +156 -0
- package/research/dogfood/strategy-a/a3-llm.md +164 -0
- package/research/dogfood/strategy-b/b-synthesis.md +228 -0
- package/research/dogfood/strategy-b/b1-architecture.md +207 -0
- package/research/dogfood/strategy-b/b2-gaps.md +258 -0
- package/research/dogfood/strategy-b/b3-workflows.md +250 -0
- package/research/dogfood/strategy-c/c-synthesis.md +451 -0
- package/research/dogfood/strategy-c/c1-explorer.md +192 -0
- package/research/dogfood/strategy-c/c2-diver-memory.md +145 -0
- package/research/dogfood/strategy-c/c3-diver-control.md +148 -0
- package/research/dogfood/strategy-c/c4-diver-failure.md +151 -0
- package/research/dogfood/strategy-c/c5-diver-execution.md +221 -0
- package/research/dogfood/strategy-c/c6-diver-org.md +221 -0
- package/research/effect-cli-error-handling.md +845 -0
- package/research/effect-errors-as-values.md +943 -0
- package/research/errors-task-analysis/00-consolidated-tasks.md +207 -0
- package/research/errors-task-analysis/cli-commands-analysis.md +909 -0
- package/research/errors-task-analysis/embeddings-analysis.md +709 -0
- package/research/errors-task-analysis/index-search-analysis.md +812 -0
- package/research/mdcontext-error-analysis.md +521 -0
- package/research/npm_publish/011-npm-workflow-research-agent2.md +792 -0
- package/research/npm_publish/012-npm-workflow-research-agent1.md +530 -0
- package/research/npm_publish/013-npm-workflow-research-agent3.md +722 -0
- package/research/npm_publish/014-npm-workflow-synthesis.md +556 -0
- package/research/npm_publish/031-npm-workflow-task-analysis.md +134 -0
- package/research/semantic-search/002-research-embedding-models.md +490 -0
- package/research/semantic-search/003-research-rag-alternatives.md +523 -0
- package/research/semantic-search/004-research-vector-search.md +841 -0
- package/research/semantic-search/032-research-semantic-search.md +427 -0
- package/research/task-management-2026/00-synthesis-recommendations.md +295 -0
- package/research/task-management-2026/01-ai-workflow-tools.md +416 -0
- package/research/task-management-2026/02-agent-framework-patterns.md +476 -0
- package/research/task-management-2026/03-lightweight-file-based.md +567 -0
- package/research/task-management-2026/04-established-tools-ai-features.md +541 -0
- package/research/task-management-2026/linear/01-core-features-workflow.md +771 -0
- package/research/task-management-2026/linear/02-api-integrations.md +930 -0
- package/research/task-management-2026/linear/03-ai-features.md +368 -0
- package/research/task-management-2026/linear/04-pricing-setup.md +205 -0
- package/research/task-management-2026/linear/05-usage-patterns-best-practices.md +605 -0
- package/scripts/rebuild-hnswlib.js +63 -0
- package/src/cli/argv-preprocessor.test.ts +210 -0
- package/src/cli/argv-preprocessor.ts +202 -0
- package/src/cli/cli.test.ts +430 -0
- package/src/cli/commands/backlinks.ts +54 -0
- package/src/cli/commands/context.ts +197 -0
- package/src/cli/commands/index-cmd.ts +300 -0
- package/src/cli/commands/index.ts +13 -0
- package/src/cli/commands/links.ts +52 -0
- package/src/cli/commands/search.ts +451 -0
- package/src/cli/commands/stats.ts +146 -0
- package/src/cli/commands/tree.ts +107 -0
- package/src/cli/flag-schemas.ts +275 -0
- package/src/cli/help.ts +386 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/main.ts +145 -0
- package/src/cli/options.ts +31 -0
- package/src/cli/typo-suggester.test.ts +105 -0
- package/src/cli/typo-suggester.ts +130 -0
- package/src/cli/utils.ts +126 -0
- package/src/core/index.ts +1 -0
- package/src/core/types.ts +140 -0
- package/src/embeddings/index.ts +8 -0
- package/src/embeddings/openai-provider.ts +165 -0
- package/src/embeddings/semantic-search.ts +583 -0
- package/src/embeddings/types.ts +82 -0
- package/src/embeddings/vector-store.ts +299 -0
- package/src/index/index.ts +4 -0
- package/src/index/indexer.ts +446 -0
- package/src/index/storage.ts +196 -0
- package/src/index/types.ts +109 -0
- package/src/index/watcher.ts +131 -0
- package/src/index.ts +8 -0
- package/src/mcp/server.ts +483 -0
- package/src/parser/index.ts +1 -0
- package/src/parser/parser.test.ts +291 -0
- package/src/parser/parser.ts +395 -0
- package/src/parser/section-filter.ts +270 -0
- package/src/search/query-parser.test.ts +260 -0
- package/src/search/query-parser.ts +319 -0
- package/src/search/searcher.test.ts +182 -0
- package/src/search/searcher.ts +602 -0
- package/src/summarize/budget-bugs.test.ts +620 -0
- package/src/summarize/formatters.ts +419 -0
- package/src/summarize/index.ts +20 -0
- package/src/summarize/summarizer.test.ts +275 -0
- package/src/summarize/summarizer.ts +528 -0
- package/src/summarize/verify-bugs.test.ts +238 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/tokens.test.ts +142 -0
- package/src/utils/tokens.ts +186 -0
- package/tests/fixtures/cli/.mdcontext/config.json +8 -0
- package/tests/fixtures/cli/.mdcontext/indexes/documents.json +33 -0
- package/tests/fixtures/cli/.mdcontext/indexes/links.json +12 -0
- package/tests/fixtures/cli/.mdcontext/indexes/sections.json +233 -0
- package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/vectors.meta.json +1264 -0
- package/tests/fixtures/cli/README.md +9 -0
- package/tests/fixtures/cli/api-reference.md +11 -0
- package/tests/fixtures/cli/getting-started.md +11 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +21 -0
- 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
|
+
}
|