Problem
The heartbeat system evaluates all 14 signal types with static confluence weights defined in tierWeight (heartbeat_decide.go):
var tierWeight = map[string]int{
TierImmediate: 10,
TierElevated: 5,
TierNormal: 3,
TierLow: 1,
}
These weights never change. A user who always ignores CheckSentiment signals and always acts on CheckDeadlines still gets both weighted the same within their tier. The responseCooldownMultiplier() tracks overall response rate but doesn't differentiate by signal type.
Proposal
Add a signal_feedback table that records per-signal-type engagement:
CREATE TABLE signal_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT 'default',
tick_id TEXT,
signal_type TEXT NOT NULL, -- "deadlines", "sentiment", "pending_work", etc.
signal_tier TEXT NOT NULL,
fired_at TEXT NOT NULL,
user_responded INTEGER, -- NULL=unchecked, 0=no, 1=yes
response_time_ms INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_signal_feedback_entity ON signal_feedback(entity_id, signal_type);
Behavior changes
- Recording: In
watcher.go checkAll(), after a tick fires with ShouldAct=true, record one row per active signal type from the HeartbeatResult
- Response check: Extend
checkResponseTracking() in heartbeat_decide.go to also update signal_feedback.user_responded using the same 2h window from GetHeartbeatActionsForResponseCheck()
- Weight adjustment: In
evaluateShouldAct(), before confluence scoring, query per-signal engagement rates:
SELECT signal_type,
AVG(CASE WHEN user_responded = 1 THEN 1.0 ELSE 0.0 END) as engage_rate
FROM signal_feedback
WHERE entity_id = ? AND fired_at > datetime('now', '-14 days')
GROUP BY signal_type
HAVING COUNT(*) >= 5
- Apply multiplier: Adjust the effective weight:
effective_weight = tierWeight[tier] * engageRateMultiplier(rate)
- rate > 0.7: 1.5x (user cares about this)
- rate 0.3-0.7: 1.0x (neutral)
- rate < 0.3: 0.5x (user ignores this)
- rate < 0.1: 0.25x (effectively suppressed)
- Rolling window: Auto-delete rows older than 30 days in the decay job
Files to modify
storage/sqlite_heartbeat.go — new table, insert/query/cleanup functions
storage/sqlite_migrate.go — migration for new table
heartbeat_decide.go — read learned weights in evaluateShouldAct(), extend checkResponseTracking()
watcher.go — record signal feedback after tick in checkAll()
Constraints
- Minimum 5 samples per signal type before adjusting (avoid cold-start noise)
- 14-day rolling window for rate calculation (behavior changes over time)
- 30-day retention for raw rows
- Multiplier bounds: [0.25, 1.5] to prevent total suppression or runaway boosting
Problem
The heartbeat system evaluates all 14 signal types with static confluence weights defined in
tierWeight(heartbeat_decide.go):These weights never change. A user who always ignores
CheckSentimentsignals and always acts onCheckDeadlinesstill gets both weighted the same within their tier. TheresponseCooldownMultiplier()tracks overall response rate but doesn't differentiate by signal type.Proposal
Add a
signal_feedbacktable that records per-signal-type engagement:Behavior changes
watcher.gocheckAll(), after a tick fires withShouldAct=true, record one row per active signal type from theHeartbeatResultcheckResponseTracking()inheartbeat_decide.goto also updatesignal_feedback.user_respondedusing the same 2h window fromGetHeartbeatActionsForResponseCheck()evaluateShouldAct(), before confluence scoring, query per-signal engagement rates:effective_weight = tierWeight[tier] * engageRateMultiplier(rate)Files to modify
storage/sqlite_heartbeat.go— new table, insert/query/cleanup functionsstorage/sqlite_migrate.go— migration for new tableheartbeat_decide.go— read learned weights inevaluateShouldAct(), extendcheckResponseTracking()watcher.go— record signal feedback after tick incheckAll()Constraints