@@ -14,6 +14,105 @@ type DbMessageRow = {
1414 local_id : string | null
1515}
1616
17+
18+ type JsonRecord = Record < string , unknown >
19+
20+ function isRecord ( value : unknown ) : value is JsonRecord {
21+ return value !== null && typeof value === 'object' && ! Array . isArray ( value )
22+ }
23+
24+ function getUserMessageText ( content : unknown ) : string | null {
25+ if ( ! isRecord ( content ) ) return null
26+ if ( content . role !== 'user' ) return null
27+ const inner = content . content
28+ if ( ! isRecord ( inner ) ) return null
29+ if ( inner . type !== 'text' ) return null
30+ return typeof inner . text === 'string' ? inner . text : null
31+ }
32+
33+ function getSentFrom ( content : unknown ) : string | null {
34+ if ( ! isRecord ( content ) ) return null
35+ const meta = content . meta
36+ if ( ! isRecord ( meta ) ) return null
37+ return typeof meta . sentFrom === 'string' ? meta . sentFrom : null
38+ }
39+
40+ function getAttachmentPaths ( content : unknown ) : string [ ] {
41+ if ( ! isRecord ( content ) ) return [ ]
42+ if ( content . role !== 'user' ) return [ ]
43+ const inner = content . content
44+ if ( ! isRecord ( inner ) || ! Array . isArray ( inner . attachments ) ) return [ ]
45+
46+ const paths : string [ ] = [ ]
47+ for ( const item of inner . attachments ) {
48+ if ( isRecord ( item ) && typeof item . path === 'string' && item . path . length > 0 ) {
49+ paths . push ( item . path )
50+ }
51+ }
52+ return paths
53+ }
54+
55+ const HAPI_BLOBS_PATH_PATTERN = / @ ( [ ^ \s " ' ` < > ( ) ] * [ / \\ ] h a p i - b l o b s [ / \\ ] [ ^ \s " ' ` < > ( ) ] + ) / g
56+
57+ function extractHapiBlobReferences ( text : string ) : string [ ] {
58+ return Array . from ( text . matchAll ( HAPI_BLOBS_PATH_PATTERN ) , ( match ) => match [ 1 ] ?? '' )
59+ . filter ( ( path ) => path . length > 0 )
60+ }
61+
62+ function basename ( path : string ) : string {
63+ return path . split ( / [ / \\ ] / ) . filter ( Boolean ) . pop ( ) ?? 'upload'
64+ }
65+
66+ function sanitizeHapiBlobReferences ( text : string ) : string {
67+ return text . replace ( HAPI_BLOBS_PATH_PATTERN , ( _match , path : string ) => `@[${ basename ( path ) } ]` )
68+ }
69+
70+ function withUserMessageText ( content : unknown , text : string ) : unknown {
71+ if ( ! isRecord ( content ) ) return content
72+ const inner = content . content
73+ if ( ! isRecord ( inner ) ) return content
74+ return {
75+ ...content ,
76+ content : {
77+ ...inner ,
78+ text
79+ }
80+ }
81+ }
82+
83+ function buildCopiedMessageRows ( rows : DbMessageRow [ ] ) : Array < { row : DbMessageRow ; content : string } > {
84+ const canonicalAttachmentPaths = new Set < string > ( )
85+ for ( const row of rows ) {
86+ const parsed = safeJsonParse ( row . content )
87+ for ( const path of getAttachmentPaths ( parsed ) ) {
88+ canonicalAttachmentPaths . add ( path )
89+ }
90+ }
91+
92+ const copied : Array < { row : DbMessageRow ; content : string } > = [ ]
93+ for ( const row of rows ) {
94+ const parsed = safeJsonParse ( row . content )
95+ const text = getUserMessageText ( parsed )
96+ const hapiBlobRefs = text ? extractHapiBlobReferences ( text ) : [ ]
97+
98+ if ( text && getSentFrom ( parsed ) === 'cli' && hapiBlobRefs . length > 0 ) {
99+ if ( hapiBlobRefs . some ( ( path ) => canonicalAttachmentPaths . has ( path ) ) ) {
100+ continue
101+ }
102+
103+ copied . push ( {
104+ row,
105+ content : JSON . stringify ( withUserMessageText ( parsed , sanitizeHapiBlobReferences ( text ) ) )
106+ } )
107+ continue
108+ }
109+
110+ copied . push ( { row, content : row . content } )
111+ }
112+
113+ return copied
114+ }
115+
17116function toStoredMessage ( row : DbMessageRow ) : StoredMessage {
18117 return {
19118 id : row . id ,
@@ -203,27 +302,28 @@ export function copySessionMessages(
203302 ) . run ( oldMaxSeq , toSessionId )
204303 }
205304
206- const result = db . prepare ( `
305+ const sourceRows = db . prepare (
306+ 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC'
307+ ) . all ( fromSessionId ) as DbMessageRow [ ]
308+ const copiedRows = buildCopiedMessageRows ( sourceRows )
309+ const insert = db . prepare ( `
207310 INSERT INTO messages (id, session_id, content, created_at, seq, local_id)
208- SELECT
209- lower(hex(randomblob(16))),
210- @to_session_id,
211- content,
212- created_at,
213- seq,
214- NULL
215- FROM messages
216- WHERE session_id = @from_session_id
217- ORDER BY seq ASC
218- ` ) . run ( {
219- to_session_id : toSessionId ,
220- from_session_id : fromSessionId
221- } )
311+ VALUES (lower(hex(randomblob(16))), @session_id, @content, @created_at, @seq, NULL)
312+ ` )
313+
314+ for ( const item of copiedRows ) {
315+ insert . run ( {
316+ session_id : toSessionId ,
317+ content : item . content ,
318+ created_at : item . row . created_at ,
319+ seq : item . row . seq
320+ } )
321+ }
222322
223323 rebuildSessionConversationTurns ( db , toSessionId )
224324
225325 db . exec ( 'COMMIT' )
226- return { copied : result . changes , oldMaxSeq, newMaxSeq }
326+ return { copied : copiedRows . length , oldMaxSeq, newMaxSeq }
227327 } catch ( error ) {
228328 db . exec ( 'ROLLBACK' )
229329 throw error
0 commit comments