@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.
- package/README.md +327 -0
- package/package.json +31 -27
- package/src/claude-md-generator.ts +265 -265
- package/src/context-builder.ts +398 -398
- package/src/index.ts +4 -4
- package/src/providers.ts +154 -154
- package/src/types.ts +69 -69
- package/tests/claude-md.test.ts +137 -137
- package/tests/smoke.test.ts +5 -5
- package/tsconfig.json +15 -15
package/src/context-builder.ts
CHANGED
|
@@ -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
|
}
|