kioku-cli-0.1.0.0: src/Kioku/Cli/Commands/Distill.hs
module Kioku.Cli.Commands.Distill
( CandidateSource (..),
DistillOptions (..),
distillOptionsParser,
runDistill,
)
where
import Data.Text qualified as Text
import Kioku.App (runAppIO, withNoopAppEnv)
import Kioku.Cli.Options (boundedIntReader)
import Kioku.Distill.L1 (L1Outcome (..), L1RunMode (..), L1Summary (..), distillSessionL1, recallCandidates, scopedScanCandidates)
import Kioku.Distill.Runtime (newDistillRuntime)
import Kioku.Id (SessionId, idText, parseId)
import Kioku.Memory.Embedding (EmbeddingConfig (..), resolveEmbeddingConfig, toEmbeddingModel)
import Kioku.Recall.Capability (detectVectorCapability)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Options.Applicative
import System.Environment (lookupEnv)
data CandidateSource = CandidateScan | CandidateRecall
deriving stock (Eq, Show)
data DistillOptions = DistillOptions
{ sessionId :: !SessionId,
candidateSource :: !CandidateSource,
candidateLimit :: !Int,
force :: !Bool
}
deriving stock (Eq, Show)
distillOptionsParser :: Parser DistillOptions
distillOptionsParser =
hsubparser $
command
"session"
( info
sessionOptionsParser
(progDesc "Run one L1 distillation pass for a session")
)
sessionOptionsParser :: Parser DistillOptions
sessionOptionsParser =
DistillOptions
<$> argument
(eitherReader parseSessionId)
(metavar "SESSION_ID")
<*> option
(eitherReader parseCandidateSource)
( long "candidates"
<> metavar "scan|recall"
<> value CandidateScan
<> help "Candidate lookup source"
)
<*> option
(boundedIntReader "LIMIT" 1 50)
( long "limit"
<> metavar "N"
<> value 5
<> help "Maximum merge candidates per extracted atom (1-50)"
)
<*> switch
( long "force"
<> help "Re-run even when the session has no turns newer than the last successful pass"
)
runDistill :: DistillOptions -> IO ()
runDistill opts = do
connStr <- requireEnv "PG_CONNECTION_STRING"
rt <- newDistillRuntime
recallConfig <-
case opts.candidateSource of
CandidateScan -> pure Nothing
CandidateRecall -> Just <$> resolveEmbeddingConfig
withNoopAppEnv (defaultConnectionSettings (Text.pack connStr)) \env -> do
result <- runAppIO env do
finder <-
case (opts.candidateSource, recallConfig) of
(CandidateRecall, Just config) -> do
capability <- detectVectorCapability config.dimensions
pure (recallCandidates (toEmbeddingModel config) capability opts.candidateLimit)
_ ->
pure (scopedScanCandidates opts.candidateLimit)
distillSessionL1 (runMode opts) rt finder opts.sessionId
case result of
Left storeErr -> ioError (userError ("kioku distill store error: " <> show storeErr))
Right (Left l1Err) -> ioError (userError ("kioku distill error: " <> show l1Err))
Right (Right (L1Distilled summary)) -> printSummary opts.sessionId summary
Right (Right L1SkippedUpToDate) ->
putStrLn "Session already distilled (no new turns); use --force to re-run."
runMode :: DistillOptions -> L1RunMode
runMode opts
| opts.force = IgnoreWatermark
| otherwise = RespectWatermark
-- | Strict: only a @kioku_session@ id is accepted here. The lenient parser this used to call
-- would take a @kioku_memory@ id, throw its prefix away, and rebrand the UUID — so the pass ran
-- against a session that does not exist and reported nothing wrong.
parseSessionId :: String -> Either String SessionId
parseSessionId raw =
case parseId (Text.pack raw) of
Left err -> Left (Text.unpack err)
Right sid -> Right sid
parseCandidateSource :: String -> Either String CandidateSource
parseCandidateSource = \case
"scan" -> Right CandidateScan
"recall" -> Right CandidateRecall
other -> Left ("unknown candidate source: " <> other)
printSummary :: SessionId -> L1Summary -> IO ()
printSummary sid summary =
putStrLn $
"Distilled session "
<> Text.unpack (idText sid)
<> ": extracted="
<> show summary.extracted
<> " stored="
<> show summary.stored
<> " merged="
<> show summary.merged
<> " skipped="
<> show summary.skipped
requireEnv :: String -> IO String
requireEnv name = do
found <- lookupEnv name
case found of
Just envValue -> pure envValue
Nothing -> ioError (userError (name <> " is not set"))