@@ -45,6 +45,7 @@ const persistence = createPersistence({ stateDir });
4545const { atomicWriteJsonSync, readJsonSafe, runQueued, emitEvent } = persistence ;
4646const discoveryPath = join ( stateDir , "discovery.json" ) ;
4747const ingestPath = join ( stateDir , "ingest.json" ) ;
48+ const parseCachePath = join ( stateDir , "parse-cache.json" ) ;
4849const linksPath = join ( stateDir , "links.json" ) ;
4950const reviewsPath = join ( stateDir , "reviews.json" ) ;
5051const githubActivityPath = join ( stateDir , "github-activity.json" ) ;
@@ -264,7 +265,7 @@ Usage:
264265 awm discover
265266 awm github status --json
266267 awm github sync --since <ISO>
267- awm ingest --limit 30
268+ awm ingest --limit 30 [--force-rebuild] [--no-write]
268269 awm serve --port 5173
269270 awm session summarize --tool codex --summary "..."
270271 awm today
@@ -716,44 +717,73 @@ function ingestSessions(values = []) {
716717 const limit = Number ( readFlag ( values , "--limit" ) ?? 30 ) ;
717718 const asJson = values . includes ( "--json" ) ;
718719 const noWrite = values . includes ( "--no-write" ) ;
719- const result = buildIngestResult ( limit ) ;
720+ const forceRebuild = values . includes ( "--force-rebuild" ) ;
721+ let result ;
722+ let cacheHit = false ;
723+ if ( ! forceRebuild && existsSync ( ingestPath ) ) {
724+ try {
725+ const cached = JSON . parse ( readFileSync ( ingestPath , "utf8" ) ) ;
726+ if ( isIngestCacheValid ( cached , currentIngestInputs ( limit ) ) ) {
727+ result = cached ;
728+ cacheHit = true ;
729+ }
730+ } catch { }
731+ }
732+ if ( ! result ) result = buildIngestResult ( limit , { persist : ! noWrite } ) ;
720733
721- if ( ! noWrite ) writeFileSync ( ingestPath , `${ JSON . stringify ( result , null , 2 ) } \n` ) ;
734+ if ( ! noWrite && ! cacheHit ) writeFileSync ( ingestPath , `${ JSON . stringify ( result , null , 2 ) } \n` ) ;
722735
723736 if ( asJson ) {
724737 console . log ( JSON . stringify ( result , null , 2 ) ) ;
725738 return ;
726739 }
727740
728- console . log ( `AWM Ingest (${ localDate ( new Date ( result . ingestedAt ) ) } )` ) ;
741+ console . log ( `AWM Ingest (${ localDate ( new Date ( result . ingestedAt ) ) } )${ cacheHit ? " — cache hit" : "" } ` ) ;
729742 console . log ( `Sessions: ${ result . sessions . length } ` ) ;
730743 console . log ( `Risks: ${ result . riskEvents . length } ` ) ;
731744 console . log ( `Repositories: ${ result . repositories . length } ` ) ;
732- if ( ! noWrite ) console . log ( `Ingest written: ${ ingestPath } ` ) ;
745+ if ( ! noWrite && ! cacheHit ) console . log ( `Ingest written: ${ ingestPath } ` ) ;
733746}
734747
735- function buildIngestResult ( limit = 30 ) {
748+ function buildIngestResult ( limit = 30 , { persist = true } = { } ) {
736749 const discovery = buildDiscoveryResult ( { includeAllFiles : true } ) ;
737750 const confirmedLinks = readLinks ( ) ;
738751 const reviews = readReviews ( ) ;
739752 const githubActivity = readGitHubActivity ( ) ;
740753 const github = buildGitHubVisibility ( githubActivity ) ;
741- writeFileSync (
742- discoveryPath ,
743- `${ JSON . stringify ( { ...discovery , sources : discovery . sources . map ( ( { allFiles, ...source } ) => source ) } , null , 2 ) } \n` ,
744- ) ;
754+ if ( persist ) {
755+ writeFileSync (
756+ discoveryPath ,
757+ `${ JSON . stringify ( { ...discovery , sources : discovery . sources . map ( ( { allFiles, ...source } ) => source ) } , null , 2 ) } \n` ,
758+ ) ;
759+ }
745760 const files = discovery . sources
746761 . flatMap ( ( source ) => source . allFiles . map ( ( file ) => ( { ...file , source } ) ) )
747762 . filter ( isUserVisibleSessionFile )
748763 . sort ( ( a , b ) => b . modifiedAt . localeCompare ( a . modifiedAt ) )
749764 . slice ( 0 , limit ) ;
750765
766+ const parseCache = loadParseCache ( ) ;
767+ const nextParseCache = { } ;
751768 const discoveredSessions = files
752- . flatMap ( ( file , index ) => parseSessionFile ( file , index ) )
769+ . flatMap ( ( file , index ) => {
770+ const cacheKey = `${ file . path } ` ;
771+ const cacheEntry = parseCache [ cacheKey ] ;
772+ let sessions ;
773+ if ( cacheEntry && cacheEntry . mtime === file . modifiedAt && cacheEntry . bytes === file . bytes && cacheEntry . index === index ) {
774+ sessions = cacheEntry . sessions ;
775+ } else {
776+ sessions = parseSessionFile ( file , index ) ;
777+ nextParseCache [ cacheKey ] = { mtime : file . modifiedAt , bytes : file . bytes , index, sessions } ;
778+ }
779+ if ( cacheEntry && ! nextParseCache [ cacheKey ] ) nextParseCache [ cacheKey ] = cacheEntry ;
780+ return sessions ;
781+ } )
753782 . filter ( Boolean )
754783 . map ( ( session ) => mergeGitHubActivityIntoSession ( session , githubActivity ) )
755784 . map ( ( session ) => applyLinksToSession ( session , confirmedLinks [ session . id ] ) )
756785 . map ( ( session ) => applyReviewToSession ( session , reviews [ session . id ] ) ) ;
786+ if ( persist ) saveParseCache ( nextParseCache ) ;
757787 const manualSessions = collectFiles ( sessionsDir , ".json" , 1 )
758788 . sort ( ( a , b ) => b . modifiedAt . localeCompare ( a . modifiedAt ) )
759789 . slice ( 0 , limit )
@@ -773,9 +803,21 @@ function buildIngestResult(limit = 30) {
773803 const workPackets = buildWorkPackets ( sessions ) ;
774804 const auditChain = buildAuditChainView ( ) ;
775805
806+ const manualFilesForInputs = collectFiles ( sessionsDir , ".json" , 1 ) ;
807+ const inputs = {
808+ limit,
809+ sources : files . map ( ( f ) => ( { path : f . path , mtime : f . modifiedAt } ) ) ,
810+ manual : manualFilesForInputs . map ( ( f ) => ( { path : f . path , mtime : f . modifiedAt } ) ) ,
811+ links : statMtimeOrNull ( linksPath ) ,
812+ reviews : statMtimeOrNull ( reviewsPath ) ,
813+ githubActivity : statMtimeOrNull ( githubActivityPath ) ,
814+ events : statMtimeOrNull ( eventsPath ) ,
815+ } ;
816+
776817 return {
777818 ingestedAt : new Date ( ) . toISOString ( ) ,
778819 limit,
820+ inputs,
779821 sources : [
780822 ...discovery . sources . map ( ( source ) => ( {
781823 id : source . id ,
@@ -2168,9 +2210,7 @@ function serveLocalApp(values = []) {
21682210 if ( url . pathname === "/api/mvp" || url . pathname === "/api/ingest" ) {
21692211 const refresh = url . searchParams . get ( "refresh" ) === "1" ;
21702212 const limit = Number ( url . searchParams . get ( "limit" ) ?? 30 ) ;
2171- const ingest = refresh || ! existsSync ( ingestPath )
2172- ? buildAndStoreIngest ( limit )
2173- : JSON . parse ( readFileSync ( ingestPath , "utf8" ) ) ;
2213+ const ingest = refresh ? buildAndStoreIngest ( limit ) : getCachedOrBuildIngest ( limit ) ;
21742214 sendJson ( response , ingest ) ;
21752215 return ;
21762216 }
@@ -2286,6 +2326,95 @@ function buildAndStoreIngest(limit = 30) {
22862326 return ingest ;
22872327}
22882328
2329+ function loadParseCache ( ) {
2330+ if ( ! existsSync ( parseCachePath ) ) return { } ;
2331+ try {
2332+ return JSON . parse ( readFileSync ( parseCachePath , "utf8" ) ) ;
2333+ } catch {
2334+ return { } ;
2335+ }
2336+ }
2337+
2338+ function saveParseCache ( cache ) {
2339+ try {
2340+ writeFileSync ( parseCachePath , JSON . stringify ( cache ) ) ;
2341+ } catch ( error ) {
2342+ const message = error instanceof Error ? error . message : String ( error ) ;
2343+ console . warn ( `[awm] parse-cache save failed (will rebuild next time): ${ message } ` ) ;
2344+ }
2345+ }
2346+
2347+ function statMtimeOrNull ( filePath ) {
2348+ try {
2349+ if ( ! existsSync ( filePath ) ) return null ;
2350+ return statSync ( filePath ) . mtime . toISOString ( ) ;
2351+ } catch {
2352+ return null ;
2353+ }
2354+ }
2355+
2356+ function currentIngestInputs ( limit ) {
2357+ const discovery = buildDiscoveryResult ( { includeAllFiles : true } ) ;
2358+ const files = discovery . sources
2359+ . flatMap ( ( source ) => source . allFiles . map ( ( file ) => ( { ...file , source } ) ) )
2360+ . filter ( isUserVisibleSessionFile )
2361+ . sort ( ( a , b ) => b . modifiedAt . localeCompare ( a . modifiedAt ) )
2362+ . slice ( 0 , limit ) ;
2363+ const manual = collectFiles ( sessionsDir , ".json" , 1 ) ;
2364+ return {
2365+ limit,
2366+ sources : files . map ( ( f ) => ( { path : f . path , mtime : f . modifiedAt } ) ) ,
2367+ manual : manual . map ( ( f ) => ( { path : f . path , mtime : f . modifiedAt } ) ) ,
2368+ links : statMtimeOrNull ( linksPath ) ,
2369+ reviews : statMtimeOrNull ( reviewsPath ) ,
2370+ githubActivity : statMtimeOrNull ( githubActivityPath ) ,
2371+ events : statMtimeOrNull ( eventsPath ) ,
2372+ } ;
2373+ }
2374+
2375+ function isIngestCacheValid ( cached , currentInputs ) {
2376+ // events.jsonl은 auditChain만 영향. heavy 캐시 무효화 기준에서 제외.
2377+ // auditChain은 응답 직전 신선하게 재생성된다 (refreshAuditChain).
2378+ if ( ! cached || ! cached . inputs ) return false ;
2379+ if ( cached . inputs . limit !== currentInputs . limit ) return false ;
2380+ if ( ! sameMtimeList ( cached . inputs . sources , currentInputs . sources ) ) return false ;
2381+ if ( ! sameMtimeList ( cached . inputs . manual , currentInputs . manual ) ) return false ;
2382+ if ( cached . inputs . links !== currentInputs . links ) return false ;
2383+ if ( cached . inputs . reviews !== currentInputs . reviews ) return false ;
2384+ if ( cached . inputs . githubActivity !== currentInputs . githubActivity ) return false ;
2385+ return true ;
2386+ }
2387+
2388+ function refreshAuditChain ( ingest ) {
2389+ // events.jsonl이 매 capture마다 갱신 — auditChain만 신선화하고 캐시는 유지.
2390+ return { ...ingest , auditChain : buildAuditChainView ( ) } ;
2391+ }
2392+
2393+ function sameMtimeList ( a , b ) {
2394+ if ( ! Array . isArray ( a ) || ! Array . isArray ( b ) ) return false ;
2395+ if ( a . length !== b . length ) return false ;
2396+ const byPath = new Map ( b . map ( ( entry ) => [ entry . path , entry . mtime ] ) ) ;
2397+ for ( const entry of a ) {
2398+ if ( byPath . get ( entry . path ) !== entry . mtime ) return false ;
2399+ }
2400+ return true ;
2401+ }
2402+
2403+ function getCachedOrBuildIngest ( limit = 30 ) {
2404+ if ( existsSync ( ingestPath ) ) {
2405+ try {
2406+ const cached = JSON . parse ( readFileSync ( ingestPath , "utf8" ) ) ;
2407+ const currentInputs = currentIngestInputs ( limit ) ;
2408+ if ( isIngestCacheValid ( cached , currentInputs ) ) {
2409+ return refreshAuditChain ( cached ) ;
2410+ }
2411+ } catch {
2412+ // fall through to rebuild
2413+ }
2414+ }
2415+ return buildAndStoreIngest ( limit ) ;
2416+ }
2417+
22892418function scheduleIngestRebuild ( limit = 30 ) {
22902419 setTimeout ( ( ) => {
22912420 try {
0 commit comments