2727 * whose parentID matches that user message's id
2828 */
2929
30+ import { createHash } from "node:crypto" ;
3031import { existsSync } from "node:fs" ;
3132import { join } from "node:path" ;
3233import { getDataDir } from "../../shared/data-path" ;
@@ -37,33 +38,44 @@ import { closeQuietly } from "../../shared/sqlite-helpers";
3738// ── ID Generation ────────────────────────────────────────────────
3839
3940const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" ;
40-
41- function randomBase62 ( length : number ) : string {
42- const chars : string [ ] = [ ] ;
43- for ( let i = 0 ; i < length ; i ++ ) {
44- chars . push ( BASE62_CHARS [ Math . floor ( Math . random ( ) * BASE62_CHARS . length ) ] ) ;
41+ const ID_PREFIX_HEX_LENGTH = 12 ;
42+ const ID_SUFFIX_LENGTH = 14 ;
43+ const ID_PREFIX_MASK = ( 1n << BigInt ( ID_PREFIX_HEX_LENGTH * 4 ) ) - 1n ;
44+
45+ function deterministicBase62 ( seed : string , length : number ) : string {
46+ let value = BigInt ( `0x${ createHash ( "sha256" ) . update ( seed ) . digest ( "hex" ) } ` ) ;
47+ const chars = Array < string > ( length ) ;
48+ for ( let index = length - 1 ; index >= 0 ; index -= 1 ) {
49+ chars [ index ] = BASE62_CHARS [ Number ( value % 62n ) ] ;
50+ value /= 62n ;
4551 }
4652 return chars . join ( "" ) ;
4753}
4854
4955/**
5056 * Generate an OpenCode-compatible ascending ID.
51- * Format: `prefix_[hex-chars][14-random -base62]`
52- * The hex encodes `BigInt(timestamp_ms) * 0x1000n + counter`.
53- * Current timestamps produce 14 hex chars; padStart(14) ensures consistency .
57+ * Format: `prefix_[12- hex-chars][14-deterministic -base62]`.
58+ * The time prefix preserves OpenCode's lexicographic ordering, while the hash
59+ * suffix makes retries for the same marker identity converge on the same rows .
5460 */
55- function generateId ( prefix : string , timestampMs : number , counter = 0n ) : string {
56- const encoded = BigInt ( timestampMs ) * 0x1000n + counter ;
57- const hex = encoded . toString ( 16 ) . padStart ( 14 , "0" ) ;
58- return `${ prefix } _${ hex } ${ randomBase62 ( 14 ) } ` ;
61+ function generateId (
62+ prefix : string ,
63+ timestampMs : number ,
64+ counter : bigint ,
65+ identity : string ,
66+ ) : string {
67+ const encoded =
68+ ( BigInt ( Math . max ( 0 , Math . floor ( timestampMs ) ) ) * 0x1000n + counter ) & ID_PREFIX_MASK ;
69+ const hex = encoded . toString ( 16 ) . padStart ( ID_PREFIX_HEX_LENGTH , "0" ) ;
70+ return `${ prefix } _${ hex } ${ deterministicBase62 ( `${ prefix } \0${ identity } ` , ID_SUFFIX_LENGTH ) } ` ;
5971}
6072
61- export function generateMessageId ( timestampMs : number , counter = 0n ) : string {
62- return generateId ( "msg" , timestampMs , counter ) ;
73+ export function generateMessageId ( timestampMs : number , counter = 0n , identity = "" ) : string {
74+ return generateId ( "msg" , timestampMs , counter , identity ) ;
6375}
6476
65- export function generatePartId ( timestampMs : number , counter = 0n ) : string {
66- return generateId ( "prt" , timestampMs , counter ) ;
77+ export function generatePartId ( timestampMs : number , counter = 0n , identity = "" ) : string {
78+ return generateId ( "prt" , timestampMs , counter , identity ) ;
6779}
6880
6981// ── DB Access ────────────────────────────────────────────────────
@@ -343,6 +355,66 @@ export interface InjectCompactionMarkerArgs {
343355 resolvedBoundary ?: BoundaryUserMessage ;
344356}
345357
358+ function removeLegacyMarkerLineageRows (
359+ db : Database ,
360+ args : {
361+ sessionId : string ;
362+ boundaryMessageId : string ;
363+ summaryText : string ;
364+ summaryMessageId : string ;
365+ compactionPartId : string ;
366+ } ,
367+ ) : void {
368+ const legacySummaries = db
369+ . prepare (
370+ `SELECT m.id
371+ FROM message m
372+ WHERE m.session_id = ?
373+ AND m.id <> ?
374+ AND COALESCE(json_extract(m.data, '$.summary'), 0) = 1
375+ AND COALESCE(json_extract(m.data, '$.finish'), '') = 'stop'
376+ AND COALESCE(json_extract(m.data, '$.parentID'), '') = ?
377+ AND EXISTS (
378+ SELECT 1
379+ FROM part p
380+ WHERE p.session_id = m.session_id
381+ AND p.message_id = m.id
382+ AND COALESCE(json_extract(p.data, '$.type'), '') = 'text'
383+ AND COALESCE(json_extract(p.data, '$.text'), '') = ?
384+ )` ,
385+ )
386+ . all (
387+ args . sessionId ,
388+ args . summaryMessageId ,
389+ args . boundaryMessageId ,
390+ args . summaryText ,
391+ ) as Array < { id ?: unknown } > ;
392+ const legacySummaryIds = legacySummaries . flatMap ( ( row ) =>
393+ typeof row . id === "string" ? [ row . id ] : [ ] ,
394+ ) ;
395+ if ( legacySummaryIds . length === 0 ) return ;
396+
397+ const deleteSummaryParts = db . prepare (
398+ "DELETE FROM part WHERE session_id = ? AND message_id = ?" ,
399+ ) ;
400+ const deleteSummary = db . prepare ( "DELETE FROM message WHERE session_id = ? AND id = ?" ) ;
401+ for ( const summaryMessageId of legacySummaryIds ) {
402+ deleteSummaryParts . run ( args . sessionId , summaryMessageId ) ;
403+ deleteSummary . run ( args . sessionId , summaryMessageId ) ;
404+ }
405+
406+ // A stale marker lineage can carry its own compaction part. Once the
407+ // lineage is identified, retain only the deterministic boundary part.
408+ db . prepare (
409+ `DELETE FROM part
410+ WHERE session_id = ?
411+ AND message_id = ?
412+ AND id <> ?
413+ AND COALESCE(json_extract(data, '$.type'), '') = 'compaction'
414+ AND COALESCE(json_extract(data, '$.auto'), 0) = 1` ,
415+ ) . run ( args . sessionId , args . boundaryMessageId , args . compactionPartId ) ;
416+ }
417+
346418/**
347419 * Inject a compaction marker into OpenCode's DB.
348420 * Returns the marker state if successful, null if boundary couldn't be found.
@@ -367,13 +439,17 @@ export function injectCompactionMarker(
367439 ) ;
368440 return null ;
369441 }
370- // Use timestamps relative to the boundary so sort order is consistent
442+ // Use timestamps relative to the boundary so OpenCode's time/id ordering
443+ // places the marker immediately after the boundary.
371444 const boundaryTime = boundary . timeCreated ;
372-
373- // Generate IDs with timestamps that sort correctly — right after the boundary
374- const summaryMsgId = generateMessageId ( boundaryTime + 1 , 1n ) ;
375- const compactionPartId = generatePartId ( boundaryTime , 1n ) ;
376- const summaryPartId = generatePartId ( boundaryTime + 1 , 2n ) ;
445+ const markerIdentity = `${ args . sessionId } \0${ args . endMessageId } ` ;
446+ const summaryMsgId = generateMessageId (
447+ boundaryTime + 1 ,
448+ 1n ,
449+ `${ markerIdentity } \0summary-message` ,
450+ ) ;
451+ const compactionPartId = generatePartId ( boundaryTime , 1n , `${ markerIdentity } \0compaction-part` ) ;
452+ const summaryPartId = generatePartId ( boundaryTime + 1 , 2n , `${ markerIdentity } \0summary-part` ) ;
377453
378454 const summaryMsgData = JSON . stringify ( {
379455 role : "assistant" ,
@@ -392,9 +468,27 @@ export function injectCompactionMarker(
392468
393469 try {
394470 db . transaction ( ( ) => {
395- // 1. Add compaction part to the boundary user message
471+ // A committed insert can outlive a failed context-state write. Remove
472+ // any stale lineage in the transaction that writes the canonical rows.
473+ removeLegacyMarkerLineageRows ( db , {
474+ sessionId : args . sessionId ,
475+ boundaryMessageId : boundary . id ,
476+ summaryText : args . summaryText ,
477+ summaryMessageId : summaryMsgId ,
478+ compactionPartId,
479+ } ) ;
480+
481+ // Deterministic IDs make this transaction an upsert on retry. Rewriting
482+ // the exact canonical row also repairs a partial or stale prior write.
396483 db . prepare (
397- "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)" ,
484+ `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
485+ VALUES (?, ?, ?, ?, ?, ?)
486+ ON CONFLICT(id) DO UPDATE SET
487+ message_id = excluded.message_id,
488+ session_id = excluded.session_id,
489+ time_created = excluded.time_created,
490+ time_updated = excluded.time_updated,
491+ data = excluded.data` ,
398492 ) . run (
399493 compactionPartId ,
400494 boundary . id ,
@@ -404,14 +498,25 @@ export function injectCompactionMarker(
404498 '{"type":"compaction","auto":true}' ,
405499 ) ;
406500
407- // 2. Insert summary assistant message
408501 db . prepare (
409- "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)" ,
502+ `INSERT INTO message (id, session_id, time_created, time_updated, data)
503+ VALUES (?, ?, ?, ?, ?)
504+ ON CONFLICT(id) DO UPDATE SET
505+ session_id = excluded.session_id,
506+ time_created = excluded.time_created,
507+ time_updated = excluded.time_updated,
508+ data = excluded.data` ,
410509 ) . run ( summaryMsgId , args . sessionId , boundaryTime + 1 , boundaryTime + 1 , summaryMsgData ) ;
411510
412- // 3. Insert text part with the summary content
413511 db . prepare (
414- "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)" ,
512+ `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
513+ VALUES (?, ?, ?, ?, ?, ?)
514+ ON CONFLICT(id) DO UPDATE SET
515+ message_id = excluded.message_id,
516+ session_id = excluded.session_id,
517+ time_created = excluded.time_created,
518+ time_updated = excluded.time_updated,
519+ data = excluded.data` ,
415520 ) . run (
416521 summaryPartId ,
417522 summaryMsgId ,
0 commit comments