← Back to task

Commit 75f03e71

commit 75f03e719cbe96a8dd16db5329ef7084a8ce0d66
Author: Ben Sima <ben@bensima.com>
Date:   Fri Jan 30 14:45:57 2026

    feat(t-536): heartbeat duplicate suppression and activity check
    
    1. Duplicate suppression:
       - Track last heartbeat message in settings
       - Suppress if same message was sent within 24 hours
       - Uses normalized comparison (lowercase, trimmed)
    
    2. Activity check:
       - Track last user message time in TVar
       - Skip heartbeat if user was active within 60 seconds
       - Prevents interrupting active conversations
    
    New HeartbeatResult: HeartbeatDuplicate for suppressed duplicates.
    
    Task-Id: t-536

diff --git a/Omni/Ava/Telegram/Bot.hs b/Omni/Ava/Telegram/Bot.hs
index 9727e0e3..d41d0f0f 100644
--- a/Omni/Ava/Telegram/Bot.hs
+++ b/Omni/Ava/Telegram/Bot.hs
@@ -852,10 +852,14 @@ runTelegramBot tgConfig modelVar projectVar providerVar = do
               -- Note: engineOnToolTrace removed - traces handled via engineOnEvent in processEngagedMessage
           }
 
-  _ <- forkIO (startHeartbeatLoop tgConfig modelVar providerVar engineCfg)
+  -- Track last user activity for heartbeat suppression during active conversations
+  now <- getCurrentTime
+  lastActivityVar <- newTVarIO now
+
+  _ <- forkIO (startHeartbeatLoop tgConfig modelVar providerVar engineCfg lastActivityVar)
   putText "Heartbeat loop started"
 
-  let processBatch = handleMessageBatchWithTracking inFlightVar tgConfig modelVar projectVar providerVar pendingLoginsVar engineCfg botName
+  let processBatch = handleMessageBatchWithTracking inFlightVar lastActivityVar tgConfig modelVar projectVar providerVar pendingLoginsVar engineCfg botName
   _ <- forkIO (IncomingQueue.startIncomingBatcher incomingQueues processBatch)
   putText "Incoming message batcher started (3s window, 200ms tick)"
 
@@ -927,6 +931,7 @@ waitForInFlight inFlightVar = go
 -- | Wrapper around handleMessageBatch that tracks in-flight requests
 handleMessageBatchWithTracking ::
   TVar Int ->
+  TVar UTCTime -> -- last user activity time
   Types.TelegramConfig ->
   TVar Text ->
   TVar ProjectContext ->
@@ -937,7 +942,10 @@ handleMessageBatchWithTracking ::
   Types.TelegramMessage ->
   Text ->
   IO ()
-handleMessageBatchWithTracking inFlightVar tgConfig modelVar projectVar providerVar pendingLoginsVar engineCfg botName msg batchedText = do
+handleMessageBatchWithTracking inFlightVar lastActivityVar tgConfig modelVar projectVar providerVar pendingLoginsVar engineCfg botName msg batchedText = do
+  -- Update last activity time (for heartbeat suppression during active chats)
+  now <- getCurrentTime
+  atomically <| writeTVar lastActivityVar now
   -- Increment in-flight counter
   atomically <| modifyTVar inFlightVar (+ 1)
   -- Run the actual handler with exception safety
@@ -971,8 +979,9 @@ startHeartbeatLoop ::
   TVar Text ->
   TVar Types.ProviderType ->
   Engine.EngineConfig ->
+  TVar UTCTime -> -- last user activity time
   IO ()
-startHeartbeatLoop tgConfig modelVar providerVar engineCfg = do
+startHeartbeatLoop tgConfig modelVar providerVar engineCfg lastActivityVar = do
   -- Get the default chat - use configured chat or most recent DM
   mChatId <- Reminders.getHeartbeatChatId
   case mChatId of
@@ -996,7 +1005,7 @@ startHeartbeatLoop tgConfig modelVar providerVar engineCfg = do
             now <- getCurrentTime
             _ <- forkIO <| Memory.saveChatHistoryEntry chatId Nothing "assistant" (Just "Ava") msg now
             pure ()
-      Heartbeat.heartbeatLoop runHeartbeat sendHeartbeatMessage
+      Heartbeat.heartbeatLoop lastActivityVar runHeartbeat sendHeartbeatMessage
 
 -- | Run a single heartbeat agent turn (uses full agent capabilities like normal messages)
 runHeartbeatTurn ::
