1use std::path::{Path, PathBuf};
5use tokio::sync::mpsc;
6
7use crate::channels::IncomingMessage;
8
9pub struct HeartbeatConfig {
10 pub interval_secs: u64,
11 pub quiet_start_hour: u32, pub quiet_end_hour: u32, pub workspace: PathBuf,
14 pub deliver_chat_id: Option<String>,
16}
17
18impl Default for HeartbeatConfig {
19 fn default() -> Self {
20 Self {
21 interval_secs: 1800, quiet_start_hour: 23,
23 quiet_end_hour: 8,
24 workspace: PathBuf::from("."),
25 deliver_chat_id: None,
26 }
27 }
28}
29
30pub fn start_heartbeat(
33 config: HeartbeatConfig,
34 tx: mpsc::Sender<IncomingMessage>,
35) -> tokio::task::JoinHandle<()> {
36 tokio::spawn(async move {
37 let mut interval =
38 tokio::time::interval(tokio::time::Duration::from_secs(config.interval_secs));
39
40 interval.tick().await;
42
43 loop {
44 interval.tick().await;
45
46 let now = chrono::Local::now();
48 let hour = now.hour();
49 if hour >= config.quiet_start_hour || hour < config.quiet_end_hour {
50 tracing::debug!("Heartbeat: quiet hours ({:02}:00), skipping", hour);
51 continue;
52 }
53
54 let heartbeat_path = config.workspace.join("HEARTBEAT.md");
56 let content = match std::fs::read_to_string(&heartbeat_path) {
57 Ok(c) => c,
58 Err(_) => continue, };
60
61 let has_tasks = content.lines().any(|line| {
63 let trimmed = line.trim();
64 !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("//")
65 });
66
67 if !has_tasks {
68 tracing::debug!("Heartbeat: HEARTBEAT.md empty/comments only, skipping");
69 continue;
70 }
71
72 let prompt = format!(
74 "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. \
75 Do not infer or repeat old tasks from prior chats. \
76 If nothing needs attention, reply HEARTBEAT_OK.\n\n\
77 Current HEARTBEAT.md:\n```\n{}\n```",
78 content
79 );
80
81 let chat_id = config
82 .deliver_chat_id
83 .clone()
84 .unwrap_or_else(|| "heartbeat".to_string());
85
86 let msg = IncomingMessage {
87 id: format!("heartbeat-{}", chrono::Utc::now().timestamp()),
88 sender_id: "system".to_string(),
89 sender_name: Some("heartbeat".to_string()),
90 chat_id,
91 text: prompt,
92 is_group: false,
93 reply_to: None,
94 timestamp: chrono::Utc::now(),
95 };
96
97 if tx.send(msg).await.is_err() {
98 tracing::warn!("Heartbeat: channel closed, stopping");
99 break;
100 }
101
102 update_heartbeat_state(&config.workspace);
103 }
104 })
105}
106
107fn update_heartbeat_state(workspace: &Path) {
108 let state_path = workspace.join("memory/heartbeat-state.json");
109 if let Some(parent) = state_path.parent() {
110 let _ = std::fs::create_dir_all(parent);
111 }
112
113 let now = chrono::Utc::now().timestamp();
114 let state = serde_json::json!({
115 "lastHeartbeat": now,
116 "lastChecks": {
117 "heartbeat": now
118 }
119 });
120
121 let _ = std::fs::write(
122 &state_path,
123 serde_json::to_string_pretty(&state).unwrap_or_default(),
124 );
125}
126
127use chrono::Timelike;