@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.
@@ -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
- // ── Tier 1: Summary (always included) ──────────────────────
45
+ // --- Tier 1: Summary (always included) ----------------------
50
46
  const summary = this.generateSummary()
51
47
  sections.push(summary)
52
- usedTokens += estimateTokens(summary)
48
+ usedTokens += countTokens(summary)
53
49
 
54
- // ── Tech stack & conventions (always included if detectable) ──
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 += estimateTokens(techSection)
54
+ usedTokens += countTokens(techSection)
59
55
  }
60
56
 
61
- // ── Build / test / run commands ─────────────────────────────
57
+ // --- Build / test / run commands -----------------------------
62
58
  const commandsSection = this.generateCommandsSection()
63
59
  if (commandsSection) {
64
60
  sections.push(commandsSection)
65
- usedTokens += estimateTokens(commandsSection)
61
+ usedTokens += countTokens(commandsSection)
66
62
  }
67
63
 
68
- // ── Tier 2: Module details (if budget allows) ──────────────
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 = estimateTokens(moduleSection)
75
+ const tokens = countTokens(moduleSection)
80
76
  if (usedTokens + tokens > this.tokenBudget) {
81
- sections.push('\n> Full details available in `mikk.lock.json`\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
- // ── Context files: schemas, data models, config ─────────
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 = estimateTokens(contextSection)
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
- // ── File import graph per module ────────────────────────────
96
+ // --- File import graph per module ----------------------------
99
97
  const importSection = this.generateImportGraphSection()
100
98
  if (importSection) {
101
- const impTokens = estimateTokens(importSection)
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
- // ── HTTP Routes (Express + Next.js) ─────────────────────────
106
+ // --- HTTP Routes (Express + Next.js) -------------------------
109
107
  const routesSection = this.generateRoutesSection()
110
108
  if (routesSection) {
111
- const routeTokens = estimateTokens(routesSection)
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
- // ── Tier 3: Constraints & decisions ────────────────────────
116
+ // --- Tier 3: Constraints & decisions ------------------------
119
117
  const constraintsSection = this.generateConstraintsSection()
120
- const constraintTokens = estimateTokens(constraintsSection)
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 = estimateTokens(decisionsSection)
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(`# ${this.contract.project.name} — Architecture Overview`)
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('## What this project does')
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('## Modules')
163
- for (const module of nonEmptyModules) {
164
- const fnCount = Object.values(this.lock.functions)
165
- .filter(f => f.moduleId === module.id).length
166
- const desc = module.intent || module.description || ''
167
- // Strip leading "N functions — " from auto-generated descriptions to avoid double-counting
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('## Critical Constraints')
167
+ lines.push(' <critical_constraints>')
182
168
  for (const c of this.contract.declared.constraints) {
183
- lines.push(`- ${c}`)
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(`## ${module.name} module`)
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(`**Location:** ${collapsed}`)
197
+ lines.push(` <location>${collapsed}</location>`)
207
198
  }
208
199
 
209
200
  // Intent
210
201
  if (module.intent) {
211
- lines.push(`**Purpose:** ${module.intent}`)
202
+ lines.push(` <purpose>${module.intent}</purpose>`)
212
203
  } else if (module.description) {
213
- lines.push(`**Purpose:** ${module.description}`)
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('**Entry points:**')
214
+ lines.push(' <entry_points>')
226
215
  for (const fn of entryPoints) {
227
216
  const sig = this.formatSignature(fn)
228
- const purpose = fn.purpose ? ` — ${this.oneLine(fn.purpose)}` : ''
229
- lines.push(` - \`${sig}\`${purpose}`)
217
+ const purpose = fn.purpose ? `${this.oneLine(fn.purpose)}` : ''
218
+ lines.push(` <function signature="${sig.replace(/"/g, '&quot;')}" purpose="${purpose.replace(/"/g, '&quot;')}" />`)
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('**Key internal functions:**')
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 ? ` — ${this.oneLine(fn.purpose)}` : ''
245
- lines.push(` - \`${fn.name}\` (called by ${callerCount})${purpose}`)
236
+ const purpose = fn.purpose ? `${this.oneLine(fn.purpose)}` : ''
237
+ lines.push(` <function name="${fn.name.replace(/"/g, '&quot;')}" callers="${callerCount}" purpose="${purpose.replace(/"/g, '&quot;')}" />`)
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(`**Depends on:** ${depNames.join(', ')}`)
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('**Constraints:**')
267
+ lines.push(' <module_constraints>')
277
268
  for (const c of moduleConstraints) {
278
- lines.push(` - ${c}`)
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('## Tech Stack')
524
- lines.push(detected.join(' · '))
525
- lines.push('')
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('## Commands')
551
+ lines.push('<commands>')
558
552
  for (const [key, cmd] of useful) {
559
- lines.push(`- \`${pm} ${key}\` \u2014 \`${cmd}\``)
553
+ lines.push(` <command>`)
554
+ lines.push(` <run>${pm} ${key}</run>`)
555
+ lines.push(` <executes>${cmd.replace(/"/g, '&quot;')}</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
 
@@ -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
- function extractKeywords(task: string): string[] {
111
- return task
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
- .filter(w => w.length > 2 && !STOP_WORDS.has(w))
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(fn: MikkLockFunction, keywords: string[]): number {
122
- if (keywords.length === 0) return 0
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
- if (nameLower === kw) {
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
- } else if (nameLower.includes(kw) || fileLower.includes(kw)) {
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
- score += keywordScore(fn, keywords)
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
- const selected: MikkLockFunction[] = []
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: this.lock.contextFiles?.map(cf => ({
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: this.lock.routes?.map(r => ({
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
+ }