@getmikk/ai-context 1.2.0 → 1.3.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.
@@ -1,399 +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
- }
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
399
  }