@@ -363,12 +363,14 @@ function formatSummary(
363363 rawText : string ,
364364 mergeCount ?: number ,
365365 skipEntities ?: boolean ,
366+ summaryId ?: string ,
366367) : string {
367368 const entitySuffix = skipEntities
368369 ? ''
369370 : ( ( ) => { const e = extractEntities ( rawText ) ; return e . length > 0 ? ` | entities: ${ e . join ( ', ' ) } ` : '' ; } ) ( ) ;
370371 const mergeSuffix = mergeCount && mergeCount > 1 ? ` (${ mergeCount } messages merged)` : '' ;
371- return `[summary: ${ summaryText } ${ mergeSuffix } ${ entitySuffix } ]` ;
372+ const prefix = summaryId ? `[summary#${ summaryId } : ` : '[summary: ' ;
373+ return `${ prefix } ${ summaryText } ${ mergeSuffix } ${ entitySuffix } ]` ;
372374}
373375
374376/** Collect consecutive non-preserved, non-codeSplit, non-dedup messages with the same role. */
@@ -418,7 +420,7 @@ function classifyAll(
418420 if ( content . length < 120 ) {
419421 return { msg, preserved : true } ;
420422 }
421- if ( content . startsWith ( '[summary:' ) ) {
423+ if ( content . startsWith ( '[summary:' ) || content . startsWith ( '[summary#' ) || content . startsWith ( '[truncated' ) ) {
422424 return { msg, preserved : true } ;
423425 }
424426 if ( dedupAnnotations ?. has ( idx ) ) {
@@ -549,9 +551,10 @@ function compressSync(
549551 // Dedup: replace earlier duplicate/near-duplicate with compact reference
550552 if ( classified [ i ] . dedup ) {
551553 const annotation = classified [ i ] . dedup ! ;
554+ const keepTargetId = messages [ annotation . duplicateOfIndex ] . id ;
552555 const tag = annotation . similarity != null
553- ? `[uc:near-dup — ${ annotation . contentLength } chars, ~${ Math . round ( annotation . similarity * 100 ) } % match, see later message ]`
554- : `[uc:dup — ${ annotation . contentLength } chars, see later message ]` ;
556+ ? `[uc:near-dup of ${ keepTargetId } — ${ annotation . contentLength } chars, ~${ Math . round ( annotation . similarity * 100 ) } % match]`
557+ : `[uc:dup of ${ keepTargetId } — ${ annotation . contentLength } chars]` ;
555558 result . push ( buildCompressedMessage ( msg , [ msg . id ] , tag , sourceVersion , verbatim , [ msg ] ) ) ;
556559 if ( annotation . similarity != null ) {
557560 messagesFuzzyDeduped ++ ;
@@ -570,7 +573,8 @@ function compressSync(
570573 const codeFences = segments . filter ( s => s . type === 'code' ) . map ( s => s . content ) ;
571574 const proseBudget = proseText . length < 600 ? 200 : 400 ;
572575 const summaryText = summarize ( proseText , proseBudget ) ;
573- const compressed = `${ formatSummary ( summaryText , proseText , undefined , true ) } \n\n${ codeFences . join ( '\n\n' ) } ` ;
576+ const embeddedId = options . embedSummaryId ? makeSummaryId ( [ msg . id ] ) : undefined ;
577+ const compressed = `${ formatSummary ( summaryText , proseText , undefined , true , embeddedId ) } \n\n${ codeFences . join ( '\n\n' ) } ` ;
574578
575579 if ( compressed . length >= content . length ) {
576580 result . push ( msg ) ;
@@ -596,10 +600,12 @@ function compressSync(
596600 : summarize ( allContent , contentBudget ) ;
597601
598602 if ( group . length > 1 ) {
599- let summary = formatSummary ( summaryText , allContent , group . length ) ;
603+ const mergeIds = group . map ( g => g . msg . id ) ;
604+ const embeddedId = options . embedSummaryId ? makeSummaryId ( mergeIds ) : undefined ;
605+ let summary = formatSummary ( summaryText , allContent , group . length , undefined , embeddedId ) ;
600606 const combinedLength = group . reduce ( ( sum , g ) => sum + contentLength ( g . msg ) , 0 ) ;
601607 if ( summary . length >= combinedLength ) {
602- summary = formatSummary ( summaryText , allContent , group . length , true ) ;
608+ summary = formatSummary ( summaryText , allContent , group . length , true , embeddedId ) ;
603609 }
604610
605611 if ( summary . length >= combinedLength ) {
@@ -609,17 +615,17 @@ function compressSync(
609615 }
610616 } else {
611617 const sourceMsgs = group . map ( g => g . msg ) ;
612- const mergeIds = sourceMsgs . map ( m => m . id ) ;
613618 const base : Message = { ...sourceMsgs [ 0 ] } ;
614619 result . push ( buildCompressedMessage ( base , mergeIds , summary , sourceVersion , verbatim , sourceMsgs ) ) ;
615620 messagesCompressed += group . length ;
616621 }
617622 } else {
618623 const single = group [ 0 ] . msg ;
619624 const content = typeof single . content === 'string' ? single . content : '' ;
620- let summary = formatSummary ( summaryText , allContent ) ;
625+ const embeddedId = options . embedSummaryId ? makeSummaryId ( [ single . id ] ) : undefined ;
626+ let summary = formatSummary ( summaryText , allContent , undefined , undefined , embeddedId ) ;
621627 if ( summary . length >= content . length ) {
622- summary = formatSummary ( summaryText , allContent , undefined , true ) ;
628+ summary = formatSummary ( summaryText , allContent , undefined , true , embeddedId ) ;
623629 }
624630
625631 if ( summary . length >= content . length ) {
@@ -718,9 +724,10 @@ async function compressAsync(
718724 // Dedup: replace earlier duplicate/near-duplicate with compact reference
719725 if ( classified [ i ] . dedup ) {
720726 const annotation = classified [ i ] . dedup ! ;
727+ const keepTargetId = messages [ annotation . duplicateOfIndex ] . id ;
721728 const tag = annotation . similarity != null
722- ? `[uc:near-dup — ${ annotation . contentLength } chars, ~${ Math . round ( annotation . similarity * 100 ) } % match, see later message ]`
723- : `[uc:dup — ${ annotation . contentLength } chars, see later message ]` ;
729+ ? `[uc:near-dup of ${ keepTargetId } — ${ annotation . contentLength } chars, ~${ Math . round ( annotation . similarity * 100 ) } % match]`
730+ : `[uc:dup of ${ keepTargetId } — ${ annotation . contentLength } chars]` ;
724731 result . push ( buildCompressedMessage ( msg , [ msg . id ] , tag , sourceVersion , verbatim , [ msg ] ) ) ;
725732 if ( annotation . similarity != null ) {
726733 messagesFuzzyDeduped ++ ;
@@ -739,7 +746,8 @@ async function compressAsync(
739746 const codeFences = segments . filter ( s => s . type === 'code' ) . map ( s => s . content ) ;
740747 const proseBudget = proseText . length < 600 ? 200 : 400 ;
741748 const summaryText = await withFallback ( proseText , userSummarizer , proseBudget ) ;
742- const compressed = `${ formatSummary ( summaryText , proseText , undefined , true ) } \n\n${ codeFences . join ( '\n\n' ) } ` ;
749+ const embeddedId = options . embedSummaryId ? makeSummaryId ( [ msg . id ] ) : undefined ;
750+ const compressed = `${ formatSummary ( summaryText , proseText , undefined , true , embeddedId ) } \n\n${ codeFences . join ( '\n\n' ) } ` ;
743751
744752 if ( compressed . length >= content . length ) {
745753 result . push ( msg ) ;
@@ -765,10 +773,12 @@ async function compressAsync(
765773 : await withFallback ( allContent , userSummarizer , contentBudget ) ;
766774
767775 if ( group . length > 1 ) {
768- let summary = formatSummary ( summaryText , allContent , group . length ) ;
776+ const mergeIds = group . map ( g => g . msg . id ) ;
777+ const embeddedId = options . embedSummaryId ? makeSummaryId ( mergeIds ) : undefined ;
778+ let summary = formatSummary ( summaryText , allContent , group . length , undefined , embeddedId ) ;
769779 const combinedLength = group . reduce ( ( sum , g ) => sum + contentLength ( g . msg ) , 0 ) ;
770780 if ( summary . length >= combinedLength ) {
771- summary = formatSummary ( summaryText , allContent , group . length , true ) ;
781+ summary = formatSummary ( summaryText , allContent , group . length , true , embeddedId ) ;
772782 }
773783
774784 if ( summary . length >= combinedLength ) {
@@ -778,17 +788,17 @@ async function compressAsync(
778788 }
779789 } else {
780790 const sourceMsgs = group . map ( g => g . msg ) ;
781- const mergeIds = sourceMsgs . map ( m => m . id ) ;
782791 const base : Message = { ...sourceMsgs [ 0 ] } ;
783792 result . push ( buildCompressedMessage ( base , mergeIds , summary , sourceVersion , verbatim , sourceMsgs ) ) ;
784793 messagesCompressed += group . length ;
785794 }
786795 } else {
787796 const single = group [ 0 ] . msg ;
788797 const content = typeof single . content === 'string' ? single . content : '' ;
789- let summary = formatSummary ( summaryText , allContent ) ;
798+ const embeddedId = options . embedSummaryId ? makeSummaryId ( [ single . id ] ) : undefined ;
799+ let summary = formatSummary ( summaryText , allContent , undefined , undefined , embeddedId ) ;
790800 if ( summary . length >= content . length ) {
791- summary = formatSummary ( summaryText , allContent , undefined , true ) ;
801+ summary = formatSummary ( summaryText , allContent , undefined , true , embeddedId ) ;
792802 }
793803
794804 if ( summary . length >= content . length ) {
@@ -846,6 +856,80 @@ function addBudgetFields(cr: CompressResult, tokenBudget: number, recencyWindow:
846856 return { ...cr , fits : tokens <= tokenBudget , tokenCount : tokens , recencyWindow } ;
847857}
848858
859+ /**
860+ * Force-converge pass: hard-truncate non-recency messages to guarantee the
861+ * result fits within the token budget. Mirrors LCM Level 3 DeterministicTruncate.
862+ */
863+ function forceConvergePass (
864+ cr : CompressResult ,
865+ tokenBudget : number ,
866+ preserveRoles : Set < string > ,
867+ sourceVersion : number ,
868+ ) : CompressResult {
869+ if ( cr . fits ) return cr ;
870+
871+ const recencyWindow = cr . recencyWindow ?? 0 ;
872+ const cutoff = Math . max ( 0 , cr . messages . length - recencyWindow ) ;
873+
874+ // Collect eligible messages: before recency cutoff, not in preserveRoles, content > 512 chars
875+ type Candidate = { idx : number ; contentLen : number } ;
876+ const candidates : Candidate [ ] = [ ] ;
877+
878+ for ( let i = 0 ; i < cutoff ; i ++ ) {
879+ const m = cr . messages [ i ] ;
880+ const content = typeof m . content === 'string' ? m . content : '' ;
881+ if ( m . role && preserveRoles . has ( m . role ) ) continue ;
882+ if ( content . length <= 512 ) continue ;
883+ candidates . push ( { idx : i , contentLen : content . length } ) ;
884+ }
885+
886+ // Sort by content length descending (biggest savings first)
887+ candidates . sort ( ( a , b ) => b . contentLen - a . contentLen ) ;
888+
889+ // Clone messages and verbatim for mutation
890+ const messages = cr . messages . map ( m => ( { ...m , metadata : m . metadata ? { ...m . metadata } : { } } ) ) ;
891+ const verbatim = { ...cr . verbatim } ;
892+ let tokenCount = cr . tokenCount ! ;
893+
894+ for ( const cand of candidates ) {
895+ if ( tokenCount <= tokenBudget ) break ;
896+
897+ const m = messages [ cand . idx ] ;
898+ const content = typeof m . content === 'string' ? m . content : '' ;
899+ const truncated = content . slice ( 0 , 512 ) ;
900+ const tag = `[truncated — ${ content . length } chars: ${ truncated } ]` ;
901+
902+ const oldTokens = estimateTokens ( m ) ;
903+
904+ // If already compressed (has _uc_original), just replace content in-place
905+ const hasOriginal = ! ! ( m . metadata ?. _uc_original ) ;
906+ if ( hasOriginal ) {
907+ messages [ cand . idx ] = { ...m , content : tag } ;
908+ } else {
909+ // Store original in verbatim and add provenance
910+ verbatim [ m . id ] = { ...m } ;
911+ messages [ cand . idx ] = {
912+ ...m ,
913+ content : tag ,
914+ metadata : {
915+ ...( m . metadata ?? { } ) ,
916+ _uc_original : {
917+ ids : [ m . id ] ,
918+ summary_id : makeSummaryId ( [ m . id ] ) ,
919+ version : sourceVersion ,
920+ } ,
921+ } ,
922+ } ;
923+ }
924+
925+ const newTokens = estimateTokens ( messages [ cand . idx ] ) ;
926+ tokenCount -= ( oldTokens - newTokens ) ;
927+ }
928+
929+ const fits = tokenCount <= tokenBudget ;
930+ return { ...cr , messages, verbatim, fits, tokenCount } ;
931+ }
932+
849933function compressSyncWithBudget (
850934 messages : Message [ ] ,
851935 tokenBudget : number ,
@@ -875,10 +959,20 @@ function compressSyncWithBudget(
875959 }
876960 }
877961
878- if ( lastRw === lo && lastResult ) return lastResult ;
962+ let result : CompressResult ;
963+ if ( lastRw === lo && lastResult ) {
964+ result = lastResult ;
965+ } else {
966+ const cr = compressSync ( messages , { ...options , recencyWindow : lo , summarizer : undefined , tokenBudget : undefined } ) ;
967+ result = addBudgetFields ( cr , tokenBudget , lo ) ;
968+ }
879969
880- const cr = compressSync ( messages , { ...options , recencyWindow : lo , summarizer : undefined , tokenBudget : undefined } ) ;
881- return addBudgetFields ( cr , tokenBudget , lo ) ;
970+ if ( ! result . fits && options . forceConverge ) {
971+ const preserveRoles = new Set ( options . preserve ?? [ 'system' ] ) ;
972+ result = forceConvergePass ( result , tokenBudget , preserveRoles , sourceVersion ) ;
973+ }
974+
975+ return result ;
882976}
883977
884978async function compressAsyncWithBudget (
@@ -910,10 +1004,20 @@ async function compressAsyncWithBudget(
9101004 }
9111005 }
9121006
913- if ( lastRw === lo && lastResult ) return lastResult ;
1007+ let result : CompressResult ;
1008+ if ( lastRw === lo && lastResult ) {
1009+ result = lastResult ;
1010+ } else {
1011+ const cr = await compressAsync ( messages , { ...options , recencyWindow : lo , tokenBudget : undefined } ) ;
1012+ result = addBudgetFields ( cr , tokenBudget , lo ) ;
1013+ }
9141014
915- const cr = await compressAsync ( messages , { ...options , recencyWindow : lo , tokenBudget : undefined } ) ;
916- return addBudgetFields ( cr , tokenBudget , lo ) ;
1015+ if ( ! result . fits && options . forceConverge ) {
1016+ const preserveRoles = new Set ( options . preserve ?? [ 'system' ] ) ;
1017+ result = forceConvergePass ( result , tokenBudget , preserveRoles , sourceVersion ) ;
1018+ }
1019+
1020+ return result ;
9171021}
9181022
9191023// ---------------------------------------------------------------------------
0 commit comments