kioku-cli-0.6.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.AI.Config (AIFeature (CandidateEmbedding))
import Kioku.App (runAppIO, withNoopAppEnv)
import Kioku.Cli.AIConfig (aiConfigOption, loadAIRuntime)
import Kioku.Cli.Context (cliMemoryContext)
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 (resolveEmbeddingConfig)
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,
aiConfig :: !(Maybe FilePath)
}
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"
)
<*> aiConfigOption
runDistill :: DistillOptions -> IO ()
runDistill opts = do
connStr <- requireEnv "PG_CONNECTION_STRING"
ai <- loadAIRuntime True opts.aiConfig
let rt = newDistillRuntime ai Nothing
context <- cliMemoryContext
withNoopAppEnv (defaultConnectionSettings (Text.pack connStr)) \env -> do
result <- runAppIO env do
finder <- case (opts.candidateSource, resolveEmbeddingConfig ai CandidateEmbedding) of
(CandidateRecall, Right _) -> do
capability <- detectVectorCapability 1536
pure (recallCandidates ai capability opts.candidateLimit)
_ -> pure (scopedScanCandidates opts.candidateLimit)
distillSessionL1 context (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"))