1+ import { writeGatewayEventAudit } from "../../audit/event-audit.js" ;
12const TIMESTAMP_PREFIX_LABEL = "[" ;
3+ const TARGET_EVENT_TYPES = new Set ( [
4+ "message.updated" ,
5+ "message.part.updated" ,
6+ "message.part.delta" ,
7+ ] ) ;
28export function formatAssistantMessageTimestamp ( timestamp ) {
39 const value = new Date ( timestamp ) ;
410 const year = value . getFullYear ( ) ;
@@ -9,48 +15,228 @@ export function formatAssistantMessageTimestamp(timestamp) {
915 const seconds = String ( value . getSeconds ( ) ) . padStart ( 2 , "0" ) ;
1016 return `[${ year } -${ month } -${ day } ${ hours } :${ minutes } :${ seconds } ]` ;
1117}
18+ function debugAuditEnabled ( ) {
19+ return process . env . MY_OPENCODE_ASSISTANT_TIMESTAMP_DEBUG === "1" ;
20+ }
1221function prependTimestampToText ( text , timestamp ) {
1322 const trimmed = text . trim ( ) ;
1423 if ( ! trimmed || trimmed . startsWith ( TIMESTAMP_PREFIX_LABEL ) ) {
1524 return text ;
1625 }
1726 return `${ timestamp } \n${ trimmed } ` ;
1827}
28+ function prependTimestampToParts ( parts , timestamp ) {
29+ if ( ! Array . isArray ( parts ) || parts . length === 0 ) {
30+ return false ;
31+ }
32+ const textPart = parts . find ( ( part ) => part ?. type === "text" && typeof part . text === "string" ) ;
33+ if ( ! textPart ) {
34+ return false ;
35+ }
36+ const next = prependTimestampToText ( textPart . text ?? "" , timestamp ) ;
37+ if ( next === textPart . text ) {
38+ return false ;
39+ }
40+ textPart . text = next ;
41+ return true ;
42+ }
1943function prependTimestampToLatestAssistantMessage ( messages , timestamp ) {
2044 if ( ! Array . isArray ( messages ) || messages . length === 0 ) {
21- return ;
45+ return false ;
2246 }
2347 for ( let index = messages . length - 1 ; index >= 0 ; index -= 1 ) {
2448 const message = messages [ index ] ;
2549 if ( message ?. info ?. role !== "assistant" ) {
2650 continue ;
2751 }
2852 const parts = Array . isArray ( message . parts ) ? message . parts : [ ] ;
29- const firstTextPart = parts . find ( ( part ) => part ?. type === "text" && typeof part . text === "string" ) ;
30- if ( firstTextPart ) {
31- firstTextPart . text = prependTimestampToText ( firstTextPart . text ?? "" , timestamp ) ;
32- return ;
53+ if ( prependTimestampToParts ( parts , timestamp ) ) {
54+ return true ;
3355 }
3456 parts . unshift ( { type : "text" , text : timestamp } ) ;
3557 message . parts = parts ;
58+ return true ;
59+ }
60+ return false ;
61+ }
62+ function assistantRole ( properties ) {
63+ return String ( properties ?. info ?. role ?? properties ?. role ?? "" ) . trim ( ) ;
64+ }
65+ function resolveMessageId ( properties ) {
66+ return String ( properties ?. info ?. id ??
67+ properties ?. messageID ??
68+ properties ?. messageId ??
69+ properties ?. part ?. messageID ??
70+ properties ?. part ?. messageId ??
71+ "" ) . trim ( ) ;
72+ }
73+ function resolvePartId ( properties ) {
74+ return String ( properties ?. partID ?? properties ?. partId ?? properties ?. part ?. id ?? "" ) . trim ( ) ;
75+ }
76+ function prependTimestampToAssistantLifecyclePayload ( properties , timestamp ) {
77+ if ( ! properties || assistantRole ( properties ) !== "assistant" ) {
78+ return false ;
79+ }
80+ if ( prependTimestampToParts ( properties . parts , timestamp ) ) {
81+ return true ;
82+ }
83+ if ( prependTimestampToParts ( properties . messageParts , timestamp ) ) {
84+ return true ;
85+ }
86+ if ( properties . message &&
87+ typeof properties . message === "object" &&
88+ prependTimestampToParts ( properties . message . parts , timestamp ) ) {
89+ return true ;
90+ }
91+ if ( properties . part ?. type === "text" && typeof properties . part . text === "string" ) {
92+ const next = prependTimestampToText ( properties . part . text , timestamp ) ;
93+ if ( next !== properties . part . text ) {
94+ properties . part . text = next ;
95+ return true ;
96+ }
97+ }
98+ if ( properties . message && typeof properties . message === "object" ) {
99+ const messageText = properties . message . text ;
100+ if ( typeof messageText === "string" ) {
101+ const next = prependTimestampToText ( messageText , timestamp ) ;
102+ if ( next !== messageText ) {
103+ properties . message . text = next ;
104+ return true ;
105+ }
106+ }
107+ }
108+ for ( const key of [ "text" , "content" , "delta" ] ) {
109+ const value = properties [ key ] ;
110+ if ( typeof value !== "string" ) {
111+ continue ;
112+ }
113+ const next = prependTimestampToText ( value , timestamp ) ;
114+ if ( next !== value ) {
115+ properties [ key ] = next ;
116+ return true ;
117+ }
118+ }
119+ return false ;
120+ }
121+ function writeDebugAudit ( directory , type , properties , applied ) {
122+ if ( ! debugAuditEnabled ( ) || ! directory || ! TARGET_EVENT_TYPES . has ( type ) ) {
36123 return ;
37124 }
125+ const messageValue = properties ?. message ;
126+ writeGatewayEventAudit ( directory , {
127+ hook : "assistant-message-timestamp" ,
128+ stage : applied ? "inject" : "state" ,
129+ reason_code : applied
130+ ? "assistant_timestamp_lifecycle_applied"
131+ : "assistant_timestamp_lifecycle_noop" ,
132+ event_type : type ,
133+ role : assistantRole ( properties ) ,
134+ message_id : resolveMessageId ( properties ) ,
135+ part_id : resolvePartId ( properties ) ,
136+ field : String ( properties ?. field ?? "" ) ,
137+ top_level_keys : properties ? Object . keys ( properties ) . join ( "," ) : "" ,
138+ info_keys : properties ?. info && typeof properties . info === "object"
139+ ? Object . keys ( properties . info ) . join ( "," )
140+ : "" ,
141+ part_keys : properties ?. part && typeof properties . part === "object"
142+ ? Object . keys ( properties . part ) . join ( "," )
143+ : "" ,
144+ has_part : Boolean ( properties ?. part ) ,
145+ has_parts : Array . isArray ( properties ?. parts ) ,
146+ has_message_parts : Boolean ( messageValue ) &&
147+ typeof messageValue === "object" &&
148+ Array . isArray ( messageValue . parts ) ,
149+ text_preview : typeof properties ?. text === "string"
150+ ? properties . text . slice ( 0 , 80 )
151+ : typeof properties ?. delta === "string"
152+ ? properties . delta . slice ( 0 , 80 )
153+ : typeof properties ?. part ?. text === "string"
154+ ? properties . part . text . slice ( 0 , 80 )
155+ : Array . isArray ( properties ?. parts ) && typeof properties . parts [ 0 ] ?. text === "string"
156+ ? properties . parts [ 0 ] . text . slice ( 0 , 80 )
157+ : "" ,
158+ } ) ;
38159}
39160export function createAssistantMessageTimestampHook ( options ) {
40161 const now = options . now ?? ( ( ) => Date . now ( ) ) ;
162+ const assistantMessageIds = new Set ( ) ;
163+ const stampedPartIds = new Set ( ) ;
164+ const stampedMessageIds = new Set ( ) ;
41165 return {
42166 id : "assistant-message-timestamp" ,
43167 priority : 341 ,
44168 async event ( type , payload ) {
45169 if ( ! options . enabled ) {
46170 return ;
47171 }
172+ if ( type === "session.deleted" ) {
173+ assistantMessageIds . clear ( ) ;
174+ stampedPartIds . clear ( ) ;
175+ stampedMessageIds . clear ( ) ;
176+ return ;
177+ }
48178 const timestamp = formatAssistantMessageTimestamp ( now ( ) ) ;
49179 if ( type === "experimental.chat.messages.transform" ) {
50180 const eventPayload = ( payload ?? { } ) ;
51181 prependTimestampToLatestAssistantMessage ( eventPayload . output ?. messages , timestamp ) ;
52182 return ;
53183 }
184+ if ( type === "experimental.text.complete" ) {
185+ const eventPayload = ( payload ?? { } ) ;
186+ if ( typeof eventPayload . output ?. text === "string" ) {
187+ eventPayload . output . text = prependTimestampToText ( eventPayload . output . text , timestamp ) ;
188+ }
189+ return ;
190+ }
191+ if ( TARGET_EVENT_TYPES . has ( type ) ) {
192+ const eventPayload = ( payload ?? { } ) ;
193+ const properties = eventPayload . properties ;
194+ let applied = false ;
195+ if ( type === "message.updated" ) {
196+ const messageId = resolveMessageId ( properties ) ;
197+ if ( assistantRole ( properties ) === "assistant" && messageId ) {
198+ assistantMessageIds . add ( messageId ) ;
199+ }
200+ applied = prependTimestampToAssistantLifecyclePayload ( properties , timestamp ) ;
201+ }
202+ else if ( type === "message.part.updated" ) {
203+ const messageId = resolveMessageId ( properties ) ;
204+ const partId = resolvePartId ( properties ) ;
205+ if ( messageId &&
206+ assistantMessageIds . has ( messageId ) &&
207+ properties ?. part ?. type === "text" &&
208+ typeof properties . part . text === "string" &&
209+ ! stampedPartIds . has ( partId || messageId ) ) {
210+ const next = prependTimestampToText ( properties . part . text , timestamp ) ;
211+ if ( next !== properties . part . text ) {
212+ properties . part . text = next ;
213+ stampedPartIds . add ( partId || messageId ) ;
214+ stampedMessageIds . add ( messageId ) ;
215+ applied = true ;
216+ }
217+ }
218+ }
219+ else if ( type === "message.part.delta" ) {
220+ const messageId = resolveMessageId ( properties ) ;
221+ const partId = resolvePartId ( properties ) ;
222+ const stampKey = partId || messageId ;
223+ const deltaText = properties ?. delta ;
224+ if ( messageId &&
225+ assistantMessageIds . has ( messageId ) &&
226+ typeof deltaText === "string" &&
227+ ! stampedPartIds . has ( stampKey ) ) {
228+ const next = prependTimestampToText ( deltaText , timestamp ) ;
229+ if ( next !== deltaText && properties ) {
230+ properties . delta = next ;
231+ stampedPartIds . add ( stampKey ) ;
232+ stampedMessageIds . add ( messageId ) ;
233+ applied = true ;
234+ }
235+ }
236+ }
237+ writeDebugAudit ( eventPayload . directory , type , eventPayload . properties , applied ) ;
238+ return ;
239+ }
54240 if ( type !== "session.idle" ) {
55241 return ;
56242 }
0 commit comments