packages feed

kioku-core-0.1.0.0: src/Kioku/Memory/ReadModel.hs

module Kioku.Memory.ReadModel
  ( memoryInlineProjection,
    MemoryRow (..),
    MemoryByIdQuery (..),
    MemoriesByNamespaceQuery (..),
    MemoriesByScopeQuery (..),
    MemoriesBySessionQuery (..),
    MemoriesByTypeQuery (..),
    MemorySupersessionChainQuery (..),
    memoryByIdReadModel,
    memoriesByNamespaceReadModel,
    memoriesByNamespaceRowsReadModel,
    memoriesByScopeReadModel,
    memoriesByScopeRowsReadModel,
    memoriesBySessionReadModel,
    memoriesBySessionRowsReadModel,
    memoriesByTypeReadModel,
    memoriesByTypeRowsReadModel,
    memorySupersessionChainReadModel,
  )
where

import Contravariant.Extras (contrazip2, contrazip3)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BL
import Data.Functor.Contravariant ((>$<))
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text.Encoding qualified as TE
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Keiro.Projection (InlineProjection (..))
import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), StrongScope (..))
import Kioku.Api.Scope (scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText)
import Kioku.Api.Types (MemoryRecord (..), confidenceToText, memoryTypeToText)
import Kioku.Id (idText)
import Kioku.Memory.Domain
import Kioku.Prelude
import Kiroku.Store.Types (RecordedEvent)

data MemoryRow = MemoryRow
  { memoryId :: !Text,
    agentId :: !Text,
    sessionId :: !(Maybe Text),
    namespace :: !Text,
    scopeKind :: !(Maybe Text),
    scopeRef :: !(Maybe Text),
    memoryType :: !Text,
    content :: !Text,
    priority :: !Int,
    confidence :: !Text,
    tags :: !(Set Text),
    status :: !Text,
    supersededBy :: !(Maybe Text),
    supersedes :: !(Maybe Text),
    createdAt :: !UTCTime,
    updatedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

newtype MemoryByIdQuery = MemoryByIdQuery Text

newtype MemoriesByNamespaceQuery = MemoriesByNamespaceQuery Text

data MemoriesByScopeQuery = MemoriesByScopeQuery Text (Maybe Text) (Maybe Text)

newtype MemoriesBySessionQuery = MemoriesBySessionQuery Text

data MemoriesByTypeQuery = MemoriesByTypeQuery Text Text

newtype MemorySupersessionChainQuery = MemorySupersessionChainQuery Text

memoryInlineProjection :: InlineProjection MemoryEvent
memoryInlineProjection =
  InlineProjection
    { name = "kioku-memory-inline",
      apply = applyMemoryEvent
    }

applyMemoryEvent :: MemoryEvent -> RecordedEvent -> Tx.Transaction ()
applyMemoryEvent event _recorded =
  case event of
    MemoryRecorded d -> Tx.statement (recordedRow d) upsertMemoryStmt
    MemorySuperseded d ->
      Tx.statement
        (idText d.memoryId, idText d.supersededBy, d.supersededAt)
        updateMemorySupersededStmt
    MemoryArchived d ->
      Tx.statement (idText d.memoryId, d.archivedAt) updateMemoryArchivedStmt
    MemoryTagsUpdated d ->
      Tx.statement (idText d.memoryId, d.tags, d.updatedAt) updateMemoryTagsStmt
    MemoryConfidenceUpdated d ->
      Tx.statement (idText d.memoryId, confidenceToText d.confidence, d.updatedAt) updateMemoryConfidenceStmt
    MemoryMerged d ->
      Tx.statement (idText d.memoryId, idText d.mergedInto, d.mergedAt) updateMemoryMergedStmt

recordedRow :: MemoryRecordedData -> MemoryRow
recordedRow d =
  MemoryRow
    { memoryId = idText d.memoryId,
      agentId = d.agentId,
      sessionId = idText <$> d.sessionId,
      namespace = scopeNamespaceText d.scope,
      scopeKind = scopeKindText d.scope,
      scopeRef = scopeRefText d.scope,
      memoryType = memoryTypeToText d.memoryType,
      content = d.content,
      priority = d.priority,
      confidence = confidenceToText d.confidence,
      tags = d.tags,
      status = "active",
      supersededBy = Nothing,
      supersedes = idText <$> d.supersedes,
      createdAt = d.recordedAt,
      updatedAt = d.recordedAt
    }

memoryByIdReadModel :: ReadModel MemoryByIdQuery (Maybe MemoryRow)
memoryByIdReadModel =
  ReadModel
    { name = "kioku-memory-by-id",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoryByIdQuery mid) -> Tx.statement mid selectMemoryByIdStmt
    }

