@@ -56,7 +56,9 @@ import {
5656import { createScheduler } from "@magic-context/core/features/magic-context/scheduler" ;
5757import {
5858 type ContextDatabase ,
59+ adoptFallbackTagMessageId ,
5960 clearPendingPiCompactionMarkerStateIf ,
61+ findAdoptableFallbackTags ,
6062 getActiveTagsBySession ,
6163 getHistorianFailureState ,
6264 getPendingOps ,
@@ -1225,6 +1227,85 @@ function firstPiTextContent(content: unknown): string | null {
12251227 return null ;
12261228}
12271229
1230+ /**
1231+ * Build a `messageId → fingerprint` map from the RAW pre-transform messages,
1232+ * keyed by the stable id each message resolves to this pass. Captured before
1233+ * `runPipeline` mutates text (temporal markers, §N§ prefix, caveman) so the
1234+ * fingerprint is byte-stable across the fallback-pass (in-flight) and the
1235+ * real-id-pass (settled) — the invariant tag adoption depends on. The
1236+ * fingerprint is persisted on the tag row at creation; only message-typed
1237+ * entries get one (tool tags are out of scope).
1238+ */
1239+ function buildEntryFingerprintMap (
1240+ messages : readonly PiAgentMessage [ ] ,
1241+ resolveStableId : ( msg : unknown , index : number ) => string | undefined ,
1242+ ) : Map < string , string > {
1243+ const map = new Map < string , string > ( ) ;
1244+ for ( let i = 0 ; i < messages . length ; i ++ ) {
1245+ const msg = messages [ i ] ;
1246+ const id = resolveStableId ( msg , i ) ;
1247+ if ( ! id ) continue ;
1248+ const fp = piMessageEntryFingerprint ( msg ) ;
1249+ if ( fp ) map . set ( id , fp ) ;
1250+ }
1251+ return map ;
1252+ }
1253+
1254+ /**
1255+ * Pi fallback-tag adoption pre-pass. Runs BEFORE tagging. For each message that
1256+ * now resolves to a REAL SessionEntry id this pass, find the tag(s) created for
1257+ * the same message under its earlier `pi-msg-*` fallback id (matched by raw
1258+ * fingerprint) and migrate them onto the real id — so the message keeps its
1259+ * tag_number (hence §N§ and all per-tag state) instead of getting a fresh tag
1260+ * and drifting. Per-part, uniqueness-guarded, race-safe; any miss degrades to a
1261+ * fresh allocation in `tagTranscript`. No-op for messages that are already on a
1262+ * real id or have no fallback predecessor.
1263+ */
1264+ function adoptPiFallbackTags (
1265+ db : ContextDatabase ,
1266+ sessionId : string ,
1267+ tagger : Tagger ,
1268+ fingerprintById : ReadonlyMap < string , string > ,
1269+ ) : void {
1270+ for ( const [ realMessageId , fingerprint ] of fingerprintById ) {
1271+ // Only real ids can be adoption targets; a pi-msg-* id has no fallback
1272+ // predecessor to migrate from.
1273+ if ( realMessageId . startsWith ( "pi-msg-" ) ) continue ;
1274+ const candidates = findAdoptableFallbackTags ( db , sessionId , fingerprint ) ;
1275+ if ( candidates . length === 0 ) continue ;
1276+ // Group candidates by their fallback message base id (strip the :pN
1277+ // suffix). A unique base means exactly one fallback message carried this
1278+ // fingerprint → safe to adopt; duplicates (same fingerprint on >1
1279+ // fallback message) are ambiguous → skip, let tagTranscript allocate
1280+ // fresh.
1281+ const baseIds = new Set < string > ( ) ;
1282+ for ( const c of candidates ) {
1283+ const m = / ^ ( .* ) : p \d + $ / . exec ( c . messageId ) ;
1284+ baseIds . add ( m ? m [ 1 ] : c . messageId ) ;
1285+ }
1286+ if ( baseIds . size !== 1 ) continue ;
1287+ for ( const c of candidates ) {
1288+ const ordinalMatch = / : p ( \d + ) $ / . exec ( c . messageId ) ;
1289+ if ( ! ordinalMatch ) continue ;
1290+ const realContentId = `${ realMessageId } :p${ ordinalMatch [ 1 ] } ` ;
1291+ const migrated = adoptFallbackTagMessageId (
1292+ db ,
1293+ sessionId ,
1294+ c . tagNumber ,
1295+ c . messageId ,
1296+ realContentId ,
1297+ ) ;
1298+ if ( migrated ) {
1299+ // Drop the stale fallback alias, bind the real key — so the
1300+ // subsequent tagTranscript exact-key lookup hits the migrated
1301+ // tag and does NOT allocate a fresh one.
1302+ tagger . unbindTag ( sessionId , c . messageId ) ;
1303+ tagger . bindTag ( sessionId , realContentId , c . tagNumber ) ;
1304+ }
1305+ }
1306+ }
1307+ }
1308+
12281309/**
12291310 * Register the Pi `context` event handler.
12301311 *
@@ -2889,6 +2970,24 @@ async function runPipeline(args: RunPipelineArgs): Promise<RunPipelineResult> {
28892970 // 1. Tagging: assigns tag numbers + injects §N§ prefixes (unless
28902971 // ctx_reduce_enabled is false, in which case prefixes are skipped
28912972 // but DB-side tag IDs still get created so drops continue to work).
2973+ //
2974+ // Pi-only fallback-tag adoption: the newest (in-flight) message is tagged
2975+ // under an unstable pi-msg-* fallback id on the pass it is newest (its real
2976+ // SessionEntry id isn't resolvable yet), then resolves to its real id one
2977+ // pass later. Build a raw-message fingerprint map (BEFORE tagging mutates
2978+ // text) and migrate any fallback-id tag onto the real id up front, so the
2979+ // message keeps its tag_number/§N§ instead of getting a fresh tag. No-op for
2980+ // OpenCode (this path is Pi-only) and for messages already on a real id.
2981+ const entryFingerprintByMessageId = buildEntryFingerprintMap (
2982+ args . messages as PiAgentMessage [ ] ,
2983+ stableIdResolver ,
2984+ ) ;
2985+ adoptPiFallbackTags (
2986+ args . db ,
2987+ args . sessionId ,
2988+ args . tagger ,
2989+ entryFingerprintByMessageId ,
2990+ ) ;
28922991 const tTag = performance . now ( ) ;
28932992 const { targets } = tagTranscript (
28942993 args . sessionId ,
@@ -2897,6 +2996,7 @@ async function runPipeline(args: RunPipelineArgs): Promise<RunPipelineResult> {
28972996 args . db ,
28982997 {
28992998 skipPrefixInjection : ! args . ctxReduceEnabled ,
2999+ entryFingerprintByMessageId,
29003000 } ,
29013001 ) ;
29023002 logTransformTiming ( args . sessionId , "tagMessages" , tTag ) ;
0 commit comments