11import { randomUUID } from 'crypto' ;
2- import { findSessionIndex , readHistory , resolveSessionDir } from '../../session/io.js' ;
3- import { estimateTokens , estimateMessageTokens } from '../utils/tokens.js' ;
2+ import { resolveSessionDir } from '../../session/io.js' ;
3+ import {
4+ estimateTokens ,
5+ estimateMessageTokens ,
6+ estimateTokensForContent ,
7+ } from '../utils/tokens.js' ;
8+ import { applyVisibilityEvents } from '../../session/messages.js' ;
49import { resolveCompactionLLM } from './llm-resolver.js' ;
510import { COMPACTION_SYSTEM_PROMPT } from './prompt.js' ;
611import type { ContextConfig } from '../config.js' ;
@@ -17,16 +22,6 @@ export interface CompressResult {
1722 promptEstimate : number ;
1823}
1924
20- interface CompressContext {
21- sessionId : string ;
22- encodedProjectPath : string ;
23- config : ContextConfig ;
24- llm : LLMClient | null ;
25- currentTurnId : number ;
26- events : SessionEvent [ ] ;
27- hiddenUuids : Set < string > ;
28- }
29-
3025const compactFailureTracker = new Map < string , { count : number ; lastAttempt : number } > ( ) ;
3126const FAILURE_TTL_MS = 24 * 60 * 60 * 1000 ;
3227
@@ -43,10 +38,12 @@ function getFailures(sessionId: string): number {
4338export async function compactIfNeeded (
4439 sessionId : string ,
4540 encodedProjectPath : string ,
46- messages : import ( '../../core/types.js' ) . Message [ ] ,
41+ messages : Message [ ] ,
4742 modelMaxTokens : number ,
4843 config : ContextConfig ,
49- llm : LLMClient | null
44+ llm : LLMClient | null ,
45+ compactedEvents ?: SessionEvent [ ] ,
46+ currentTurnId ?: number
5047) : Promise < CompressResult > {
5148 const promptEstimate = estimateTokens ( messages ) ;
5249 const failures = getFailures ( sessionId ) ;
@@ -64,6 +61,8 @@ export async function compactIfNeeded(
6461 encodedProjectPath ,
6562 config ,
6663 llm ,
64+ compactedEvents ,
65+ currentTurnId ,
6766 promptEstimate ,
6867 modelMaxTokens
6968 ) ;
@@ -82,91 +81,33 @@ export async function compactWithLLM(
8281 encodedProjectPath : string ,
8382 config : ContextConfig ,
8483 llm : LLMClient | null ,
84+ compactedEvents ?: SessionEvent [ ] ,
85+ currentTurnId ?: number ,
8586 usage ?: number ,
8687 modelMaxTokens ?: number
8788) : Promise < CompressResult > {
88- const idx = findSessionIndex ( sessionId ) ;
89- const currentTurnId = idx ?. currentTurnId ?? 0 ;
90- const ctx = buildContext ( sessionId , encodedProjectPath , config , llm , currentTurnId ) ;
89+ if ( ! compactedEvents || currentTurnId === undefined ) {
90+ const payload = assemblePayload ( sessionId , encodedProjectPath , config , modelMaxTokens ) ;
91+ compactedEvents = payload . compactedEvents ;
92+ currentTurnId = payload . currentTurnId ;
93+ }
9194
9295 let released = 0 ;
9396
9497 const threshold = modelMaxTokens ? modelMaxTokens * config . compactionThreshold : Infinity ;
9598 if ( usage === undefined || usage - released > threshold ) {
96- released += await tryL5Compaction ( ctx ) ;
99+ released += await tryCompaction ( sessionId , config , llm , compactedEvents , currentTurnId ) ;
97100 }
98101
99102 const payload = assemblePayload ( sessionId , encodedProjectPath , config , modelMaxTokens ) ;
100- const promptEstimate = estimateTokens ( payload . messages ) ;
101-
102- return { didCompress : released > 0 , released, promptEstimate } ;
103- }
104-
105- // ---------- Context building ----------
106-
107- function buildContext (
108- sessionId : string ,
109- encodedProjectPath : string ,
110- config : ContextConfig ,
111- llm : LLMClient | null ,
112- currentTurnId : number
113- ) : CompressContext {
114- const dir = resolveSessionDir ( sessionId ) ;
115- if ( ! dir ) throw new Error ( `Session ${ sessionId } not found` ) ;
116- const jsonlPath = join ( dir , `${ sessionId } .jsonl` ) ;
117- const events = readHistory ( jsonlPath ) ;
118-
119- // Compute which event uuids are already hidden by prior summary/hide events
120- const { hidden } = buildFilteredView ( events ) ;
121-
122103 return {
123- sessionId,
124- encodedProjectPath,
125- config,
126- llm,
127- currentTurnId,
128- events,
129- hiddenUuids : hidden ,
104+ didCompress : released > 0 ,
105+ released,
106+ promptEstimate : estimateTokens ( payload . messages ) ,
130107 } ;
131108}
132109
133- function buildFilteredView ( events : SessionEvent [ ] ) : { hidden : Set < string > } {
134- const hidden = new Set < string > ( ) ;
135- const hideEffects = new Map < string , Set < string > > ( ) ;
136-
137- for ( const ev of events ) {
138- switch ( ev . type ) {
139- case 'hide' : {
140- let effect : Set < string > ;
141- if ( ev . kind === 'message' ) {
142- effect = new Set ( [ ev . targetUuid ] ) ;
143- } else {
144- effect = new Set < string > ( ) ;
145- for ( const prior of events ) {
146- if ( prior === ev ) break ;
147- if ( 'turnId' in prior && prior . turnId >= ev . throughTurnId && 'uuid' in prior ) {
148- effect . add ( prior . uuid ) ;
149- }
150- }
151- }
152- hideEffects . set ( ev . uuid , effect ) ;
153- for ( const u of effect ) hidden . add ( u ) ;
154- break ;
155- }
156- case 'unhide' : {
157- const effect = hideEffects . get ( ev . targetHideUuid ) ;
158- if ( effect ) for ( const u of effect ) hidden . delete ( u ) ;
159- break ;
160- }
161- case 'summary' : {
162- for ( const u of ev . replaces ) hidden . add ( u ) ;
163- break ;
164- }
165- }
166- }
167-
168- return { hidden } ;
169- }
110+ // ---------- Summary persistence ----------
170111
171112function appendSummaryToSession ( sessionId : string , event : SummaryEvent ) : void {
172113 const dir = resolveSessionDir ( sessionId ) ;
@@ -177,69 +118,136 @@ function appendSummaryToSession(sessionId: string, event: SummaryEvent): void {
177118
178119// ---------- LLM Compaction ----------
179120
180- async function tryL5Compaction ( ctx : CompressContext ) : Promise < number > {
181- const { sessionId , config , currentTurnId , events , hiddenUuids } = ctx ;
121+ const ESTIMATED_SUMMARY_TOKENS = 5000 ;
122+ const MAX_TOOL_RESULT_TOKENS = 30000 ;
182123
183- const startTurn = 1 ;
184- const endTurn = currentTurnId - config . keepRecentTurns ;
185- if ( endTurn < startTurn ) return 0 ;
186- const turnsInRange = endTurn - startTurn + 1 ;
187- if ( turnsInRange < config . minTurnsBetweenCompactions ) return 0 ;
124+ async function tryCompaction (
125+ sessionId : string ,
126+ config : ContextConfig ,
127+ llm : LLMClient | null ,
128+ compactedEvents : SessionEvent [ ] ,
129+ currentTurnId : number
130+ ) : Promise < number > {
131+ const endTurn = currentTurnId - config . keepRecentTurns - 1 ;
132+ if ( endTurn < 1 ) return 0 ;
188133
189- // Collect visible messages in the range for LLM transcript
190- const inRange = events . filter ( ( ev ) => {
134+ const { hidden } = applyVisibilityEvents ( compactedEvents ) ;
135+
136+ const inRange = compactedEvents . filter ( ( ev ) => {
191137 if ( ev . type === 'session_meta' ) return false ;
192- if ( 'uuid' in ev && hiddenUuids . has ( ( ev as any ) . uuid ) ) return false ;
193- if ( 'turnId' in ev && ( ev as any ) . turnId >= startTurn && ( ev as any ) . turnId <= endTurn )
194- return true ;
138+ if ( 'uuid' in ev && hidden . has ( ( ev as any ) . uuid ) ) return false ;
139+ if ( 'turnId' in ev && ( ev as any ) . turnId >= 1 && ( ev as any ) . turnId <= endTurn ) return true ;
195140 return false ;
196141 } ) ;
197-
198142 if ( inRange . length === 0 ) return 0 ;
199143
200- const transcript : Message [ ] = [ ] ;
144+ const targetEvents = getIncrementalEvents ( inRange ) ;
145+ if ( targetEvents . length === 0 ) return 0 ;
146+
147+ const totalTokens = targetEvents . reduce ( ( sum , e ) => sum + estimateEventTokens ( e ) , 0 ) ;
148+
149+ let compactionLlm = await resolveCompactionLLM ( config , llm ) ;
150+ if ( compactionLlm && compactionLlm . modelInfo . maxTokens < totalTokens + 25000 ) {
151+ compactionLlm = llm ;
152+ }
153+
154+ const transcript = buildTranscript ( targetEvents ) ;
155+ const summary = await callLLMForCompaction ( transcript , compactionLlm , config ) ;
156+ if ( ! summary ) return 0 ;
157+
201158 const replacedUuids : string [ ] = [ ] ;
202- for ( const ev of inRange ) {
203- if ( 'uuid' in ev ) replacedUuids . push ( ( ev as any ) . uuid ) ;
159+ for ( const ev of targetEvents ) {
160+ if ( 'uuid' in ( ev as any ) ) replacedUuids . push ( ( ev as any ) . uuid ) ;
161+ }
162+
163+ const lastTurnId = Math . max (
164+ ...targetEvents . filter ( ( e ) => 'turnId' in e ) . map ( ( e ) => ( e as any ) . turnId ) ,
165+ 0
166+ ) ;
167+
168+ const event : SummaryEvent = {
169+ type : 'summary' ,
170+ uuid : randomUUID ( ) ,
171+ replaces : replacedUuids ,
172+ summaryText : summary ,
173+ lastSummarizedTurnId : lastTurnId ,
174+ method : 'auto-compact' ,
175+ timestamp : new Date ( ) . toISOString ( ) ,
176+ } ;
177+ appendSummaryToSession ( sessionId , event ) ;
178+ for ( const u of replacedUuids ) hidden . add ( u ) ;
179+
180+ const summaryMsg : Message = { role : 'system' , name : 'compacted_history' , content : summary } ;
181+ return Math . max ( 0 , totalTokens - estimateMessageTokens ( summaryMsg ) ) ;
182+ }
183+
184+ function getIncrementalEvents ( inRange : SessionEvent [ ] ) : SessionEvent [ ] {
185+ const existingSummary = [ ...inRange ]
186+ . reverse ( )
187+ . find ( ( e ) : e is SummaryEvent => e . type === 'summary' ) ;
188+
189+ if ( ! existingSummary ) return inRange ;
190+
191+ const lastTurn = existingSummary . lastSummarizedTurnId ?? 0 ;
192+ return inRange . filter ( ( e ) => 'turnId' in e && ( e as any ) . turnId > lastTurn ) ;
193+ }
194+
195+ function buildTranscript ( events : SessionEvent [ ] ) : Message [ ] {
196+ const transcript : Message [ ] = [ ] ;
197+ for ( const ev of events ) {
204198 switch ( ev . type ) {
205199 case 'user' :
206200 transcript . push ( { role : 'user' , content : ev . content } ) ;
207201 break ;
208202 case 'assistant' :
209203 transcript . push ( { role : 'assistant' , content : ev . content } ) ;
210204 break ;
211- case 'tool_result' :
205+ case 'tool_result' : {
206+ let content = ev . output ;
207+ const tokens = estimateTokensForContent ( content ) ;
208+ if ( tokens > MAX_TOOL_RESULT_TOKENS ) {
209+ const ratio = MAX_TOOL_RESULT_TOKENS / tokens ;
210+ const keepChars = Math . floor ( content . length * ratio ) ;
211+ content =
212+ content . slice ( 0 , keepChars ) +
213+ `\n\n[...truncated: ${ tokens } tokens total, showing first ${ MAX_TOOL_RESULT_TOKENS } ]` ;
214+ }
212215 transcript . push ( {
213216 role : 'tool' ,
214- content : ev . output ,
217+ content,
215218 tool_call_id : ev . toolCallId ,
216219 tool_name : ev . toolName ,
217220 } as any ) ;
218221 break ;
222+ }
219223 case 'summary' :
220224 transcript . push ( { role : 'system' , name : 'compacted_history' , content : ev . summaryText } ) ;
221225 break ;
222226 }
223227 }
228+ return transcript ;
229+ }
224230
225- const summary = await callLLMForCompaction ( transcript , ctx . llm , config ) ;
226- if ( ! summary ) return 0 ;
227-
228- const event : SummaryEvent = {
229- type : 'summary' ,
230- uuid : randomUUID ( ) ,
231- replaces : replacedUuids ,
232- summaryText : summary ,
233- method : 'auto-compact' ,
234- timestamp : new Date ( ) . toISOString ( ) ,
235- } ;
236- appendSummaryToSession ( sessionId , event ) ;
237- for ( const u of replacedUuids ) hiddenUuids . add ( u ) ;
238-
239- const replacedTokens = transcript . reduce ( ( sum , m ) => sum + estimateMessageTokens ( m ) , 0 ) ;
240- const summaryMsg : Message = { role : 'system' , name : 'compacted_history' , content : summary } ;
241- const summaryTokens = estimateMessageTokens ( summaryMsg ) ;
242- return Math . max ( 0 , replacedTokens - summaryTokens ) ;
231+ function estimateEventTokens ( e : SessionEvent ) : number {
232+ if ( e . type === 'user' ) return estimateMessageTokens ( { role : 'user' , content : e . content } ) ;
233+ if ( e . type === 'assistant' )
234+ return estimateMessageTokens ( { role : 'assistant' , content : e . content } ) ;
235+ if ( e . type === 'tool_result' ) {
236+ return estimateMessageTokens ( {
237+ role : 'tool' ,
238+ content : e . output ,
239+ tool_call_id : e . toolCallId ,
240+ tool_name : e . toolName ,
241+ } as any ) ;
242+ }
243+ if ( e . type === 'summary' ) {
244+ return estimateMessageTokens ( {
245+ role : 'system' ,
246+ name : 'compacted_history' ,
247+ content : e . summaryText ,
248+ } ) ;
249+ }
250+ return 0 ;
243251}
244252
245253async function callLLMForCompaction (
@@ -274,5 +282,3 @@ function extractSummary(raw: string): string {
274282 const m = raw . match ( / < s u m m a r y > ( [ \s \S ] * ?) < \/ s u m m a r y > / ) ;
275283 return ( m ?. [ 1 ] ?? raw ) . trim ( ) ;
276284}
277-
278- // ---------- Helpers ----------
0 commit comments