memoriesByNamespaceReadModel :: ReadModel MemoriesByNamespaceQuery [MemoryRecord]
memoriesByNamespaceReadModel =
  ReadModel
    { name = "kioku-memories-by-namespace",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByNamespaceQuery ns) -> Tx.statement ns selectActiveByNamespaceStmt
    }

memoriesByNamespaceRowsReadModel :: ReadModel MemoriesByNamespaceQuery [MemoryRow]
memoriesByNamespaceRowsReadModel =
  ReadModel
    { name = "kioku-memory-rows-by-namespace",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByNamespaceQuery ns) -> Tx.statement ns selectActiveByNamespaceRowsStmt
    }

memoriesByScopeReadModel :: ReadModel MemoriesByScopeQuery [MemoryRecord]
memoriesByScopeReadModel =
  ReadModel
    { name = "kioku-memories-by-scope",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByScopeQuery ns sk sr) -> Tx.statement (ns, sk, sr) selectActiveByScopeStmt
    }

memoriesByScopeRowsReadModel :: ReadModel MemoriesByScopeQuery [MemoryRow]
memoriesByScopeRowsReadModel =
  ReadModel
    { name = "kioku-memory-rows-by-scope",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByScopeQuery ns sk sr) -> Tx.statement (ns, sk, sr) selectActiveByScopeRowsStmt
    }

memoriesBySessionReadModel :: ReadModel MemoriesBySessionQuery [MemoryRecord]
memoriesBySessionReadModel =
  ReadModel
    { name = "kioku-memories-by-session",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesBySessionQuery sid) -> Tx.statement sid selectBySessionStmt
    }

memoriesBySessionRowsReadModel :: ReadModel MemoriesBySessionQuery [MemoryRow]
memoriesBySessionRowsReadModel =
  ReadModel
    { name = "kioku-memory-rows-by-session",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesBySessionQuery sid) -> Tx.statement sid selectBySessionRowsStmt
    }

memoriesByTypeReadModel :: ReadModel MemoriesByTypeQuery [MemoryRecord]
memoriesByTypeReadModel =
  ReadModel
    { name = "kioku-memories-by-type",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByTypeQuery ns mt) -> Tx.statement (ns, mt) selectByTypeStmt
    }

memoriesByTypeRowsReadModel :: ReadModel MemoriesByTypeQuery [MemoryRow]
memoriesByTypeRowsReadModel =
  ReadModel
    { name = "kioku-memory-rows-by-type",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemoriesByTypeQuery ns mt) -> Tx.statement (ns, mt) selectByTypeRowsStmt
    }

memorySupersessionChainReadModel :: ReadModel MemorySupersessionChainQuery [MemoryRow]
memorySupersessionChainReadModel =
  ReadModel
    { name = "kioku-memory-supersession-chain",
      schema = "kiroku",
      tableName = "kioku_memories",
      subscriptionName = "kioku-memory-inline",
      version = 1,
      shapeHash = "kioku-memory-v1",
      defaultConsistency = Eventual,
      strongScope = EntireLog,
      query = \(MemorySupersessionChainQuery mid) -> Tx.statement mid selectSupersessionChainStmt
    }

memoryRowDecoder :: D.Row MemoryRow
memoryRowDecoder =
  MemoryRow
    <$> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> (fromIntegral @Int32 @Int <$> D.column (D.nonNullable D.int4))
    <*> D.column (D.nonNullable D.text)
    <*> (decodeTags <$> D.column (D.nonNullable D.text))
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nonNullable D.timestamptz)

memoryRecordDecoder :: D.Row MemoryRecord
memoryRecordDecoder =
  toRecord <$> memoryRowDecoder

toRecord :: MemoryRow -> MemoryRecord
toRecord row =
  MemoryRecord
    { memoryId = row.memoryId,
      agentId = row.agentId,
      sessionId = row.sessionId,
      scope = scopeFromColumns row.namespace row.scopeKind row.scopeRef,
      memoryType = row.memoryType,
      content = row.content,
      priority = row.priority,
      confidence = row.confidence,
      tags = row.tags,
      status = row.status,
      createdAt = row.createdAt
    }

