Skip to main content

heartbit_core/config/
sensor.rs

1//! Sensor pipeline configuration types.
2use serde::Deserialize;
3
4use super::SensorModality;
5use super::agent::McpServerEntry;
6
7/// Sensor layer configuration for continuous perception.
8#[derive(Debug, Clone, Deserialize)]
9pub struct SensorConfig {
10    /// Master switch for the sensor layer. Defaults to `true`.
11    #[serde(default = "super::default_true")]
12    pub enabled: bool,
13    /// Model routing configuration for triage decisions.
14    #[serde(default)]
15    pub routing: Option<SensorRoutingConfig>,
16    /// Salience scoring weights for triage promotion.
17    #[serde(default)]
18    pub salience: Option<SalienceConfig>,
19    /// Token budget limits for the sensor pipeline.
20    #[serde(default)]
21    pub token_budget: Option<TokenBudgetConfig>,
22    /// Story correlation settings.
23    #[serde(default)]
24    pub stories: Option<StoryCorrelationConfig>,
25    /// Sensor source definitions.
26    #[serde(default)]
27    pub sources: Vec<SensorSourceConfig>,
28}
29
30/// Model routing configuration for sensor triage.
31#[derive(Debug, Clone, Deserialize)]
32pub struct SensorRoutingConfig {
33    /// Which model tier to use for triage: "local", "cloud_light", "cloud_frontier".
34    #[serde(default = "default_triage_model")]
35    pub triage_model: String,
36    /// Path to local GGUF model file (for local SLM inference).
37    pub local_model_path: Option<String>,
38    /// Confidence threshold below which to escalate to a higher model tier.
39    #[serde(default = "default_confidence_threshold")]
40    pub confidence_threshold: f64,
41}
42
43fn default_triage_model() -> String {
44    "cloud_light".into()
45}
46
47fn default_confidence_threshold() -> f64 {
48    0.85
49}
50
51/// Salience scoring weights for triage promotion decisions.
52#[derive(Debug, Clone, Deserialize)]
53pub struct SalienceConfig {
54    /// Weight for urgency signals (0.0-1.0).
55    #[serde(default = "default_urgency_weight")]
56    pub urgency_weight: f64,
57    /// Weight for novelty signals (0.0-1.0).
58    #[serde(default = "default_novelty_weight")]
59    pub novelty_weight: f64,
60    /// Weight for relevance signals (0.0-1.0).
61    #[serde(default = "default_relevance_weight")]
62    pub relevance_weight: f64,
63    /// Minimum salience score for promotion (0.0-1.0).
64    #[serde(default = "default_salience_threshold")]
65    pub threshold: f64,
66}
67
68fn default_urgency_weight() -> f64 {
69    0.3
70}
71
72fn default_novelty_weight() -> f64 {
73    0.3
74}
75
76fn default_relevance_weight() -> f64 {
77    0.4
78}
79
80fn default_salience_threshold() -> f64 {
81    0.3
82}
83
84/// Token budget limits for the sensor pipeline.
85#[derive(Debug, Clone, Deserialize)]
86pub struct TokenBudgetConfig {
87    /// Maximum tokens per hour across all sensor processing.
88    #[serde(default = "default_hourly_limit")]
89    pub hourly_limit: usize,
90    /// Maximum queued events before back-pressure.
91    #[serde(default = "default_queue_size")]
92    pub queue_size: usize,
93}
94
95fn default_hourly_limit() -> usize {
96    100_000
97}
98
99fn default_queue_size() -> usize {
100    200
101}
102
103/// Story correlation configuration.
104#[derive(Debug, Clone, Deserialize)]
105pub struct StoryCorrelationConfig {
106    /// Time window in hours for correlating events into stories.
107    #[serde(default = "default_correlation_window_hours")]
108    pub correlation_window_hours: u64,
109    /// Maximum events tracked per story before archival.
110    #[serde(default = "default_max_events_per_story")]
111    pub max_events_per_story: usize,
112    /// Hours of inactivity after which a story is marked stale.
113    #[serde(default = "default_stale_after_hours")]
114    pub stale_after_hours: u64,
115}
116
117fn default_correlation_window_hours() -> u64 {
118    4
119}
120
121fn default_max_events_per_story() -> usize {
122    50
123}
124
125fn default_stale_after_hours() -> u64 {
126    24
127}
128
129/// A sensor source definition.
130#[derive(Debug, Clone, Deserialize)]
131#[serde(tag = "type", rename_all = "snake_case")]
132pub enum SensorSourceConfig {
133    /// JMAP email sensor (push/poll).
134    JmapEmail {
135        /// Logical sensor name (used in events and routing).
136        name: String,
137        /// JMAP server URL.
138        server: String,
139        /// Account username for authentication.
140        username: String,
141        /// Environment variable containing the password.
142        password_env: String,
143        /// Senders that get automatic `Priority::High`.
144        #[serde(default)]
145        priority_senders: Vec<String>,
146        /// Senders whose emails are silently dropped during triage.
147        #[serde(default)]
148        blocked_senders: Vec<String>,
149        /// Polling interval in seconds.
150        #[serde(default = "default_email_poll_interval")]
151        poll_interval_seconds: u64,
152    },
153    /// RSS/Atom feed sensor.
154    Rss {
155        /// Logical sensor name.
156        name: String,
157        /// One or more feed URLs to poll.
158        feeds: Vec<String>,
159        /// Keywords that bump matched items to higher priority.
160        #[serde(default)]
161        interest_keywords: Vec<String>,
162        /// Polling interval in seconds.
163        #[serde(default = "default_rss_poll_interval")]
164        poll_interval_seconds: u64,
165    },
166    /// Directory watcher for images.
167    Image {
168        /// Logical sensor name.
169        name: String,
170        /// Directory to watch for new image files.
171        watch_directory: String,
172        /// Polling interval in seconds.
173        #[serde(default = "default_file_poll_interval")]
174        poll_interval_seconds: u64,
175    },
176    /// Directory watcher for audio files.
177    Audio {
178        /// Logical sensor name.
179        name: String,
180        /// Directory to watch for new audio files.
181        watch_directory: String,
182        /// Whisper model size: "tiny", "base", "small", "medium", "large".
183        #[serde(default = "default_whisper_model")]
184        whisper_model: String,
185        /// Known contacts whose voice recordings get priority triage.
186        #[serde(default)]
187        known_contacts: Vec<String>,
188        /// Polling interval in seconds.
189        #[serde(default = "default_file_poll_interval")]
190        poll_interval_seconds: u64,
191    },
192    /// Weather API sensor.
193    Weather {
194        /// Logical sensor name.
195        name: String,
196        /// Environment variable containing the API key.
197        api_key_env: String,
198        /// Locations to fetch weather for.
199        locations: Vec<String>,
200        /// Polling interval in seconds.
201        #[serde(default = "default_weather_poll_interval")]
202        poll_interval_seconds: u64,
203        /// When true, only promote weather alerts (not regular readings).
204        #[serde(default)]
205        alert_only: bool,
206    },
207    /// Generic webhook receiver.
208    Webhook {
209        /// Logical sensor name.
210        name: String,
211        /// URL path for the webhook endpoint (e.g., "/webhooks/github").
212        path: String,
213        /// Environment variable containing the webhook secret.
214        secret_env: Option<String>,
215    },
216    /// Generic MCP sensor — polls a tool on any MCP server.
217    Mcp {
218        /// Logical sensor name.
219        name: String,
220        /// MCP server endpoint (string URL, `{url, auth_header}`, or `{command, args, env}`).
221        server: Box<McpServerEntry>,
222        /// MCP tool to call each poll cycle.
223        tool_name: String,
224        /// Arguments passed to the tool (default: `{}`).
225        #[serde(default = "default_empty_object")]
226        tool_args: serde_json::Value,
227        /// Kafka topic to produce events to.
228        kafka_topic: String,
229        /// Sensory modality of produced events (default: `"text"`).
230        #[serde(default = "default_mcp_modality")]
231        modality: SensorModality,
232        /// Poll interval in seconds (default: 60).
233        #[serde(default = "default_mcp_poll_interval")]
234        poll_interval_seconds: u64,
235        /// JSON field path for item ID (default: `"id"`).
236        #[serde(default = "default_id_field")]
237        id_field: String,
238        /// JSON field for event content (default: entire item as JSON).
239        #[serde(default)]
240        content_field: Option<String>,
241        /// JSON field containing items array in tool result (default: root is array).
242        #[serde(default)]
243        items_field: Option<String>,
244        /// Priority senders for email triage (only when `kafka_topic = "hb.sensor.email"`).
245        #[serde(default)]
246        priority_senders: Vec<String>,
247        /// Blocked senders for email triage.
248        #[serde(default)]
249        blocked_senders: Vec<String>,
250        /// Optional enrichment tool to call for each new item (e.g., `gmail_get_message`).
251        /// When set, the sensor calls this tool with the item's ID to fetch detailed
252        /// metadata (headers, body, labels) before producing to Kafka.
253        #[serde(default)]
254        enrich_tool: Option<String>,
255        /// Parameter name for the item ID when calling the enrichment tool (default: `"id"`).
256        #[serde(default)]
257        enrich_id_param: Option<String>,
258        /// Dedup TTL in seconds. Seen IDs older than this are evicted. Default: 7 days.
259        #[serde(default = "default_dedup_ttl_seconds")]
260        dedup_ttl_seconds: u64,
261    },
262}
263
264fn default_dedup_ttl_seconds() -> u64 {
265    7 * 24 * 3600 // 7 days
266}
267
268impl SensorSourceConfig {
269    /// Get the name of this sensor source.
270    pub fn name(&self) -> &str {
271        match self {
272            SensorSourceConfig::JmapEmail { name, .. }
273            | SensorSourceConfig::Rss { name, .. }
274            | SensorSourceConfig::Image { name, .. }
275            | SensorSourceConfig::Audio { name, .. }
276            | SensorSourceConfig::Weather { name, .. }
277            | SensorSourceConfig::Webhook { name, .. }
278            | SensorSourceConfig::Mcp { name, .. } => name,
279        }
280    }
281
282    /// Get priority and blocked sender lists for trust resolution.
283    ///
284    /// Returns `(priority_senders, blocked_senders)`. Only email-type sources
285    /// have these lists; other source types return empty slices.
286    pub fn sender_lists(&self) -> (&[String], &[String]) {
287        match self {
288            SensorSourceConfig::JmapEmail {
289                priority_senders,
290                blocked_senders,
291                ..
292            }
293            | SensorSourceConfig::Mcp {
294                priority_senders,
295                blocked_senders,
296                ..
297            } => (priority_senders, blocked_senders),
298            _ => (&[], &[]),
299        }
300    }
301}
302
303fn default_email_poll_interval() -> u64 {
304    60
305}
306
307fn default_rss_poll_interval() -> u64 {
308    900
309}
310
311fn default_file_poll_interval() -> u64 {
312    30
313}
314
315fn default_whisper_model() -> String {
316    "base".into()
317}
318
319fn default_weather_poll_interval() -> u64 {
320    1800
321}
322
323fn default_mcp_poll_interval() -> u64 {
324    60
325}
326
327fn default_mcp_modality() -> SensorModality {
328    SensorModality::Text
329}
330
331fn default_id_field() -> String {
332    "id".into()
333}
334
335fn default_empty_object() -> serde_json::Value {
336    serde_json::json!({})
337}