1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
|
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | Calendar tool using khal CLI.
--
-- Provides calendar access for agents via local khal/CalDAV.
--
-- : out omni-agent-tools-calendar
-- : dep aeson
-- : dep process
module Omni.Agent.Tools.Calendar
( -- * Tools
calendarListTool,
calendarAddTool,
calendarSearchTool,
-- * Direct API
listEvents,
addEvent,
searchEvents,
listCalendars,
-- * Testing
main,
test,
)
where
import Alpha
import Data.Aeson ((.!=), (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Text as Text
import qualified Omni.Agent.Engine as Engine
import qualified Omni.Test as Test
import System.Process (readProcessWithExitCode)
main :: IO ()
main = Test.run test
test :: Test.Tree
test =
Test.group
"Omni.Agent.Tools.Calendar"
[ Test.unit "calendarListTool has correct schema" <| do
let tool = calendarListTool
Engine.toolName tool Test.@=? "calendar_list",
Test.unit "calendarAddTool has correct schema" <| do
let tool = calendarAddTool
Engine.toolName tool Test.@=? "calendar_add",
Test.unit "calendarSearchTool has correct schema" <| do
let tool = calendarSearchTool
Engine.toolName tool Test.@=? "calendar_search",
Test.unit "listCalendars returns calendars" <| do
result <- listCalendars
case result of
Left _ -> pure ()
Right cals -> (not (null cals) || null cals) Test.@=? True
]
defaultCalendars :: [String]
defaultCalendars = ["BenSimaShared", "Kate"]
listEvents :: Text -> Maybe Text -> IO (Either Text Text)
listEvents range maybeCalendar = do
let rangeArg = if Text.null range then "today 7d" else Text.unpack range
calArgs = case maybeCalendar of
Just cal -> ["-a", Text.unpack cal]
Nothing -> concatMap (\c -> ["-a", c]) defaultCalendars
formatArg = ["-f", "[{calendar}] {title} | {start-time} - {end-time}"]
result <-
try <| readProcessWithExitCode "khal" (["list"] <> calArgs <> formatArg <> [rangeArg, "-o"]) ""
case result of
Left (e :: SomeException) ->
pure (Left ("khal error: " <> tshow e))
Right (exitCode, stdoutStr, stderrStr) ->
case exitCode of
ExitSuccess -> pure (Right (Text.pack stdoutStr))
ExitFailure code ->
pure (Left ("khal failed (" <> tshow code <> "): " <> Text.pack stderrStr))
addEvent :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> IO (Either Text Text)
addEvent calendarName eventSpec location alarm description = do
let baseArgs = ["new", "-a", Text.unpack calendarName]
locArgs = maybe [] (\l -> ["-l", Text.unpack l]) location
alarmArgs = maybe [] (\a -> ["-m", Text.unpack a]) alarm
specParts = Text.unpack eventSpec
descParts = maybe [] (\d -> ["::", Text.unpack d]) description
allArgs = baseArgs <> locArgs <> alarmArgs <> [specParts] <> descParts
result <- try <| readProcessWithExitCode "khal" allArgs ""
case result of
Left (e :: SomeException) ->
pure (Left ("khal error: " <> tshow e))
Right (exitCode, stdoutStr, stderrStr) ->
case exitCode of
ExitSuccess ->
pure (Right ("Event created: " <> Text.pack stdoutStr))
ExitFailure code ->
pure (Left ("khal failed (" <> tshow code <> "): " <> Text.pack stderrStr))
searchEvents :: Text -> IO (Either Text Text)
searchEvents query = do
let calArgs = concatMap (\c -> ["-a", c]) defaultCalendars
result <-
try <| readProcessWithExitCode "khal" (["search"] <> calArgs <> [Text.unpack query]) ""
case result of
Left (e :: SomeException) ->
pure (Left ("khal error: " <> tshow e))
Right (exitCode, stdoutStr, stderrStr) ->
case exitCode of
ExitSuccess -> pure (Right (Text.pack stdoutStr))
ExitFailure code ->
pure (Left ("khal failed (" <> tshow code <> "): " <> Text.pack stderrStr))
listCalendars :: IO (Either Text [Text])
listCalendars = do
result <-
try <| readProcessWithExitCode "khal" ["printcalendars"] ""
case result of
Left (e :: SomeException) ->
pure (Left ("khal error: " <> tshow e))
Right (exitCode, stdoutStr, stderrStr) ->
case exitCode of
ExitSuccess ->
pure (Right (filter (not <. Text.null) (Text.lines (Text.pack stdoutStr))))
ExitFailure code ->
pure (Left ("khal failed (" <> tshow code <> "): " <> Text.pack stderrStr))
calendarListTool :: Engine.Tool
calendarListTool =
Engine.Tool
{ Engine.toolName = "calendar_list",
Engine.toolDescription =
"List upcoming calendar events. Use to check what's scheduled. "
<> "Range can be like 'today', 'tomorrow', 'today 7d', 'next week', etc. "
<> "Available calendars: BenSimaShared, Kate.",
Engine.toolJsonSchema =
Aeson.object
[ "type" .= ("object" :: Text),
"properties"
.= Aeson.object
[ "range"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Time range like 'today 7d', 'tomorrow', 'next week' (default: today 7d)" :: Text)
],
"calendar"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Filter to specific calendar: 'BenSimaShared' or 'Kate' (default: both)" :: Text)
]
],
"required" .= ([] :: [Text])
],
Engine.toolExecute = executeCalendarList
}
executeCalendarList :: Aeson.Value -> IO Aeson.Value
executeCalendarList v =
case Aeson.fromJSON v of
Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
Aeson.Success (args :: CalendarListArgs) -> do
result <- listEvents (clRange args) (clCalendar args)
case result of
Left err ->
pure (Aeson.object ["error" .= err])
Right events ->
pure
( Aeson.object
[ "success" .= True,
"events" .= events
]
)
data CalendarListArgs = CalendarListArgs
{ clRange :: Text,
clCalendar :: Maybe Text
}
deriving (Generic)
instance Aeson.FromJSON CalendarListArgs where
parseJSON =
Aeson.withObject "CalendarListArgs" <| \v ->
(CalendarListArgs </ (v .:? "range" .!= "today 7d"))
<*> (v .:? "calendar")
calendarAddTool :: Engine.Tool
calendarAddTool =
Engine.Tool
{ Engine.toolName = "calendar_add",
Engine.toolDescription =
"Add a new calendar event. The event_spec format is: "
<> "'START [END] SUMMARY' where START/END are dates or times. "
<> "Examples: '2024-12-25 Christmas', 'tomorrow 10:00 11:00 Meeting', "
<> "'friday 14:00 1h Doctor appointment'.",
Engine.toolJsonSchema =
Aeson.object
[ "type" .= ("object" :: Text),
"properties"
.= Aeson.object
[ "calendar"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Calendar name to add to (e.g., 'BenSimaShared', 'Kate')" :: Text)
],
"event_spec"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Event specification: 'START [END] SUMMARY' (e.g., 'tomorrow 10:00 11:00 Team meeting')" :: Text)
],
"location"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Location of the event (optional)" :: Text)
],
"alarm"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Alarm time before event, e.g., '15m', '1h', '1d' (optional)" :: Text)
],
"description"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Detailed description of the event (optional)" :: Text)
]
],
"required" .= (["calendar", "event_spec"] :: [Text])
],
Engine.toolExecute = executeCalendarAdd
}
executeCalendarAdd :: Aeson.Value -> IO Aeson.Value
executeCalendarAdd v =
case Aeson.fromJSON v of
Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
Aeson.Success (args :: CalendarAddArgs) -> do
result <-
addEvent
(caCalendar args)
(caEventSpec args)
(caLocation args)
(caAlarm args)
(caDescription args)
case result of
Left err ->
pure (Aeson.object ["error" .= err])
Right msg ->
pure
( Aeson.object
[ "success" .= True,
"message" .= msg
]
)
data CalendarAddArgs = CalendarAddArgs
{ caCalendar :: Text,
caEventSpec :: Text,
caLocation :: Maybe Text,
caAlarm :: Maybe Text,
caDescription :: Maybe Text
}
deriving (Generic)
instance Aeson.FromJSON CalendarAddArgs where
parseJSON =
Aeson.withObject "CalendarAddArgs" <| \v ->
(CalendarAddArgs </ (v .: "calendar"))
<*> (v .: "event_spec")
<*> (v .:? "location")
<*> (v .:? "alarm")
<*> (v .:? "description")
calendarSearchTool :: Engine.Tool
calendarSearchTool =
Engine.Tool
{ Engine.toolName = "calendar_search",
Engine.toolDescription =
"Search for calendar events by text. Finds events matching the query "
<> "in title, description, or location.",
Engine.toolJsonSchema =
Aeson.object
[ "type" .= ("object" :: Text),
"properties"
.= Aeson.object
[ "query"
.= Aeson.object
[ "type" .= ("string" :: Text),
"description" .= ("Search text to find in events" :: Text)
]
],
"required" .= (["query"] :: [Text])
],
Engine.toolExecute = executeCalendarSearch
}
executeCalendarSearch :: Aeson.Value -> IO Aeson.Value
executeCalendarSearch v =
case Aeson.fromJSON v of
Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
Aeson.Success (args :: CalendarSearchArgs) -> do
result <- searchEvents (csQuery args)
case result of
Left err ->
pure (Aeson.object ["error" .= err])
Right events ->
pure
( Aeson.object
[ "success" .= True,
"results" .= events
]
)
newtype CalendarSearchArgs = CalendarSearchArgs
{ csQuery :: Text
}
deriving (Generic)
instance Aeson.FromJSON CalendarSearchArgs where
parseJSON =
Aeson.withObject "CalendarSearchArgs" <| \v ->
CalendarSearchArgs </ (v .: "query")
|