encodeTags :: Set Text -> Text
encodeTags = TE.decodeUtf8 . BL.toStrict . Aeson.encode

decodeTags :: Text -> Set Text
decodeTags =
  fromMaybe Set.empty . Aeson.decode . BL.fromStrict . TE.encodeUtf8

memoryRowColumns :: Text
memoryRowColumns =
  "memory_id, agent_id, session_id, namespace, scope_kind, scope_ref, memory_type, content, priority, confidence, tags::text, status, superseded_by, supersedes, created_at, updated_at"

qualifiedMemoryRowColumns :: Text -> Text
qualifiedMemoryRowColumns prefix =
  prefix
    <> ".memory_id, "
    <> prefix
    <> ".agent_id, "
    <> prefix
    <> ".session_id, "
    <> prefix
    <> ".namespace, "
    <> prefix
    <> ".scope_kind, "
    <> prefix
    <> ".scope_ref, "
    <> prefix
    <> ".memory_type, "
    <> prefix
    <> ".content, "
    <> prefix
    <> ".priority, "
    <> prefix
    <> ".confidence, "
    <> prefix
    <> ".tags::text, "
    <> prefix
    <> ".status, "
    <> prefix
    <> ".superseded_by, "
    <> prefix
    <> ".supersedes, "
    <> prefix
    <> ".created_at, "
    <> prefix
    <> ".updated_at"

selectMemoryByIdStmt :: Statement Text (Maybe MemoryRow)
selectMemoryByIdStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE memory_id = $1"
    )
    (E.param (E.nonNullable E.text))
    (D.rowMaybe memoryRowDecoder)

selectActiveByNamespaceStmt :: Statement Text [MemoryRecord]
selectActiveByNamespaceStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 ORDER BY priority ASC, created_at DESC"
    )
    (E.param (E.nonNullable E.text))
    (D.rowList memoryRecordDecoder)

selectActiveByNamespaceRowsStmt :: Statement Text [MemoryRow]
selectActiveByNamespaceRowsStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 ORDER BY priority ASC, created_at DESC"
    )
    (E.param (E.nonNullable E.text))
    (D.rowList memoryRowDecoder)

-- | Active memories carrying __exactly__ the given scope.
--
-- Recall searches namespace-wide for a global scope; scoped reads are exact-scope. The
-- predicate below /requires/ @scope_kind@ and @scope_ref@ to be NULL for a global scope,
-- where 'Kioku.Recall.selectFtsCandidatesStmt' would drop the scope filter entirely and
-- return the whole namespace. Both behaviours are intentional; see docs/user/recall.md.
selectActiveByScopeStmt :: Statement (Text, Maybe Text, Maybe Text) [MemoryRecord]
selectActiveByScopeStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND ((scope_kind = $2 AND scope_ref = $3) OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL)) ORDER BY priority ASC, created_at DESC"
    )
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.text))
        (E.param (E.nullable E.text))
    )
    (D.rowList memoryRecordDecoder)

selectActiveByScopeRowsStmt :: Statement (Text, Maybe Text, Maybe Text) [MemoryRow]
selectActiveByScopeRowsStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND ((scope_kind = $2 AND scope_ref = $3) OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL)) ORDER BY priority ASC, created_at DESC"
    )
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.text))
        (E.param (E.nullable E.text))
    )
    (D.rowList memoryRowDecoder)

selectBySessionStmt :: Statement Text [MemoryRecord]
selectBySessionStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE session_id = $1 ORDER BY created_at DESC"
    )
    (E.param (E.nonNullable E.text))
    (D.rowList memoryRecordDecoder)

selectBySessionRowsStmt :: Statement Text [MemoryRow]
selectBySessionRowsStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE session_id = $1 ORDER BY created_at DESC"
    )
    (E.param (E.nonNullable E.text))
    (D.rowList memoryRowDecoder)

selectByTypeStmt :: Statement (Text, Text) [MemoryRecord]
selectByTypeStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND memory_type = $2 ORDER BY priority ASC, created_at DESC"
    )
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowList memoryRecordDecoder)

selectByTypeRowsStmt :: Statement (Text, Text) [MemoryRow]
selectByTypeRowsStmt =
  preparable
    ( "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND memory_type = $2 ORDER BY priority ASC, created_at DESC"
    )
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowList memoryRowDecoder)

