@getmikk/ai-context 1.7.1 → 1.9.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 +59 -288
- package/package.json +3 -3
- package/src/claude-md-generator.ts +69 -73
- package/src/context-builder.ts +196 -22
- package/src/providers.ts +42 -1
- package/src/token-counter.ts +224 -0
- package/src/types.ts +15 -1
- package/tests/claude-md.test.ts +88 -8
- package/tests/context-builder.test.ts +159 -0
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
2
|
import * as fs from 'node:fs'
|
|
3
3
|
import * as path from 'node:path'
|
|
4
|
+
import { countTokens, estimateFileTokens } from './token-counter.js'
|
|
4
5
|
|
|
5
6
|
/** Default token budget for claude.md — generous but still bounded */
|
|
6
7
|
const DEFAULT_TOKEN_BUDGET = 12000
|
|
7
8
|
|
|
8
|
-
/** Rough token estimation: ~4 chars per token */
|
|
9
|
-
function estimateTokens(text: string): number {
|
|
10
|
-
return Math.ceil(text.length / 4)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
9
|
/** Metadata from package.json that enriches the AI context */
|
|
14
10
|
export interface ProjectMeta {
|
|
15
11
|
description?: string
|
|
@@ -46,26 +42,26 @@ export class ClaudeMdGenerator {
|
|
|
46
42
|
const sections: string[] = []
|
|
47
43
|
let usedTokens = 0
|
|
48
44
|
|
|
49
|
-
//
|
|
45
|
+
// --- Tier 1: Summary (always included) ----------------------
|
|
50
46
|
const summary = this.generateSummary()
|
|
51
47
|
sections.push(summary)
|
|
52
|
-
usedTokens +=
|
|
48
|
+
usedTokens += countTokens(summary)
|
|
53
49
|
|
|
54
|
-
//
|
|
50
|
+
// --- Tech stack & conventions (always included if detectable) ---
|
|
55
51
|
const techSection = this.generateTechStackSection()
|
|
56
52
|
if (techSection) {
|
|
57
53
|
sections.push(techSection)
|
|
58
|
-
usedTokens +=
|
|
54
|
+
usedTokens += countTokens(techSection)
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
//
|
|
57
|
+
// --- Build / test / run commands -----------------------------
|
|
62
58
|
const commandsSection = this.generateCommandsSection()
|
|
63
59
|
if (commandsSection) {
|
|
64
60
|
sections.push(commandsSection)
|
|
65
|
-
usedTokens +=
|
|
61
|
+
usedTokens += countTokens(commandsSection)
|
|
66
62
|
}
|
|
67
63
|
|
|
68
|
-
//
|
|
64
|
+
// --- Tier 2: Module details (if budget allows) --------------
|
|
69
65
|
// Skip modules with zero functions — they waste AI tokens
|
|
70
66
|
const modules = this.getModulesSortedByDependencyOrder()
|
|
71
67
|
.filter(m => {
|
|
@@ -76,55 +72,57 @@ export class ClaudeMdGenerator {
|
|
|
76
72
|
|
|
77
73
|
for (const module of modules) {
|
|
78
74
|
const moduleSection = this.generateModuleSection(module.id)
|
|
79
|
-
const tokens =
|
|
75
|
+
const tokens = countTokens(moduleSection)
|
|
80
76
|
if (usedTokens + tokens > this.tokenBudget) {
|
|
81
|
-
sections.push('\n
|
|
77
|
+
sections.push('\n <!-- Full details truncated due to context budget -->\n')
|
|
82
78
|
break
|
|
83
79
|
}
|
|
84
80
|
sections.push(moduleSection)
|
|
85
81
|
usedTokens += tokens
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
|
|
84
|
+
sections.push('</modules>\n')
|
|
85
|
+
|
|
86
|
+
// --- Context files: schemas, data models, config ---------
|
|
89
87
|
const contextSection = this.generateContextFilesSection()
|
|
90
88
|
if (contextSection) {
|
|
91
|
-
const ctxTokens =
|
|
89
|
+
const ctxTokens = countTokens(contextSection)
|
|
92
90
|
if (usedTokens + ctxTokens <= this.tokenBudget) {
|
|
93
91
|
sections.push(contextSection)
|
|
94
92
|
usedTokens += ctxTokens
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
//
|
|
96
|
+
// --- File import graph per module ----------------------------
|
|
99
97
|
const importSection = this.generateImportGraphSection()
|
|
100
98
|
if (importSection) {
|
|
101
|
-
const impTokens =
|
|
99
|
+
const impTokens = countTokens(importSection)
|
|
102
100
|
if (usedTokens + impTokens <= this.tokenBudget) {
|
|
103
101
|
sections.push(importSection)
|
|
104
102
|
usedTokens += impTokens
|
|
105
103
|
}
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
//
|
|
106
|
+
// --- HTTP Routes (Express + Next.js) -------------------------
|
|
109
107
|
const routesSection = this.generateRoutesSection()
|
|
110
108
|
if (routesSection) {
|
|
111
|
-
const routeTokens =
|
|
109
|
+
const routeTokens = countTokens(routesSection)
|
|
112
110
|
if (usedTokens + routeTokens <= this.tokenBudget) {
|
|
113
111
|
sections.push(routesSection)
|
|
114
112
|
usedTokens += routeTokens
|
|
115
113
|
}
|
|
116
114
|
}
|
|
117
115
|
|
|
118
|
-
//
|
|
116
|
+
// --- Tier 3: Constraints & decisions ------------------------
|
|
119
117
|
const constraintsSection = this.generateConstraintsSection()
|
|
120
|
-
const constraintTokens =
|
|
118
|
+
const constraintTokens = countTokens(constraintsSection)
|
|
121
119
|
if (usedTokens + constraintTokens <= this.tokenBudget) {
|
|
122
120
|
sections.push(constraintsSection)
|
|
123
121
|
usedTokens += constraintTokens
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
const decisionsSection = this.generateDecisionsSection()
|
|
127
|
-
const decisionTokens =
|
|
125
|
+
const decisionTokens = countTokens(decisionsSection)
|
|
128
126
|
if (usedTokens + decisionTokens <= this.tokenBudget) {
|
|
129
127
|
sections.push(decisionsSection)
|
|
130
128
|
usedTokens += decisionTokens
|
|
@@ -141,15 +139,13 @@ export class ClaudeMdGenerator {
|
|
|
141
139
|
const functionCount = Object.keys(this.lock.functions).length
|
|
142
140
|
const fileCount = Object.keys(this.lock.files).length
|
|
143
141
|
|
|
144
|
-
lines.push(
|
|
145
|
-
lines.push(
|
|
142
|
+
lines.push('<repository_context>')
|
|
143
|
+
lines.push(` <name>${this.contract.project.name}</name>`)
|
|
146
144
|
|
|
147
145
|
// Project description: prefer contract, fall back to package.json
|
|
148
146
|
const description = this.contract.project.description || this.meta.description
|
|
149
147
|
if (description) {
|
|
150
|
-
lines.push(
|
|
151
|
-
lines.push(description)
|
|
152
|
-
lines.push('')
|
|
148
|
+
lines.push(` <description>${description}</description>`)
|
|
153
149
|
}
|
|
154
150
|
|
|
155
151
|
// Only list modules that have functions (skip empty ones)
|
|
@@ -159,32 +155,26 @@ export class ClaudeMdGenerator {
|
|
|
159
155
|
return fnCount > 0
|
|
160
156
|
})
|
|
161
157
|
|
|
162
|
-
lines.push(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const cleanDesc = desc.replace(/^\d+ functions\s*—\s*/, '')
|
|
169
|
-
const descStr = cleanDesc ? ` — ${cleanDesc}` : ''
|
|
170
|
-
lines.push(`- **${module.name}** (\`${module.id}\`): ${fnCount} functions${descStr}`)
|
|
171
|
-
}
|
|
172
|
-
lines.push('')
|
|
173
|
-
|
|
174
|
-
lines.push(`## Stats`)
|
|
175
|
-
lines.push(`- ${fileCount} files, ${functionCount} functions, ${nonEmptyModules.length} modules`)
|
|
176
|
-
lines.push(`- Language: ${this.contract.project.language}`)
|
|
177
|
-
lines.push('')
|
|
158
|
+
lines.push(` <stats>`)
|
|
159
|
+
lines.push(` <files>${fileCount}</files>`)
|
|
160
|
+
lines.push(` <functions>${functionCount}</functions>`)
|
|
161
|
+
lines.push(` <modules>${nonEmptyModules.length}</modules>`)
|
|
162
|
+
lines.push(` <language>${this.contract.project.language}</language>`)
|
|
163
|
+
lines.push(` </stats>`)
|
|
178
164
|
|
|
179
165
|
// Critical constraints summary
|
|
180
166
|
if (this.contract.declared.constraints.length > 0) {
|
|
181
|
-
lines.push('
|
|
167
|
+
lines.push(' <critical_constraints>')
|
|
182
168
|
for (const c of this.contract.declared.constraints) {
|
|
183
|
-
lines.push(
|
|
169
|
+
lines.push(` <constraint>${c}</constraint>`)
|
|
184
170
|
}
|
|
185
|
-
lines.push('')
|
|
171
|
+
lines.push(' </critical_constraints>')
|
|
186
172
|
}
|
|
187
173
|
|
|
174
|
+
lines.push('</repository_context>')
|
|
175
|
+
lines.push('')
|
|
176
|
+
lines.push('<modules>')
|
|
177
|
+
|
|
188
178
|
return lines.join('\n')
|
|
189
179
|
}
|
|
190
180
|
|
|
@@ -198,23 +188,22 @@ export class ClaudeMdGenerator {
|
|
|
198
188
|
const moduleFunctions = Object.values(this.lock.functions)
|
|
199
189
|
.filter(f => f.moduleId === moduleId)
|
|
200
190
|
|
|
201
|
-
lines.push(
|
|
191
|
+
lines.push(` <module id="${moduleId}">`)
|
|
192
|
+
lines.push(` <name>${module.name}</name>`)
|
|
202
193
|
|
|
203
194
|
// Location — collapse to common prefix when many paths share a root
|
|
204
195
|
if (module.paths.length > 0) {
|
|
205
196
|
const collapsed = this.collapsePaths(module.paths)
|
|
206
|
-
lines.push(
|
|
197
|
+
lines.push(` <location>${collapsed}</location>`)
|
|
207
198
|
}
|
|
208
199
|
|
|
209
200
|
// Intent
|
|
210
201
|
if (module.intent) {
|
|
211
|
-
lines.push(
|
|
202
|
+
lines.push(` <purpose>${module.intent}</purpose>`)
|
|
212
203
|
} else if (module.description) {
|
|
213
|
-
lines.push(
|
|
204
|
+
lines.push(` <purpose>${module.description}</purpose>`)
|
|
214
205
|
}
|
|
215
206
|
|
|
216
|
-
lines.push('')
|
|
217
|
-
|
|
218
207
|
// Entry points: functions with no calledBy (likely public API surface)
|
|
219
208
|
const entryPoints = moduleFunctions
|
|
220
209
|
.filter(fn => fn.calledBy.length === 0)
|
|
@@ -222,29 +211,32 @@ export class ClaudeMdGenerator {
|
|
|
222
211
|
.slice(0, 5)
|
|
223
212
|
|
|
224
213
|
if (entryPoints.length > 0) {
|
|
225
|
-
lines.push('
|
|
214
|
+
lines.push(' <entry_points>')
|
|
226
215
|
for (const fn of entryPoints) {
|
|
227
216
|
const sig = this.formatSignature(fn)
|
|
228
|
-
const purpose = fn.purpose ?
|
|
229
|
-
lines.push(`
|
|
217
|
+
const purpose = fn.purpose ? `${this.oneLine(fn.purpose)}` : ''
|
|
218
|
+
lines.push(` <function signature="${sig.replace(/"/g, '"')}" purpose="${purpose.replace(/"/g, '"')}" />`)
|
|
230
219
|
}
|
|
231
|
-
lines.push('')
|
|
220
|
+
lines.push(' </entry_points>')
|
|
232
221
|
}
|
|
233
222
|
|
|
234
223
|
// Key functions: top 5 by calledBy count (most depended upon)
|
|
224
|
+
// Exclude functions already in entry points to avoid duplicates
|
|
225
|
+
const entryPointIds = new Set(entryPoints.map(fn => fn.id))
|
|
235
226
|
const keyFunctions = [...moduleFunctions]
|
|
227
|
+
.filter(fn => !entryPointIds.has(fn.id)) // Exclude duplicates
|
|
236
228
|
.sort((a, b) => b.calledBy.length - a.calledBy.length)
|
|
237
229
|
.filter(fn => fn.calledBy.length > 0)
|
|
238
230
|
.slice(0, 5)
|
|
239
231
|
|
|
240
232
|
if (keyFunctions.length > 0) {
|
|
241
|
-
lines.push('
|
|
233
|
+
lines.push(' <key_internal_functions>')
|
|
242
234
|
for (const fn of keyFunctions) {
|
|
243
235
|
const callerCount = fn.calledBy.length
|
|
244
|
-
const purpose = fn.purpose ?
|
|
245
|
-
lines.push(`
|
|
236
|
+
const purpose = fn.purpose ? `${this.oneLine(fn.purpose)}` : ''
|
|
237
|
+
lines.push(` <function name="${fn.name.replace(/"/g, '"')}" callers="${callerCount}" purpose="${purpose.replace(/"/g, '"')}" />`)
|
|
246
238
|
}
|
|
247
|
-
lines.push('')
|
|
239
|
+
lines.push(' </key_internal_functions>')
|
|
248
240
|
}
|
|
249
241
|
|
|
250
242
|
// Dependencies: other modules this module imports from
|
|
@@ -263,8 +255,7 @@ export class ClaudeMdGenerator {
|
|
|
263
255
|
const mod = this.contract.declared.modules.find(m => m.id === id)
|
|
264
256
|
return mod?.name || id
|
|
265
257
|
})
|
|
266
|
-
lines.push(
|
|
267
|
-
lines.push('')
|
|
258
|
+
lines.push(` <depends_on>${depNames.join(', ')}</depends_on>`)
|
|
268
259
|
}
|
|
269
260
|
|
|
270
261
|
// Module-specific constraints
|
|
@@ -273,13 +264,14 @@ export class ClaudeMdGenerator {
|
|
|
273
264
|
c.toLowerCase().includes(module.name.toLowerCase())
|
|
274
265
|
)
|
|
275
266
|
if (moduleConstraints.length > 0) {
|
|
276
|
-
lines.push('
|
|
267
|
+
lines.push(' <module_constraints>')
|
|
277
268
|
for (const c of moduleConstraints) {
|
|
278
|
-
lines.push(`
|
|
269
|
+
lines.push(` <constraint>${c}</constraint>`)
|
|
279
270
|
}
|
|
280
|
-
lines.push('')
|
|
271
|
+
lines.push(' </module_constraints>')
|
|
281
272
|
}
|
|
282
273
|
|
|
274
|
+
lines.push(` </module>`)
|
|
283
275
|
return lines.join('\n')
|
|
284
276
|
}
|
|
285
277
|
|
|
@@ -520,9 +512,11 @@ export class ClaudeMdGenerator {
|
|
|
520
512
|
if (detected.length === 0) return null
|
|
521
513
|
|
|
522
514
|
const lines: string[] = []
|
|
523
|
-
lines.push('
|
|
524
|
-
|
|
525
|
-
|
|
515
|
+
lines.push('<tech_stack>')
|
|
516
|
+
for (const d of detected) {
|
|
517
|
+
lines.push(` <technology>${d}</technology>`)
|
|
518
|
+
}
|
|
519
|
+
lines.push('</tech_stack>')
|
|
526
520
|
return lines.join('\n')
|
|
527
521
|
}
|
|
528
522
|
|
|
@@ -554,12 +548,14 @@ export class ClaudeMdGenerator {
|
|
|
554
548
|
if (useful.length === 0) return null
|
|
555
549
|
|
|
556
550
|
const lines: string[] = []
|
|
557
|
-
lines.push('
|
|
551
|
+
lines.push('<commands>')
|
|
558
552
|
for (const [key, cmd] of useful) {
|
|
559
|
-
lines.push(
|
|
553
|
+
lines.push(` <command>`)
|
|
554
|
+
lines.push(` <run>${pm} ${key}</run>`)
|
|
555
|
+
lines.push(` <executes>${cmd.replace(/"/g, '"')}</executes>`)
|
|
556
|
+
lines.push(` </command>`)
|
|
560
557
|
}
|
|
561
|
-
|
|
562
|
-
lines.push('')
|
|
558
|
+
lines.push('</commands>')
|
|
563
559
|
return lines.join('\n')
|
|
564
560
|
}
|
|
565
561
|
|
package/src/context-builder.ts
CHANGED
|
@@ -107,31 +107,92 @@ const STOP_WORDS = new Set([
|
|
|
107
107
|
'want', 'like', 'just', 'also', 'some', 'all', 'any', 'my', 'your',
|
|
108
108
|
])
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
const SHORT_TECH_WORDS = new Set([
|
|
111
|
+
'ai', 'ml', 'ui', 'ux', 'ts', 'js', 'db', 'io', 'id', 'ip',
|
|
112
|
+
'ci', 'cd', 'qa', 'api', 'mcp', 'jwt', 'sql',
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
function normalizeKeyword(value: string): string {
|
|
116
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9_-]/g, '')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractKeywords(task: string, requiredKeywords: string[] = []): string[] {
|
|
120
|
+
const out: string[] = []
|
|
121
|
+
const seen = new Set<string>()
|
|
122
|
+
|
|
123
|
+
for (const match of task.matchAll(/"([^"]+)"|'([^']+)'/g)) {
|
|
124
|
+
const phrase = (match[1] ?? match[2] ?? '').toLowerCase().trim()
|
|
125
|
+
if (!phrase || seen.has(phrase)) continue
|
|
126
|
+
seen.add(phrase)
|
|
127
|
+
out.push(phrase)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const words = task
|
|
112
131
|
.toLowerCase()
|
|
113
132
|
.replace(/[^a-z0-9\s_-]/g, ' ')
|
|
114
133
|
.split(/\s+/)
|
|
115
|
-
.
|
|
134
|
+
.map(normalizeKeyword)
|
|
135
|
+
.filter(w => {
|
|
136
|
+
if (!w || STOP_WORDS.has(w)) return false
|
|
137
|
+
if (w.length > 2) return true
|
|
138
|
+
return SHORT_TECH_WORDS.has(w)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
for (const w of words) {
|
|
142
|
+
if (seen.has(w)) continue
|
|
143
|
+
seen.add(w)
|
|
144
|
+
out.push(w)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const expandedRequired = requiredKeywords
|
|
148
|
+
.flatMap(item => item.split(/[,\s]+/))
|
|
149
|
+
.map(normalizeKeyword)
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
|
|
152
|
+
for (const kw of expandedRequired) {
|
|
153
|
+
if (seen.has(kw)) continue
|
|
154
|
+
seen.add(kw)
|
|
155
|
+
out.push(kw)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
/**
|
|
119
162
|
* Keyword score for a function: exact match > partial match
|
|
120
163
|
*/
|
|
121
|
-
function keywordScore(
|
|
122
|
-
|
|
164
|
+
function keywordScore(
|
|
165
|
+
fn: MikkLockFunction,
|
|
166
|
+
keywords: string[]
|
|
167
|
+
): { score: number; matchedKeywords: string[] } {
|
|
168
|
+
if (keywords.length === 0) return { score: 0, matchedKeywords: [] }
|
|
123
169
|
const nameLower = fn.name.toLowerCase()
|
|
124
170
|
const fileLower = fn.file.toLowerCase()
|
|
171
|
+
const fileNoExt = fileLower.replace(/\.(d\.ts|ts|tsx|js|jsx|mjs|cjs|mts|cts)\b/g, ' ')
|
|
172
|
+
const purposeLower = (fn.purpose ?? '').toLowerCase()
|
|
173
|
+
const tokenSet = new Set<string>([
|
|
174
|
+
...(nameLower.match(/[a-z0-9]+/g) ?? []),
|
|
175
|
+
...(fileNoExt.match(/[a-z0-9]+/g) ?? []),
|
|
176
|
+
...(purposeLower.match(/[a-z0-9]+/g) ?? []),
|
|
177
|
+
])
|
|
125
178
|
let score = 0
|
|
179
|
+
const matched: string[] = []
|
|
126
180
|
|
|
127
181
|
for (const kw of keywords) {
|
|
128
|
-
|
|
182
|
+
const shortKw = kw.length <= 2
|
|
183
|
+
const exactName = nameLower === kw
|
|
184
|
+
const partial = shortKw
|
|
185
|
+
? tokenSet.has(kw)
|
|
186
|
+
: (nameLower.includes(kw) || fileLower.includes(kw) || purposeLower.includes(kw))
|
|
187
|
+
if (exactName) {
|
|
129
188
|
score = Math.max(score, WEIGHT.KEYWORD_EXACT)
|
|
130
|
-
|
|
189
|
+
matched.push(kw)
|
|
190
|
+
} else if (partial) {
|
|
131
191
|
score = Math.max(score, WEIGHT.KEYWORD_PARTIAL)
|
|
192
|
+
matched.push(kw)
|
|
132
193
|
}
|
|
133
194
|
}
|
|
134
|
-
return score
|
|
195
|
+
return { score, matchedKeywords: matched }
|
|
135
196
|
}
|
|
136
197
|
|
|
137
198
|
// ---------------------------------------------------------------------------
|
|
@@ -145,8 +206,10 @@ function keywordScore(fn: MikkLockFunction, keywords: string[]): number {
|
|
|
145
206
|
function resolveSeeds(
|
|
146
207
|
query: ContextQuery,
|
|
147
208
|
contract: MikkContract,
|
|
148
|
-
lock: MikkLock
|
|
209
|
+
lock: MikkLock,
|
|
210
|
+
keywords: string[]
|
|
149
211
|
): string[] {
|
|
212
|
+
const strictMode = query.relevanceMode === 'strict'
|
|
150
213
|
const seeds = new Set<string>()
|
|
151
214
|
|
|
152
215
|
// 1. Explicit focus files → all functions in those files
|
|
@@ -171,16 +234,15 @@ function resolveSeeds(
|
|
|
171
234
|
|
|
172
235
|
// 3. Keyword match against function names and file paths
|
|
173
236
|
if (seeds.size === 0) {
|
|
174
|
-
const keywords = extractKeywords(query.task)
|
|
175
237
|
for (const fn of Object.values(lock.functions)) {
|
|
176
|
-
if (keywordScore(fn, keywords) >= WEIGHT.KEYWORD_PARTIAL) {
|
|
238
|
+
if (keywordScore(fn, keywords).score >= WEIGHT.KEYWORD_PARTIAL) {
|
|
177
239
|
seeds.add(fn.id)
|
|
178
240
|
}
|
|
179
241
|
}
|
|
180
242
|
}
|
|
181
243
|
|
|
182
244
|
// 4. Module name match against task
|
|
183
|
-
if (seeds.size === 0) {
|
|
245
|
+
if (!strictMode && seeds.size === 0) {
|
|
184
246
|
const taskLower = query.task.toLowerCase()
|
|
185
247
|
for (const mod of contract.declared.modules) {
|
|
186
248
|
if (
|
|
@@ -219,11 +281,22 @@ export class ContextBuilder {
|
|
|
219
281
|
* 6. Group survivors by module, emit structured context
|
|
220
282
|
*/
|
|
221
283
|
build(query: ContextQuery): AIContext {
|
|
284
|
+
const relevanceMode = query.relevanceMode ?? 'balanced'
|
|
285
|
+
const strictMode = relevanceMode === 'strict'
|
|
222
286
|
const tokenBudget = query.tokenBudget ?? DEFAULT_TOKEN_BUDGET
|
|
223
287
|
const maxHops = query.maxHops ?? 4
|
|
288
|
+
const requiredKeywords = query.requiredKeywords ?? []
|
|
289
|
+
const keywords = extractKeywords(query.task, requiredKeywords)
|
|
290
|
+
const requiredKeywordSet = new Set(
|
|
291
|
+
requiredKeywords
|
|
292
|
+
.flatMap(item => item.split(/[,\s]+/))
|
|
293
|
+
.map(normalizeKeyword)
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
)
|
|
224
296
|
|
|
225
297
|
// ── Step 1: Resolve seeds ──────────────────────────────────────────
|
|
226
|
-
const seeds = resolveSeeds(query, this.contract, this.lock)
|
|
298
|
+
const seeds = resolveSeeds(query, this.contract, this.lock, keywords)
|
|
299
|
+
const seedSet = new Set(seeds)
|
|
227
300
|
|
|
228
301
|
// ── Step 2: BFS proximity scores ──────────────────────────────────
|
|
229
302
|
const proximityMap = seeds.length > 0
|
|
@@ -231,8 +304,15 @@ export class ContextBuilder {
|
|
|
231
304
|
: new Map<string, number>()
|
|
232
305
|
|
|
233
306
|
// ── Step 3: Score every function ──────────────────────────────────
|
|
234
|
-
const keywords = extractKeywords(query.task)
|
|
235
307
|
const allFunctions = Object.values(this.lock.functions)
|
|
308
|
+
const focusFiles = query.focusFiles ?? []
|
|
309
|
+
const focusModules = new Set(query.focusModules ?? [])
|
|
310
|
+
const requireAllKeywords = query.requireAllKeywords ?? false
|
|
311
|
+
const minKeywordMatches = query.minKeywordMatches ?? 1
|
|
312
|
+
const strictPassIds = new Set<string>()
|
|
313
|
+
const reasons: string[] = []
|
|
314
|
+
const suggestions: string[] = []
|
|
315
|
+
const nearMissSuggestions: string[] = []
|
|
236
316
|
|
|
237
317
|
const scored: { fn: MikkLockFunction; score: number }[] = allFunctions.map(fn => {
|
|
238
318
|
let score = 0
|
|
@@ -244,19 +324,55 @@ export class ContextBuilder {
|
|
|
244
324
|
}
|
|
245
325
|
|
|
246
326
|
// Keyword match
|
|
247
|
-
|
|
327
|
+
const kwInfo = keywordScore(fn, keywords)
|
|
328
|
+
score += kwInfo.score
|
|
329
|
+
|
|
330
|
+
const matchedSet = new Set(kwInfo.matchedKeywords)
|
|
331
|
+
const inFocusFile = focusFiles.some(filePath => fn.file.includes(filePath) || filePath.includes(fn.file))
|
|
332
|
+
const inFocusModule = focusModules.has(fn.moduleId)
|
|
333
|
+
const inFocus = inFocusFile || inFocusModule
|
|
334
|
+
|
|
335
|
+
const requiredPass = requiredKeywordSet.size === 0
|
|
336
|
+
? true
|
|
337
|
+
: [...requiredKeywordSet].every(kw => matchedSet.has(kw))
|
|
338
|
+
const generalPass = requireAllKeywords
|
|
339
|
+
? (keywords.length > 0 && matchedSet.size >= keywords.length)
|
|
340
|
+
: (keywords.length === 0 ? false : matchedSet.size >= minKeywordMatches)
|
|
341
|
+
const keywordPass = requiredPass && generalPass
|
|
342
|
+
if (keywordPass) strictPassIds.add(fn.id)
|
|
343
|
+
|
|
344
|
+
if (strictMode) {
|
|
345
|
+
const isSeed = seedSet.has(fn.id)
|
|
346
|
+
const seedFromFocus = isSeed && (inFocus || focusFiles.length > 0 || focusModules.size > 0)
|
|
347
|
+
if (!(inFocus || keywordPass || seedFromFocus)) {
|
|
348
|
+
if (kwInfo.score > 0) {
|
|
349
|
+
nearMissSuggestions.push(`${fn.name} (${fn.file}:${fn.startLine})`)
|
|
350
|
+
}
|
|
351
|
+
return { fn, score: -1 }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
248
354
|
|
|
249
355
|
// Entry-point bonus
|
|
250
|
-
if (fn.calledBy.length === 0) score += WEIGHT.ENTRY_POINT
|
|
356
|
+
if (!strictMode && fn.calledBy.length === 0) score += WEIGHT.ENTRY_POINT
|
|
251
357
|
|
|
252
358
|
return { fn, score }
|
|
253
359
|
})
|
|
254
360
|
|
|
255
361
|
// ── Step 4: Sort by score descending ──────────────────────────────
|
|
256
362
|
scored.sort((a, b) => b.score - a.score)
|
|
363
|
+
for (const { fn, score } of scored) {
|
|
364
|
+
if (score <= 0) continue
|
|
365
|
+
suggestions.push(`${fn.name} (${fn.file}:${fn.startLine})`)
|
|
366
|
+
if (suggestions.length >= 5) break
|
|
367
|
+
}
|
|
368
|
+
for (const s of nearMissSuggestions) {
|
|
369
|
+
if (suggestions.includes(s)) continue
|
|
370
|
+
suggestions.push(s)
|
|
371
|
+
if (suggestions.length >= 5) break
|
|
372
|
+
}
|
|
257
373
|
|
|
258
374
|
// ── Step 5: Fill token budget ──────────────────────────────────────
|
|
259
|
-
|
|
375
|
+
let selected: MikkLockFunction[] = []
|
|
260
376
|
let usedTokens = 0
|
|
261
377
|
|
|
262
378
|
for (const { fn, score } of scored) {
|
|
@@ -271,6 +387,57 @@ export class ContextBuilder {
|
|
|
271
387
|
usedTokens += tokens
|
|
272
388
|
}
|
|
273
389
|
|
|
390
|
+
if (strictMode) {
|
|
391
|
+
if (requiredKeywordSet.size > 0) {
|
|
392
|
+
reasons.push(`required terms: ${[...requiredKeywordSet].join(', ')}`)
|
|
393
|
+
}
|
|
394
|
+
if (strictPassIds.size === 0) {
|
|
395
|
+
reasons.push('no functions matched strict keyword filters')
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (strictMode && query.exactOnly) {
|
|
400
|
+
selected = selected.filter(fn => strictPassIds.has(fn.id))
|
|
401
|
+
usedTokens = selected.reduce(
|
|
402
|
+
(sum, fn) => sum + estimateTokens(this.buildFunctionSnippet(fn, query)),
|
|
403
|
+
0
|
|
404
|
+
)
|
|
405
|
+
if (selected.length === 0 && strictPassIds.size > 0) {
|
|
406
|
+
reasons.push('exact matches exist but did not fit token budget or max function limit')
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (strictMode && query.failFast && selected.length === 0) {
|
|
411
|
+
reasons.push('fail-fast enabled: returning no context when exact match set is empty')
|
|
412
|
+
return {
|
|
413
|
+
project: {
|
|
414
|
+
name: this.contract.project.name,
|
|
415
|
+
language: this.contract.project.language,
|
|
416
|
+
description: this.contract.project.description,
|
|
417
|
+
moduleCount: this.contract.declared.modules.length,
|
|
418
|
+
functionCount: Object.keys(this.lock.functions).length,
|
|
419
|
+
},
|
|
420
|
+
modules: [],
|
|
421
|
+
constraints: this.contract.declared.constraints,
|
|
422
|
+
decisions: this.contract.declared.decisions.map(d => ({
|
|
423
|
+
title: d.title,
|
|
424
|
+
reason: d.reason,
|
|
425
|
+
})),
|
|
426
|
+
contextFiles: [],
|
|
427
|
+
routes: [],
|
|
428
|
+
prompt: '',
|
|
429
|
+
meta: {
|
|
430
|
+
seedCount: seeds.length,
|
|
431
|
+
totalFunctionsConsidered: allFunctions.length,
|
|
432
|
+
selectedFunctions: 0,
|
|
433
|
+
estimatedTokens: 0,
|
|
434
|
+
keywords,
|
|
435
|
+
reasons,
|
|
436
|
+
suggestions: suggestions.length > 0 ? suggestions : undefined,
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
274
441
|
// ── Step 6: Group by module ────────────────────────────────────────
|
|
275
442
|
const byModule = new Map<string, MikkLockFunction[]>()
|
|
276
443
|
for (const fn of selected) {
|
|
@@ -298,6 +465,10 @@ export class ContextBuilder {
|
|
|
298
465
|
// Sort modules: ones with more selected functions first
|
|
299
466
|
contextModules.sort((a, b) => b.functions.length - a.functions.length)
|
|
300
467
|
|
|
468
|
+
// Strict mode favors precision and token efficiency: keep only function graph context.
|
|
469
|
+
const contextFiles = strictMode ? [] : this.lock.contextFiles
|
|
470
|
+
const routes = strictMode ? [] : this.lock.routes
|
|
471
|
+
|
|
301
472
|
return {
|
|
302
473
|
project: {
|
|
303
474
|
name: this.contract.project.name,
|
|
@@ -312,12 +483,12 @@ export class ContextBuilder {
|
|
|
312
483
|
title: d.title,
|
|
313
484
|
reason: d.reason,
|
|
314
485
|
})),
|
|
315
|
-
contextFiles:
|
|
486
|
+
contextFiles: contextFiles?.map(cf => ({
|
|
316
487
|
path: cf.path,
|
|
317
488
|
content: readContextFile(cf.path, query.projectRoot),
|
|
318
489
|
type: cf.type,
|
|
319
490
|
})),
|
|
320
|
-
routes:
|
|
491
|
+
routes: routes?.map(r => ({
|
|
321
492
|
method: r.method,
|
|
322
493
|
path: r.path,
|
|
323
494
|
handler: r.handler,
|
|
@@ -332,6 +503,8 @@ export class ContextBuilder {
|
|
|
332
503
|
selectedFunctions: selected.length,
|
|
333
504
|
estimatedTokens: usedTokens,
|
|
334
505
|
keywords,
|
|
506
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
507
|
+
suggestions: (selected.length === 0 && suggestions.length > 0) ? suggestions : undefined,
|
|
335
508
|
},
|
|
336
509
|
}
|
|
337
510
|
}
|
|
@@ -414,6 +587,7 @@ export class ContextBuilder {
|
|
|
414
587
|
/** Generate the natural-language prompt section */
|
|
415
588
|
private generatePrompt(query: ContextQuery, modules: ContextModule[]): string {
|
|
416
589
|
const lines: string[] = []
|
|
590
|
+
const strictMode = query.relevanceMode === 'strict'
|
|
417
591
|
|
|
418
592
|
lines.push('=== ARCHITECTURAL CONTEXT ===')
|
|
419
593
|
lines.push(`Project: ${this.contract.project.name} (${this.contract.project.language})`)
|
|
@@ -425,7 +599,7 @@ export class ContextBuilder {
|
|
|
425
599
|
|
|
426
600
|
// Include routes (API endpoints) — critical for understanding how the app works
|
|
427
601
|
const routes = this.lock.routes
|
|
428
|
-
if (routes && routes.length > 0) {
|
|
602
|
+
if (!strictMode && routes && routes.length > 0) {
|
|
429
603
|
lines.push('=== HTTP ROUTES ===')
|
|
430
604
|
for (const r of routes) {
|
|
431
605
|
const mw = r.middlewares.length > 0 ? ` [${r.middlewares.join(', ')}]` : ''
|
|
@@ -436,7 +610,7 @@ export class ContextBuilder {
|
|
|
436
610
|
|
|
437
611
|
// Include context files (schemas, data models) first — they define the shape
|
|
438
612
|
const ctxFiles = this.lock.contextFiles
|
|
439
|
-
if (ctxFiles && ctxFiles.length > 0) {
|
|
613
|
+
if (!strictMode && ctxFiles && ctxFiles.length > 0) {
|
|
440
614
|
lines.push('=== DATA MODELS & SCHEMAS ===')
|
|
441
615
|
for (const cf of ctxFiles) {
|
|
442
616
|
lines.push(`--- ${cf.path} (${cf.type}) ---`)
|
|
@@ -746,4 +920,4 @@ function dedent(lines: string[]): string[] {
|
|
|
746
920
|
const spaces = l.length - l.trimStart().length
|
|
747
921
|
return l.substring(Math.min(min, spaces))
|
|
748
922
|
})
|
|
749
|
-
}
|
|
923
|
+
}
|