@@ -62,6 +62,9 @@ __factories["./src/config/defaults"] = function(module, exports) {
6262 // Sort recently git-committed files higher in output
6363 diffPriority : true ,
6464
65+ // Debounce delay (ms) between file-system events and regeneration in watch mode
66+ watchDebounce : 300 ,
67+
6568 // Append model routing hints section to the context output
6669 // Routes files to fast/balanced/powerful model tiers based on complexity
6770 routing : false ,
@@ -1581,6 +1584,9 @@ __factories["./src/health/scorer"] = function(module, exports) {
15811584 * 2. Average token reduction percentage (low-reduction penalty 20 pts)
15821585 * 3. Over-budget run rate (budget penalty 20 pts)
15831586 *
1587+ * Strategy-aware: thresholds adjust based on the active strategy so that
1588+ * hot-cold (90% reduction intentional) is not penalized as 'low reduction'.
1589+ *
15841590 * Grade scale: A ≥ 90 | B ≥ 75 | C ≥ 60 | D < 60
15851591 *
15861592 * Never throws — returns graceful result with nulls for unavailable metrics.
@@ -1589,8 +1595,10 @@ __factories["./src/health/scorer"] = function(module, exports) {
15891595 * @returns {{
15901596 * score: number,
15911597 * grade: 'A'|'B'|'C'|'D',
1598+ * strategy: string,
15921599 * tokenReductionPct: number|null,
15931600 * daysSinceRegen: number|null,
1601+ * strategyFreshnessDays: number|null,
15941602 * totalRuns: number,
15951603 * overBudgetRuns: number,
15961604 * }}
@@ -1601,30 +1609,52 @@ __factories["./src/health/scorer"] = function(module, exports) {
16011609
16021610 let tokenReductionPct = null ;
16031611 let daysSinceRegen = null ;
1612+ let strategyFreshnessDays = null ;
16041613 let overBudgetRuns = 0 ;
16051614 let totalRuns = 0 ;
16061615
1616+ // ── Detect active strategy ──────────────────────────────────────────────
1617+ let strategy = 'full' ;
1618+ try {
1619+ const cfgPath = path . join ( cwd , 'gen-context.config.json' ) ;
1620+ if ( fs . existsSync ( cfgPath ) ) {
1621+ const cfg = JSON . parse ( fs . readFileSync ( cfgPath , 'utf8' ) ) ;
1622+ strategy = cfg . strategy || 'full' ;
1623+ }
1624+ } catch ( _ ) { }
1625+
16071626 // ── Read usage log via tracking logger ──────────────────────────────────
16081627 try {
16091628 const { readLog, summarize } = __require ( './src/tracking/logger' ) ;
16101629 const entries = readLog ( cwd ) ;
16111630 const s = summarize ( entries ) ;
1612- tokenReductionPct = s . avgReductionPct ;
1631+ // Only set tokenReductionPct when there is actual history; a brand-new/
1632+ // untracked project should not be penalised for "0% reduction".
1633+ if ( s . totalRuns > 0 ) tokenReductionPct = s . avgReductionPct ;
16131634 overBudgetRuns = s . overBudgetRuns ;
16141635 totalRuns = s . totalRuns ;
16151636 } catch ( _ ) {
16161637 // No usage log yet — proceed with nulls
16171638 }
16181639
1619- // ── Days since context file was last regenerated ──────── ─────────────────
1640+ // ── Days since primary context file was last regenerated ─────────────────
16201641 try {
16211642 const ctxFile = path . join ( cwd , '.github' , 'copilot-instructions.md' ) ;
16221643 if ( fs . existsSync ( ctxFile ) ) {
16231644 const mtime = fs . statSync ( ctxFile ) . mtimeMs ;
16241645 daysSinceRegen = parseFloat ( ( ( Date . now ( ) - mtime ) / ( 1000 * 60 * 60 * 24 ) ) . toFixed ( 1 ) ) ;
16251646 }
1626- } catch ( _ ) {
1627- // File not found or stat failed — leave as null
1647+ } catch ( _ ) { }
1648+
1649+ // ── Strategy freshness: context-cold.md age (hot-cold only) ─────────────
1650+ if ( strategy === 'hot-cold' ) {
1651+ try {
1652+ const coldFile = path . join ( cwd , '.github' , 'context-cold.md' ) ;
1653+ if ( fs . existsSync ( coldFile ) ) {
1654+ const mtime = fs . statSync ( coldFile ) . mtimeMs ;
1655+ strategyFreshnessDays = parseFloat ( ( ( Date . now ( ) - mtime ) / ( 1000 * 60 * 60 * 24 ) ) . toFixed ( 1 ) ) ;
1656+ }
1657+ } catch ( _ ) { }
16281658 }
16291659
16301660 // ── Compute composite score ───────────────────────────────────────────────
@@ -1635,11 +1665,20 @@ __factories["./src/health/scorer"] = function(module, exports) {
16351665 points -= Math . min ( 30 , Math . floor ( ( daysSinceRegen - 7 ) * 4 ) ) ;
16361666 }
16371667
1638- // Low-reduction penalty: context is barely smaller than the raw source (-20)
1639- if ( tokenReductionPct !== null && tokenReductionPct < 60 ) {
1668+ // Low-reduction penalty — threshold depends on strategy:
1669+ // - hot-cold: primary output tiny by design; use cold freshness instead
1670+ // - per-module: per-file budgets; global < 60% is expected, no penalty
1671+ // - full: standard 60% threshold
1672+ const reductionThreshold = ( strategy === 'full' ) ? 60 : 0 ;
1673+ if ( tokenReductionPct !== null && tokenReductionPct < reductionThreshold ) {
16401674 points -= 20 ;
16411675 }
16421676
1677+ // hot-cold strategy freshness penalty: context-cold.md older than 1 day (-10 pts)
1678+ if ( strategy === 'hot-cold' && strategyFreshnessDays !== null && strategyFreshnessDays > 1 ) {
1679+ points -= Math . min ( 10 , Math . floor ( strategyFreshnessDays - 1 ) * 3 ) ;
1680+ }
1681+
16431682 // Over-budget penalty: more than 20% of runs exceeded the token budget (-20)
16441683 if ( overBudgetRuns > 0 && totalRuns > 0 ) {
16451684 const overBudgetRate = ( overBudgetRuns / totalRuns ) * 100 ;
@@ -1654,7 +1693,7 @@ __factories["./src/health/scorer"] = function(module, exports) {
16541693 else if ( points >= 60 ) grade = 'C' ;
16551694 else grade = 'D' ;
16561695
1657- return { score : points , grade, tokenReductionPct, daysSinceRegen, totalRuns, overBudgetRuns } ;
1696+ return { score : points , grade, strategy , tokenReductionPct, daysSinceRegen, strategyFreshnessDays , totalRuns, overBudgetRuns } ;
16581697 }
16591698
16601699 module . exports = { score } ;
@@ -2327,7 +2366,156 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
23272366 }
23282367 }
23292368
2330- module . exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting } ;
2369+ function explainFile ( args , cwd ) {
2370+ if ( ! args || ! args . path ) return 'Missing required argument: path' ;
2371+
2372+ const targetRel = args . path . replace ( / \\ / g, '/' ) . replace ( / ^ \/ / , '' ) ;
2373+ const targetAbs = path . resolve ( cwd , targetRel ) ;
2374+ const contextPath = path . join ( cwd , CONTEXT_FILE ) ;
2375+
2376+ const lines = [ '# explain_file: ' + targetRel , '' ] ;
2377+
2378+ lines . push ( '## Signatures' ) ;
2379+ let indexedFiles = [ ] ;
2380+
2381+ if ( fs . existsSync ( contextPath ) ) {
2382+ const ctxContent = fs . readFileSync ( contextPath , 'utf8' ) ;
2383+ const ctxLines = ctxContent . split ( '\n' ) ;
2384+ let capturing = false ;
2385+ const sigLines = [ ] ;
2386+
2387+ for ( const line of ctxLines ) {
2388+ if ( line . startsWith ( '### ' ) ) {
2389+ if ( capturing ) break ;
2390+ const rel = line . slice ( 4 ) . trim ( ) . replace ( / \\ / g, '/' ) ;
2391+ capturing = rel === targetRel || rel . endsWith ( '/' + targetRel ) || targetRel . endsWith ( '/' + rel ) ;
2392+ if ( capturing ) continue ;
2393+ } else if ( capturing ) {
2394+ sigLines . push ( line ) ;
2395+ }
2396+ }
2397+
2398+ const sigs = sigLines . filter ( ( l ) => l !== '```' && l . trim ( ) !== '' ) ;
2399+ if ( sigs . length > 0 ) {
2400+ lines . push ( ...sigs ) ;
2401+ } else {
2402+ lines . push ( '_No signatures indexed for this file. Run: node gen-context.js_' ) ;
2403+ }
2404+
2405+ indexedFiles = ctxContent
2406+ . split ( '\n' )
2407+ . filter ( ( l ) => l . startsWith ( '### ' ) )
2408+ . map ( ( l ) => path . resolve ( cwd , l . slice ( 4 ) . trim ( ) ) ) ;
2409+ } else {
2410+ lines . push ( '_No context file found. Run: node gen-context.js_' ) ;
2411+ }
2412+
2413+ if ( ! fs . existsSync ( targetAbs ) ) {
2414+ lines . push ( '' ) ;
2415+ lines . push ( '> File not found on disk: ' + targetRel ) ;
2416+ return lines . join ( '\n' ) ;
2417+ }
2418+
2419+ lines . push ( '' ) ;
2420+
2421+ lines . push ( '## Imports (direct dependencies)' ) ;
2422+ try {
2423+ const { extractImports } = __require ( './src/map/import-graph' ) ;
2424+ const fileContent = fs . readFileSync ( targetAbs , 'utf8' ) ;
2425+ const fileSet = new Set ( indexedFiles ) ;
2426+ fileSet . add ( targetAbs ) ;
2427+ const imports = extractImports ( targetAbs , fileContent , fileSet ) ;
2428+ if ( imports . length > 0 ) {
2429+ for ( const imp of imports ) lines . push ( '- ' + path . relative ( cwd , imp ) . replace ( / \\ / g, '/' ) ) ;
2430+ } else {
2431+ lines . push ( '_No resolvable relative imports found._' ) ;
2432+ }
2433+ } catch ( err ) {
2434+ lines . push ( '_Could not analyze imports: ' + err . message + '_' ) ;
2435+ }
2436+
2437+ lines . push ( '' ) ;
2438+
2439+ lines . push ( '## Callers (files that import this file)' ) ;
2440+ try {
2441+ const { extractImports } = __require ( './src/map/import-graph' ) ;
2442+ const fileSet = new Set ( indexedFiles ) ;
2443+ fileSet . add ( targetAbs ) ;
2444+ const callers = [ ] ;
2445+ for ( const f of indexedFiles ) {
2446+ if ( f === targetAbs || ! fs . existsSync ( f ) ) continue ;
2447+ try {
2448+ const fc = fs . readFileSync ( f , 'utf8' ) ;
2449+ const imps = extractImports ( f , fc , fileSet ) ;
2450+ if ( imps . includes ( targetAbs ) ) callers . push ( path . relative ( cwd , f ) . replace ( / \\ / g, '/' ) ) ;
2451+ } catch ( _ ) { }
2452+ }
2453+ if ( callers . length > 0 ) {
2454+ for ( const c of callers ) lines . push ( '- ' + c ) ;
2455+ } else {
2456+ lines . push ( '_No indexed files import this file._' ) ;
2457+ }
2458+ } catch ( err ) {
2459+ lines . push ( '_Could not analyze callers: ' + err . message + '_' ) ;
2460+ }
2461+
2462+ return lines . join ( '\n' ) ;
2463+ }
2464+
2465+ function listModules ( args , cwd ) {
2466+ const contextPath = path . join ( cwd , CONTEXT_FILE ) ;
2467+ if ( ! fs . existsSync ( contextPath ) ) {
2468+ return 'No context file found. Run: node gen-context.js' ;
2469+ }
2470+
2471+ const content = fs . readFileSync ( contextPath , 'utf8' ) ;
2472+ const ctxLines = content . split ( '\n' ) ;
2473+ const groups = { } ;
2474+ let currentGroup = null ;
2475+ let blockBuf = [ ] ;
2476+
2477+ function flushBlock ( ) {
2478+ if ( currentGroup === null || blockBuf . length === 0 ) return ;
2479+ if ( ! groups [ currentGroup ] ) groups [ currentGroup ] = { fileCount : 0 , tokenCount : 0 } ;
2480+ groups [ currentGroup ] . fileCount ++ ;
2481+ groups [ currentGroup ] . tokenCount += Math . ceil ( blockBuf . join ( '\n' ) . length / 4 ) ;
2482+ blockBuf = [ ] ;
2483+ }
2484+
2485+ for ( const line of ctxLines ) {
2486+ if ( line . startsWith ( '### ' ) ) {
2487+ flushBlock ( ) ;
2488+ const rel = line . slice ( 4 ) . trim ( ) . replace ( / \\ / g, '/' ) ;
2489+ const parts = rel . split ( '/' ) ;
2490+ currentGroup = parts . length > 1 ? parts [ 0 ] : '.' ;
2491+ } else if ( currentGroup !== null ) {
2492+ blockBuf . push ( line ) ;
2493+ }
2494+ }
2495+ flushBlock ( ) ;
2496+
2497+ const sorted = Object . entries ( groups )
2498+ . map ( ( [ mod , data ] ) => ( { module : mod , fileCount : data . fileCount , tokenCount : data . tokenCount } ) )
2499+ . sort ( ( a , b ) => b . tokenCount - a . tokenCount ) ;
2500+
2501+ if ( sorted . length === 0 ) return 'No modules found in context file.' ;
2502+
2503+ const total = sorted . reduce ( ( s , m ) => s + m . tokenCount , 0 ) ;
2504+
2505+ return [
2506+ '# Modules' ,
2507+ '' ,
2508+ '| Module | Files | Tokens |' ,
2509+ '|--------|-------|--------|' ,
2510+ ...sorted . map ( ( m ) => `| ${ m . module } | ${ m . fileCount } | ~${ m . tokenCount } |` ) ,
2511+ '' ,
2512+ `**Total context tokens: ~${ total } **` ,
2513+ '' ,
2514+ '_Use `read_context({ module: "name" })` to get signatures for a specific module._' ,
2515+ ] . join ( '\n' ) ;
2516+ }
2517+
2518+ module . exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules } ;
23312519} ;
23322520
23332521// ── ./src/mcp/server ──
@@ -2347,11 +2535,11 @@ __factories["./src/mcp/server"] = function(module, exports) {
23472535
23482536 const readline = require ( 'readline' ) ;
23492537 const { TOOLS } = __require ( './src/mcp/tools' ) ;
2350- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting } = __require ( './src/mcp/handlers' ) ;
2538+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile , listModules } = __require ( './src/mcp/handlers' ) ;
23512539
23522540 const SERVER_INFO = {
23532541 name : 'context-forge' ,
2354- version : '1.3 .0' ,
2542+ version : '1.4 .0' ,
23552543 description : 'ContextForge MCP server — code signatures on demand' ,
23562544 } ;
23572545
@@ -2404,6 +2592,8 @@ __factories["./src/mcp/server"] = function(module, exports) {
24042592 else if ( name === 'get_map' ) text = getMap ( args , cwd ) ;
24052593 else if ( name === 'create_checkpoint' ) text = createCheckpoint ( args , cwd ) ;
24062594 else if ( name === 'get_routing' ) text = getRouting ( args , cwd ) ;
2595+ else if ( name === 'explain_file' ) text = explainFile ( args , cwd ) ;
2596+ else if ( name === 'list_modules' ) text = listModules ( args , cwd ) ;
24072597 else {
24082598 respondError ( id , - 32601 , `Unknown tool: ${ name } ` ) ;
24092599 return ;
@@ -2551,6 +2741,38 @@ __factories["./src/mcp/tools"] = function(module, exports) {
25512741 required : [ ] ,
25522742 } ,
25532743 } ,
2744+ {
2745+ name : 'explain_file' ,
2746+ description :
2747+ 'Explain a specific file: returns its extracted signatures, direct imports ' +
2748+ '(files it depends on), and callers (files that import it). ' +
2749+ 'Ideal for understanding a file in isolation without reading raw source. ' +
2750+ 'Requires the context file to have been generated first.' ,
2751+ inputSchema : {
2752+ type : 'object' ,
2753+ properties : {
2754+ path : {
2755+ type : 'string' ,
2756+ description :
2757+ 'Relative path from the project root (e.g. "src/services/auth.ts"). ' +
2758+ 'Use the paths shown in read_context output.' ,
2759+ } ,
2760+ } ,
2761+ required : [ 'path' ] ,
2762+ } ,
2763+ } ,
2764+ {
2765+ name : 'list_modules' ,
2766+ description :
2767+ 'List all top-level modules (srcDirs) present in the context file, ' +
2768+ 'sorted by token count descending. Use this to decide which module to ' +
2769+ 'pass to read_context before querying a specific area of the codebase.' ,
2770+ inputSchema : {
2771+ type : 'object' ,
2772+ properties : { } ,
2773+ required : [ ] ,
2774+ } ,
2775+ } ,
25542776 ] ;
25552777
25562778 module . exports = { TOOLS } ;
@@ -2997,7 +3219,7 @@ const path = require('path');
29973219const os = require ( 'os' ) ;
29983220const { execSync } = require ( 'child_process' ) ;
29993221
3000- const VERSION = '1.3 .0' ;
3222+ const VERSION = '1.4 .0' ;
30013223const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n' ;
30023224
30033225// ---------------------------------------------------------------------------
@@ -4004,8 +4226,12 @@ function main() {
40044226 } else {
40054227 console . log ( '[context-forge] health:' ) ;
40064228 console . log ( ` score : ${ result . score } /100 (grade ${ result . grade } )` ) ;
4229+ console . log ( ` strategy : ${ result . strategy } ` ) ;
40074230 console . log ( ` token reduction : ${ result . tokenReductionPct !== null ? result . tokenReductionPct + '%' : 'no history' } ` ) ;
40084231 console . log ( ` days since regen: ${ result . daysSinceRegen !== null ? result . daysSinceRegen : 'context file not found' } ` ) ;
4232+ if ( result . strategyFreshnessDays !== null ) {
4233+ console . log ( ` cold freshness : ${ result . strategyFreshnessDays } day(s)` ) ;
4234+ }
40094235 console . log ( ` total runs : ${ result . totalRuns } ` ) ;
40104236 console . log ( ` over-budget runs: ${ result . overBudgetRuns } ` ) ;
40114237 }
0 commit comments