diff --git a/Omni/Ava/Telegram/Heartbeat.hs b/Omni/Ava/Telegram/Heartbeat.hs
index 074087ae..805ae581 100644
--- a/Omni/Ava/Telegram/Heartbeat.hs
+++ b/Omni/Ava/Telegram/Heartbeat.hs
@@ -51,18 +51,22 @@ module Omni.Ava.Telegram.Heartbeat
 where
 
 import Alpha
+import Control.Concurrent.STM (TVar, readTVarIO)
 import qualified Data.Text as Text
 import qualified Data.Text.IO as TextIO
 import Data.Time
   ( LocalTime (..),
+    NominalDiffTime,
     TimeOfDay (..),
     TimeZone,
     UTCTime,
+    diffUTCTime,
     getCurrentTime,
     localTimeOfDay,
     minutesToTimeZone,
     utcToLocalTime,
   )
+import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM)
 import qualified Omni.Agent.Memory as Memory
 import qualified Omni.Agent.Paths as Paths
 import qualified Omni.Test as Test
@@ -253,6 +257,7 @@ data HeartbeatResult
   = HeartbeatOk -- Agent said HEARTBEAT_OK, nothing to report
   | HeartbeatContent Text -- Agent has something to say
   | HeartbeatEmpty -- Empty response
+  | HeartbeatDuplicate -- Same message as recently sent (suppressed)
   deriving (Show, Eq)
 
 -- | Parse agent response to determine if it should be sent to user
@@ -285,10 +290,55 @@ stripHeartbeatOk txt =
           |> Text.strip
    in cleaned
 
