Problem
Response rate is a single number across all signals. GetResponseRate() in sqlite_heartbeat.go returns one float64 for the entire entity:
func (s *SQLiteStore) GetResponseRate(ctx context.Context, entityID, agentID string, days int) (float64, int, error)
This means responseCooldownMultiplier() applies the same backoff to all signals. But users have different response patterns per topic: fast on contributor issues, slow on documentation tasks, immediate on deadlines.
The heartbeat_actions table already stores topic_entities (JSON array of entity IDs from TopicEntities on HeartbeatResult), and the knowledge graph has EntityType classification (person, organization, product, concept, event). This is enough to cluster topics.
Proposal
Topic clustering
Map signals to topic clusters using entity types from the knowledge graph:
| Entity Type |
Topic Cluster |
person |
contributors |
organization |
organizations |
product |
projects |
concept |
architecture |
event |
events |
| No entity (pure memory) |
Derive from MemoryType: PLAN→tasks, ACTIVITY→activities, CONTEXT→conversations |
New table
CREATE TABLE topic_response_profile (
entity_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT 'default',
topic_cluster TEXT NOT NULL,
avg_response_time_ms INTEGER,
response_rate REAL,
sample_count INTEGER DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (entity_id, agent_id, topic_cluster)
);
Behavior changes
-
Recording: After checkResponseTracking() detects a response, classify the tick's signals into topic clusters using topic_entities from heartbeat_actions + entity type lookup. Update topic_response_profile with rolling averages.
-
Per-topic cooldown: In evaluateShouldAct(), replace the single responseCooldownMultiplier(rate) call with per-topic multipliers:
func (k *Keyoku) topicCooldownMultiplier(ctx context.Context, entityID, agentID string, signals []Signal) float64 {
// Get primary topic cluster for current signals
cluster := classifySignalCluster(signals)
profile := k.store.GetTopicProfile(ctx, entityID, agentID, cluster)
if profile == nil || profile.SampleCount < 5 {
return responseCooldownMultiplier(globalRate) // fallback
}
return responseCooldownMultiplier(profile.ResponseRate)
}
-
Priority inference: Expose per-topic profiles in the watcher status API so users can see what the system learned about their priorities.
Files to modify
storage/sqlite_heartbeat.go — new table, UpsertTopicProfile(), GetTopicProfile(), ListTopicProfiles()
storage/sqlite_migrate.go — migration
heartbeat_decide.go — classifySignalCluster(), replace responseCooldownMultiplier with per-topic version
watcher.go — expose profiles in Status() response
storage/models.go — TopicResponseProfile struct
Constraints
- Minimum 5 samples per cluster before using learned rate (fallback to global rate)
- 14-day rolling window for averages
- Cluster classification is best-effort: if no entities are extracted, fall back to memory type
- Profile updates happen asynchronously (don't block the tick)
Problem
Response rate is a single number across all signals.
GetResponseRate()insqlite_heartbeat.goreturns onefloat64for the entire entity:This means
responseCooldownMultiplier()applies the same backoff to all signals. But users have different response patterns per topic: fast on contributor issues, slow on documentation tasks, immediate on deadlines.The
heartbeat_actionstable already storestopic_entities(JSON array of entity IDs fromTopicEntitiesonHeartbeatResult), and the knowledge graph hasEntityTypeclassification (person, organization, product, concept, event). This is enough to cluster topics.Proposal
Topic clustering
Map signals to topic clusters using entity types from the knowledge graph:
personcontributorsorganizationorganizationsproductprojectsconceptarchitectureeventeventsMemoryType: PLAN→tasks, ACTIVITY→activities, CONTEXT→conversationsNew table
Behavior changes
Recording: After
checkResponseTracking()detects a response, classify the tick's signals into topic clusters usingtopic_entitiesfromheartbeat_actions+ entity type lookup. Updatetopic_response_profilewith rolling averages.Per-topic cooldown: In
evaluateShouldAct(), replace the singleresponseCooldownMultiplier(rate)call with per-topic multipliers:Priority inference: Expose per-topic profiles in the watcher status API so users can see what the system learned about their priorities.
Files to modify
storage/sqlite_heartbeat.go— new table,UpsertTopicProfile(),GetTopicProfile(),ListTopicProfiles()storage/sqlite_migrate.go— migrationheartbeat_decide.go—classifySignalCluster(), replaceresponseCooldownMultiplierwith per-topic versionwatcher.go— expose profiles inStatus()responsestorage/models.go—TopicResponseProfilestructConstraints