@@ -39,23 +39,25 @@ const COOLDOWN_MS = Number(process.env.OPERATOR_COOLDOWN_HOURS || 36) * 3600 * 1
3939// Returns { map: {id: dismissedAt}, migrated } — migrates the legacy array
4040// (permanent set) into a timestamped map so old entries get a fresh window once.
4141function loadDismissedRaw ( now = Date . now ( ) ) {
42+ const norm = ( v ) => ( typeof v === 'number' ? { at : v , mtime : null } : { at : v ?. at || 0 , mtime : v ?. mtime ?? null } ) ;
4243 try {
4344 const j = JSON . parse ( fs . readFileSync ( DISMISS_FILE , 'utf-8' ) ) ;
44- if ( Array . isArray ( j ) ) { const map = { } ; for ( const id of j ) map [ id ] = now ; return { map, migrated : true } ; }
45- return { map : ( j && typeof j === 'object' ) ? j : { } , migrated : false } ;
45+ if ( Array . isArray ( j ) ) { const map = { } ; for ( const id of j ) map [ id ] = { at : now , mtime : null } ; return { map, migrated : true } ; }
46+ if ( j && typeof j === 'object' ) { const map = { } ; for ( const [ id , v ] of Object . entries ( j ) ) map [ id ] = norm ( v ) ; return { map, migrated : false } ; }
47+ return { map : { } , migrated : false } ;
4648 } catch { return { map : { } , migrated : false } ; }
4749}
4850export function saveDismissed ( map ) { fs . mkdirSync ( path . dirname ( DISMISS_FILE ) , { recursive : true } ) ; fs . writeFileSync ( DISMISS_FILE , JSON . stringify ( map ) ) ; }
4951// Ids still inside the quiet window. Expired entries (and the legacy array form)
5052// are persisted away on read so timestamps don't keep getting reset.
5153export function activeDismissed ( now = Date . now ( ) ) {
5254 const { map, migrated } = loadDismissedRaw ( now ) ;
53- const live = { } ; const set = new Set ( ) ;
54- for ( const [ id , at ] of Object . entries ( map ) ) { if ( now - at < COOLDOWN_MS ) { live [ id ] = at ; set . add ( id ) ; } }
55+ const live = { } ;
56+ for ( const [ id , info ] of Object . entries ( map ) ) if ( now - info . at < COOLDOWN_MS ) live [ id ] = info ;
5557 if ( migrated || Object . keys ( live ) . length !== Object . keys ( map ) . length ) saveDismissed ( live ) ;
56- return set ;
58+ return live ;
5759}
58- export function dismiss ( id , now = Date . now ( ) ) { const { map } = loadDismissedRaw ( now ) ; map [ id ] = now ; saveDismissed ( map ) ; }
60+ export function dismiss ( id , now = Date . now ( ) , mtime = null ) { const { map } = loadDismissedRaw ( now ) ; map [ id ] = { at : now , mtime : ( mtime == null ? null : Number ( mtime ) ) } ; saveDismissed ( map ) ; }
5961
6062async function noteProjectSet ( adapter ) {
6163 const set = new Set ( ) ;
@@ -95,7 +97,7 @@ function draftIntents(tasks) {
9597 } ) ;
9698}
9799
98- export async function buildBatch ( adapter , { existingIds = new Set ( ) , dismissed = activeDismissed ( ) , limit = BATCH } = { } ) {
100+ export async function buildBatch ( adapter , { existingIds = new Set ( ) , dismissed : dismissedMap = activeDismissed ( ) , limit = BATCH } = { } ) {
99101 const now = Date . now ( ) ;
100102 const { corpus } = await loadCorpus ( adapter , { cache : true } ) ;
101103 const notes = await noteProjectSet ( adapter ) ;
@@ -104,6 +106,18 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed =
104106 const meta = new Map ( active . map ( ( t ) => [ t . id , safeMeta ( t ) ] ) ) ;
105107 const state = loadState ( ) ;
106108
109+ // Re-arm: a dismissed card is only still suppressing if the user hasn't edited
110+ // the task since it was dismissed. modifiedTime now exceeding the baseline we
111+ // captured (after the agent's own write) means a human edit -> offer it again.
112+ const mtimeById = new Map ( active . map ( ( t ) => [ t . id , new Date ( t . modifiedTime || 0 ) . getTime ( ) ] ) ) ;
113+ const primaryTaskId = ( id ) => { const p = String ( id ) . split ( ':' ) ; return p [ 0 ] === 'relate' ? p [ 1 ] : p . slice ( 1 ) . join ( ':' ) ; } ;
114+ const dismissed = new Set ( ) ;
115+ for ( const [ id , info ] of Object . entries ( dismissedMap ) ) {
116+ const cur = mtimeById . get ( primaryTaskId ( id ) ) || 0 ;
117+ if ( info . mtime != null && cur > info . mtime ) continue ; // user touched the task -> re-armed
118+ dismissed . add ( id ) ;
119+ }
120+
107121 // Candidate pool: active, non-note, titled, and NOT decayed out (benched).
108122 const candidates = active . filter ( ( t ) => ! isNote ( t ) && ( t . title || '' ) . trim ( ) && ! benchReason ( state , t , now ) ) ;
109123 const byId = new Map ( candidates . map ( ( t ) => [ t . id , t ] ) ) ;
0 commit comments