never executed always true always false
1 module PureClaw.Memory.SQLite
2 ( -- * Construction
3 mkSQLiteMemoryHandle
4 , withSQLiteMemory
5 ) where
6
7 import Data.IORef
8 import Data.Map.Strict qualified as Map
9 import Data.Text (Text)
10 import Data.Text qualified as T
11 import Data.Time
12 import Database.SQLite.Simple
13
14 import PureClaw.Core.Types
15 import PureClaw.Handles.Memory
16
17 -- | Create a SQLite-backed memory handle. Opens the database at the
18 -- given path and creates the schema if needed.
19 mkSQLiteMemoryHandle :: FilePath -> IO MemoryHandle
20 mkSQLiteMemoryHandle dbPath = do
21 conn <- open dbPath
22 initSchema conn
23 counterRef <- newIORef (0 :: Int)
24 pure MemoryHandle
25 { _mh_search = searchMemories conn
26 , _mh_save = saveMemory conn counterRef
27 , _mh_recall = recallMemory conn
28 }
29
30 -- | Convenience: open a SQLite memory handle, run an action, then close.
31 withSQLiteMemory :: FilePath -> (MemoryHandle -> IO a) -> IO a
32 withSQLiteMemory dbPath action = do
33 mh <- mkSQLiteMemoryHandle dbPath
34 action mh
35
36 -- | Initialize the database schema.
37 initSchema :: Connection -> IO ()
38 initSchema conn = execute_ conn
39 "CREATE TABLE IF NOT EXISTS memories (\
40 \ id TEXT PRIMARY KEY,\
41 \ content TEXT NOT NULL,\
42 \ metadata TEXT NOT NULL DEFAULT '{}',\
43 \ created_at TEXT NOT NULL\
44 \)"
45
46 -- | Save a memory entry.
47 saveMemory :: Connection -> IORef Int -> MemorySource -> IO (Maybe MemoryId)
48 saveMemory conn counterRef source = do
49 n <- atomicModifyIORef' counterRef (\i -> (i + 1, i + 1))
50 now <- getCurrentTime
51 let mid = MemoryId (T.pack ("mem-" <> show n))
52 ts = T.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)
53 metaText = renderMetadata (_ms_metadata source)
54 execute conn
55 "INSERT INTO memories (id, content, metadata, created_at) VALUES (?, ?, ?, ?)"
56 (unMemoryId mid, _ms_content source, metaText, ts)
57 pure (Just mid)
58
59 -- | Search memories using LIKE (case-insensitive substring match).
60 -- SQLite FTS5 would be better for production, but LIKE is sufficient
61 -- to get the interface working.
62 searchMemories :: Connection -> Text -> SearchConfig -> IO [SearchResult]
63 searchMemories conn queryText config = do
64 rows <- query conn
65 "SELECT id, content FROM memories WHERE content LIKE ? LIMIT ?"
66 ("%" <> queryText <> "%" :: Text, _sc_maxResults config)
67 pure [ SearchResult (MemoryId mid) content 1.0
68 | (mid, content) <- rows :: [(Text, Text)]
69 ]
70
71 -- | Recall a specific memory by ID.
72 recallMemory :: Connection -> MemoryId -> IO (Maybe MemoryEntry)
73 recallMemory conn mid = do
74 rows <- query conn
75 "SELECT content, metadata, created_at FROM memories WHERE id = ?"
76 (Only (unMemoryId mid))
77 case rows of
78 [] -> pure Nothing
79 ((content, metaText, tsText) : _) ->
80 pure $ Just MemoryEntry
81 { _me_memoryId = mid
82 , _me_content = content
83 , _me_metadata = parseMetadata metaText
84 , _me_createdAt = parseTimestamp tsText
85 }
86
87 -- | Render metadata map as a simple key=value text.
88 renderMetadata :: Map.Map Text Text -> Text
89 renderMetadata m = T.intercalate ";" [ k <> "=" <> v | (k, v) <- Map.toList m ]
90
91 -- | Parse metadata from key=value text.
92 parseMetadata :: Text -> Map.Map Text Text
93 parseMetadata t
94 | T.null t = Map.empty
95 | otherwise = Map.fromList
96 [ (k, v)
97 | pair <- T.splitOn ";" t
98 , let (k, rawV) = T.breakOn "=" pair
99 , not (T.null rawV)
100 , let v = T.drop 1 rawV
101 ]
102
103 -- | Parse a timestamp, with a fallback.
104 parseTimestamp :: Text -> UTCTime
105 parseTimestamp t =
106 case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" (T.unpack t) of
107 Just ts -> ts
108 Nothing -> UTCTime (fromGregorian 2000 1 1) 0