@getmikk/ai-context 1.3.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/ai-context",
3
- "version": "1.3.2",
3
+ "version": "1.5.1",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "dev": "tsc --watch"
22
22
  },
23
23
  "dependencies": {
24
- "@getmikk/core": "^1.3.2",
25
- "@getmikk/intent-engine": "^1.3.2"
24
+ "@getmikk/core": "^1.5.1",
25
+ "@getmikk/intent-engine": "^1.5.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "typescript": "^5.7.0",
@@ -1,13 +1,21 @@
1
1
  import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
2
2
 
3
- /** Default token budget for claude.md — prevents bloating the context window */
4
- const DEFAULT_TOKEN_BUDGET = 6000
3
+ /** Default token budget for claude.md — generous but still bounded */
4
+ const DEFAULT_TOKEN_BUDGET = 12000
5
5
 
6
6
  /** Rough token estimation: ~4 chars per token */
7
7
  function estimateTokens(text: string): number {
8
8
  return Math.ceil(text.length / 4)
9
9
  }
10
10
 
11
+ /** Metadata from package.json that enriches the AI context */
12
+ export interface ProjectMeta {
13
+ description?: string
14
+ scripts?: Record<string, string>
15
+ dependencies?: Record<string, string>
16
+ devDependencies?: Record<string, string>
17
+ }
18
+
11
19
  /**
12
20
  * ClaudeMdGenerator — generates an always-accurate `claude.md` and `AGENTS.md`
13
21
  * from the lock file and contract. Every function name, file path, and module
@@ -19,11 +27,16 @@ function estimateTokens(text: string): number {
19
27
  * Tier 3: Recent changes (~50 tokens/change) — last section added
20
28
  */
21
29
  export class ClaudeMdGenerator {
30
+ private meta: ProjectMeta
31
+
22
32
  constructor(
23
33
  private contract: MikkContract,
24
34
  private lock: MikkLock,
25
- private tokenBudget: number = DEFAULT_TOKEN_BUDGET
26
- ) { }
35
+ private tokenBudget: number = DEFAULT_TOKEN_BUDGET,
36
+ meta?: ProjectMeta
37
+ ) {
38
+ this.meta = meta || {}
39
+ }
27
40
 
28
41
  /** Generate the full claude.md content */
29
42
  generate(): string {
@@ -35,8 +48,29 @@ export class ClaudeMdGenerator {
35
48
  sections.push(summary)
36
49
  usedTokens += estimateTokens(summary)
37
50
 
51
+ // ── Tech stack & conventions (always included if detectable) ──
52
+ const techSection = this.generateTechStackSection()
53
+ if (techSection) {
54
+ sections.push(techSection)
55
+ usedTokens += estimateTokens(techSection)
56
+ }
57
+
58
+ // ── Build / test / run commands ─────────────────────────────
59
+ const commandsSection = this.generateCommandsSection()
60
+ if (commandsSection) {
61
+ sections.push(commandsSection)
62
+ usedTokens += estimateTokens(commandsSection)
63
+ }
64
+
38
65
  // ── Tier 2: Module details (if budget allows) ──────────────
66
+ // Skip modules with zero functions — they waste AI tokens
39
67
  const modules = this.getModulesSortedByDependencyOrder()
68
+ .filter(m => {
69
+ const fnCount = Object.values(this.lock.functions)
70
+ .filter(f => f.moduleId === m.id).length
71
+ return fnCount > 0
72
+ })
73
+
40
74
  for (const module of modules) {
41
75
  const moduleSection = this.generateModuleSection(module.id)
42
76
  const tokens = estimateTokens(moduleSection)
@@ -48,6 +82,36 @@ export class ClaudeMdGenerator {
48
82
  usedTokens += tokens
49
83
  }
50
84
 
85
+ // ── Context files: schemas, data models, config ─────────
86
+ const contextSection = this.generateContextFilesSection()
87
+ if (contextSection) {
88
+ const ctxTokens = estimateTokens(contextSection)
89
+ if (usedTokens + ctxTokens <= this.tokenBudget) {
90
+ sections.push(contextSection)
91
+ usedTokens += ctxTokens
92
+ }
93
+ }
94
+
95
+ // ── File import graph per module ────────────────────────────
96
+ const importSection = this.generateImportGraphSection()
97
+ if (importSection) {
98
+ const impTokens = estimateTokens(importSection)
99
+ if (usedTokens + impTokens <= this.tokenBudget) {
100
+ sections.push(importSection)
101
+ usedTokens += impTokens
102
+ }
103
+ }
104
+
105
+ // ── HTTP Routes (Express + Next.js) ─────────────────────────
106
+ const routesSection = this.generateRoutesSection()
107
+ if (routesSection) {
108
+ const routeTokens = estimateTokens(routesSection)
109
+ if (usedTokens + routeTokens <= this.tokenBudget) {
110
+ sections.push(routesSection)
111
+ usedTokens += routeTokens
112
+ }
113
+ }
114
+
51
115
  // ── Tier 3: Constraints & decisions ────────────────────────
52
116
  const constraintsSection = this.generateConstraintsSection()
53
117
  const constraintTokens = estimateTokens(constraintsSection)
@@ -77,24 +141,35 @@ export class ClaudeMdGenerator {
77
141
  lines.push(`# ${this.contract.project.name} — Architecture Overview`)
78
142
  lines.push('')
79
143
 
80
- if (this.contract.project.description) {
144
+ // Project description: prefer contract, fall back to package.json
145
+ const description = this.contract.project.description || this.meta.description
146
+ if (description) {
81
147
  lines.push('## What this project does')
82
- lines.push(this.contract.project.description)
148
+ lines.push(description)
83
149
  lines.push('')
84
150
  }
85
151
 
152
+ // Only list modules that have functions (skip empty ones)
153
+ const nonEmptyModules = this.contract.declared.modules.filter(m => {
154
+ const fnCount = Object.values(this.lock.functions)
155
+ .filter(f => f.moduleId === m.id).length
156
+ return fnCount > 0
157
+ })
158
+
86
159
  lines.push('## Modules')
87
- for (const module of this.contract.declared.modules) {
160
+ for (const module of nonEmptyModules) {
88
161
  const fnCount = Object.values(this.lock.functions)
89
162
  .filter(f => f.moduleId === module.id).length
90
163
  const desc = module.intent || module.description || ''
91
- const descStr = desc ? ` ${desc}` : ''
164
+ // Strip leading "N functions" from auto-generated descriptions to avoid double-counting
165
+ const cleanDesc = desc.replace(/^\d+ functions\s*—\s*/, '')
166
+ const descStr = cleanDesc ? ` — ${cleanDesc}` : ''
92
167
  lines.push(`- **${module.name}** (\`${module.id}\`): ${fnCount} functions${descStr}`)
93
168
  }
94
169
  lines.push('')
95
170
 
96
171
  lines.push(`## Stats`)
97
- lines.push(`- ${fileCount} files, ${functionCount} functions, ${moduleCount} modules`)
172
+ lines.push(`- ${fileCount} files, ${functionCount} functions, ${nonEmptyModules.length} modules`)
98
173
  lines.push(`- Language: ${this.contract.project.language}`)
99
174
  lines.push('')
100
175
 
@@ -122,9 +197,10 @@ export class ClaudeMdGenerator {
122
197
 
123
198
  lines.push(`## ${module.name} module`)
124
199
 
125
- // Location
200
+ // Location — collapse to common prefix when many paths share a root
126
201
  if (module.paths.length > 0) {
127
- lines.push(`**Location:** ${module.paths.join(', ')}`)
202
+ const collapsed = this.collapsePaths(module.paths)
203
+ lines.push(`**Location:** ${collapsed}`)
128
204
  }
129
205
 
130
206
  // Intent
@@ -146,7 +222,7 @@ export class ClaudeMdGenerator {
146
222
  lines.push('**Entry points:**')
147
223
  for (const fn of entryPoints) {
148
224
  const sig = this.formatSignature(fn)
149
- const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
225
+ const purpose = fn.purpose ? ` — ${this.oneLine(fn.purpose)}` : ''
150
226
  lines.push(` - \`${sig}\`${purpose}`)
151
227
  }
152
228
  lines.push('')
@@ -162,7 +238,7 @@ export class ClaudeMdGenerator {
162
238
  lines.push('**Key internal functions:**')
163
239
  for (const fn of keyFunctions) {
164
240
  const callerCount = fn.calledBy.length
165
- const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
241
+ const purpose = fn.purpose ? ` — ${this.oneLine(fn.purpose)}` : ''
166
242
  lines.push(` - \`${fn.name}\` (called by ${callerCount})${purpose}`)
167
243
  }
168
244
  lines.push('')
@@ -228,11 +304,410 @@ export class ClaudeMdGenerator {
228
304
  return lines.join('\n')
229
305
  }
230
306
 
307
+ // ── Context Files Section ──────────────────────────────────────
308
+
309
+ /** Generate a section with discovered schema/config files inlined */
310
+ private generateContextFilesSection(): string | null {
311
+ const ctxFiles = this.lock.contextFiles
312
+ if (!ctxFiles || ctxFiles.length === 0) return null
313
+
314
+ const lines: string[] = []
315
+ lines.push('## Data Models & Schemas')
316
+ lines.push('')
317
+ lines.push('These files define the project\'s data structures, schemas, and configuration.')
318
+ lines.push('They are auto-discovered and included verbatim from the source.')
319
+ lines.push('')
320
+
321
+ for (const cf of ctxFiles) {
322
+ const ext = cf.path.split('.').pop() || 'txt'
323
+ const lang = this.extToLang(ext)
324
+ lines.push(`### \`${cf.path}\` (${cf.type})`)
325
+ lines.push('')
326
+ lines.push('```' + lang)
327
+ // Trim content to avoid blowing up the token budget
328
+ const maxChars = 8000 // ~2000 tokens per file
329
+ if (cf.content.length > maxChars) {
330
+ lines.push(cf.content.slice(0, maxChars))
331
+ lines.push(`// ... truncated (${cf.size} bytes total)`)
332
+ } else {
333
+ lines.push(cf.content.trimEnd())
334
+ }
335
+ lines.push('```')
336
+ lines.push('')
337
+ }
338
+
339
+ return lines.join('\n')
340
+ }
341
+
342
+ /** Map file extensions to Markdown code fence languages */
343
+ private extToLang(ext: string): string {
344
+ const map: Record<string, string> = {
345
+ ts: 'typescript', js: 'javascript', tsx: 'tsx', jsx: 'jsx',
346
+ mjs: 'javascript', cjs: 'javascript',
347
+ py: 'python', go: 'go', rs: 'rust', rb: 'ruby',
348
+ java: 'java', kt: 'kotlin', cs: 'csharp', swift: 'swift',
349
+ dart: 'dart', ex: 'elixir', exs: 'elixir', php: 'php',
350
+ prisma: 'prisma', graphql: 'graphql', gql: 'graphql',
351
+ sql: 'sql', proto: 'protobuf', yaml: 'yaml', yml: 'yaml',
352
+ json: 'json', toml: 'toml', xml: 'xml',
353
+ css: 'css', scss: 'scss', html: 'html',
354
+ svelte: 'svelte', vue: 'vue',
355
+ md: 'markdown', sh: 'bash', bash: 'bash', zsh: 'bash',
356
+ dockerfile: 'dockerfile', tf: 'hcl',
357
+ avsc: 'json', thrift: 'thrift',
358
+ }
359
+ return map[ext.toLowerCase()] || ext
360
+ }
361
+
362
+ // ── Routes Section ──────────────────────────────────────────
363
+
364
+ /** Generate a section with detected HTTP route registrations + Next.js filesystem routes */
365
+ private generateRoutesSection(): string | null {
366
+ const expressRoutes = this.lock.routes || []
367
+ const nextRoutes = this.detectNextJsRoutes()
368
+ if (expressRoutes.length === 0 && nextRoutes.length === 0) return null
369
+
370
+ const lines: string[] = []
371
+ lines.push('## HTTP Routes')
372
+ lines.push('')
373
+
374
+ // Next.js App Router routes (detected from file paths)
375
+ if (nextRoutes.length > 0) {
376
+ lines.push('### API Routes (Next.js App Router)')
377
+ for (const r of nextRoutes) {
378
+ const methods = r.methods.length > 0 ? r.methods.join(', ') : 'handler'
379
+ lines.push(`- **${methods}** \`${r.urlPath}\` *(${r.file})*`)
380
+ }
381
+ lines.push('')
382
+ }
383
+
384
+ // Express/Koa/Hono routes
385
+ if (expressRoutes.length > 0) {
386
+ if (nextRoutes.length > 0) lines.push('### Server Routes')
387
+ for (const r of expressRoutes) {
388
+ const mw = r.middlewares.length > 0 ? ` → [${r.middlewares.join(', ')}]` : ''
389
+ lines.push(`- **${r.method}** \`${r.path}\` → \`${r.handler}\`${mw} *(${r.file}:${r.line})*`)
390
+ }
391
+ lines.push('')
392
+ }
393
+
394
+ return lines.join('\n')
395
+ }
396
+
397
+ // ── Tech Stack Section ──────────────────────────────────────
398
+
399
+ /** Detect technology stack from dependencies and config */
400
+ private generateTechStackSection(): string | null {
401
+ const deps = { ...this.meta.dependencies, ...this.meta.devDependencies }
402
+ if (Object.keys(deps).length === 0) return null
403
+
404
+ const detected: string[] = []
405
+
406
+ // Language / Runtime
407
+ const lang = this.contract.project.language
408
+ if (lang && lang !== 'typescript' && lang !== 'javascript') {
409
+ detected.push(lang.charAt(0).toUpperCase() + lang.slice(1))
410
+ }
411
+
412
+ // Frameworks — JS/TS
413
+ if (deps['next']) detected.push(`Next.js ${deps['next'].replace(/^\^|~/, '')}`)
414
+ else if (deps['nuxt']) detected.push(`Nuxt ${deps['nuxt'].replace(/^\^|~/, '')}`)
415
+ else if (deps['@sveltejs/kit']) detected.push('SvelteKit')
416
+ else if (deps['@remix-run/react'] || deps['@remix-run/node']) detected.push('Remix')
417
+ else if (deps['astro']) detected.push('Astro')
418
+ else if (deps['gatsby']) detected.push('Gatsby')
419
+ else if (deps['express']) detected.push('Express')
420
+ else if (deps['fastify']) detected.push('Fastify')
421
+ else if (deps['hono']) detected.push('Hono')
422
+ else if (deps['koa']) detected.push('Koa')
423
+ else if (deps['nestjs'] || deps['@nestjs/core']) detected.push('NestJS')
424
+ if (deps['react']) detected.push('React')
425
+ if (deps['vue']) detected.push('Vue')
426
+ if (deps['svelte']) detected.push('Svelte')
427
+ if (deps['solid-js']) detected.push('SolidJS')
428
+ if (deps['angular'] || deps['@angular/core']) detected.push('Angular')
429
+
430
+ // Mobile / Desktop
431
+ if (deps['react-native']) detected.push('React Native')
432
+ if (deps['expo']) detected.push('Expo')
433
+ if (deps['@capacitor/core']) detected.push('Capacitor')
434
+ if (deps['electron']) detected.push('Electron')
435
+ if (deps['tauri'] || deps['@tauri-apps/api']) detected.push('Tauri')
436
+
437
+ // Database / ORM
438
+ if (deps['prisma'] || deps['@prisma/client']) detected.push('Prisma ORM')
439
+ else if (deps['drizzle-orm']) detected.push('Drizzle ORM')
440
+ else if (deps['typeorm']) detected.push('TypeORM')
441
+ else if (deps['mongoose']) detected.push('Mongoose')
442
+ else if (deps['sequelize']) detected.push('Sequelize')
443
+ else if (deps['knex']) detected.push('Knex')
444
+ else if (deps['pg'] || deps['mysql2'] || deps['better-sqlite3']) detected.push('SQL client')
445
+ if (deps['redis'] || deps['ioredis']) detected.push('Redis')
446
+
447
+ // Auth
448
+ if (deps['next-auth'] || deps['@auth/core']) detected.push('NextAuth')
449
+ else if (deps['passport']) detected.push('Passport.js')
450
+ else if (deps['clerk'] || deps['@clerk/nextjs']) detected.push('Clerk')
451
+ else if (deps['lucia'] || deps['lucia-auth']) detected.push('Lucia')
452
+
453
+ // Styling
454
+ if (deps['tailwindcss']) detected.push('Tailwind CSS')
455
+ if (deps['radix-ui'] || deps['@radix-ui/react-dialog'] || deps['@radix-ui/react-slot']) detected.push('Radix UI')
456
+ if (deps['shadcn'] || deps['shadcn-ui']) detected.push('shadcn/ui')
457
+ if (deps['styled-components']) detected.push('styled-components')
458
+ if (deps['@emotion/react']) detected.push('Emotion')
459
+
460
+ // State / Data
461
+ if (deps['@tanstack/react-query']) detected.push('TanStack Query')
462
+ if (deps['zustand']) detected.push('Zustand')
463
+ if (deps['jotai']) detected.push('Jotai')
464
+ if (deps['@reduxjs/toolkit'] || deps['redux']) detected.push('Redux')
465
+ if (deps['zod']) detected.push('Zod validation')
466
+ if (deps['@trpc/server'] || deps['@trpc/client']) detected.push('tRPC')
467
+ if (deps['graphql'] || deps['@apollo/client']) detected.push('GraphQL')
468
+
469
+ // Content / Docs
470
+ if (deps['fumadocs-core'] || deps['fumadocs-mdx']) detected.push('Fumadocs')
471
+ if (deps['next-mdx-remote'] || deps['@next/mdx']) detected.push('MDX')
472
+ if (deps['contentlayer'] || deps['contentlayer2']) detected.push('Contentlayer')
473
+
474
+ // Animation
475
+ if (deps['motion'] || deps['framer-motion']) detected.push('Motion')
476
+
477
+ // Analytics / Monitoring
478
+ if (deps['posthog-js'] || deps['@posthog/react']) detected.push('PostHog')
479
+ if (deps['@vercel/analytics']) detected.push('Vercel Analytics')
480
+ if (deps['@sentry/node'] || deps['@sentry/nextjs'] || deps['@sentry/browser']) detected.push('Sentry')
481
+
482
+ // URL state
483
+ if (deps['nuqs']) detected.push('nuqs')
484
+
485
+ // Messaging / Queue
486
+ if (deps['bullmq'] || deps['bull']) detected.push('BullMQ')
487
+ if (deps['amqplib']) detected.push('RabbitMQ')
488
+ if (deps['kafkajs']) detected.push('Kafka')
489
+
490
+ // Cloud / Infra
491
+ if (deps['aws-sdk'] || deps['@aws-sdk/client-s3']) detected.push('AWS SDK')
492
+ if (deps['@google-cloud/storage']) detected.push('Google Cloud')
493
+ if (deps['firebase'] || deps['firebase-admin']) detected.push('Firebase')
494
+ if (deps['@supabase/supabase-js']) detected.push('Supabase')
495
+ if (deps['convex']) detected.push('Convex')
496
+
497
+ // Testing
498
+ if (deps['jest']) detected.push('Jest')
499
+ else if (deps['vitest']) detected.push('Vitest')
500
+ if (deps['playwright'] || deps['@playwright/test']) detected.push('Playwright')
501
+ if (deps['cypress']) detected.push('Cypress')
502
+ if (deps['@testing-library/react']) detected.push('Testing Library')
503
+
504
+ // Build
505
+ if (deps['turbo'] || deps['turbo-json']) detected.push('Turborepo')
506
+ if (deps['nx']) detected.push('Nx')
507
+ if (deps['webpack']) detected.push('Webpack')
508
+ if (deps['vite']) detected.push('Vite')
509
+ if (deps['esbuild']) detected.push('esbuild')
510
+ if (deps['rollup']) detected.push('Rollup')
511
+
512
+ if (detected.length === 0) return null
513
+
514
+ const lines: string[] = []
515
+ lines.push('## Tech Stack')
516
+ lines.push(detected.join(' · '))
517
+ lines.push('')
518
+ return lines.join('\n')
519
+ }
520
+
521
+ // ── Commands Section ────────────────────────────────────────
522
+
523
+ /** Detect build/test/dev commands from package.json scripts */
524
+ private generateCommandsSection(): string | null {
525
+ const scripts = this.meta.scripts
526
+ if (!scripts || Object.keys(scripts).length === 0) return null
527
+
528
+ // Auto-detect package manager from lockfile hints in meta, or fall back to heuristic
529
+ let pm = 'npm run'
530
+ const deps = { ...this.meta.dependencies, ...this.meta.devDependencies }
531
+ // Heuristic: check for telltale signs in scripts values
532
+ const allScriptValues = Object.values(scripts).join(' ')
533
+ if (allScriptValues.includes('bun ') || deps['bun-types']) pm = 'bun run'
534
+ else if (allScriptValues.includes('pnpm ') || allScriptValues.includes('pnpm-')) pm = 'pnpm'
535
+ else if (allScriptValues.includes('yarn ')) pm = 'yarn'
536
+
537
+ const useful: [string, string][] = []
538
+ const interestingKeys = ['dev', 'build', 'start', 'test', 'lint', 'format', 'typecheck', 'check', 'e2e', 'storybook', 'db:push', 'db:migrate', 'db:seed', 'prisma:generate', 'generate']
539
+
540
+ for (const key of interestingKeys) {
541
+ if (scripts[key]) {
542
+ useful.push([key, scripts[key]])
543
+ }
544
+ }
545
+
546
+ if (useful.length === 0) return null
547
+
548
+ const lines: string[] = []
549
+ lines.push('## Commands')
550
+ for (const [key, cmd] of useful) {
551
+ lines.push(`- \`${pm} ${key}\` \u2014 \`${cmd}\``)
552
+ }
553
+
554
+ lines.push('')
555
+ return lines.join('\n')
556
+ }
557
+
558
+ // ── Next.js Route Detection ─────────────────────────────────
559
+
560
+ /** Detect Next.js App Router routes from file paths in the lock */
561
+ private detectNextJsRoutes(): { urlPath: string; methods: string[]; file: string }[] {
562
+ const routes: { urlPath: string; methods: string[]; file: string }[] = []
563
+ const allFiles = Object.keys(this.lock.files)
564
+
565
+ for (const filePath of allFiles) {
566
+ const normalised = filePath.replace(/\\/g, '/')
567
+
568
+ // App Router API routes: app/**/route.ts
569
+ const routeMatch = normalised.match(/^(?:src\/)?app\/(.+)\/route\.[jt]sx?$/)
570
+ if (routeMatch) {
571
+ const urlSegments = routeMatch[1]
572
+ .replace(/\([\w-]+\)\/?/g, '') // strip route groups (marketing)/
573
+ .replace(/\[\.\.\.(\w+)\]/g, ':$1*') // [...slug] → :slug*
574
+ .replace(/\[(\w+)\]/g, ':$1') // [id] → :id
575
+ const urlPath = `/${urlSegments}` || '/'
576
+
577
+ // Detect exported HTTP methods from functions AND generics
578
+ // (Next.js handlers can be `export async function GET` → fn: or `export const GET =` → const:)
579
+ const methods: string[] = []
580
+ const HTTP_VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
581
+ for (const [fnId, fn] of Object.entries(this.lock.functions)) {
582
+ if (fn.file === filePath && fn.isExported && HTTP_VERBS.includes(fn.name)) {
583
+ methods.push(fn.name)
584
+ }
585
+ }
586
+ if (this.lock.generics) {
587
+ for (const [gId, g] of Object.entries(this.lock.generics)) {
588
+ // Direct match: this generic is in this file
589
+ if (g.file === filePath && g.isExported && HTTP_VERBS.includes(g.name)) {
590
+ if (!methods.includes(g.name)) methods.push(g.name)
591
+ }
592
+ // alsoIn match: deduped generics list the same verb in other files
593
+ if (g.isExported && HTTP_VERBS.includes(g.name) && g.alsoIn?.includes(filePath)) {
594
+ if (!methods.includes(g.name)) methods.push(g.name)
595
+ }
596
+ }
597
+ }
598
+
599
+ routes.push({ urlPath, methods, file: filePath })
600
+ }
601
+
602
+ // App Router pages: app/**/page.tsx (informational)
603
+ const pageMatch = normalised.match(/^(?:src\/)?app\/(.+)\/page\.[jt]sx?$/)
604
+ if (pageMatch) {
605
+ const urlSegments = pageMatch[1]
606
+ .replace(/\([\w-]+\)\/?/g, '') // strip route groups
607
+ .replace(/\[\.\.\.(\w+)\]/g, ':$1*')
608
+ .replace(/\[(\w+)\]/g, ':$1')
609
+ const urlPath = `/${urlSegments}` || '/'
610
+ routes.push({ urlPath, methods: ['PAGE'], file: filePath })
611
+ }
612
+ }
613
+
614
+ // Sort by URL path
615
+ return routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath))
616
+ }
617
+
618
+ // ── Import Graph Section ──────────────────────────────────────
619
+
620
+ /** Generate a per-module file import map */
621
+ private generateImportGraphSection(): string | null {
622
+ const filesWithImports = Object.values(this.lock.files)
623
+ .filter(f => f.imports && f.imports.length > 0)
624
+ if (filesWithImports.length === 0) return null
625
+
626
+ const lines: string[] = []
627
+ lines.push('## File Import Graph')
628
+ lines.push('')
629
+ lines.push('Which files import which — useful for understanding data flow.')
630
+ lines.push('')
631
+
632
+ // Group by module
633
+ const byModule = new Map<string, typeof filesWithImports>()
634
+ for (const f of filesWithImports) {
635
+ const existing = byModule.get(f.moduleId) || []
636
+ existing.push(f)
637
+ byModule.set(f.moduleId, existing)
638
+ }
639
+
640
+ for (const [moduleId, files] of byModule) {
641
+ const mod = this.contract.declared.modules.find(m => m.id === moduleId)
642
+ const name = mod?.name || moduleId
643
+ lines.push(`### ${name}`)
644
+ for (const f of files) {
645
+ const imports = f.imports!.map(imp => `\`${imp}\``).join(', ')
646
+ lines.push(`- \`${f.path}\` → ${imports}`)
647
+ }
648
+ lines.push('')
649
+ }
650
+
651
+ return lines.join('\n')
652
+ }
653
+
231
654
  // ── Helpers ───────────────────────────────────────────────────
232
655
 
233
- /** Format a function into a readable signature */
656
+ /** Collapse multi-line text to a single trimmed line */
657
+ private oneLine(text: string): string {
658
+ return text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
659
+ }
660
+
661
+ /** Format a function into a compact single-line signature */
234
662
  private formatSignature(fn: MikkLockFunction): string {
235
- return `${fn.name}() [${fn.file}:${fn.startLine}]`
663
+ const asyncPrefix = fn.isAsync ? 'async ' : ''
664
+ const params = fn.params && fn.params.length > 0
665
+ ? fn.params.map(p => {
666
+ const opt = p.optional ? '?' : ''
667
+ // Use just name (skip destructured object types — they bloat multi-line)
668
+ const name = p.name.replace(/\n/g, ' ').replace(/\s+/g, ' ')
669
+ return `${name}${opt}`
670
+ }).join(', ')
671
+ : ''
672
+ return `${asyncPrefix}${fn.name}(${params}) [${fn.file}:${fn.startLine}]`
673
+ }
674
+
675
+ /**
676
+ * Collapse many paths into fewest glob patterns.
677
+ * e.g. ["src/features/portfolio/components/awards/**", "src/features/portfolio/components/bookmarks/**", ...]
678
+ * becomes "src/features/portfolio/**"
679
+ */
680
+ private collapsePaths(paths: string[]): string {
681
+ if (paths.length <= 2) return paths.join(', ')
682
+
683
+ // Split each path into segments (strip trailing **)
684
+ const stripped = paths.map(p => p.replace(/\/\*\*$/, ''))
685
+
686
+ // Try progressively shorter common prefixes
687
+ // Find the longest common directory prefix shared by majority of paths
688
+ const segments = stripped.map(p => p.split('/'))
689
+ let bestPrefix = ''
690
+ for (let depth = 1; depth <= (segments[0]?.length ?? 0); depth++) {
691
+ const prefix = segments[0].slice(0, depth).join('/')
692
+ const matching = stripped.filter(p => p === prefix || p.startsWith(prefix + '/'))
693
+ if (matching.length >= Math.ceil(paths.length * 0.6)) {
694
+ bestPrefix = prefix
695
+ }
696
+ }
697
+
698
+ if (bestPrefix) {
699
+ // Some paths outside the prefix
700
+ const outside = paths.filter(p => {
701
+ const s = p.replace(/\/\*\*$/, '')
702
+ return s !== bestPrefix && !s.startsWith(bestPrefix + '/')
703
+ })
704
+ if (outside.length === 0) {
705
+ return `${bestPrefix}/**`
706
+ }
707
+ return [`${bestPrefix}/**`, ...outside].join(', ')
708
+ }
709
+
710
+ return paths.join(', ')
236
711
  }
237
712
 
238
713
  /** Sort modules by inter-module dependency order (depended-on modules first) */
@@ -1,5 +1,7 @@
1
1
  import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
2
2
  import type { AIContext, ContextQuery, ContextModule, ContextFunction } from './types.js'
3
+ import * as fs from 'node:fs'
4
+ import * as path from 'node:path'
3
5
 
4
6
  // ---------------------------------------------------------------------------
5
7
  // Scoring weights — tune these to adjust what "relevant" means
@@ -301,6 +303,19 @@ export class ContextBuilder {
301
303
  title: d.title,
302
304
  reason: d.reason,
303
305
  })),
306
+ contextFiles: this.lock.contextFiles?.map(cf => ({
307
+ path: cf.path,
308
+ content: cf.content,
309
+ type: cf.type,
310
+ })),
311
+ routes: this.lock.routes?.map(r => ({
312
+ method: r.method,
313
+ path: r.path,
314
+ handler: r.handler,
315
+ middlewares: r.middlewares,
316
+ file: r.file,
317
+ line: r.line,
318
+ })),
304
319
  prompt: this.generatePrompt(query, contextModules),
305
320
  meta: {
306
321
  seedCount: seeds.length,
@@ -315,17 +330,53 @@ export class ContextBuilder {
315
330
  // ── Private helpers ────────────────────────────────────────────────────
316
331
 
317
332
  private toContextFunction(fn: MikkLockFunction, query: ContextQuery): ContextFunction {
318
- return {
333
+ const base: ContextFunction = {
319
334
  name: fn.name,
320
335
  file: fn.file,
321
336
  startLine: fn.startLine,
322
337
  endLine: fn.endLine,
323
338
  calls: query.includeCallGraph !== false ? fn.calls : [],
324
339
  calledBy: query.includeCallGraph !== false ? fn.calledBy : [],
340
+ params: fn.params,
341
+ returnType: fn.returnType,
342
+ isAsync: fn.isAsync,
343
+ isExported: fn.isExported,
325
344
  purpose: fn.purpose,
326
345
  errorHandling: fn.errorHandling?.map(e => `${e.type} @ line ${e.line}: ${e.detail}`),
327
346
  edgeCases: fn.edgeCasesHandled,
328
347
  }
348
+
349
+ // Attach body if requested and projectRoot is available
350
+ if (query.includeBodies !== false && query.projectRoot) {
351
+ base.body = this.readFunctionBody(fn, query.projectRoot)
352
+ }
353
+
354
+ return base
355
+ }
356
+
357
+ /**
358
+ * Read the actual source code of a function from disk.
359
+ * Uses startLine/endLine from the lock to extract the relevant lines.
360
+ * Large bodies are compressed to preserve logic while stripping noise.
361
+ */
362
+ private readFunctionBody(fn: MikkLockFunction, projectRoot: string): string | undefined {
363
+ try {
364
+ const filePath = path.resolve(projectRoot, fn.file)
365
+ if (!fs.existsSync(filePath)) return undefined
366
+
367
+ const content = fs.readFileSync(filePath, 'utf-8')
368
+ const lines = content.split('\n')
369
+ const start = Math.max(0, fn.startLine - 1) // Convert to 0-based
370
+ const end = Math.min(lines.length, fn.endLine)
371
+ const body = lines.slice(start, end).join('\n')
372
+
373
+ // Skip if body is trivially small (single-line setters etc.)
374
+ if (body.length < 20) return undefined
375
+
376
+ return compressBody(body)
377
+ } catch {
378
+ return undefined
379
+ }
329
380
  }
330
381
 
331
382
  /**
@@ -333,11 +384,21 @@ export class ContextBuilder {
333
384
  * Mirrors what the providers will emit.
334
385
  */
335
386
  private buildFunctionSnippet(fn: MikkLockFunction, query: ContextQuery): string {
336
- const parts = [`${fn.name}(${fn.file}:${fn.startLine}-${fn.endLine})`]
387
+ const asyncStr = fn.isAsync ? 'async ' : ''
388
+ const params = fn.params?.map(p => `${p.name}: ${p.type}`).join(', ') || ''
389
+ const retStr = fn.returnType ? `: ${fn.returnType}` : ''
390
+ const parts = [`${asyncStr}${fn.name}(${params})${retStr} ${fn.file}:${fn.startLine}-${fn.endLine}`]
337
391
  if (fn.purpose) parts.push(` — ${fn.purpose}`)
338
392
  if (query.includeCallGraph !== false && fn.calls.length > 0) {
339
393
  parts.push(` calls:[${fn.calls.join(',')}]`)
340
394
  }
395
+ // Estimate body contribution to tokens if bodies will be included
396
+ if (query.includeBodies !== false && query.projectRoot) {
397
+ const bodyLines = (fn.endLine - fn.startLine) + 1
398
+ // Compressed bodies are ~40-60% smaller; use reduced estimate
399
+ const charsPerLine = bodyLines > 15 ? 20 : 40
400
+ parts.push('X'.repeat(bodyLines * charsPerLine))
401
+ }
341
402
  return parts.join('')
342
403
  }
343
404
 
@@ -353,6 +414,35 @@ export class ContextBuilder {
353
414
  lines.push(`Task: ${query.task}`)
354
415
  lines.push('')
355
416
 
417
+ // Include routes (API endpoints) — critical for understanding how the app works
418
+ const routes = this.lock.routes
419
+ if (routes && routes.length > 0) {
420
+ lines.push('=== HTTP ROUTES ===')
421
+ for (const r of routes) {
422
+ const mw = r.middlewares.length > 0 ? ` [${r.middlewares.join(', ')}]` : ''
423
+ lines.push(` ${r.method} ${r.path} → ${r.handler}${mw} (${r.file}:${r.line})`)
424
+ }
425
+ lines.push('')
426
+ }
427
+
428
+ // Include context files (schemas, data models) first — they define the shape
429
+ const ctxFiles = this.lock.contextFiles
430
+ if (ctxFiles && ctxFiles.length > 0) {
431
+ lines.push('=== DATA MODELS & SCHEMAS ===')
432
+ for (const cf of ctxFiles) {
433
+ lines.push(`--- ${cf.path} (${cf.type}) ---`)
434
+ // Trim to ~2000 chars per file in prompt output
435
+ const maxChars = 2000
436
+ if (cf.content.length > maxChars) {
437
+ lines.push(cf.content.slice(0, maxChars))
438
+ lines.push(`... (truncated, ${cf.size} bytes total)`)
439
+ } else {
440
+ lines.push(cf.content.trimEnd())
441
+ }
442
+ lines.push('')
443
+ }
444
+ }
445
+
356
446
  for (const mod of modules) {
357
447
  lines.push(`--- Module: ${mod.name} (${mod.id}) ---`)
358
448
  if (mod.description) lines.push(mod.description)
@@ -360,13 +450,22 @@ export class ContextBuilder {
360
450
  lines.push('')
361
451
 
362
452
  for (const fn of mod.functions) {
453
+ // Rich signature
454
+ const asyncStr = fn.isAsync ? 'async ' : ''
455
+ const params = fn.params && fn.params.length > 0
456
+ ? fn.params.map(p => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', ')
457
+ : ''
458
+ const retStr = fn.returnType ? `: ${fn.returnType}` : ''
459
+ const exported = fn.isExported ? 'export ' : ''
460
+ const sig = `${exported}${asyncStr}${fn.name}(${params})${retStr}`
461
+
363
462
  const callStr = fn.calls.length > 0
364
463
  ? ` → [${fn.calls.join(', ')}]`
365
464
  : ''
366
465
  const calledByStr = fn.calledBy.length > 0
367
466
  ? ` ← called by [${fn.calledBy.join(', ')}]`
368
467
  : ''
369
- lines.push(` ${fn.name} ${fn.file}:${fn.startLine}-${fn.endLine}${callStr}${calledByStr}`)
468
+ lines.push(` ${sig} ${fn.file}:${fn.startLine}-${fn.endLine}${callStr}${calledByStr}`)
370
469
  if (fn.purpose) lines.push(` purpose: ${fn.purpose}`)
371
470
  if (fn.edgeCases && fn.edgeCases.length > 0) {
372
471
  lines.push(` edge cases: ${fn.edgeCases.join('; ')}`)
@@ -374,6 +473,11 @@ export class ContextBuilder {
374
473
  if (fn.errorHandling && fn.errorHandling.length > 0) {
375
474
  lines.push(` error handling: ${fn.errorHandling.join('; ')}`)
376
475
  }
476
+ if (fn.body) {
477
+ lines.push(' ```')
478
+ lines.push(fn.body)
479
+ lines.push(' ```')
480
+ }
377
481
  }
378
482
  lines.push('')
379
483
  }
@@ -396,4 +500,240 @@ export class ContextBuilder {
396
500
 
397
501
  return lines.join('\n')
398
502
  }
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Body compressor — produces dense pseudo-code preserving all logic
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Compress a function body for context output.
511
+ * Bodies ≤ 15 lines pass through unchanged.
512
+ * Larger bodies get noise stripped, templates collapsed, and blocks condensed.
513
+ */
514
+ function compressBody(raw: string): string {
515
+ const lines = raw.split('\n')
516
+ if (lines.length <= 15) return raw
517
+
518
+ let result = stripNoise(lines)
519
+ result = removeEmptyBlocks(result)
520
+ result = collapseTemplates(result)
521
+ result = collapseChains(result)
522
+ result = collapseSimpleBlocks(result)
523
+ result = dedent(result)
524
+
525
+ return result.join('\n')
526
+ }
527
+
528
+ /** Strip blank lines, comment-only lines, and console.* statements */
529
+ function stripNoise(lines: string[]): string[] {
530
+ const out: string[] = []
531
+ let inBlock = false
532
+
533
+ for (const line of lines) {
534
+ const t = line.trim()
535
+
536
+ // Track block comments
537
+ if (inBlock) {
538
+ if (t.includes('*/')) inBlock = false
539
+ continue
540
+ }
541
+ if (t.startsWith('/*')) {
542
+ if (!t.includes('*/')) inBlock = true
543
+ continue
544
+ }
545
+
546
+ // Skip blank lines
547
+ if (!t) continue
548
+
549
+ // Skip single-line comments (preserve TODO/FIXME/NOTE)
550
+ if (t.startsWith('//') && !/\b(TODO|FIXME|HACK|NOTE)\b/i.test(t)) continue
551
+
552
+ // Skip console.log/error/warn/info/debug statements
553
+ if (/^\s*console\.(log|error|warn|info|debug)\s*\(/.test(line)) continue
554
+
555
+ out.push(line)
556
+ }
557
+
558
+ return out
559
+ }
560
+
561
+ /** Remove empty blocks left after noise stripping (empty else {}, catch {}) */
562
+ function removeEmptyBlocks(lines: string[]): string[] {
563
+ const out: string[] = []
564
+ let i = 0
565
+
566
+ while (i < lines.length) {
567
+ const t = lines[i].trim()
568
+ const next = i + 1 < lines.length ? lines[i + 1].trim() : ''
569
+
570
+ // "} else {" followed by "}" → just "}" (closing the if-block)
571
+ if (/^}\s*else\s*\{$/.test(t) && next === '}') {
572
+ const indent = lines[i].match(/^(\s*)/)?.[1] || ''
573
+ out.push(`${indent}}`)
574
+ i += 2
575
+ continue
576
+ }
577
+
578
+ // "} catch (...) {" followed by "}" → "} catch (...) {}" on one line
579
+ if (/^}\s*catch\s*(\(.*\))?\s*\{$/.test(t) && next === '}') {
580
+ const indent = lines[i].match(/^(\s*)/)?.[1] || ''
581
+ out.push(`${indent}${t} }`)
582
+ i += 2
583
+ continue
584
+ }
585
+
586
+ out.push(lines[i])
587
+ i++
588
+ }
589
+
590
+ return out
591
+ }
592
+ /** Collapse multi-line template literals (>5 lines) into short descriptors */
593
+ function collapseTemplates(lines: string[]): string[] {
594
+ const out: string[] = []
595
+ let i = 0
596
+
597
+ while (i < lines.length) {
598
+ const line = lines[i]
599
+ const t = line.trim()
600
+
601
+ // Count unescaped backticks on this line
602
+ const bts = (t.replace(/\\`/g, '').match(/`/g) || []).length
603
+
604
+ if (bts % 2 === 1) {
605
+ // Odd count → opens a multi-line template literal
606
+ const start = i
607
+ const collected: string[] = [t]
608
+ i++
609
+
610
+ while (i < lines.length) {
611
+ const tl = lines[i].trim()
612
+ collected.push(tl)
613
+ const tlBts = (tl.replace(/\\`/g, '').match(/`/g) || []).length
614
+ if (tlBts % 2 === 1) { i++; break }
615
+ i++
616
+ }
617
+
618
+ // Only collapse if the template is large (>5 lines)
619
+ if (collected.length > 5) {
620
+ const content = collected.join('\n')
621
+ const desc = describeTemplate(content)
622
+ const indent = line.match(/^(\s*)/)?.[1] || ''
623
+ const btIdx = t.indexOf('`')
624
+ const prefix = btIdx >= 0 ? t.substring(0, btIdx) : ''
625
+ out.push(`${indent}${prefix}[template: ${desc}]`)
626
+ } else {
627
+ // Small template — keep original lines
628
+ for (let j = start; j < i; j++) out.push(lines[j])
629
+ }
630
+ continue
631
+ }
632
+
633
+ out.push(line)
634
+ i++
635
+ }
636
+
637
+ return out
638
+ }
639
+
640
+ /** Analyze template content and produce a short description */
641
+ function describeTemplate(content: string): string {
642
+ const lower = content.toLowerCase()
643
+ const f: string[] = []
644
+
645
+ if (lower.includes('<!doctype') || lower.includes('<html')) f.push('HTML page')
646
+ else if (lower.includes('<div') || lower.includes('<span')) f.push('HTML fragment')
647
+ if (lower.includes('<style>') || lower.includes('font-family')) f.push('with CSS')
648
+ if (lower.includes('<script>')) f.push('with JS')
649
+ if (/\bselect\b|\binsert\b|\bupdate\b.*\bset\b/i.test(content)) f.push('SQL query')
650
+
651
+ const interps = (content.match(/\$\{/g) || []).length
652
+ if (interps > 0) f.push(`${interps} vars`)
653
+
654
+ return f.length > 0 ? f.join(', ') : `${content.split('\n').length}-line string`
655
+ }
656
+
657
+ /** Collapse 3+ consecutive .replace() lines into a summary */
658
+ function collapseChains(lines: string[]): string[] {
659
+ const out: string[] = []
660
+ let i = 0
661
+
662
+ while (i < lines.length) {
663
+ if (lines[i].trim().includes('.replace(')) {
664
+ const start = i
665
+ let count = 0
666
+ while (i < lines.length && lines[i].trim().includes('.replace(')) {
667
+ count++
668
+ i++
669
+ }
670
+
671
+ if (count >= 3) {
672
+ const indent = lines[start].match(/^(\s*)/)?.[1] || ''
673
+ // If the previous line is the chain's assignment target (no semicolon), merge
674
+ if (out.length > 0) {
675
+ const prev = out[out.length - 1].trimEnd()
676
+ if (!prev.endsWith(';') && !prev.endsWith('{') && !prev.endsWith('}')) {
677
+ out[out.length - 1] = `${prev} [${count}x .replace() chain]`
678
+ continue
679
+ }
680
+ }
681
+ out.push(`${indent}[${count}x .replace() chain]`)
682
+ } else {
683
+ for (let j = start; j < i; j++) out.push(lines[j])
684
+ }
685
+ continue
686
+ }
687
+
688
+ out.push(lines[i])
689
+ i++
690
+ }
691
+
692
+ return out
693
+ }
694
+
695
+ /** Collapse single-statement if/else blocks (3 lines → 1 line) */
696
+ function collapseSimpleBlocks(lines: string[]): string[] {
697
+ const out: string[] = []
698
+ let i = 0
699
+
700
+ while (i < lines.length) {
701
+ const t = lines[i].trim()
702
+
703
+ // Match: if (...) { or else if (...) { or else {
704
+ if (/^(if\s*\(.*\)|else\s+if\s*\(.*\)|else)\s*\{\s*$/.test(t) && i + 2 < lines.length) {
705
+ const body = lines[i + 1].trim()
706
+ const close = lines[i + 2].trim()
707
+
708
+ // Only collapse if the next line is a single statement and line after is closing }
709
+ if (close === '}' && !body.startsWith('if') && !body.startsWith('for') &&
710
+ !body.startsWith('while') && !body.startsWith('switch')) {
711
+ const indent = lines[i].match(/^(\s*)/)?.[1] || ''
712
+ out.push(`${indent}${t} ${body} }`)
713
+ i += 3
714
+ continue
715
+ }
716
+ }
717
+
718
+ out.push(lines[i])
719
+ i++
720
+ }
721
+
722
+ return out
723
+ }
724
+
725
+ /** Remove common leading indentation */
726
+ function dedent(lines: string[]): string[] {
727
+ let min = Infinity
728
+ for (const l of lines) {
729
+ const m = l.match(/^(\s+)\S/)
730
+ if (m && m[1].length < min) min = m[1].length
731
+ }
732
+ if (min === Infinity || min <= 0) return lines
733
+
734
+ return lines.map(l => {
735
+ if (!l.trim()) return l
736
+ const spaces = l.length - l.trimStart().length
737
+ return l.substring(Math.min(min, spaces))
738
+ })
399
739
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ContextBuilder } from './context-builder.js'
2
2
  export { ClaudeMdGenerator } from './claude-md-generator.js'
3
+ export type { ProjectMeta } from './claude-md-generator.js'
3
4
  export { ClaudeProvider, GenericProvider, getProvider } from './providers.js'
4
5
  export type { AIContext, ContextModule, ContextFunction, ContextQuery, ContextProvider } from './types.js'
package/src/providers.ts CHANGED
@@ -51,7 +51,14 @@ export class ClaudeProvider implements ContextProvider {
51
51
  const calledBy = fn.calledBy.length > 0
52
52
  ? ` calledBy="${esc(fn.calledBy.join(','))}"`
53
53
  : ''
54
- lines.push(` <fn name="${esc(fn.name)}" file="${esc(fn.file)}" lines="${fn.startLine}-${fn.endLine}"${calls}${calledBy}>`)
54
+ // Rich signature attributes
55
+ const asyncAttr = fn.isAsync ? ' async="true"' : ''
56
+ const exportedAttr = fn.isExported ? ' exported="true"' : ''
57
+ const retAttr = fn.returnType ? ` returns="${esc(fn.returnType)}"` : ''
58
+ const paramStr = fn.params && fn.params.length > 0
59
+ ? ` params="${esc(fn.params.map(p => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', '))}"`
60
+ : ''
61
+ lines.push(` <fn name="${esc(fn.name)}" file="${esc(fn.file)}" lines="${fn.startLine}-${fn.endLine}"${asyncAttr}${exportedAttr}${paramStr}${retAttr}${calls}${calledBy}>`)
55
62
  if (fn.purpose) lines.push(` <purpose>${esc(fn.purpose)}</purpose>`)
56
63
  if (fn.edgeCases && fn.edgeCases.length > 0) {
57
64
  lines.push(` <edge_cases>${esc(fn.edgeCases.join('; '))}</edge_cases>`)
@@ -59,6 +66,11 @@ export class ClaudeProvider implements ContextProvider {
59
66
  if (fn.errorHandling && fn.errorHandling.length > 0) {
60
67
  lines.push(` <error_handling>${esc(fn.errorHandling.join('; '))}</error_handling>`)
61
68
  }
69
+ if (fn.body) {
70
+ lines.push(` <body>`)
71
+ lines.push(esc(fn.body))
72
+ lines.push(` </body>`)
73
+ }
62
74
  lines.push(' </fn>')
63
75
  }
64
76
  lines.push(' </functions>')
@@ -67,6 +79,36 @@ export class ClaudeProvider implements ContextProvider {
67
79
  lines.push('')
68
80
  }
69
81
 
82
+ // ── Context files (schemas, data models, config) ───────────────────
83
+ if (context.contextFiles && context.contextFiles.length > 0) {
84
+ lines.push('<context_files>')
85
+ for (const cf of context.contextFiles) {
86
+ lines.push(` <file path="${esc(cf.path)}" type="${esc(cf.type)}">`)
87
+ // Trim to ~2000 chars per file to stay within token budget
88
+ const maxChars = 2000
89
+ if (cf.content.length > maxChars) {
90
+ lines.push(esc(cf.content.slice(0, maxChars)))
91
+ lines.push(`... (truncated)`)
92
+ } else {
93
+ lines.push(esc(cf.content.trimEnd()))
94
+ }
95
+ lines.push(' </file>')
96
+ }
97
+ lines.push('</context_files>')
98
+ lines.push('')
99
+ }
100
+
101
+ // ── Routes (HTTP endpoints) ────────────────────────────────────────
102
+ if (context.routes && context.routes.length > 0) {
103
+ lines.push('<routes>')
104
+ for (const r of context.routes) {
105
+ const mw = r.middlewares.length > 0 ? ` middlewares="${esc(r.middlewares.join(','))}"` : ''
106
+ lines.push(` <route method="${esc(r.method)}" path="${esc(r.path)}" handler="${esc(r.handler)}" file="${esc(r.file)}" line="${r.line}"${mw}/>`)
107
+ }
108
+ lines.push('</routes>')
109
+ lines.push('')
110
+ }
111
+
70
112
  // ── Constraints ────────────────────────────────────────────────────
71
113
  if (context.constraints.length > 0) {
72
114
  lines.push('<constraints>')
@@ -121,8 +163,32 @@ export class CompactProvider implements ContextProvider {
121
163
  for (const mod of context.modules) {
122
164
  lines.push(`## ${mod.name}`)
123
165
  for (const fn of mod.functions) {
166
+ const asyncStr = fn.isAsync ? 'async ' : ''
167
+ const params = fn.params?.map(p => `${p.name}: ${p.type}`).join(', ') || ''
168
+ const retStr = fn.returnType ? `: ${fn.returnType}` : ''
124
169
  const calls = fn.calls.length > 0 ? ` → ${fn.calls.join(',')}` : ''
125
- lines.push(` ${fn.name} [${fn.file}:${fn.startLine}]${calls}`)
170
+ lines.push(` ${asyncStr}${fn.name}(${params})${retStr} [${fn.file}:${fn.startLine}]${calls}`)
171
+ if (fn.body) {
172
+ lines.push(' ```')
173
+ lines.push(fn.body)
174
+ lines.push(' ```')
175
+ }
176
+ }
177
+ lines.push('')
178
+ }
179
+ if (context.contextFiles && context.contextFiles.length > 0) {
180
+ lines.push('## Schemas & Data Models')
181
+ for (const cf of context.contextFiles) {
182
+ lines.push(`### ${cf.path} (${cf.type})`)
183
+ lines.push(cf.content.length > 1500 ? cf.content.slice(0, 1500) + '\n...(truncated)' : cf.content.trimEnd())
184
+ lines.push('')
185
+ }
186
+ }
187
+ if (context.routes && context.routes.length > 0) {
188
+ lines.push('## Routes')
189
+ for (const r of context.routes) {
190
+ const mw = r.middlewares.length > 0 ? ` [${r.middlewares.join(', ')}]` : ''
191
+ lines.push(` ${r.method} ${r.path} → ${r.handler}${mw} (${r.file}:${r.line})`)
126
192
  }
127
193
  lines.push('')
128
194
  }
package/src/types.ts CHANGED
@@ -12,6 +12,10 @@ export interface AIContext {
12
12
  modules: ContextModule[]
13
13
  constraints: string[]
14
14
  decisions: { title: string; reason: string }[]
15
+ /** Discovered schema/config/model files included verbatim */
16
+ contextFiles?: { path: string; content: string; type: string }[]
17
+ /** Detected HTTP route registrations */
18
+ routes?: { method: string; path: string; handler: string; middlewares: string[]; file: string; line: number }[]
15
19
  prompt: string
16
20
  /** Diagnostic info — helpful for debugging context quality */
17
21
  meta: {
@@ -39,9 +43,15 @@ export interface ContextFunction {
39
43
  endLine: number
40
44
  calls: string[]
41
45
  calledBy: string[]
46
+ params?: { name: string; type: string; optional?: boolean }[]
47
+ returnType?: string
48
+ isAsync?: boolean
49
+ isExported?: boolean
42
50
  purpose?: string
43
51
  errorHandling?: string[]
44
52
  edgeCases?: string[]
53
+ /** The actual source code body (only included for top-scored functions) */
54
+ body?: string
45
55
  }
46
56
 
47
57
  /** Query options for context generation */
@@ -60,6 +70,10 @@ export interface ContextQuery {
60
70
  tokenBudget?: number
61
71
  /** Include call graph arrows (default true) */
62
72
  includeCallGraph?: boolean
73
+ /** Include function bodies for top-scored functions (default true) */
74
+ includeBodies?: boolean
75
+ /** Absolute filesystem path to the project root (needed for body reading) */
76
+ projectRoot?: string
63
77
  }
64
78
 
65
79
  /** Context provider interface for different AI platforms */