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