selectSupersessionChainStmt :: Statement Text [MemoryRow]
selectSupersessionChainStmt =
  preparable
    ( "WITH RECURSIVE chain AS ("
        <> "SELECT "
        <> memoryRowColumns
        <> " FROM kioku_memories WHERE memory_id = $1 "
        <> "UNION "
        <> "SELECT "
        <> qualifiedMemoryRowColumns "m"
        <> " FROM kioku_memories m JOIN chain c ON "
        <> "m.memory_id = c.supersedes "
        <> "OR m.supersedes = c.memory_id "
        <> "OR m.memory_id = c.superseded_by "
        <> "OR m.superseded_by = c.memory_id"
        <> ") SELECT "
        <> memoryRowColumns
        <> " FROM chain ORDER BY created_at ASC, memory_id ASC"
    )
    (E.param (E.nonNullable E.text))
    (D.rowList memoryRowDecoder)

upsertMemoryStmt :: Statement MemoryRow ()
upsertMemoryStmt =
  preparable
    """
    INSERT INTO kioku_memories
      (memory_id, agent_id, session_id, namespace, scope_kind, scope_ref, memory_type, content,
       priority, confidence, tags, status, superseded_by, supersedes, created_at, updated_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14, $15, $16)
    ON CONFLICT (memory_id) DO UPDATE SET
      agent_id = EXCLUDED.agent_id,
      session_id = EXCLUDED.session_id,
      namespace = EXCLUDED.namespace,
      scope_kind = EXCLUDED.scope_kind,
      scope_ref = EXCLUDED.scope_ref,
      memory_type = EXCLUDED.memory_type,
      content = EXCLUDED.content,
      priority = EXCLUDED.priority,
      confidence = EXCLUDED.confidence,
      tags = EXCLUDED.tags,
      status = EXCLUDED.status,
      superseded_by = EXCLUDED.superseded_by,
      supersedes = EXCLUDED.supersedes,
      updated_at = EXCLUDED.updated_at
    """
    memoryRowEncoder
    D.noResult

memoryRowEncoder :: E.Params MemoryRow
memoryRowEncoder =
  ((\row -> row.memoryId) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.agentId) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.sessionId) >$< E.param (E.nullable E.text))
    <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.scopeKind) >$< E.param (E.nullable E.text))
    <> ((\row -> row.scopeRef) >$< E.param (E.nullable E.text))
    <> ((\row -> row.memoryType) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.content) >$< E.param (E.nonNullable E.text))
    <> ((fromIntegral @Int @Int32 . \row -> row.priority) >$< E.param (E.nonNullable E.int4))
    <> ((\row -> row.confidence) >$< E.param (E.nonNullable E.text))
    <> ((encodeTags . (\row -> row.tags)) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.status) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.supersededBy) >$< E.param (E.nullable E.text))
    <> ((\row -> row.supersedes) >$< E.param (E.nullable E.text))
    <> ((\row -> row.createdAt) >$< E.param (E.nonNullable E.timestamptz))
    <> ((\row -> row.updatedAt) >$< E.param (E.nonNullable E.timestamptz))

updateMemorySupersededStmt :: Statement (Text, Text, UTCTime) ()
updateMemorySupersededStmt =
  preparable
    "UPDATE kioku_memories SET status = 'superseded', superseded_by = $2, updated_at = $3 WHERE memory_id = $1"
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

updateMemoryArchivedStmt :: Statement (Text, UTCTime) ()
updateMemoryArchivedStmt =
  preparable
    "UPDATE kioku_memories SET status = 'archived', updated_at = $2 WHERE memory_id = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult

updateMemoryTagsStmt :: Statement (Text, Set Text, UTCTime) ()
updateMemoryTagsStmt =
  preparable
    "UPDATE kioku_memories SET tags = $2::jsonb, updated_at = $3 WHERE memory_id = $1"
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (encodeTags >$< E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

updateMemoryConfidenceStmt :: Statement (Text, Text, UTCTime) ()
updateMemoryConfidenceStmt =
  preparable
    "UPDATE kioku_memories SET confidence = $2, updated_at = $3 WHERE memory_id = $1"
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

updateMemoryMergedStmt :: Statement (Text, Text, UTCTime) ()
updateMemoryMergedStmt =
  preparable
    "UPDATE kioku_memories SET status = 'merged', superseded_by = $2, updated_at = $3 WHERE memory_id = $1"
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult