@getmikk/ai-context 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@getmikk/ai-context",
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "bun test",
16
+ "publish": "npm publish --access public",
17
+ "dev": "tsc --watch"
18
+ },
19
+ "dependencies": {
20
+ "@getmikk/core": "workspace:*",
21
+ "@getmikk/intent-engine": "workspace:*"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.7.0",
25
+ "@types/node": "^22.0.0"
26
+ }
27
+ }
@@ -0,0 +1,265 @@
1
+ import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
2
+
3
+ /** Default token budget for claude.md — prevents bloating the context window */
4
+ const DEFAULT_TOKEN_BUDGET = 6000
5
+
6
+ /** Rough token estimation: ~4 chars per token */
7
+ function estimateTokens(text: string): number {
8
+ return Math.ceil(text.length / 4)
9
+ }
10
+
11
+ /**
12
+ * ClaudeMdGenerator — generates an always-accurate `claude.md` and `AGENTS.md`
13
+ * from the lock file and contract. Every function name, file path, and module
14
+ * relationship is sourced from the AST-derived lock file — never hand-authored.
15
+ *
16
+ * Tiered system per spec:
17
+ * Tier 1: Summary (~500 tokens) — always included
18
+ * Tier 2: Module details (~300 tokens/module) — included if budget allows
19
+ * Tier 3: Recent changes (~50 tokens/change) — last section added
20
+ */
21
+ export class ClaudeMdGenerator {
22
+ constructor(
23
+ private contract: MikkContract,
24
+ private lock: MikkLock,
25
+ private tokenBudget: number = DEFAULT_TOKEN_BUDGET
26
+ ) { }
27
+
28
+ /** Generate the full claude.md content */
29
+ generate(): string {
30
+ const sections: string[] = []
31
+ let usedTokens = 0
32
+
33
+ // ── Tier 1: Summary (always included) ──────────────────────
34
+ const summary = this.generateSummary()
35
+ sections.push(summary)
36
+ usedTokens += estimateTokens(summary)
37
+
38
+ // ── Tier 2: Module details (if budget allows) ──────────────
39
+ const modules = this.getModulesSortedByDependencyOrder()
40
+ for (const module of modules) {
41
+ const moduleSection = this.generateModuleSection(module.id)
42
+ const tokens = estimateTokens(moduleSection)
43
+ if (usedTokens + tokens > this.tokenBudget) {
44
+ sections.push('\n> Full details available in `mikk.lock.json`\n')
45
+ break
46
+ }
47
+ sections.push(moduleSection)
48
+ usedTokens += tokens
49
+ }
50
+
51
+ // ── Tier 3: Constraints & decisions ────────────────────────
52
+ const constraintsSection = this.generateConstraintsSection()
53
+ const constraintTokens = estimateTokens(constraintsSection)
54
+ if (usedTokens + constraintTokens <= this.tokenBudget) {
55
+ sections.push(constraintsSection)
56
+ usedTokens += constraintTokens
57
+ }
58
+
59
+ const decisionsSection = this.generateDecisionsSection()
60
+ const decisionTokens = estimateTokens(decisionsSection)
61
+ if (usedTokens + decisionTokens <= this.tokenBudget) {
62
+ sections.push(decisionsSection)
63
+ usedTokens += decisionTokens
64
+ }
65
+
66
+ return sections.join('\n')
67
+ }
68
+
69
+ // ── Tier 1: Summary ───────────────────────────────────────────
70
+
71
+ private generateSummary(): string {
72
+ const lines: string[] = []
73
+ const moduleCount = this.contract.declared.modules.length
74
+ const functionCount = Object.keys(this.lock.functions).length
75
+ const fileCount = Object.keys(this.lock.files).length
76
+
77
+ lines.push(`# ${this.contract.project.name} — Architecture Overview`)
78
+ lines.push('')
79
+
80
+ if (this.contract.project.description) {
81
+ lines.push('## What this project does')
82
+ lines.push(this.contract.project.description)
83
+ lines.push('')
84
+ }
85
+
86
+ lines.push('## Modules')
87
+ for (const module of this.contract.declared.modules) {
88
+ const fnCount = Object.values(this.lock.functions)
89
+ .filter(f => f.moduleId === module.id).length
90
+ const desc = module.intent || module.description || ''
91
+ const descStr = desc ? ` — ${desc}` : ''
92
+ lines.push(`- **${module.name}** (\`${module.id}\`): ${fnCount} functions${descStr}`)
93
+ }
94
+ lines.push('')
95
+
96
+ lines.push(`## Stats`)
97
+ lines.push(`- ${fileCount} files, ${functionCount} functions, ${moduleCount} modules`)
98
+ lines.push(`- Language: ${this.contract.project.language}`)
99
+ lines.push('')
100
+
101
+ // Critical constraints summary
102
+ if (this.contract.declared.constraints.length > 0) {
103
+ lines.push('## Critical Constraints')
104
+ for (const c of this.contract.declared.constraints) {
105
+ lines.push(`- ${c}`)
106
+ }
107
+ lines.push('')
108
+ }
109
+
110
+ return lines.join('\n')
111
+ }
112
+
113
+ // ── Tier 2: Module Details ────────────────────────────────────
114
+
115
+ private generateModuleSection(moduleId: string): string {
116
+ const module = this.contract.declared.modules.find(m => m.id === moduleId)
117
+ if (!module) return ''
118
+
119
+ const lines: string[] = []
120
+ const moduleFunctions = Object.values(this.lock.functions)
121
+ .filter(f => f.moduleId === moduleId)
122
+
123
+ lines.push(`## ${module.name} module`)
124
+
125
+ // Location
126
+ if (module.paths.length > 0) {
127
+ lines.push(`**Location:** ${module.paths.join(', ')}`)
128
+ }
129
+
130
+ // Intent
131
+ if (module.intent) {
132
+ lines.push(`**Purpose:** ${module.intent}`)
133
+ } else if (module.description) {
134
+ lines.push(`**Purpose:** ${module.description}`)
135
+ }
136
+
137
+ lines.push('')
138
+
139
+ // Entry points: functions with no calledBy (likely public API surface)
140
+ const entryPoints = moduleFunctions
141
+ .filter(fn => fn.calledBy.length === 0)
142
+ .sort((a, b) => b.calls.length - a.calls.length)
143
+ .slice(0, 5)
144
+
145
+ if (entryPoints.length > 0) {
146
+ lines.push('**Entry points:**')
147
+ for (const fn of entryPoints) {
148
+ const sig = this.formatSignature(fn)
149
+ const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
150
+ lines.push(` - \`${sig}\`${purpose}`)
151
+ }
152
+ lines.push('')
153
+ }
154
+
155
+ // Key functions: top 5 by calledBy count (most depended upon)
156
+ const keyFunctions = [...moduleFunctions]
157
+ .sort((a, b) => b.calledBy.length - a.calledBy.length)
158
+ .filter(fn => fn.calledBy.length > 0)
159
+ .slice(0, 5)
160
+
161
+ if (keyFunctions.length > 0) {
162
+ lines.push('**Key internal functions:**')
163
+ for (const fn of keyFunctions) {
164
+ const callerCount = fn.calledBy.length
165
+ const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
166
+ lines.push(` - \`${fn.name}\` (called by ${callerCount})${purpose}`)
167
+ }
168
+ lines.push('')
169
+ }
170
+
171
+ // Dependencies: other modules this module imports from
172
+ const depModuleIds = new Set<string>()
173
+ for (const fn of moduleFunctions) {
174
+ for (const callId of fn.calls) {
175
+ const target = this.lock.functions[callId]
176
+ if (target && target.moduleId !== moduleId) {
177
+ depModuleIds.add(target.moduleId)
178
+ }
179
+ }
180
+ }
181
+
182
+ if (depModuleIds.size > 0) {
183
+ const depNames = [...depModuleIds].map(id => {
184
+ const mod = this.contract.declared.modules.find(m => m.id === id)
185
+ return mod?.name || id
186
+ })
187
+ lines.push(`**Depends on:** ${depNames.join(', ')}`)
188
+ lines.push('')
189
+ }
190
+
191
+ // Module-specific constraints
192
+ const moduleConstraints = this.contract.declared.constraints.filter(c =>
193
+ c.toLowerCase().includes(moduleId.toLowerCase()) ||
194
+ c.toLowerCase().includes(module.name.toLowerCase())
195
+ )
196
+ if (moduleConstraints.length > 0) {
197
+ lines.push('**Constraints:**')
198
+ for (const c of moduleConstraints) {
199
+ lines.push(` - ${c}`)
200
+ }
201
+ lines.push('')
202
+ }
203
+
204
+ return lines.join('\n')
205
+ }
206
+
207
+ // ── Tier 3: Constraints & Decisions ───────────────────────────
208
+
209
+ private generateConstraintsSection(): string {
210
+ if (this.contract.declared.constraints.length === 0) return ''
211
+ const lines: string[] = []
212
+ lines.push('## Cross-Cutting Constraints')
213
+ for (const c of this.contract.declared.constraints) {
214
+ lines.push(`- ${c}`)
215
+ }
216
+ lines.push('')
217
+ return lines.join('\n')
218
+ }
219
+
220
+ private generateDecisionsSection(): string {
221
+ if (this.contract.declared.decisions.length === 0) return ''
222
+ const lines: string[] = []
223
+ lines.push('## Architectural Decisions')
224
+ for (const d of this.contract.declared.decisions) {
225
+ lines.push(`- **${d.title}:** ${d.reason}`)
226
+ }
227
+ lines.push('')
228
+ return lines.join('\n')
229
+ }
230
+
231
+ // ── Helpers ───────────────────────────────────────────────────
232
+
233
+ /** Format a function into a readable signature */
234
+ private formatSignature(fn: MikkLockFunction): string {
235
+ return `${fn.name}() [${fn.file}:${fn.startLine}]`
236
+ }
237
+
238
+ /** Sort modules by inter-module dependency order (depended-on modules first) */
239
+ private getModulesSortedByDependencyOrder(): typeof this.contract.declared.modules {
240
+ const modules = [...this.contract.declared.modules]
241
+ const dependencyCount = new Map<string, number>()
242
+
243
+ for (const mod of modules) {
244
+ dependencyCount.set(mod.id, 0)
245
+ }
246
+
247
+ // Count how many other modules depend on each module
248
+ for (const fn of Object.values(this.lock.functions)) {
249
+ for (const callId of fn.calls) {
250
+ const target = this.lock.functions[callId]
251
+ if (target && target.moduleId !== fn.moduleId) {
252
+ dependencyCount.set(
253
+ target.moduleId,
254
+ (dependencyCount.get(target.moduleId) || 0) + 1
255
+ )
256
+ }
257
+ }
258
+ }
259
+
260
+ // Sort: most depended-on first
261
+ return modules.sort((a, b) =>
262
+ (dependencyCount.get(b.id) || 0) - (dependencyCount.get(a.id) || 0)
263
+ )
264
+ }
265
+ }
@@ -0,0 +1,399 @@
1
+ import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
2
+ import type { AIContext, ContextQuery, ContextModule, ContextFunction } from './types.js'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Scoring weights — tune these to adjust what "relevant" means
6
+ // ---------------------------------------------------------------------------
7
+ const WEIGHT = {
8
+ // Call-graph proximity (closer = more relevant)
9
+ DIRECT_CALL: 1.00, // fn directly calls or is called by focus node
10
+ HOP_2: 0.60, // 2 hops away
11
+ HOP_3: 0.35,
12
+ HOP_4: 0.15,
13
+ // Name/keyword match
14
+ KEYWORD_EXACT: 0.90, // function name exactly matches a task keyword
15
+ KEYWORD_PARTIAL: 0.45, // function name contains a task keyword
16
+ // Entry-point bonus — functions nothing calls deserve attention
17
+ ENTRY_POINT: 0.20,
18
+ // Exported function bonus
19
+ EXPORTED: 0.10,
20
+ }
21
+
22
+ // Default token budget per context payload
23
+ const DEFAULT_TOKEN_BUDGET = 6000
24
+
25
+ /**
26
+ * Rough token estimator: 1 token ≈ 4 chars for code/identifiers
27
+ */
28
+ function estimateTokens(text: string): number {
29
+ return Math.ceil(text.length / 4)
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Graph traversal helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * BFS from a set of seed node IDs, walking BOTH upstream and downstream
38
+ * edges up to `maxDepth` hops. Returns a Map<nodeId, depth>.
39
+ */
40
+ function bfsNeighbors(
41
+ seeds: string[],
42
+ functions: Record<string, MikkLockFunction>,
43
+ maxDepth: number
44
+ ): Map<string, number> {
45
+ const visited = new Map<string, number>()
46
+ const queue: { id: string; depth: number }[] = seeds.map(id => ({ id, depth: 0 }))
47
+
48
+ while (queue.length > 0) {
49
+ const { id, depth } = queue.shift()!
50
+ if (visited.has(id)) continue
51
+ visited.set(id, depth)
52
+ if (depth >= maxDepth) continue
53
+
54
+ const fn = functions[id]
55
+ if (!fn) continue
56
+
57
+ // Walk downstream (what this fn calls)
58
+ for (const callee of fn.calls) {
59
+ if (!visited.has(callee)) {
60
+ queue.push({ id: callee, depth: depth + 1 })
61
+ }
62
+ }
63
+ // Walk upstream (what calls this fn)
64
+ for (const caller of fn.calledBy) {
65
+ if (!visited.has(caller)) {
66
+ queue.push({ id: caller, depth: depth + 1 })
67
+ }
68
+ }
69
+ }
70
+
71
+ return visited
72
+ }
73
+
74
+ /**
75
+ * Convert a depth value to a relevance score using the WEIGHT table.
76
+ */
77
+ function depthToScore(depth: number): number {
78
+ switch (depth) {
79
+ case 0: return 1.0
80
+ case 1: return WEIGHT.DIRECT_CALL
81
+ case 2: return WEIGHT.HOP_2
82
+ case 3: return WEIGHT.HOP_3
83
+ default: return WEIGHT.HOP_4
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Keyword extraction — pull meaningful tokens from the task string
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const STOP_WORDS = new Set([
92
+ 'a', 'an', 'the', 'and', 'or', 'for', 'in', 'on', 'of', 'to',
93
+ 'how', 'does', 'do', 'is', 'are', 'add', 'new', 'create', 'make',
94
+ 'update', 'fix', 'get', 'set', 'this', 'that', 'with', 'from',
95
+ 'what', 'where', 'when', 'why', 'should', 'can', 'will', 'need',
96
+ 'want', 'like', 'just', 'also', 'some', 'all', 'any', 'my', 'your',
97
+ ])
98
+
99
+ function extractKeywords(task: string): string[] {
100
+ return task
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9\s_-]/g, ' ')
103
+ .split(/\s+/)
104
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w))
105
+ }
106
+
107
+ /**
108
+ * Keyword score for a function: exact match > partial match
109
+ */
110
+ function keywordScore(fn: MikkLockFunction, keywords: string[]): number {
111
+ if (keywords.length === 0) return 0
112
+ const nameLower = fn.name.toLowerCase()
113
+ const fileLower = fn.file.toLowerCase()
114
+ let score = 0
115
+
116
+ for (const kw of keywords) {
117
+ if (nameLower === kw) {
118
+ score = Math.max(score, WEIGHT.KEYWORD_EXACT)
119
+ } else if (nameLower.includes(kw) || fileLower.includes(kw)) {
120
+ score = Math.max(score, WEIGHT.KEYWORD_PARTIAL)
121
+ }
122
+ }
123
+ return score
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Seed resolution — find the best starting nodes for graph traversal
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Find seed function IDs from focusFiles, focusModules, or task keywords.
132
+ * Seeds are the "center of gravity" for the BFS walk.
133
+ */
134
+ function resolveSeeds(
135
+ query: ContextQuery,
136
+ contract: MikkContract,
137
+ lock: MikkLock
138
+ ): string[] {
139
+ const seeds = new Set<string>()
140
+
141
+ // 1. Explicit focus files → all functions in those files
142
+ if (query.focusFiles && query.focusFiles.length > 0) {
143
+ for (const filePath of query.focusFiles) {
144
+ for (const fn of Object.values(lock.functions)) {
145
+ if (fn.file.includes(filePath) || filePath.includes(fn.file)) {
146
+ seeds.add(fn.id)
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // 2. Explicit focus modules → all functions in those modules
153
+ if (query.focusModules && query.focusModules.length > 0) {
154
+ for (const modId of query.focusModules) {
155
+ for (const fn of Object.values(lock.functions)) {
156
+ if (fn.moduleId === modId) seeds.add(fn.id)
157
+ }
158
+ }
159
+ }
160
+
161
+ // 3. Keyword match against function names and file paths
162
+ if (seeds.size === 0) {
163
+ const keywords = extractKeywords(query.task)
164
+ for (const fn of Object.values(lock.functions)) {
165
+ if (keywordScore(fn, keywords) >= WEIGHT.KEYWORD_PARTIAL) {
166
+ seeds.add(fn.id)
167
+ }
168
+ }
169
+ }
170
+
171
+ // 4. Module name match against task
172
+ if (seeds.size === 0) {
173
+ const taskLower = query.task.toLowerCase()
174
+ for (const mod of contract.declared.modules) {
175
+ if (
176
+ taskLower.includes(mod.id.toLowerCase()) ||
177
+ taskLower.includes(mod.name.toLowerCase())
178
+ ) {
179
+ for (const fn of Object.values(lock.functions)) {
180
+ if (fn.moduleId === mod.id) seeds.add(fn.id)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return [...seeds]
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Main ContextBuilder
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export class ContextBuilder {
194
+ constructor(
195
+ private contract: MikkContract,
196
+ private lock: MikkLock
197
+ ) { }
198
+
199
+ /**
200
+ * Build AI context for a given query.
201
+ *
202
+ * Algorithm:
203
+ * 1. Resolve seed nodes from focusFiles / focusModules / keyword match
204
+ * 2. BFS outward up to maxHops, collecting proximity scores
205
+ * 3. Add keyword scores on top
206
+ * 4. Sort all functions by total score, descending
207
+ * 5. Fill a token budget greedily — highest-scored functions first
208
+ * 6. Group survivors by module, emit structured context
209
+ */
210
+ build(query: ContextQuery): AIContext {
211
+ const tokenBudget = query.tokenBudget ?? DEFAULT_TOKEN_BUDGET
212
+ const maxHops = query.maxHops ?? 4
213
+
214
+ // ── Step 1: Resolve seeds ──────────────────────────────────────────
215
+ const seeds = resolveSeeds(query, this.contract, this.lock)
216
+
217
+ // ── Step 2: BFS proximity scores ──────────────────────────────────
218
+ const proximityMap = seeds.length > 0
219
+ ? bfsNeighbors(seeds, this.lock.functions, maxHops)
220
+ : new Map<string, number>()
221
+
222
+ // ── Step 3: Score every function ──────────────────────────────────
223
+ const keywords = extractKeywords(query.task)
224
+ const allFunctions = Object.values(this.lock.functions)
225
+
226
+ const scored: { fn: MikkLockFunction; score: number }[] = allFunctions.map(fn => {
227
+ let score = 0
228
+
229
+ // Proximity from BFS
230
+ const depth = proximityMap.get(fn.id)
231
+ if (depth !== undefined) {
232
+ score += depthToScore(depth)
233
+ }
234
+
235
+ // Keyword match
236
+ score += keywordScore(fn, keywords)
237
+
238
+ // Entry-point bonus
239
+ if (fn.calledBy.length === 0) score += WEIGHT.ENTRY_POINT
240
+
241
+ return { fn, score }
242
+ })
243
+
244
+ // ── Step 4: Sort by score descending ──────────────────────────────
245
+ scored.sort((a, b) => b.score - a.score)
246
+
247
+ // ── Step 5: Fill token budget ──────────────────────────────────────
248
+ const selected: MikkLockFunction[] = []
249
+ let usedTokens = 0
250
+
251
+ for (const { fn, score } of scored) {
252
+ if (score <= 0 && seeds.length > 0) break // Nothing relevant left
253
+ if (selected.length >= (query.maxFunctions ?? 80)) break
254
+
255
+ const snippet = this.buildFunctionSnippet(fn, query)
256
+ const tokens = estimateTokens(snippet)
257
+
258
+ if (usedTokens + tokens > tokenBudget) continue // skip, try smaller ones later
259
+ selected.push(fn)
260
+ usedTokens += tokens
261
+ }
262
+
263
+ // ── Step 6: Group by module ────────────────────────────────────────
264
+ const byModule = new Map<string, MikkLockFunction[]>()
265
+ for (const fn of selected) {
266
+ if (!byModule.has(fn.moduleId)) byModule.set(fn.moduleId, [])
267
+ byModule.get(fn.moduleId)!.push(fn)
268
+ }
269
+
270
+ const contextModules: ContextModule[] = []
271
+ for (const [modId, fns] of byModule) {
272
+ const modDef = this.contract.declared.modules.find(m => m.id === modId)
273
+ const moduleFiles = Object.values(this.lock.files)
274
+ .filter(f => f.moduleId === modId)
275
+ .map(f => f.path)
276
+
277
+ contextModules.push({
278
+ id: modId,
279
+ name: modDef?.name ?? modId,
280
+ description: modDef?.description ?? '',
281
+ intent: modDef?.intent,
282
+ functions: fns.map(fn => this.toContextFunction(fn, query)),
283
+ files: moduleFiles,
284
+ })
285
+ }
286
+
287
+ // Sort modules: ones with more selected functions first
288
+ contextModules.sort((a, b) => b.functions.length - a.functions.length)
289
+
290
+ return {
291
+ project: {
292
+ name: this.contract.project.name,
293
+ language: this.contract.project.language,
294
+ description: this.contract.project.description,
295
+ moduleCount: this.contract.declared.modules.length,
296
+ functionCount: Object.keys(this.lock.functions).length,
297
+ },
298
+ modules: contextModules,
299
+ constraints: this.contract.declared.constraints,
300
+ decisions: this.contract.declared.decisions.map(d => ({
301
+ title: d.title,
302
+ reason: d.reason,
303
+ })),
304
+ prompt: this.generatePrompt(query, contextModules),
305
+ meta: {
306
+ seedCount: seeds.length,
307
+ totalFunctionsConsidered: allFunctions.length,
308
+ selectedFunctions: selected.length,
309
+ estimatedTokens: usedTokens,
310
+ keywords,
311
+ },
312
+ }
313
+ }
314
+
315
+ // ── Private helpers ────────────────────────────────────────────────────
316
+
317
+ private toContextFunction(fn: MikkLockFunction, query: ContextQuery): ContextFunction {
318
+ return {
319
+ name: fn.name,
320
+ file: fn.file,
321
+ startLine: fn.startLine,
322
+ endLine: fn.endLine,
323
+ calls: query.includeCallGraph !== false ? fn.calls : [],
324
+ calledBy: query.includeCallGraph !== false ? fn.calledBy : [],
325
+ purpose: fn.purpose,
326
+ errorHandling: fn.errorHandling?.map(e => `${e.type} @ line ${e.line}: ${e.detail}`),
327
+ edgeCases: fn.edgeCasesHandled,
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Build a compact text snippet for token estimation.
333
+ * Mirrors what the providers will emit.
334
+ */
335
+ private buildFunctionSnippet(fn: MikkLockFunction, query: ContextQuery): string {
336
+ const parts = [`${fn.name}(${fn.file}:${fn.startLine}-${fn.endLine})`]
337
+ if (fn.purpose) parts.push(` — ${fn.purpose}`)
338
+ if (query.includeCallGraph !== false && fn.calls.length > 0) {
339
+ parts.push(` calls:[${fn.calls.join(',')}]`)
340
+ }
341
+ return parts.join('')
342
+ }
343
+
344
+ /** Generate the natural-language prompt section */
345
+ private generatePrompt(query: ContextQuery, modules: ContextModule[]): string {
346
+ const lines: string[] = []
347
+
348
+ lines.push('=== ARCHITECTURAL CONTEXT ===')
349
+ lines.push(`Project: ${this.contract.project.name} (${this.contract.project.language})`)
350
+ if (this.contract.project.description) {
351
+ lines.push(`Description: ${this.contract.project.description}`)
352
+ }
353
+ lines.push(`Task: ${query.task}`)
354
+ lines.push('')
355
+
356
+ for (const mod of modules) {
357
+ lines.push(`--- Module: ${mod.name} (${mod.id}) ---`)
358
+ if (mod.description) lines.push(mod.description)
359
+ if (mod.intent) lines.push(`Intent: ${mod.intent}`)
360
+ lines.push('')
361
+
362
+ for (const fn of mod.functions) {
363
+ const callStr = fn.calls.length > 0
364
+ ? ` → [${fn.calls.join(', ')}]`
365
+ : ''
366
+ const calledByStr = fn.calledBy.length > 0
367
+ ? ` ← called by [${fn.calledBy.join(', ')}]`
368
+ : ''
369
+ lines.push(` ${fn.name} ${fn.file}:${fn.startLine}-${fn.endLine}${callStr}${calledByStr}`)
370
+ if (fn.purpose) lines.push(` purpose: ${fn.purpose}`)
371
+ if (fn.edgeCases && fn.edgeCases.length > 0) {
372
+ lines.push(` edge cases: ${fn.edgeCases.join('; ')}`)
373
+ }
374
+ if (fn.errorHandling && fn.errorHandling.length > 0) {
375
+ lines.push(` error handling: ${fn.errorHandling.join('; ')}`)
376
+ }
377
+ }
378
+ lines.push('')
379
+ }
380
+
381
+ if (this.contract.declared.constraints.length > 0) {
382
+ lines.push('=== CONSTRAINTS (MUST follow) ===')
383
+ for (const c of this.contract.declared.constraints) {
384
+ lines.push(` • ${c}`)
385
+ }
386
+ lines.push('')
387
+ }
388
+
389
+ if (this.contract.declared.decisions.length > 0) {
390
+ lines.push('=== ARCHITECTURAL DECISIONS ===')
391
+ for (const d of this.contract.declared.decisions) {
392
+ lines.push(` • ${d.title}: ${d.reason}`)
393
+ }
394
+ lines.push('')
395
+ }
396
+
397
+ return lines.join('\n')
398
+ }
399
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { ContextBuilder } from './context-builder.js'
2
+ export { ClaudeMdGenerator } from './claude-md-generator.js'
3
+ export { ClaudeProvider, GenericProvider, getProvider } from './providers.js'
4
+ export type { AIContext, ContextModule, ContextFunction, ContextQuery, ContextProvider } from './types.js'
@@ -0,0 +1,155 @@
1
+ import type { AIContext, ContextProvider } from './types.js'
2
+
3
+ /**
4
+ * ClaudeProvider — formats context for Anthropic Claude models.
5
+ *
6
+ * Uses structured XML tags so Claude can parse boundaries clearly.
7
+ * Includes meta block so the model knows how much context was trimmed.
8
+ */
9
+ export class ClaudeProvider implements ContextProvider {
10
+ name = 'claude'
11
+ maxTokens = 200000
12
+
13
+ formatContext(context: AIContext): string {
14
+ const lines: string[] = []
15
+
16
+ lines.push('<mikk_context>')
17
+
18
+ // ── Project ────────────────────────────────────────────────────────
19
+ lines.push(`<project name="${esc(context.project.name)}" language="${esc(context.project.language)}">`)
20
+ lines.push(` <description>${esc(context.project.description)}</description>`)
21
+ lines.push(` <stats modules="${context.project.moduleCount}" functions="${context.project.functionCount}"/>`)
22
+ lines.push('</project>')
23
+ lines.push('')
24
+
25
+ // ── Context quality meta ───────────────────────────────────────────
26
+ lines.push('<context_meta>')
27
+ lines.push(` <task>${esc(context.meta?.keywords?.join(', ') ?? '')}</task>`)
28
+ lines.push(` <seeds_found>${context.meta?.seedCount ?? 0}</seeds_found>`)
29
+ lines.push(` <functions_selected>${context.meta?.selectedFunctions ?? 0} of ${context.meta?.totalFunctionsConsidered ?? 0}</functions_selected>`)
30
+ lines.push(` <estimated_tokens>${context.meta?.estimatedTokens ?? 0}</estimated_tokens>`)
31
+ lines.push('</context_meta>')
32
+ lines.push('')
33
+
34
+ // ── Modules ────────────────────────────────────────────────────────
35
+ for (const mod of context.modules) {
36
+ lines.push(`<module id="${esc(mod.id)}" name="${esc(mod.name)}">`)
37
+ lines.push(` <description>${esc(mod.description)}</description>`)
38
+ if (mod.intent) lines.push(` <intent>${esc(mod.intent)}</intent>`)
39
+ lines.push(` <files count="${mod.files.length}">`)
40
+ for (const f of mod.files) {
41
+ lines.push(` <file>${esc(f)}</file>`)
42
+ }
43
+ lines.push(' </files>')
44
+
45
+ if (mod.functions.length > 0) {
46
+ lines.push(' <functions>')
47
+ for (const fn of mod.functions) {
48
+ const calls = fn.calls.length > 0
49
+ ? ` calls="${esc(fn.calls.join(','))}"`
50
+ : ''
51
+ const calledBy = fn.calledBy.length > 0
52
+ ? ` calledBy="${esc(fn.calledBy.join(','))}"`
53
+ : ''
54
+ lines.push(` <fn name="${esc(fn.name)}" file="${esc(fn.file)}" lines="${fn.startLine}-${fn.endLine}"${calls}${calledBy}>`)
55
+ if (fn.purpose) lines.push(` <purpose>${esc(fn.purpose)}</purpose>`)
56
+ if (fn.edgeCases && fn.edgeCases.length > 0) {
57
+ lines.push(` <edge_cases>${esc(fn.edgeCases.join('; '))}</edge_cases>`)
58
+ }
59
+ if (fn.errorHandling && fn.errorHandling.length > 0) {
60
+ lines.push(` <error_handling>${esc(fn.errorHandling.join('; '))}</error_handling>`)
61
+ }
62
+ lines.push(' </fn>')
63
+ }
64
+ lines.push(' </functions>')
65
+ }
66
+ lines.push('</module>')
67
+ lines.push('')
68
+ }
69
+
70
+ // ── Constraints ────────────────────────────────────────────────────
71
+ if (context.constraints.length > 0) {
72
+ lines.push('<constraints>')
73
+ for (const c of context.constraints) {
74
+ lines.push(` <constraint>${esc(c)}</constraint>`)
75
+ }
76
+ lines.push('</constraints>')
77
+ lines.push('')
78
+ }
79
+
80
+ // ── Decisions ─────────────────────────────────────────────────────
81
+ if (context.decisions.length > 0) {
82
+ lines.push('<architectural_decisions>')
83
+ for (const d of context.decisions) {
84
+ lines.push(` <decision title="${esc(d.title)}">${esc(d.reason)}</decision>`)
85
+ }
86
+ lines.push('</architectural_decisions>')
87
+ }
88
+
89
+ lines.push('</mikk_context>')
90
+ return lines.join('\n')
91
+ }
92
+ }
93
+
94
+ /**
95
+ * GenericProvider — clean plain-text format for any model.
96
+ * Identical to the natural-language prompt generated by ContextBuilder.
97
+ */
98
+ export class GenericProvider implements ContextProvider {
99
+ name = 'generic'
100
+ maxTokens = 128000
101
+
102
+ formatContext(context: AIContext): string {
103
+ return context.prompt
104
+ }
105
+ }
106
+
107
+ /**
108
+ * CompactProvider — ultra-minimal format for small context windows.
109
+ * One line per function, no XML, no prose.
110
+ */
111
+ export class CompactProvider implements ContextProvider {
112
+ name = 'compact'
113
+ maxTokens = 16000
114
+
115
+ formatContext(context: AIContext): string {
116
+ const lines: string[] = [
117
+ `# ${context.project.name} (${context.project.language})`,
118
+ `Task keywords: ${context.meta?.keywords?.join(', ') ?? ''}`,
119
+ '',
120
+ ]
121
+ for (const mod of context.modules) {
122
+ lines.push(`## ${mod.name}`)
123
+ for (const fn of mod.functions) {
124
+ const calls = fn.calls.length > 0 ? ` → ${fn.calls.join(',')}` : ''
125
+ lines.push(` ${fn.name} [${fn.file}:${fn.startLine}]${calls}`)
126
+ }
127
+ lines.push('')
128
+ }
129
+ if (context.constraints.length > 0) {
130
+ lines.push('CONSTRAINTS: ' + context.constraints.join(' | '))
131
+ }
132
+ return lines.join('\n')
133
+ }
134
+ }
135
+
136
+ export function getProvider(name: string): ContextProvider {
137
+ switch (name.toLowerCase()) {
138
+ case 'claude':
139
+ case 'anthropic':
140
+ return new ClaudeProvider()
141
+ case 'compact':
142
+ return new CompactProvider()
143
+ default:
144
+ return new GenericProvider()
145
+ }
146
+ }
147
+
148
+ /** Minimal XML attribute escaping */
149
+ function esc(s: string): string {
150
+ return s
151
+ .replace(/&/g, '&amp;')
152
+ .replace(/</g, '&lt;')
153
+ .replace(/>/g, '&gt;')
154
+ .replace(/"/g, '&quot;')
155
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
2
+
3
+ /** The structured context object passed to AI models */
4
+ export interface AIContext {
5
+ project: {
6
+ name: string
7
+ language: string
8
+ description: string
9
+ moduleCount: number
10
+ functionCount: number
11
+ }
12
+ modules: ContextModule[]
13
+ constraints: string[]
14
+ decisions: { title: string; reason: string }[]
15
+ prompt: string
16
+ /** Diagnostic info — helpful for debugging context quality */
17
+ meta: {
18
+ seedCount: number
19
+ totalFunctionsConsidered: number
20
+ selectedFunctions: number
21
+ estimatedTokens: number
22
+ keywords: string[]
23
+ }
24
+ }
25
+
26
+ export interface ContextModule {
27
+ id: string
28
+ name: string
29
+ description: string
30
+ intent?: string
31
+ functions: ContextFunction[]
32
+ files: string[]
33
+ }
34
+
35
+ export interface ContextFunction {
36
+ name: string
37
+ file: string
38
+ startLine: number
39
+ endLine: number
40
+ calls: string[]
41
+ calledBy: string[]
42
+ purpose?: string
43
+ errorHandling?: string[]
44
+ edgeCases?: string[]
45
+ }
46
+
47
+ /** Query options for context generation */
48
+ export interface ContextQuery {
49
+ /** The user's task description — the primary relevance signal */
50
+ task: string
51
+ /** Specific files to anchor the graph traversal from */
52
+ focusFiles?: string[]
53
+ /** Specific modules to include */
54
+ focusModules?: string[]
55
+ /** Max functions to include in output (hard cap) */
56
+ maxFunctions?: number
57
+ /** Max BFS hops from seed nodes (default 4) */
58
+ maxHops?: number
59
+ /** Approximate token budget for function listings (default 6000) */
60
+ tokenBudget?: number
61
+ /** Include call graph arrows (default true) */
62
+ includeCallGraph?: boolean
63
+ }
64
+
65
+ /** Context provider interface for different AI platforms */
66
+ export interface ContextProvider {
67
+ name: string
68
+ formatContext(context: AIContext): string
69
+ maxTokens: number
70
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { ClaudeMdGenerator } from '../src/claude-md-generator'
3
+ import type { MikkContract, MikkLock } from '@getmikk/core'
4
+
5
+ const mockContract: MikkContract = {
6
+ version: '1.0.0',
7
+ project: {
8
+ name: 'TestProject',
9
+ description: 'A test project for validating claude.md generation',
10
+ language: 'TypeScript',
11
+ entryPoints: ['src/index.ts'],
12
+ },
13
+ declared: {
14
+ modules: [
15
+ { id: 'auth', name: 'Authentication', description: 'Handles user authentication', intent: 'JWT-based auth flow', paths: ['src/auth/**'] },
16
+ { id: 'api', name: 'API', description: 'REST API layer', paths: ['src/api/**'] },
17
+ ],
18
+ constraints: [
19
+ 'No direct DB access outside db/',
20
+ 'All auth must go through auth.middleware',
21
+ ],
22
+ decisions: [
23
+ { id: 'd1', title: 'Use JWT', reason: 'Stateless auth for scalability', date: '2024-01-01' },
24
+ ],
25
+ },
26
+ overwrite: { mode: 'never', requireConfirmation: true },
27
+ }
28
+
29
+ const mockLock: MikkLock = {
30
+ version: '1.0.0',
31
+ generatedAt: new Date().toISOString(),
32
+ generatorVersion: '1.1.0',
33
+ projectRoot: '/test',
34
+ syncState: { status: 'clean', lastSyncAt: new Date().toISOString(), lockHash: 'a', contractHash: 'b' },
35
+ modules: {
36
+ auth: { id: 'auth', files: ['src/auth/verify.ts'], hash: 'h1', fragmentPath: '.mikk/fragments/auth.json' },
37
+ api: { id: 'api', files: ['src/api/login.ts'], hash: 'h2', fragmentPath: '.mikk/fragments/api.json' },
38
+ },
39
+ functions: {
40
+ 'fn:auth:verifyToken': {
41
+ id: 'fn:auth:verifyToken', name: 'verifyToken', file: 'src/auth/verify.ts',
42
+ startLine: 1, endLine: 10, hash: 'h1', calls: [], calledBy: ['fn:api:handleLogin'],
43
+ moduleId: 'auth', purpose: 'Verify JWT tokens',
44
+ },
45
+ 'fn:auth:refreshToken': {
46
+ id: 'fn:auth:refreshToken', name: 'refreshToken', file: 'src/auth/refresh.ts',
47
+ startLine: 1, endLine: 15, hash: 'h2', calls: [], calledBy: [],
48
+ moduleId: 'auth',
49
+ },
50
+ 'fn:api:handleLogin': {
51
+ id: 'fn:api:handleLogin', name: 'handleLogin', file: 'src/api/login.ts',
52
+ startLine: 1, endLine: 20, hash: 'h3', calls: ['fn:auth:verifyToken'], calledBy: [],
53
+ moduleId: 'api',
54
+ },
55
+ },
56
+ files: {
57
+ 'src/auth/verify.ts': { path: 'src/auth/verify.ts', hash: 'fh1', moduleId: 'auth', lastModified: new Date().toISOString() },
58
+ 'src/api/login.ts': { path: 'src/api/login.ts', hash: 'fh2', moduleId: 'api', lastModified: new Date().toISOString() },
59
+ },
60
+ graph: { nodes: 3, edges: 1, rootHash: 'root' },
61
+ }
62
+
63
+ describe('ClaudeMdGenerator', () => {
64
+ test('generates valid markdown', () => {
65
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
66
+ const md = gen.generate()
67
+ expect(md).toContain('# TestProject')
68
+ expect(md).toContain('Architecture Overview')
69
+ })
70
+
71
+ test('includes project description', () => {
72
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
73
+ const md = gen.generate()
74
+ expect(md).toContain('A test project for validating claude.md generation')
75
+ })
76
+
77
+ test('includes module sections', () => {
78
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
79
+ const md = gen.generate()
80
+ expect(md).toContain('Authentication module')
81
+ expect(md).toContain('API module')
82
+ })
83
+
84
+ test('includes function names', () => {
85
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
86
+ const md = gen.generate()
87
+ expect(md).toContain('verifyToken')
88
+ expect(md).toContain('handleLogin')
89
+ })
90
+
91
+ test('includes constraints', () => {
92
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
93
+ const md = gen.generate()
94
+ expect(md).toContain('No direct DB access outside db/')
95
+ })
96
+
97
+ test('includes decisions', () => {
98
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
99
+ const md = gen.generate()
100
+ expect(md).toContain('Use JWT')
101
+ expect(md).toContain('Stateless auth for scalability')
102
+ })
103
+
104
+ test('includes dependency info', () => {
105
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
106
+ const md = gen.generate()
107
+ // API depends on Auth (handleLogin calls verifyToken)
108
+ expect(md).toContain('Depends on')
109
+ })
110
+
111
+ test('respects token budget', () => {
112
+ // Use a very small budget — should truncate
113
+ const gen = new ClaudeMdGenerator(mockContract, mockLock, 200)
114
+ const md = gen.generate()
115
+ // Should have the summary but may be truncated
116
+ expect(md).toContain('TestProject')
117
+ })
118
+
119
+ test('includes stats', () => {
120
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
121
+ const md = gen.generate()
122
+ expect(md).toContain('3 functions')
123
+ expect(md).toContain('2 modules')
124
+ })
125
+
126
+ test('shows purpose when available', () => {
127
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
128
+ const md = gen.generate()
129
+ expect(md).toContain('Verify JWT tokens')
130
+ })
131
+
132
+ test('shows calledBy count for key functions', () => {
133
+ const gen = new ClaudeMdGenerator(mockContract, mockLock)
134
+ const md = gen.generate()
135
+ expect(md).toContain('called by 1')
136
+ })
137
+ })
@@ -0,0 +1,5 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("smoke test - ai-context", () => {
4
+ expect(true).toBe(true);
5
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": [
8
+ "src/**/*"
9
+ ],
10
+ "exclude": [
11
+ "node_modules",
12
+ "dist",
13
+ "tests"
14
+ ]
15
+ }