Problem
When the same signal fires every tick for days (e.g., CheckPendingWork for a task that stays open), the user gets desensitized. The current dedup system prevents spam within a short window:
- Fingerprint dedup:
2 × SignalCooldownNormal window (~30 min for act mode)
- Entity overlap dedup: 1h window, 85% overlap threshold
- Content hash dedup: 1h window
But none of these track fatigue across days. A stale task that fires CheckPendingWork every tick for a week will keep appearing once each cooldown expires.
Proposal
Add a signal_fatigue table:
CREATE TABLE signal_fatigue (
entity_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT 'default',
signal_type TEXT NOT NULL,
memory_id TEXT NOT NULL,
fire_count INTEGER DEFAULT 1,
act_count INTEGER DEFAULT 0, -- times user acted on it
first_fired_at TEXT NOT NULL,
last_fired_at TEXT NOT NULL,
suppressed_until TEXT,
PRIMARY KEY (entity_id, signal_type, memory_id)
);
Behavior changes
- Recording: In
evaluateShouldAct(), after signals pass confluence, upsert fatigue rows for each signal's memory IDs
- Fatigue escalation:
- fire_count 1-3, no user action: normal behavior
- fire_count 4-6, no user action: double the cooldown for this signal+memory pair
- fire_count 7+, no user action: suppress entirely (set
suppressed_until to +48h)
- Reset on engagement: When
checkResponseTracking() detects a user response, reset fire_count and clear suppressed_until for all signal+memory pairs in that tick's fingerprint
- Check in decision pipeline: In
evaluateShouldAct(), filter out signals whose memory IDs are in the fatigue table with active suppressed_until
- Cleanup: Remove rows where
last_fired_at is older than 30 days (in decay job)
Where it fits in the pipeline
Insert between step 6 (signal classification) and step 7 (confluence scoring) in evaluateShouldAct(). Fatigued signals get removed before confluence is calculated, so they don't contribute to the score.
Files to modify
storage/sqlite_heartbeat.go — new table, upsert/query/reset/cleanup
storage/sqlite_migrate.go — migration
heartbeat_decide.go — filter fatigued signals before confluence, reset on response
watcher.go — record fire events after tick
Constraints
- Only applies to signals tied to specific memory IDs (
CheckPendingWork, CheckDeadlines, CheckStaleMonitors, CheckDecaying, CheckConflicts)
- Does NOT apply to aggregate signals (
CheckSentiment, CheckPatterns, CheckMemoryVelocity) since they don't have stable memory IDs
- Suppression cap: 48h max, then re-evaluate
- User action on ANY signal in the same tick resets fatigue for all signals in that tick (assumes engagement = attention)
Problem
When the same signal fires every tick for days (e.g.,
CheckPendingWorkfor a task that stays open), the user gets desensitized. The current dedup system prevents spam within a short window:2 × SignalCooldownNormalwindow (~30 min foractmode)But none of these track fatigue across days. A stale task that fires
CheckPendingWorkevery tick for a week will keep appearing once each cooldown expires.Proposal
Add a
signal_fatiguetable:Behavior changes
evaluateShouldAct(), after signals pass confluence, upsert fatigue rows for each signal's memory IDssuppressed_untilto +48h)checkResponseTracking()detects a user response, resetfire_countand clearsuppressed_untilfor all signal+memory pairs in that tick's fingerprintevaluateShouldAct(), filter out signals whose memory IDs are in the fatigue table with activesuppressed_untillast_fired_atis older than 30 days (in decay job)Where it fits in the pipeline
Insert between step 6 (signal classification) and step 7 (confluence scoring) in
evaluateShouldAct(). Fatigued signals get removed before confluence is calculated, so they don't contribute to the score.Files to modify
storage/sqlite_heartbeat.go— new table, upsert/query/reset/cleanupstorage/sqlite_migrate.go— migrationheartbeat_decide.go— filter fatigued signals before confluence, reset on responsewatcher.go— record fire events after tickConstraints
CheckPendingWork,CheckDeadlines,CheckStaleMonitors,CheckDecaying,CheckConflicts)CheckSentiment,CheckPatterns,CheckMemoryVelocity) since they don't have stable memory IDs