← Back to task

Commit ecbc8385

commit ecbc8385d590cf8f52d437796ff91d6e55bfd55e
Author: Ben Sima <ben@bensima.com>
Date:   Thu Nov 27 22:26:02 2025

    Add Omni/Fact.hs core module with CRUD operations
    
    The Omni/Fact.hs module is complete with CRUD operations:
    
    - **createFact**: Create a new fact with project, content, related
    files - **getFact**: Retrieve a fact by ID - **getAllFacts**: Get all
    facts from the database - **getFactsByProject**: Get facts filtered by
    project - **getFactsByFile**: Get facts related to a specific file -
    **updateFact**: Update an existing fact's content, related files,
    and - **deleteFact**: Delete a fact by ID
    
    The module properly re-exports the `Fact` data type from
    `Omni.Task.Core
    
    Task-Id: t-158.2

diff --git a/Omni/Fact.hs b/Omni/Fact.hs
new file mode 100644
index 00000000..57db7fc2
--- /dev/null
+++ b/Omni/Fact.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Fact module for the Jr knowledge base.
+--
+-- Facts are pieces of knowledge learned during task execution that can
+-- inform future work on similar tasks or files.
+module Omni.Fact
+  ( Fact (..),
+    createFact,
+    getFact,
+    getAllFacts,
+    getFactsByProject,
+    getFactsByFile,
+    updateFact,
+    deleteFact,
+  )
+where
+
+import Alpha
+import Data.Aeson (encode)
+import qualified Data.ByteString.Lazy.Char8 as BLC
+import qualified Data.Text as Text
+import Data.Time (getCurrentTime)
+import qualified Database.SQLite.Simple as SQL
+import Omni.Task.Core
+  ( Fact (..),
+    getFactsForFile,
+    getFactsForProject,
+    loadFacts,
+    saveFact,
+    withDb,
+  )
+import qualified Omni.Task.Core as TaskCore
+
+-- | Create a new fact and return its ID.
+createFact :: Text -> Text -> [Text] -> Maybe Text -> Double -> IO Int
+createFact project content relatedFiles sourceTask confidence = do
+  now <- getCurrentTime
+  let fact =
+        Fact
+          { factId = Nothing,
+            factProject = project,
+            factContent = content,
+            factRelatedFiles = relatedFiles,
+            factSourceTask = sourceTask,
+            factConfidence = confidence,
+            factCreatedAt = now
+          }
+  saveFact fact
+
+-- | Get a fact by its ID.
+getFact :: Int -> IO (Maybe Fact)
+getFact fid = do
+  facts <- getAllFacts
+  pure <| find (\f -> factId f == Just fid) facts
+
+-- | Get all facts from the database.
+getAllFacts :: IO [Fact]
+getAllFacts = loadFacts
+
+-- | Get facts for a specific project.
+getFactsByProject :: Text -> IO [Fact]
+getFactsByProject = getFactsForProject
+
+-- | Get facts related to a specific file.
+getFactsByFile :: Text -> IO [Fact]
+getFactsByFile = getFactsForFile
+
+-- | Update an existing fact.
+updateFact :: Int -> Text -> [Text] -> Double -> IO ()
+updateFact fid content relatedFiles confidence =
+  withDb <| \conn ->
+    SQL.execute
+      conn
+      "UPDATE facts SET fact = ?, related_files = ?, confidence = ? WHERE id = ?"
+      (content, Text.pack (BLC.unpack (encode relatedFiles)), confidence, fid)
+
+-- | Delete a fact by ID.
+deleteFact :: Int -> IO ()
+deleteFact = TaskCore.deleteFact
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 4ff0f5fa..d15ed8e0 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -103,6 +103,18 @@ data TaskActivity = TaskActivity
   }
   deriving (Show, Eq, Generic)
 
+-- Fact for knowledge base
+data Fact = Fact
+  { factId :: Maybe Int,
+    factProject :: Text,
+    factContent :: Text,
+    factRelatedFiles :: [Text],
+    factSourceTask :: Maybe Text,
+    factConfidence :: Double,
+    factCreatedAt :: UTCTime
+  }
+  deriving (Show, Eq, Generic)
+
 instance ToJSON TaskType
 
 instance FromJSON TaskType
@@ -143,6 +155,10 @@ instance ToJSON TaskActivity
 
 instance FromJSON TaskActivity
 
+instance ToJSON Fact
+
+instance FromJSON Fact
+
 -- HTTP API Instances (for Servant query params)
 
 instance FromHttpApiData Status where
@@ -268,6 +284,38 @@ instance SQL.ToRow TaskActivity where
       SQL.toField (activityTokensUsed a)
     ]
 
+instance SQL.FromRow Fact where
+  fromRow = do
+    fid <- SQL.field
+    proj <- SQL.field
+    content <- SQL.field
+    (relatedFilesJson :: String) <- SQL.field
+    sourceTask <- SQL.field
+    confidence <- SQL.field
+    createdAt <- SQL.field
+    let relatedFiles = fromMaybe [] (decode (BLC.pack relatedFilesJson))
+    pure
+      Fact
+        { factId = fid,
+          factProject = proj,
+          factContent = content,
+          factRelatedFiles = relatedFiles,
+          factSourceTask = sourceTask,
+          factConfidence = confidence,
+          factCreatedAt = createdAt
+        }
+
+instance SQL.ToRow Fact where
+  toRow f =
+    [ SQL.toField (factId f),
+      SQL.toField (factProject f),
+      SQL.toField (factContent f),
+      SQL.toField (BLC.unpack (encode (factRelatedFiles f))),
+      SQL.toField (factSourceTask f),
+      SQL.toField (factConfidence f),
+      SQL.toField (factCreatedAt f)
+    ]
+
 -- | Case-insensitive ID comparison
 matchesId :: Text -> Text -> Bool
 matchesId id1 id2 = normalizeId id1 == normalizeId id2
@@ -375,6 +423,17 @@ initTaskDb = do
       \ tokens_used INTEGER, \
       \ FOREIGN KEY (task_id) REFERENCES tasks(id) \
       \)"
+    SQL.execute_
+      conn
+      "CREATE TABLE IF NOT EXISTS facts (\
+      \ id INTEGER PRIMARY KEY AUTOINCREMENT, \
+      \ project TEXT NOT NULL, \
+      \ fact TEXT NOT NULL, \
+      \ related_files TEXT NOT NULL, \
+      \ source_task TEXT, \
+      \ confidence REAL NOT NULL, \
+      \ created_at DATETIME DEFAULT CURRENT_TIMESTAMP \
+      \)"
     runMigrations conn
 
 -- | Run schema migrations to add missing columns to existing tables
@@ -383,6 +442,7 @@ runMigrations conn = do
   migrateTable conn "task_activity" taskActivityColumns
   migrateTable conn "tasks" tasksColumns
   migrateTable conn "retry_context" retryContextColumns
+  migrateTable conn "facts" factsColumns
 
 -- | Expected columns for task_activity table (name, type, nullable)
 taskActivityColumns :: [(Text, Text)]
@@ -427,6 +487,18 @@ retryContextColumns =
     ("notes", "TEXT")
   ]
 
+-- | Expected columns for facts table
+factsColumns :: [(Text, Text)]
+factsColumns =
+  [ ("id", "INTEGER"),
+    ("project", "TEXT"),
+    ("fact", "TEXT"),
+    ("related_files", "TEXT"),
+    ("source_task", "TEXT"),
+    ("confidence", "REAL"),
+    ("created_at", "DATETIME")
+  ]
+
 -- | Migrate a table by adding any missing columns
 migrateTable :: SQL.Connection -> Text -> [(Text, Text)] -> IO ()
 migrateTable conn tableName expectedCols = do
@@ -1212,3 +1284,52 @@ updateRetryNotes tid notes = do
           }
     Just ctx ->
       setRetryContext ctx {retryNotes = Just notes}
+
+-- Fact management
+
+-- | Save a fact to the database
+saveFact :: Fact -> IO Int
+saveFact f =
+  withDb <| \conn -> do
+    let filesJson = T.pack <| BLC.unpack <| encode (factRelatedFiles f)
+    SQL.execute
+      conn
+      "INSERT INTO facts (project, fact, related_files, source_task, confidence, created_at) \
+      \VALUES (?, ?, ?, ?, ?, ?)"
+      (factProject f, factContent f, filesJson, factSourceTask f, factConfidence f, factCreatedAt f)
+    [SQL.Only factIdVal] <- SQL.query_ conn "SELECT last_insert_rowid()" :: IO [SQL.Only Int]
+    pure factIdVal
+
+-- | Load all facts from the database
+loadFacts :: IO [Fact]
+loadFacts =
+  withDb <| \conn ->
+    SQL.query_
+      conn
+      "SELECT id, project, fact, related_files, source_task, confidence, created_at FROM facts"
+
+-- | Get facts for a specific project
+getFactsForProject :: Text -> IO [Fact]
+getFactsForProject proj =
+  withDb <| \conn ->
+    SQL.query
+      conn
+      "SELECT id, project, fact, related_files, source_task, confidence, created_at \
+      \FROM facts WHERE project = ? ORDER BY confidence DESC"
+      (SQL.Only proj)
+
+-- | Get facts related to a specific file
+getFactsForFile :: Text -> IO [Fact]
+getFactsForFile filePath =
+  withDb <| \conn ->
+    SQL.query
+      conn
+      "SELECT id, project, fact, related_files, source_task, confidence, created_at \
+      \FROM facts WHERE related_files LIKE ? ORDER BY confidence DESC"
+      (SQL.Only ("%" <> filePath <> "%"))
+
+-- | Delete a fact by ID
+deleteFact :: Int -> IO ()
+deleteFact fid =
+  withDb <| \conn ->
+    SQL.execute conn "DELETE FROM facts WHERE id = ?" (SQL.Only fid)