@@ -31,6 +31,7 @@ import {
3131 extractJsonNullableStringFieldPrefix ,
3232 extractJsonNumberFieldPrefix ,
3333 extractJsonStringFieldPrefix ,
34+ normalizeOptionalString ,
3435} from "./session-transcript-json.js" ;
3536import type { SessionPreviewItem } from "./session-utils.types.js" ;
3637
@@ -158,6 +159,14 @@ export function attachOpenClawTranscriptMeta(
158159 } ;
159160}
160161
162+ function readTranscriptMessageIdempotencyKey ( message : unknown ) : string | undefined {
163+ if ( ! message || typeof message !== "object" || Array . isArray ( message ) ) {
164+ return undefined ;
165+ }
166+ const value = ( message as Record < string , unknown > ) . idempotencyKey ;
167+ return typeof value === "string" && value . trim ( ) ? value : undefined ;
168+ }
169+
161170/** Read all visible transcript messages for a session from the first existing candidate file. */
162171export function readSessionMessages (
163172 sessionId : string ,
@@ -301,12 +310,60 @@ async function readRecentTranscriptTailLinesAsync(
301310
302311const MAX_TRANSCRIPT_PARSE_LINE_BYTES = 256 * 1024 ;
303312const OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS = 64 * 1024 ;
313+ const OVERSIZED_TRANSCRIPT_METADATA_SUFFIX_CHARS = 64 * 1024 ;
304314const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message too large]" ;
305315
306316function isOversizedTranscriptLine ( line : string ) : boolean {
307317 return Buffer . byteLength ( line , "utf8" ) > MAX_TRANSCRIPT_PARSE_LINE_BYTES ;
308318}
309319
320+ function isJsonObjectFieldToken ( source : string , tokenIndex : number ) : boolean {
321+ for ( let index = tokenIndex - 1 ; index >= 0 ; index -- ) {
322+ const char = source [ index ] ;
323+ if ( / \s / . test ( char ) ) {
324+ continue ;
325+ }
326+ return char === "{" || char === "," ;
327+ }
328+ return true ;
329+ }
330+
331+ function extractJsonStringFieldWindow (
332+ source : string ,
333+ field : string ,
334+ startIndex = 0 ,
335+ endIndex = source . length ,
336+ ) : string | undefined {
337+ const fieldToken = JSON . stringify ( field ) ;
338+ let searchIndex = startIndex ;
339+ while ( searchIndex < endIndex ) {
340+ const tokenIndex = source . indexOf ( fieldToken , searchIndex ) ;
341+ if ( tokenIndex < 0 || tokenIndex >= endIndex ) {
342+ return undefined ;
343+ }
344+ searchIndex = tokenIndex + fieldToken . length ;
345+ if ( ! isJsonObjectFieldToken ( source , tokenIndex ) ) {
346+ continue ;
347+ }
348+ const match = / ^ \s * : \s * " ( (?: \\ .| [ ^ " \\ ] ) * ) " / . exec ( source . slice ( searchIndex , endIndex ) ) ;
349+ if ( ! match ) {
350+ continue ;
351+ }
352+ try {
353+ const decoded = JSON . parse ( `"${ match [ 1 ] } "` ) as unknown ;
354+ return normalizeOptionalString ( decoded ) ;
355+ } catch {
356+ return undefined ;
357+ }
358+ }
359+ return undefined ;
360+ }
361+
362+ function extractJsonStringFieldSuffix ( source : string , field : string ) : string | undefined {
363+ const startIndex = Math . max ( 0 , source . length - OVERSIZED_TRANSCRIPT_METADATA_SUFFIX_CHARS ) ;
364+ return extractJsonStringFieldWindow ( source , field , startIndex ) ;
365+ }
366+
310367function buildOversizedTranscriptRecord ( line : string ) : TailTranscriptRecord {
311368 const prefix = line . slice ( 0 , OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS ) ;
312369 const messageMatch = / " m e s s a g e " \s * : / . exec ( prefix ) ;
@@ -318,13 +375,17 @@ function buildOversizedTranscriptRecord(line: string): TailTranscriptRecord {
318375 extractJsonStringFieldPrefix ( recordPrefix , "timestamp" ) ??
319376 extractJsonNumberFieldPrefix ( recordPrefix , "timestamp" ) ;
320377 const role = extractJsonStringFieldPrefix ( prefix , "role" ) ?? "assistant" ;
378+ const idempotencyKey =
379+ extractJsonStringFieldPrefix ( prefix , "idempotencyKey" ) ??
380+ extractJsonStringFieldSuffix ( line , "idempotencyKey" ) ;
321381 const record : Record < string , unknown > = {
322382 ...( type ? { type } : { } ) ,
323383 ...( id ? { id } : { } ) ,
324384 ...( parentId !== undefined ? { parentId } : { } ) ,
325385 ...( timestamp !== undefined ? { timestamp } : { } ) ,
326386 message : {
327387 role,
388+ ...( idempotencyKey ? { idempotencyKey } : { } ) ,
328389 content : [ { type : "text" , text : TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER } ] ,
329390 __openclaw : { truncated : true , reason : "oversized" } ,
330391 } ,
@@ -838,8 +899,10 @@ function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
838899 : typeof entry . timestamp === "number"
839900 ? entry . timestamp
840901 : Number . NaN ;
902+ const idempotencyKey = readTranscriptMessageIdempotencyKey ( entry . message ) ;
841903 return attachOpenClawTranscriptMeta ( entry . message , {
842904 ...( typeof entry . id === "string" ? { id : entry . id } : { } ) ,
905+ ...( idempotencyKey ? { idempotencyKey } : { } ) ,
843906 ...( Number . isFinite ( recordTimestampMs ) ? { recordTimestampMs } : { } ) ,
844907 seq,
845908 } ) ;
0 commit comments