summaryrefslogtreecommitdiff
path: root/Omni/Agent/Tools/Notes.hs
blob: e3cef5de671fcd11d66f7032ce5ed63431cc0443 (plain)
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | Quick notes tool for agents.
--
-- Provides simple CRUD for tagged notes stored in memory.db.
--
-- : out omni-agent-tools-notes
-- : dep aeson
-- : dep sqlite-simple
module Omni.Agent.Tools.Notes
  ( -- * Tools
    noteAddTool,
    noteListTool,
    noteDeleteTool,

    -- * Direct API
    Note (..),
    createNote,
    listNotes,
    listNotesByTopic,
    deleteNote,

    -- * Database
    initNotesTable,

    -- * Testing
    main,
    test,
  )
where

import Alpha
import Data.Aeson ((.!=), (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Text as Text
import Data.Time (UTCTime, getCurrentTime)
import qualified Database.SQLite.Simple as SQL
import qualified Omni.Agent.Engine as Engine
import qualified Omni.Agent.Memory as Memory
import qualified Omni.Test as Test

main :: IO ()
main = Test.run test

test :: Test.Tree
test =
  Test.group
    "Omni.Agent.Tools.Notes"
    [ Test.unit "noteAddTool has correct schema" <| do
        let tool = noteAddTool "test-user-id"
        Engine.toolName tool Test.@=? "note_add",
      Test.unit "noteListTool has correct schema" <| do
        let tool = noteListTool "test-user-id"
        Engine.toolName tool Test.@=? "note_list",
      Test.unit "noteDeleteTool has correct schema" <| do
        let tool = noteDeleteTool "test-user-id"
        Engine.toolName tool Test.@=? "note_delete",
      Test.unit "Note JSON roundtrip" <| do
        now <- getCurrentTime
        let n =
              Note
                { noteId = 1,
                  noteUserId = "user-123",
                  noteTopic = "groceries",
                  noteContent = "Buy milk",
                  noteCreatedAt = now
                }
        case Aeson.decode (Aeson.encode n) of
          Nothing -> Test.assertFailure "Failed to decode Note"
          Just decoded -> do
            noteContent decoded Test.@=? "Buy milk"
            noteTopic decoded Test.@=? "groceries"
    ]

data Note = Note
  { noteId :: Int,
    noteUserId :: Text,
    noteTopic :: Text,
    noteContent :: Text,
    noteCreatedAt :: UTCTime
  }
  deriving (Show, Eq, Generic)

instance Aeson.ToJSON Note where
  toJSON n =
    Aeson.object
      [ "id" .= noteId n,
        "user_id" .= noteUserId n,
        "topic" .= noteTopic n,
        "content" .= noteContent n,
        "created_at" .= noteCreatedAt n
      ]

instance Aeson.FromJSON Note where
  parseJSON =
    Aeson.withObject "Note" <| \v ->
      (Note </ (v .: "id"))
        <*> (v .: "user_id")
        <*> (v .: "topic")
        <*> (v .: "content")
        <*> (v .: "created_at")

instance SQL.FromRow Note where
  fromRow =
    (Note </ SQL.field)
      <*> SQL.field
      <*> SQL.field
      <*> SQL.field
      <*> SQL.field

initNotesTable :: SQL.Connection -> IO ()
initNotesTable conn = do
  SQL.execute_
    conn
    "CREATE TABLE IF NOT EXISTS notes (\
    \  id INTEGER PRIMARY KEY AUTOINCREMENT,\
    \  user_id TEXT NOT NULL,\
    \  topic TEXT NOT NULL,\
    \  content TEXT NOT NULL,\
    \  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\
    \)"
  SQL.execute_
    conn
    "CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)"
  SQL.execute_
    conn
    "CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(user_id, topic)"

createNote :: Text -> Text -> Text -> IO Note
createNote uid topic content = do
  now <- getCurrentTime
  Memory.withMemoryDb <| \conn -> do
    initNotesTable conn
    SQL.execute
      conn
      "INSERT INTO notes (user_id, topic, content, created_at) VALUES (?, ?, ?, ?)"
      (uid, topic, content, now)
    rowId <- SQL.lastInsertRowId conn
    pure
      Note
        { noteId = fromIntegral rowId,
          noteUserId = uid,
          noteTopic = topic,
          noteContent = content,
          noteCreatedAt = now
        }

listNotes :: Text -> Int -> IO [Note]
listNotes uid limit =
  Memory.withMemoryDb <| \conn -> do
    initNotesTable conn
    SQL.query
      conn
      "SELECT id, user_id, topic, content, created_at \
      \FROM notes WHERE user_id = ? \
      \ORDER BY created_at DESC LIMIT ?"
      (uid, limit)

listNotesByTopic :: Text -> Text -> Int -> IO [Note]
listNotesByTopic uid topic limit =
  Memory.withMemoryDb <| \conn -> do
    initNotesTable conn
    SQL.query
      conn
      "SELECT id, user_id, topic, content, created_at \
      \FROM notes WHERE user_id = ? AND topic = ? \
      \ORDER BY created_at DESC LIMIT ?"
      (uid, topic, limit)

deleteNote :: Text -> Int -> IO Bool
deleteNote uid nid =
  Memory.withMemoryDb <| \conn -> do
    initNotesTable conn
    SQL.execute
      conn
      "DELETE FROM notes WHERE id = ? AND user_id = ?"
      (nid, uid)
    changes <- SQL.changes conn
    pure (changes > 0)

noteAddTool :: Text -> Engine.Tool
noteAddTool uid =
  Engine.Tool
    { Engine.toolName = "note_add",
      Engine.toolDescription =
        "Add a quick note on a topic. Use for reminders, lists, ideas, or anything "
          <> "the user wants to jot down. Topics help organize notes (e.g., 'groceries', "
          <> "'ideas', 'todo', 'recipes').",
      Engine.toolJsonSchema =
        Aeson.object
          [ "type" .= ("object" :: Text),
            "properties"
              .= Aeson.object
                [ "topic"
                    .= Aeson.object
                      [ "type" .= ("string" :: Text),
                        "description" .= ("Topic/category for the note (e.g., 'groceries', 'todo')" :: Text)
                      ],
                  "content"
                    .= Aeson.object
                      [ "type" .= ("string" :: Text),
                        "description" .= ("The note content" :: Text)
                      ]
                ],
            "required" .= (["topic", "content"] :: [Text])
          ],
      Engine.toolExecute = executeNoteAdd uid
    }

executeNoteAdd :: Text -> Aeson.Value -> IO Aeson.Value
executeNoteAdd uid v =
  case Aeson.fromJSON v of
    Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
    Aeson.Success (args :: NoteAddArgs) -> do
      newNote <- createNote uid (naTopic args) (naContent args)
      pure
        ( Aeson.object
            [ "success" .= True,
              "note_id" .= noteId newNote,
              "message" .= ("Added note to '" <> noteTopic newNote <> "': " <> noteContent newNote)
            ]
        )

data NoteAddArgs = NoteAddArgs
  { naTopic :: Text,
    naContent :: Text
  }
  deriving (Generic)

instance Aeson.FromJSON NoteAddArgs where
  parseJSON =
    Aeson.withObject "NoteAddArgs" <| \v ->
      (NoteAddArgs </ (v .: "topic"))
        <*> (v .: "content")

noteListTool :: Text -> Engine.Tool
noteListTool uid =
  Engine.Tool
    { Engine.toolName = "note_list",
      Engine.toolDescription =
        "List notes, optionally filtered by topic. Use to show the user their "
          <> "saved notes or check what's on a specific list.",
      Engine.toolJsonSchema =
        Aeson.object
          [ "type" .= ("object" :: Text),
            "properties"
              .= Aeson.object
                [ "topic"
                    .= Aeson.object
                      [ "type" .= ("string" :: Text),
                        "description" .= ("Filter by topic (optional, omit to list all)" :: Text)
                      ],
                  "limit"
                    .= Aeson.object
                      [ "type" .= ("integer" :: Text),
                        "description" .= ("Max notes to return (default: 20)" :: Text)
                      ]
                ],
            "required" .= ([] :: [Text])
          ],
      Engine.toolExecute = executeNoteList uid
    }

executeNoteList :: Text -> Aeson.Value -> IO Aeson.Value
executeNoteList uid v =
  case Aeson.fromJSON v of
    Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
    Aeson.Success (args :: NoteListArgs) -> do
      let lim = min 50 (max 1 (nlLimit args))
      notes <- case nlTopic args of
        Just topic -> listNotesByTopic uid topic lim
        Nothing -> listNotes uid lim
      pure
        ( Aeson.object
            [ "success" .= True,
              "count" .= length notes,
              "notes" .= formatNotesForLLM notes
            ]
        )

formatNotesForLLM :: [Note] -> Text
formatNotesForLLM [] = "No notes found."
formatNotesForLLM notes =
  Text.unlines (map formatNote notes)
  where
    formatNote n =
      "[" <> noteTopic n <> "] " <> noteContent n <> " (id: " <> tshow (noteId n) <> ")"

data NoteListArgs = NoteListArgs
  { nlTopic :: Maybe Text,
    nlLimit :: Int
  }
  deriving (Generic)

instance Aeson.FromJSON NoteListArgs where
  parseJSON =
    Aeson.withObject "NoteListArgs" <| \v ->
      (NoteListArgs </ (v .:? "topic"))
        <*> (v .:? "limit" .!= 20)

noteDeleteTool :: Text -> Engine.Tool
noteDeleteTool uid =
  Engine.Tool
    { Engine.toolName = "note_delete",
      Engine.toolDescription =
        "Delete a note by its ID. Use after the user says they've completed an item "
          <> "or no longer need a note.",
      Engine.toolJsonSchema =
        Aeson.object
          [ "type" .= ("object" :: Text),
            "properties"
              .= Aeson.object
                [ "note_id"
                    .= Aeson.object
                      [ "type" .= ("integer" :: Text),
                        "description" .= ("The ID of the note to delete" :: Text)
                      ]
                ],
            "required" .= (["note_id"] :: [Text])
          ],
      Engine.toolExecute = executeNoteDelete uid
    }

executeNoteDelete :: Text -> Aeson.Value -> IO Aeson.Value
executeNoteDelete uid v =
  case Aeson.fromJSON v of
    Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e])
    Aeson.Success (args :: NoteDeleteArgs) -> do
      deleted <- deleteNote uid (ndNoteId args)
      if deleted
        then
          pure
            ( Aeson.object
                [ "success" .= True,
                  "message" .= ("Note deleted" :: Text)
                ]
            )
        else
          pure
            ( Aeson.object
                [ "success" .= False,
                  "error" .= ("Note not found or already deleted" :: Text)
                ]
            )

newtype NoteDeleteArgs = NoteDeleteArgs
  { ndNoteId :: Int
  }
  deriving (Generic)

instance Aeson.FromJSON NoteDeleteArgs where
  parseJSON =
    Aeson.withObject "NoteDeleteArgs" <| \v ->
      NoteDeleteArgs </ (v .: "note_id")