diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index e6f25379..115b8159 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -178,7 +178,7 @@ impl HeartbeatRunner { pub async fn check_heartbeat(&self) -> HeartbeatResult { // Get the heartbeat checklist let checklist = match self.workspace.heartbeat_checklist().await { - Ok(Some(content)) if !content.trim().is_empty() => content, + Ok(Some(content)) if !is_effectively_empty(&content) => content, Ok(_) => return HeartbeatResult::Skipped, Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)), }; @@ -257,6 +257,45 @@ impl HeartbeatRunner { } } +/// Check if heartbeat content is effectively empty. +/// +/// Returns true if the content contains only: +/// - Whitespace +/// - Markdown headers (lines starting with #) +/// - HTML comments (``) +/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`) +/// +/// This skips the LLM call when the user hasn't added real tasks yet, +/// saving API costs. +fn is_effectively_empty(content: &str) -> bool { + let without_comments = strip_html_comments(content); + + without_comments.lines().all(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed == "- [ ]" + || trimmed == "- [x]" + || trimmed == "-" + || trimmed == "*" + }) +} + +/// Remove HTML comments from content. +fn strip_html_comments(content: &str) -> String { + let mut result = String::with_capacity(content.len()); + let mut rest = content; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + None => return result, // unclosed comment, treat rest as comment + } + } + result.push_str(rest); + result +} + /// Spawn the heartbeat runner as a background task. /// /// Returns a handle that can be used to stop the runner. @@ -301,4 +340,107 @@ mod tests { let disabled = HeartbeatConfig::default().disabled(); assert!(!disabled.enabled); } + + // ==================== strip_html_comments ==================== + + #[test] + fn test_strip_html_comments_no_comments() { + assert_eq!(strip_html_comments("hello world"), "hello world"); + } + + #[test] + fn test_strip_html_comments_single() { + assert_eq!( + strip_html_comments("beforeafter"), + "beforeafter" + ); + } + + #[test] + fn test_strip_html_comments_multiple() { + let input = "abc"; + assert_eq!(strip_html_comments(input), "abc"); + } + + #[test] + fn test_strip_html_comments_multiline() { + let input = "# Title\n\nreal content"; + assert_eq!(strip_html_comments(input), "# Title\n\nreal content"); + } + + #[test] + fn test_strip_html_comments_unclosed() { + let input = "before")); + } + + #[test] + fn test_effectively_empty_empty_checkboxes() { + assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]")); + } + + #[test] + fn test_effectively_empty_bare_list_markers() { + assert!(is_effectively_empty("-\n*\n-")); + } + + #[test] + fn test_effectively_empty_seeded_template() { + let template = "\ +# Heartbeat Checklist + +"; + assert!(is_effectively_empty(template)); + } + + #[test] + fn test_effectively_empty_real_checklist() { + let content = "\ +# Heartbeat Checklist + +- [ ] Check for unread emails needing a reply +- [ ] Review today's calendar for upcoming meetings"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_mixed_real_and_headers() { + let content = "# Title\n\nDo something important"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_comment_plus_real_content() { + let content = "\nActual task here"; + assert!(!is_effectively_empty(content)); + } } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 32cc2121..24f77217 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -60,6 +60,23 @@ use uuid::Uuid; use crate::error::WorkspaceError; +/// Default template seeded into HEARTBEAT.md on first access. +/// +/// Intentionally comment-only so the heartbeat runner treats it as +/// "effectively empty" and skips the LLM call until the user adds +/// real tasks. +const HEARTBEAT_SEED: &str = "\ +# Heartbeat Checklist + +"; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -246,10 +263,17 @@ impl Workspace { } /// Get the heartbeat checklist (HEARTBEAT.md). + /// + /// Returns the DB-stored checklist if it exists, otherwise falls back + /// to the in-memory seed template. The seed is never written to the + /// database; the user creates the real file via `memory_write` when + /// they actually want periodic checks. The seed content is all HTML + /// comments, which the heartbeat runner treats as "effectively empty" + /// and skips the LLM call. pub async fn heartbeat_checklist(&self) -> Result, WorkspaceError> { match self.read(paths::HEARTBEAT).await { Ok(doc) => Ok(Some(doc.content)), - Err(WorkspaceError::DocumentNotFound { .. }) => Ok(None), + Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())), Err(e) => Err(e), } }