1+ import * as fs from "fs" ;
12import * as path from "path" ;
3+ import { readTextFileWithMetadata } from "./file-utils" ;
24import { posixPathToWindowsPath } from "./shell-utils" ;
35
46export type FileLineEnding = "LF" | "CRLF" ;
@@ -25,6 +27,11 @@ export type FileSnippet = {
2527 scopeType : "snippet" | "full" ;
2628} ;
2729
30+ export type SessionStateHistoryMessage = {
31+ role ?: unknown ;
32+ content ?: unknown ;
33+ } ;
34+
2835const fileStatesBySession = new Map < string , Map < string , FileState > > ( ) ;
2936const snippetsBySession = new Map < string , Map < string , FileSnippet > > ( ) ;
3037const snippetCountersBySession = new Map < string , number > ( ) ;
@@ -39,9 +46,24 @@ export function clearSessionState(sessionId: string): void {
3946 fileStatesBySession . delete ( sessionId ) ;
4047 snippetsBySession . delete ( sessionId ) ;
4148 snippetCountersBySession . delete ( sessionId ) ;
49+ fullFileSnippetCountersBySession . delete ( sessionId ) ;
4250 fileVersionsBySession . delete ( sessionId ) ;
4351}
4452
53+ export function hasSessionState ( sessionId : string ) : boolean {
54+ if ( ! sessionId ) {
55+ return false ;
56+ }
57+
58+ return Boolean (
59+ fileStatesBySession . get ( sessionId ) ?. size ||
60+ snippetsBySession . get ( sessionId ) ?. size ||
61+ snippetCountersBySession . has ( sessionId ) ||
62+ fullFileSnippetCountersBySession . has ( sessionId ) ||
63+ fileVersionsBySession . get ( sessionId ) ?. size
64+ ) ;
65+ }
66+
4567export function normalizeFilePath ( filePath : string , platform : NodeJS . Platform = process . platform ) : string {
4668 const nativePath = normalizeNativeFilePath ( filePath , platform ) ;
4769 return platform === "win32" ? path . win32 . normalize ( nativePath ) : path . normalize ( nativePath ) ;
@@ -178,6 +200,32 @@ export function createFullFileSnippet(
178200 return createSnippetWithId ( sessionId , filePath , startLine , endLine , preview , `full_file_${ nextCounter } ` , "full" ) ;
179201}
180202
203+ export function restoreSnippet (
204+ sessionId : string ,
205+ snippet : {
206+ id : string ;
207+ filePath : string ;
208+ startLine : number ;
209+ endLine : number ;
210+ preview ?: string ;
211+ scopeType ?: FileSnippet [ "scopeType" ] ;
212+ }
213+ ) : FileSnippet | null {
214+ const restored = createSnippetWithId (
215+ sessionId ,
216+ snippet . filePath ,
217+ snippet . startLine ,
218+ snippet . endLine ,
219+ snippet . preview ?? "" ,
220+ snippet . id ,
221+ snippet . scopeType ?? inferSnippetScopeType ( snippet . id )
222+ ) ;
223+ if ( restored ) {
224+ updateSnippetCounters ( sessionId , snippet . id ) ;
225+ }
226+ return restored ;
227+ }
228+
181229function createSnippetWithId (
182230 sessionId : string ,
183231 filePath : string ,
@@ -210,6 +258,27 @@ function createSnippetWithId(
210258 return snippet ;
211259}
212260
261+ function inferSnippetScopeType ( id : string ) : FileSnippet [ "scopeType" ] {
262+ return id . startsWith ( "full_file_" ) ? "full" : "snippet" ;
263+ }
264+
265+ function updateSnippetCounters ( sessionId : string , id : string ) : void {
266+ const fullFileMatch = / ^ f u l l _ f i l e _ ( \d + ) $ / . exec ( id ) ;
267+ if ( fullFileMatch ) {
268+ const nextCounter = Number ( fullFileMatch [ 1 ] ) + 1 ;
269+ const current = fullFileSnippetCountersBySession . get ( sessionId ) ?? 0 ;
270+ fullFileSnippetCountersBySession . set ( sessionId , Math . max ( current , nextCounter ) ) ;
271+ return ;
272+ }
273+
274+ const snippetMatch = / ^ s n i p p e t _ ( \d + ) $ / . exec ( id ) ;
275+ if ( snippetMatch ) {
276+ const currentCounter = Number ( snippetMatch [ 1 ] ) ;
277+ const current = snippetCountersBySession . get ( sessionId ) ?? 0 ;
278+ snippetCountersBySession . set ( sessionId , Math . max ( current , currentCounter ) ) ;
279+ }
280+ }
281+
213282export function getSnippet ( sessionId : string , snippetId : string ) : FileSnippet | null {
214283 if ( ! sessionId || ! snippetId ) {
215284 return null ;
@@ -220,3 +289,247 @@ export function getSnippet(sessionId: string, snippetId: string): FileSnippet |
220289export function hasSnippetOutdatedFileVersion ( sessionId : string , snippet : FileSnippet ) : boolean {
221290 return getFileVersion ( sessionId , snippet . filePath ) > snippet . fileVersion ;
222291}
292+
293+ export function rebuildSessionStateFromHistory (
294+ sessionId : string ,
295+ messages : Iterable < SessionStateHistoryMessage >
296+ ) : void {
297+ if ( ! sessionId || hasSessionState ( sessionId ) ) {
298+ return ;
299+ }
300+
301+ for ( const message of messages ) {
302+ if ( message . role !== "tool" || typeof message . content !== "string" ) {
303+ continue ;
304+ }
305+
306+ const result = parsePersistedToolResult ( message . content ) ;
307+ if ( ! result || result . ok !== true ) {
308+ continue ;
309+ }
310+
311+ const metadata = asRecord ( result . metadata ) ;
312+ if ( ! metadata ) {
313+ continue ;
314+ }
315+
316+ if ( result . name === "read" ) {
317+ rebuildReadResult ( sessionId , result , metadata ) ;
318+ } else if ( result . name === "edit" ) {
319+ rebuildEditResult ( sessionId , metadata ) ;
320+ } else if ( result . name === "write" ) {
321+ rebuildWriteResult ( sessionId , metadata ) ;
322+ }
323+ }
324+ }
325+
326+ function rebuildReadResult (
327+ sessionId : string ,
328+ result : Record < string , unknown > ,
329+ metadata : Record < string , unknown >
330+ ) : void {
331+ const snippet = asRecord ( metadata . snippet ) ;
332+ if ( ! snippet ) {
333+ return ;
334+ }
335+
336+ const restored = restoreSnippetFromRecord ( sessionId , snippet , {
337+ idKey : "id" ,
338+ filePathKey : "filePath" ,
339+ startLineKey : "startLine" ,
340+ endLineKey : "endLine" ,
341+ preview : typeof result . output === "string" ? result . output : "" ,
342+ } ) ;
343+ if ( ! restored ) {
344+ return ;
345+ }
346+
347+ refreshRebuiltFileState ( sessionId , restored . filePath , {
348+ scopeType : restored . scopeType ,
349+ startLine : restored . startLine ,
350+ endLine : restored . endLine ,
351+ incrementVersion : false ,
352+ } ) ;
353+ }
354+
355+ function rebuildEditResult ( sessionId : string , metadata : Record < string , unknown > ) : void {
356+ const scope = asRecord ( metadata . scope ) ;
357+ if ( scope ) {
358+ restoreSnippetFromRecord ( sessionId , scope , {
359+ idKey : "snippet_id" ,
360+ filePathKey : "file_path" ,
361+ startLineKey : "start_line" ,
362+ endLineKey : "end_line" ,
363+ scopeType : metadata . read_scope_type === "full" ? "full" : undefined ,
364+ } ) ;
365+ }
366+
367+ const scopeFilePath = typeof scope ?. file_path === "string" ? scope . file_path : undefined ;
368+ rebuildCandidateSnippets ( sessionId , metadata , scopeFilePath ) ;
369+
370+ const filePath = typeof metadata . file_path === "string" ? metadata . file_path : scopeFilePath ;
371+ if ( filePath && metadata . cache_refreshed === true ) {
372+ refreshRebuiltFileState ( sessionId , filePath , { incrementVersion : true } ) ;
373+ }
374+ }
375+
376+ function rebuildWriteResult ( sessionId : string , metadata : Record < string , unknown > ) : void {
377+ if ( metadata . cache_refreshed !== true || typeof metadata . file_path !== "string" ) {
378+ return ;
379+ }
380+
381+ refreshRebuiltFileState ( sessionId , metadata . file_path , { incrementVersion : true } ) ;
382+ }
383+
384+ function rebuildCandidateSnippets (
385+ sessionId : string ,
386+ metadata : Record < string , unknown > ,
387+ filePath : string | undefined
388+ ) : void {
389+ if ( ! filePath ) {
390+ return ;
391+ }
392+
393+ const candidates = Array . isArray ( metadata . candidates ) ? metadata . candidates : [ ] ;
394+ for ( const candidate of candidates ) {
395+ const record = asRecord ( candidate ) ;
396+ if ( ! record ) {
397+ continue ;
398+ }
399+ restoreSnippetFromRecord (
400+ sessionId ,
401+ { ...record , file_path : filePath } ,
402+ {
403+ idKey : "snippet_id" ,
404+ filePathKey : "file_path" ,
405+ startLineKey : "start_line" ,
406+ endLineKey : "end_line" ,
407+ scopeType : "snippet" ,
408+ preview : typeof record . preview === "string" ? record . preview : "" ,
409+ }
410+ ) ;
411+ }
412+
413+ const closestMatch = asRecord ( metadata . closest_match ) ;
414+ if ( closestMatch ) {
415+ restoreSnippetFromRecord (
416+ sessionId ,
417+ { ...closestMatch , file_path : filePath } ,
418+ {
419+ idKey : "snippet_id" ,
420+ filePathKey : "file_path" ,
421+ startLineKey : "start_line" ,
422+ endLineKey : "end_line" ,
423+ scopeType : "snippet" ,
424+ preview : typeof closestMatch . preview === "string" ? closestMatch . preview : "" ,
425+ }
426+ ) ;
427+ }
428+ }
429+
430+ function restoreSnippetFromRecord (
431+ sessionId : string ,
432+ record : Record < string , unknown > ,
433+ options : {
434+ idKey : string ;
435+ filePathKey : string ;
436+ startLineKey : string ;
437+ endLineKey : string ;
438+ preview ?: string ;
439+ scopeType ?: FileSnippet [ "scopeType" ] ;
440+ }
441+ ) : FileSnippet | null {
442+ const rawId = record [ options . idKey ] ;
443+ const rawFilePath = record [ options . filePathKey ] ;
444+ const id = typeof rawId === "string" ? rawId . trim ( ) : "" ;
445+ const filePath = typeof rawFilePath === "string" ? normalizeFilePath ( rawFilePath ) : "" ;
446+ const startLine = toPositiveInteger ( record [ options . startLineKey ] ) ;
447+ const endLine = toPositiveInteger ( record [ options . endLineKey ] ) ;
448+ if ( ! id || ! filePath || startLine === null || endLine === null ) {
449+ return null ;
450+ }
451+
452+ return restoreSnippet ( sessionId , {
453+ id,
454+ filePath,
455+ startLine,
456+ endLine,
457+ preview : options . preview ,
458+ scopeType : options . scopeType ,
459+ } ) ;
460+ }
461+
462+ function refreshRebuiltFileState (
463+ sessionId : string ,
464+ rawFilePath : string ,
465+ options : {
466+ scopeType ?: FileSnippet [ "scopeType" ] ;
467+ startLine ?: number ;
468+ endLine ?: number ;
469+ incrementVersion ?: boolean ;
470+ } = { }
471+ ) : void {
472+ const filePath = normalizeFilePath ( rawFilePath ) ;
473+ if ( ! filePath || ! fs . existsSync ( filePath ) ) {
474+ return ;
475+ }
476+
477+ try {
478+ const stat = fs . statSync ( filePath ) ;
479+ if ( stat . isDirectory ( ) ) {
480+ return ;
481+ }
482+
483+ const metadata = readTextFileWithMetadata ( filePath ) ;
484+ const isPartialView = options . scopeType === "snippet" ;
485+ const content = isPartialView
486+ ? metadata . content
487+ . split ( "\n" )
488+ . slice ( ( options . startLine ?? 1 ) - 1 , options . endLine )
489+ . join ( "\n" )
490+ : metadata . content ;
491+
492+ recordFileState (
493+ sessionId ,
494+ {
495+ filePath,
496+ content,
497+ timestamp : metadata . timestamp ,
498+ offset : isPartialView ? options . startLine : undefined ,
499+ limit :
500+ isPartialView && options . startLine !== undefined && options . endLine !== undefined
501+ ? Math . max ( 1 , options . endLine - options . startLine + 1 )
502+ : undefined ,
503+ isPartialView,
504+ encoding : metadata . encoding ,
505+ lineEndings : metadata . lineEndings ,
506+ } ,
507+ { incrementVersion : options . incrementVersion }
508+ ) ;
509+ } catch {
510+ // Best-effort restore: later tool execution will return the precise filesystem error.
511+ }
512+ }
513+
514+ function parsePersistedToolResult ( content : string ) : Record < string , unknown > | null {
515+ try {
516+ return asRecord ( JSON . parse ( content ) ) ;
517+ } catch {
518+ return null ;
519+ }
520+ }
521+
522+ function asRecord ( value : unknown ) : Record < string , unknown > | null {
523+ if ( value === null || typeof value !== "object" || Array . isArray ( value ) ) {
524+ return null ;
525+ }
526+ return value as Record < string , unknown > ;
527+ }
528+
529+ function toPositiveInteger ( value : unknown ) : number | null {
530+ const numberValue = typeof value === "number" ? value : typeof value === "string" ? Number ( value ) : NaN ;
531+ if ( ! Number . isInteger ( numberValue ) || numberValue < 1 ) {
532+ return null ;
533+ }
534+ return numberValue ;
535+ }
0 commit comments