+-- -----------------------------------------------------------------------------
+-- Duplicate Suppression
+-- -----------------------------------------------------------------------------
+
+-- | Get the last heartbeat message that was sent (if any)
+getLastHeartbeatMessage :: IO (Maybe (Text, UTCTime))
+getLastHeartbeatMessage = do
+  msgMaybe <- Memory.getSetting "heartbeat.last_message"
+  timeMaybe <- Memory.getSetting "heartbeat.last_sent_at"
+  case (msgMaybe, timeMaybe) of
+    (Just msg, Just timeStr) ->
+      case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (Text.unpack timeStr) of
+        Just time -> pure (Just (msg, time))
+        Nothing -> pure Nothing
+    _ -> pure Nothing
+
+-- | Save the last heartbeat message
+setLastHeartbeatMessage :: Text -> UTCTime -> IO ()
+setLastHeartbeatMessage msg time = do
+  Memory.setSetting "heartbeat.last_message" msg
+  Memory.setSetting "heartbeat.last_sent_at" (Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" time))
+
+-- | Check if a message is a duplicate of the last one (within suppression window)
+isDuplicateHeartbeat :: Text -> IO Bool
+isDuplicateHeartbeat content = do
+  lastMsgMaybe <- getLastHeartbeatMessage
+  now <- getCurrentTime
+  case lastMsgMaybe of
+    Just (prevText, prevTime) ->
+      let hoursSinceLast = diffUTCTime now prevTime / 3600
+          isSameMessage = normalizeForComparison content == normalizeForComparison prevText
+       in pure (isSameMessage && hoursSinceLast < 24)
+    Nothing -> pure False
+
+-- | Normalize message for duplicate comparison (ignore minor differences)
+normalizeForComparison :: Text -> Text
+normalizeForComparison =
+  Text.toLower <. Text.strip
+
+-- | Minimum idle time before heartbeat runs (seconds)
+-- If user sent a message within this time, skip heartbeat to avoid interrupting
+minIdleSeconds :: NominalDiffTime
+minIdleSeconds = 60
+
 -- | The main heartbeat loop
 -- Takes a callback that runs the actual agent turn and returns the response
-heartbeatLoop :: IO Text -> (Text -> IO ()) -> IO ()
-heartbeatLoop runHeartbeat sendMessage = do
+-- lastActivityVar tracks when the user last sent a message (for activity check)
+heartbeatLoop :: TVar UTCTime -> IO Text -> (Text -> IO ()) -> IO ()
+heartbeatLoop lastActivityVar runHeartbeat sendMessage = do
   cfg <- loadHeartbeatConfig
   if not (heartbeatEnabled cfg)
     then putText "Heartbeat disabled, not starting loop"
@@ -309,16 +359,24 @@ heartbeatLoop runHeartbeat sendMessage = do
           putText "Heartbeat disabled, stopping loop"
           pure ()
         else do
-          -- Check active hours
           now <- getCurrentTime
-          if isWithinActiveHours cfg' now
-            then do
-              result <- runHeartbeatOnce runHeartbeat sendMessage
-              case result of
-                HeartbeatOk -> putText "Heartbeat: all clear (suppressed)"
-                HeartbeatContent _ -> putText "Heartbeat: sent message to user"
-                HeartbeatEmpty -> putText "Heartbeat: empty response (suppressed)"
-            else putText "Heartbeat: outside active hours (skipped)"
+
+          -- Check active hours
+          if not (isWithinActiveHours cfg' now)
+            then putText "Heartbeat: outside active hours (skipped)"
+            else do
+              -- Check if user is actively chatting
+              lastActivity <- readTVarIO lastActivityVar
+              let idleTime = diffUTCTime now lastActivity
+              if idleTime < minIdleSeconds
+                then putText <| "Heartbeat: user active " <> tshow (round idleTime :: Int) <> "s ago (skipped)"
+                else do
+                  result <- runHeartbeatOnce runHeartbeat sendMessage
+                  case result of
+                    HeartbeatOk -> putText "Heartbeat: all clear (suppressed)"
+                    HeartbeatContent _ -> putText "Heartbeat: sent message to user"
+                    HeartbeatEmpty -> putText "Heartbeat: empty response (suppressed)"
+                    HeartbeatDuplicate -> putText "Heartbeat: duplicate of recent message (suppressed)"
           loop cfg'
 
 -- | Run a single heartbeat check
@@ -339,9 +397,15 @@ runHeartbeatOnce runHeartbeat sendMessage = do
       -- Parse response
       let result = parseHeartbeatResponse response
 
-      -- Send if there's content
+      -- Send if there's content (with duplicate check)
       case result of
-        HeartbeatContent content -> sendMessage content
-        _ -> pure ()
-
-      pure result
+        HeartbeatContent content -> do
+          isDupe <- isDuplicateHeartbeat content
+          if isDupe
+            then pure HeartbeatDuplicate
+            else do
+              sendMessage content
+              now <- getCurrentTime
+              setLastHeartbeatMessage content now
+              pure result
+        _ -> pure result
diff --git a/skills/manage-calendar.md b/skills/manage-calendar.md
index ac79e85b..381de012 100644
--- a/skills/manage-calendar.md
+++ b/skills/manage-calendar.md
@@ -1,32 +1,57 @@
 # Manage Calendar
 
-Access calendar data via CLI tools or API to view and manage events.
+Access calendar data via khal (CLI tool) to view and manage events.
 
 ## Process
 
-1. **Check available tools** - Try gcalcli first, fallback to API
-2. **Authenticate** - Ensure credentials are configured
-3. **Identify operation** - List events, create, update, or delete
-4. **Execute command** - Use appropriate CLI tool or API call
-5. **Verify result** - Confirm the operation completed successfully
-6. **Handle errors** - Check for authentication or permission issues
+1. **Check what you need** - List events, create, update, search, or delete
+2. **Execute khal command** - Use appropriate khal subcommand
+3. **Verify result** - Confirm the operation completed successfully
+4. **Handle errors** - Check for configuration or permission issues
 
-## Examples
+## Common Commands
 
 ```bash
-# gcalcli (if configured)
-# List upcoming events
-gcalcli agenda
+# List upcoming events (today through next week)
+khal list
 
-# Create event
-gcalcli add --title "Meeting" --when "tomorrow 10am" --duration 60
-```
+# List events for specific date range
+khal list today 7d
+khal list tomorrow
+khal list 2026-02-01 2026-02-07
+
+# List with JSON output for parsing
+khal list --json summary,start,end,location today 30d
+
+# Show events at specific datetime (defaults to now)
+khal at
+khal at tomorrow 3pm
+
+# Search for events
+khal search "meeting"
+khal search "dentist"
 
-## API fallback
+# Create a new event
+khal new tomorrow 10am 11am "Team meeting"
+khal new 2026-02-01 14:00 15:30 "Coffee with Alice" :: "Discussing the project"
 
-Use run_bash with curl to call the provider API if credentials are available.
+# Create event with location
+khal new --location "Conference Room A" tomorrow 2pm 3pm "Budget review"
+
+# Create repeating event
+khal new --repeat weekly --until 2026-03-31 "Monday standup" monday 9am 30min
+
+# Print all calendars
+khal printcalendars
+
+# Interactive edit/delete (requires event search)
+khal edit "meeting"
+```
 
 ## Notes
 
-- Avoid storing secrets in command history.
-- If calendar tooling is not configured, ask for manual input.
+- khal reads from ~/.config/khal/config by default
+- All times are in the user's local timezone (America/New_York for Ben)
+- When creating events, be specific about times to avoid ambiguity
+- Use `--json` flag when you need to parse output programmatically
+- For complex edits, use `khal edit` which opens an interactive interface