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