kioku-cli 0.5.2.0 → 0.6.0.0
raw patch · 8 files changed
+300/−95 lines, 8 filesdep +aesondep +baikaidep +filepathdep ~kioku-apidep ~kioku-clidep ~kioku-corePVP ok
version bump matches the API change (PVP)
Dependencies added: aeson, baikai, filepath, keiro, temporary, uuid
Dependency ranges changed: kioku-api, kioku-cli, kioku-core, kioku-migrations
API changes (from Hackage documentation)
+ Kioku.Cli.AIConfig: aiConfigOption :: Parser (Maybe FilePath)
+ Kioku.Cli.AIConfig: loadAIRuntime :: Bool -> Maybe FilePath -> IO AIRuntime
+ Kioku.Cli.AIConfig: parseAIConfig :: Value -> Parser (AIConfig, [ExecutionMode])
+ Kioku.Cli.Commands.Distill: [aiConfig] :: DistillOptions -> !Maybe FilePath
+ Kioku.Cli.Commands.Recall: [aiConfig] :: RecallOptions -> !Maybe FilePath
+ Kioku.Cli.Commands.Worker: WorkerConfigured :: !FilePath -> !WorkerOptions -> WorkerOptions
+ Kioku.Cli.Commands.Worker: WorkerDeferredList :: WorkerOptions
+ Kioku.Cli.Commands.Worker: WorkerDeferredResume :: !TimerId -> WorkerOptions
- Kioku.Cli.Commands.Distill: DistillOptions :: !SessionId -> !CandidateSource -> !Int -> !Bool -> DistillOptions
+ Kioku.Cli.Commands.Distill: DistillOptions :: !SessionId -> !CandidateSource -> !Int -> !Bool -> !Maybe FilePath -> DistillOptions
- Kioku.Cli.Commands.Recall: RecallOptions :: !Text -> !RecallTarget -> !RecallStrategy -> !Int -> !Bool -> RecallOptions
+ Kioku.Cli.Commands.Recall: RecallOptions :: !Text -> !RecallTarget -> !RecallStrategy -> !Int -> !Bool -> !Maybe FilePath -> RecallOptions
Files
- CHANGELOG.md +24/−0
- kioku-cli.cabal +29/−20
- src/Kioku/Cli/AIConfig.hs +8/−0
- src/Kioku/Cli/Commands/Distill.hs +13/−14
- src/Kioku/Cli/Commands/Recall.hs +7/−6
- src/Kioku/Cli/Commands/Worker.hs +133/−50
- test/Kioku/Cli/ParserSpec.hs +12/−1
- test/Kioku/Cli/RecallEndToEndSpec.hs +74/−4
CHANGELOG.md view
@@ -1,5 +1,29 @@ # Changelog +## 0.6.0.0 — 2026-09-08++### Breaking Changes++- AI features are configured only through explicit host configuration. `--ai-config FILE` or+ `KIOKU_AI_CONFIG` selects it; when neither is present AI is disabled, and credentials in the+ environment no longer enable it. The `KIOKU_EMBEDDING_*` and `OPENAI_API_KEY` variables that+ previously configured embedding are not consulted.+- `kioku-migrate`-composed deployments move to a 56-migration plan; see the `kioku-migrations`+ changelog. Any script asserting on the previous count needs updating before this release.++### Added++- `Kioku.Cli.AIConfig`, exposing the `--ai-config` option and the same versioned file boundary+ embedded hosts use.+- `kioku worker deferred list` and `kioku worker deferred resume TIMER_ID --ai-config FILE`.+ Foreground resume uses the configured execution capabilities and preserves the original parked+ work; background workers never receive interactive session ownership.++### Changed++- `recall`, `distill`, and the embedding worker all resolve their models through the configured+ runtime, so a host that disables a feature disables it uniformly across commands.+ ## 0.5.2.0 — 2026-08-31 ### Changed
kioku-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: kioku-cli-version: 0.5.2.0+version: 0.6.0.0 synopsis: kioku command-line interface description: Command-line entry point for kioku demos and operational commands.@@ -46,6 +46,7 @@ hs-source-dirs: src exposed-modules: Kioku.Cli+ Kioku.Cli.AIConfig Kioku.Cli.Commands.Artifacts Kioku.Cli.Commands.Demo Kioku.Cli.Commands.DemoSession@@ -71,17 +72,20 @@ -- mori://MMZK1526/mmzk-typeid/upstream-issues/mmzk-typeid-kindid-ghc-9-12-4-profiling-coercionkind-panic ghc-prof-options: -fno-opt-coercion build-depends:- , async >=2.2 && <2.3- , base >=4.21 && <5- , containers >=0.6 && <0.8- , directory >=1.3 && <1.4- , effectful >=2.5 && <2.7- , kioku-api ^>=0.5.2.0- , kioku-core ^>=0.5.2.0+ , async >=2.2 && <2.3+ , baikai ^>=0.7.0.0+ , base >=4.21 && <5+ , containers >=0.6 && <0.8+ , directory >=1.3 && <1.4+ , effectful >=2.5 && <2.7+ , keiro ^>=0.16.0.0+ , kioku-api ^>=0.6.0.0+ , kioku-core ^>=0.6.0.0 , kiroku-store ^>=0.8.0.0- , optparse-applicative >=0.18 && <0.20- , text >=2.1 && <2.2- , time >=1.12 && <1.15+ , optparse-applicative >=0.18 && <0.20+ , text >=2.1 && <2.2+ , time >=1.12 && <1.15+ , uuid >=1.3 && <1.4 executable kioku import: warnings, shared@@ -90,7 +94,7 @@ ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends: , base >=4.21 && <5- , kioku-cli ^>=0.5.2.0+ , kioku-cli ^>=0.6.0.0 test-suite kioku-cli-test import: warnings, shared@@ -108,16 +112,21 @@ -- process would race the rest of the suite. build-tool-depends: kioku-cli:kioku build-depends:- , base >=4.21 && <5- , effectful >=2.5 && <2.7- , kioku-api ^>=0.5.2.0- , kioku-cli ^>=0.5.2.0- , kioku-core ^>=0.5.2.0- , kioku-migrations:test-support ^>=0.5.2.0+ , aeson >=2.2 && <2.3+ , base >=4.21 && <5+ , effectful >=2.5 && <2.7+ , filepath >=1.4 && <1.6+ , keiro ^>=0.16.0.0+ , kioku-api ^>=0.6.0.0+ , kioku-cli ^>=0.6.0.0+ , kioku-core ^>=0.6.0.0+ , kioku-migrations:test-support ^>=0.6.0.0 , kiroku-store ^>=0.8.0.0 , optparse-applicative >=0.18- , process >=1.6 && <1.7+ , process >=1.6 && <1.7 , tasty >=1.5 , tasty-hunit >=0.10+ , temporary >=1.3 , text >=2.1- , time >=1.12 && <1.15+ , time >=1.12 && <1.15+ , uuid >=1.3 && <1.4
+ src/Kioku/Cli/AIConfig.hs view
@@ -0,0 +1,8 @@+-- | CLI assembly delegates to the same versioned file boundary as embedded hosts.+module Kioku.Cli.AIConfig (aiConfigOption, loadAIRuntime, parseAIConfig) where++import Kioku.AI.File (loadAIRuntime, parseAIConfig)+import Options.Applicative qualified as Opt++aiConfigOption :: Opt.Parser (Maybe FilePath)+aiConfigOption = Opt.optional (Opt.strOption (Opt.long "ai-config" <> Opt.metavar "FILE" <> Opt.help "Explicit AI configuration (otherwise KIOKU_AI_CONFIG; absent means disabled)"))
src/Kioku/Cli/Commands/Distill.hs view
@@ -7,13 +7,15 @@ 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 (EmbeddingConfig (..), resolveEmbeddingConfig, toEmbeddingModel)+import Kioku.Memory.Embedding (resolveEmbeddingConfig) import Kioku.Recall.Capability (detectVectorCapability) import Kiroku.Store.Connection (defaultConnectionSettings) import Options.Applicative@@ -26,7 +28,8 @@ { sessionId :: !SessionId, candidateSource :: !CandidateSource, candidateLimit :: !Int,- force :: !Bool+ force :: !Bool,+ aiConfig :: !(Maybe FilePath) } deriving stock (Eq, Show) @@ -64,25 +67,21 @@ ( 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"- rt <- newDistillRuntime+ ai <- loadAIRuntime True opts.aiConfig+ let rt = newDistillRuntime ai Nothing context <- cliMemoryContext- 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)+ 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))
src/Kioku/Cli/Commands/Recall.hs view
@@ -37,10 +37,10 @@ import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (MemoryRecord (..)) import Kioku.App (runAppIO, withNoopAppEnv)+import Kioku.Cli.AIConfig (aiConfigOption, loadAIRuntime) import Kioku.Cli.Context (cliMemoryContext) import Kioku.Cli.Options (boundedIntReader) import Kioku.Cli.Scope (parseNamespaceOnly, parseScope)-import Kioku.Memory.Embedding (EmbeddingConfig (..), resolveEmbeddingConfig, toEmbeddingModel) import Kioku.Recall ( RecallHit (..), RecallStrategy (..),@@ -63,7 +63,8 @@ target :: !RecallTarget, strategy :: !RecallStrategy, limit :: !Int,- showScores :: !Bool+ showScores :: !Bool,+ aiConfig :: !(Maybe FilePath) } deriving stock (Eq, Show) @@ -90,6 +91,7 @@ ( long "show-scores" <> help "Print fused scores and component ranks" )+ <*> aiConfigOption -- | Exactly one of the three target flags, and never two. --@@ -168,7 +170,7 @@ runRecall :: RecallOptions -> IO () runRecall opts = do connStr <- requireEnv "PG_CONNECTION_STRING"- config <- resolveEmbeddingConfig+ ai <- loadAIRuntime False opts.aiConfig context <- cliMemoryContext request <- case mkRecallQuery opts.target opts.query opts.strategy opts.limit of@@ -185,10 +187,9 @@ <> Text.unpack (memorySpaceIdText (memoryContextSpace context)) ) withNoopAppEnv (defaultConnectionSettings (Text.pack connStr)) \env -> do- let model = toEmbeddingModel config result <- runAppIO env do- capability <- detectVectorCapability config.dimensions- recall model capability context request+ capability <- detectVectorCapability 1536+ recall ai capability context request case result of Left storeErr -> ioError (userError ("kioku recall store error: " <> show storeErr)) Right (Left recallErr) -> ioError (userError ("kioku recall error: " <> show recallErr))
src/Kioku/Cli/Commands/Worker.hs view
@@ -5,19 +5,27 @@ ) where +import Baikai.Embedding (EmbeddingModel) import Control.Concurrent (threadDelay) import Control.Concurrent.Async (race) import Control.Exception (SomeException, displayException, try) import Data.Text qualified as Text import Data.Time (getCurrentTime)+import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>))+import Keiro.Timer qualified as Timer+import Kioku.AI.Config (AIFeature (MemoryEmbedding))+import Kioku.AI.Runtime (AIRuntime) import Kioku.Api.Access (MemorySpaceId, memorySpaceIdText, mkMemorySpaceId) import Kioku.App (AppEffects, AppEnv, runAppIO, withNoopAppEnv)+import Kioku.Cli.AIConfig (aiConfigOption, loadAIRuntime) import Kioku.Cli.Context (cliContextProvider) import Kioku.Distill.L1 (FindMergeCandidates, recallCandidates) import Kioku.Distill.Runtime (newDistillRuntime)+import Kioku.Distill.Timer.Deferred+import Kioku.Distill.Timer.Outcome (FireOutcome (..)) import Kioku.Distill.Timer.Worker (drainKiokuTimers, runKiokuTimerWorkerOnce)-import Kioku.Memory.Embedding (EmbeddingConfig (..), resolveEmbeddingConfig, toEmbeddingModel)+import Kioku.Memory.Embedding (resolveEmbeddingConfig) import Kioku.Memory.Embedding.Worker ( EmbeddingBackfillScope (..), backfillMissingEmbeddings,@@ -44,20 +52,28 @@ -- operator run a backfill, see a count, and never learn that the other spaces are still -- unsearchable. data WorkerOptions- = WorkerContinuous+ = WorkerConfigured !FilePath !WorkerOptions+ | WorkerContinuous | WorkerBackfill !EmbeddingBackfillScope+ | WorkerDeferredList+ | WorkerDeferredResume !Timer.TimerId | WorkerTimersOnce deriving stock (Eq, Show) workerOptionsParser :: Parser WorkerOptions-workerOptionsParser =- ( flag'- WorkerBackfill- ( long "backfill"- <> help "Run one embedding backfill pass and exit (conflicts with --timers-once)"- )- <*> backfillScopeParser- )+workerOptionsParser = (\config mode -> maybe mode (`WorkerConfigured` mode) config) <$> aiConfigOption <*> workerModeParser++workerModeParser :: Parser WorkerOptions+workerModeParser =+ hsubparser+ (command "deferred" (info deferredParser (progDesc "List or resume authorized deferred timers")))+ <|> ( flag'+ WorkerBackfill+ ( long "backfill"+ <> help "Run one embedding backfill pass and exit (conflicts with --timers-once)"+ )+ <*> backfillScopeParser+ ) <|> flag' WorkerTimersOnce ( long "timers-once"@@ -65,6 +81,21 @@ ) <|> pure WorkerContinuous +deferredParser :: Parser WorkerOptions+deferredParser =+ hsubparser+ ( command "list" (info (pure WorkerDeferredList) (progDesc "List deferred distillation work"))+ <> command+ "resume"+ ( info+ (WorkerDeferredResume <$> argument timerIdReader (metavar "TIMER_ID"))+ (progDesc "Resume one deferred timer using foreground AI capabilities")+ )+ )+ where+ timerIdReader = eitherReader $ \raw ->+ maybe (Left "TIMER_ID must be a UUID") (Right . Timer.TimerId) (UUID.fromString raw)+ backfillScopeParser :: Parser EmbeddingBackfillScope backfillScopeParser = maybe BackfillEverySpace BackfillOneSpace@@ -84,22 +115,36 @@ Right space -> Right space runWorker :: WorkerOptions -> IO ()-runWorker opts = do+runWorker opts = case opts of+ WorkerConfigured path mode -> runConfiguredWorker (Just path) mode+ _ -> runConfiguredWorker Nothing opts++runConfiguredWorker :: Maybe FilePath -> WorkerOptions -> IO ()+runConfiguredWorker path opts = do+ ai <- loadAIRuntime (case opts of WorkerDeferredResume _ -> True; _ -> False) path+ case opts of+ WorkerBackfill _ -> either (dieWorker . show) (const (pure ())) (resolveEmbeddingConfig ai MemoryEmbedding)+ _ -> pure () connStr <- requireEnv "PG_CONNECTION_STRING"- config <- resolveEmbeddingConfig let settings = defaultConnectionSettings (Text.pack connStr) withStore settings $ \st ->- withNoopAppEnv settings \env ->- case opts of- WorkerTimersOnce -> runTimerOnce env config- WorkerBackfill scope -> withCapability env config \capability ->- runBackfill env capability config scope- WorkerContinuous -> withCapability env config \capability ->- runContinuousWorker env st capability config+ withNoopAppEnv settings \env -> case opts of+ WorkerDeferredList -> runDeferredList env+ WorkerDeferredResume tid -> runDeferredResume ai env tid+ WorkerTimersOnce -> runTimerOnce ai env+ WorkerBackfill scope -> case resolveEmbeddingConfig ai MemoryEmbedding of+ Left err -> dieWorker (show err)+ Right model -> withCapability env model $ \capability -> runBackfill ai env capability scope+ WorkerContinuous -> case resolveEmbeddingConfig ai MemoryEmbedding of+ Left _ -> do+ putStrLn "Memory embeddings disabled by AI policy; running timer worker only."+ runTimerLoop ai env VectorExtensionUnavailable+ Right model -> withCapability env model $ \capability -> runContinuousWorker ai env st capability model+ WorkerConfigured nested mode -> runConfiguredWorker (Just nested) mode -withCapability :: AppEnv -> EmbeddingConfig -> (VectorCapability -> IO a) -> IO a-withCapability env config k = do- result <- runAppIO env (detectVectorCapability config.dimensions)+withCapability :: AppEnv -> EmbeddingModel -> (VectorCapability -> IO a) -> IO a+withCapability env _config k = do+ result <- runAppIO env (detectVectorCapability 1536) case result of Left storeErr -> ioError (userError ("kioku worker store error: " <> show storeErr)) Right capability -> k capability@@ -111,25 +156,25 @@ -- so no capability gating is needed here. mergeCandidateFinder :: (IOE :> es, Store :> es) =>- EmbeddingConfig ->+ AIRuntime -> VectorCapability -> FindMergeCandidates es-mergeCandidateFinder config capability =- recallCandidates (toEmbeddingModel config) capability mergeCandidateLimit+mergeCandidateFinder ai capability =+ recallCandidates ai capability mergeCandidateLimit mergeCandidateLimit :: Int mergeCandidateLimit = 8 -runBackfill :: AppEnv -> VectorCapability -> EmbeddingConfig -> EmbeddingBackfillScope -> IO ()-runBackfill env capability config scope = do+runBackfill :: AIRuntime -> AppEnv -> VectorCapability -> EmbeddingBackfillScope -> IO ()+runBackfill ai env capability scope = do -- Refuse before any event is touched: a backfill under a mismatched dimension count would -- embed every memory in the store and fail the ::vector cast on every single one. case capability of VectorDimensionMismatch configured actual -> dieWorker (dimensionMismatchMessage configured actual) _ -> pure ()- let model = toEmbeddingModel config- result <- runAppIO env (backfillMissingEmbeddings capability (mkEmbeddingWorkerEnv model config.dimensions) scope)+ embeddingEnv <- either (ioError . userError . show) pure (mkEmbeddingWorkerEnv ai)+ result <- runAppIO env (backfillMissingEmbeddings capability embeddingEnv scope) case result of Left storeErr -> ioError (userError ("kioku worker backfill store error: " <> show storeErr)) Right count ->@@ -152,18 +197,17 @@ -- 'race' makes both directions loud: whichever pipeline stops first ends the -- race, and the process exits non-zero with a reason so a supervisor restarts -- it. Neither side is expected to return at all.-runContinuousWorker :: AppEnv -> KirokuStore -> VectorCapability -> EmbeddingConfig -> IO ()-runContinuousWorker env store capability config = do- let model = toEmbeddingModel config+runContinuousWorker :: AIRuntime -> AppEnv -> KirokuStore -> VectorCapability -> EmbeddingModel -> IO ()+runContinuousWorker ai env store capability _config = do contexts <- cliContextProvider @(Eff AppEffects) case capability of VectorAvailable -> do- startupBackfill env capability config+ startupBackfill ai env capability outcome <- try @SomeException $ race- (runTimerLoop env capability config)- (runAppIO env (runEmbeddingWorkerHost store contexts capability model config.dimensions))+ (runTimerLoop ai env capability)+ (runAppIO env (runEmbeddingWorkerHost store contexts capability ai)) case outcome of -- A halted processor can tear its own machinery down hard enough to -- surface as an exception rather than a clean return (shibuya's halt path@@ -181,16 +225,16 @@ dieWorker "embedding worker stopped (processor halted or subscription ended)" VectorExtensionUnavailable -> do putStrLn "pgvector is not available; recall will run FTS-only; running kioku timer worker only."- runTimerLoop env capability config+ runTimerLoop ai env capability VectorColumnsUnavailable missing -> do putStrLn ("pgvector columns are missing (" <> Text.unpack (Text.intercalate ", " missing) <> "); running kioku timer worker only.")- runTimerLoop env capability config+ runTimerLoop ai env capability -- Loud, but not fatal. Every embedding write would fail on the ::vector cast, so there -- is no point starting the embedding host — but distillation timers have nothing to do -- with embeddings, and killing the whole worker would stop them too. VectorDimensionMismatch configured actual -> do hPutStrLn stderr ("kioku worker: " <> dimensionMismatchMessage configured actual <> "; running kioku timer worker only.")- runTimerLoop env capability config+ runTimerLoop ai env capability -- | A dimension mismatch would otherwise be discovered one failed event at a time, forever. dimensionMismatchMessage :: Int -> Int -> String@@ -209,14 +253,15 @@ -- It covers every space, not @KIOKU_MEMORY_SPACE@: this process is about to subscribe to every -- space's memory events, so recovering only one space's would leave the others' recall degraded -- with nothing to say so.-startupBackfill :: AppEnv -> VectorCapability -> EmbeddingConfig -> IO ()-startupBackfill env capability config = do+startupBackfill :: AIRuntime -> AppEnv -> VectorCapability -> IO ()+startupBackfill ai env capability = do+ embeddingEnv <- either (ioError . userError . show) pure (mkEmbeddingWorkerEnv ai) result <- runAppIO env ( backfillMissingEmbeddings capability- (mkEmbeddingWorkerEnv (toEmbeddingModel config) config.dimensions)+ embeddingEnv BackfillEverySpace ) case result of@@ -230,14 +275,14 @@ hPutStrLn stderr ("kioku worker: " <> msg <> "; exiting") exitWith (ExitFailure 1) -runTimerOnce :: AppEnv -> EmbeddingConfig -> IO ()-runTimerOnce env config = do- rt <- newDistillRuntime+runTimerOnce :: AIRuntime -> AppEnv -> IO ()+runTimerOnce ai env = do+ let rt = newDistillRuntime ai Nothing contexts <- cliContextProvider @(Eff AppEffects) now <- getCurrentTime result <- runAppIO env do- capability <- detectVectorCapability config.dimensions- runKiokuTimerWorkerOnce Nothing contexts rt (mergeCandidateFinder config capability) now+ capability <- detectVectorCapability 1536+ runKiokuTimerWorkerOnce Nothing contexts rt (mergeCandidateFinder ai capability) now case result of Left storeErr -> ioError (userError ("kioku timer worker store error: " <> show storeErr)) Right Nothing -> putStrLn "No due kioku distillation timers."@@ -255,13 +300,13 @@ -- This never returns normally, so 'race' seeing it finish genuinely means -- something impossible happened. Non-store exceptions propagate to 'race', which -- is equally loud.-runTimerLoop :: AppEnv -> VectorCapability -> EmbeddingConfig -> IO ()-runTimerLoop env capability config = do- rt <- newDistillRuntime+runTimerLoop :: AIRuntime -> AppEnv -> VectorCapability -> IO ()+runTimerLoop ai env capability = do+ let rt = newDistillRuntime ai Nothing contexts <- cliContextProvider @(Eff AppEffects) putStrLn "kioku timer worker started." let go failures = do- result <- runAppIO env (drainKiokuTimers Nothing contexts rt (mergeCandidateFinder config capability))+ result <- runAppIO env (drainKiokuTimers Nothing contexts rt (mergeCandidateFinder ai capability)) case result of Left storeErr -> do hPutStrLn stderr ("kioku timer worker: store error (will retry): " <> show storeErr)@@ -288,3 +333,41 @@ case found of Just envValue -> pure envValue Nothing -> ioError (userError (name <> " is not set"))++runDeferredList :: AppEnv -> IO ()+runDeferredList env = do+ contexts <- cliContextProvider @(Eff AppEffects)+ let go cursor = do+ result <- runAppIO env (listDeferredTimers contexts (Timer.DeadTimerPageRequest 100 cursor))+ case result of+ Left err -> dieWorker (show err)+ Right (Left err) -> dieWorker (show err)+ Right (Right page) -> do+ mapM_ render page.entries+ maybe (pure ()) (go . Just) page.nextAfterTimerId+ render entry = do+ let Timer.TimerId uuid = entry.timer.timerId+ putStrLn+ ( UUID.toString uuid+ <> " space="+ <> Text.unpack (memorySpaceIdText entry.memorySpace)+ <> " features="+ <> show entry.features+ <> " attempts="+ <> show entry.timer.attempts+ <> " reason="+ <> Text.unpack entry.reason+ )+ putStrLn (" Resume: kioku worker deferred resume " <> UUID.toString uuid <> " --ai-config FILE")+ go Nothing++runDeferredResume :: AIRuntime -> AppEnv -> Timer.TimerId -> IO ()+runDeferredResume ai env tid = do+ contexts <- cliContextProvider @(Eff AppEffects)+ result <- runAppIO env do+ capability <- detectVectorCapability 1536+ resumeDeferredTimer contexts (newDistillRuntime ai Nothing) (mergeCandidateFinder ai capability) tid+ case result of+ Right (DeferredFinished (FireCompleted _)) -> putStrLn "Completed the original deferred timer."+ Right outcome -> dieWorker (show outcome <> "; unfinished work remains parked; inspect configuration, authorization, and attempt ceiling before retrying")+ Left err -> dieWorker (show err)
test/Kioku/Cli/ParserSpec.hs view
@@ -6,6 +6,8 @@ import Data.List (isInfixOf) import Data.Text qualified as Text+import Data.UUID qualified as UUID+import Keiro.Timer (TimerId (..)) import Kioku.Api.Access (mkMemorySpaceId) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Cli.Commands.Demo (DemoOptions (..), demoOptionsParser, demoScope)@@ -303,7 +305,16 @@ workerModeTests = testGroup "worker one-shot modes are mutually exclusive"- [ testCase "no flags means the continuous worker" do+ [ testCase "deferred resume accepts a UUID and trailing AI config" do+ parseWith workerOptionsParser ["deferred", "resume", UUID.toString UUID.nil, "--ai-config", "ai.json"]+ @?= Right (WorkerConfigured "ai.json" (WorkerDeferredResume (TimerId UUID.nil))),+ testCase "deferred listing parses" do+ parseWith workerOptionsParser ["deferred", "list"] @?= Right WorkerDeferredList,+ testCase "deferred resume rejects malformed timer IDs" do+ case parseWith workerOptionsParser ["deferred", "resume", "bad"] of+ Left _ -> pure ()+ Right _ -> assertBool "invalid UUID accepted" False,+ testCase "no flags means the continuous worker" do parseWith workerOptionsParser [] @?= Right WorkerContinuous, testCase "--backfill covers every space unless one is named" do parseWith workerOptionsParser ["--backfill"] @?= Right (WorkerBackfill BackfillEverySpace),
test/Kioku/Cli/RecallEndToEndSpec.hs view
@@ -16,12 +16,15 @@ module Kioku.Cli.RecallEndToEndSpec (tests) where import Control.Monad.IO.Class (liftIO)+import Data.Aeson (toJSON) import Data.List (isInfixOf) import Data.Text (Text) import Data.Text qualified as Text import Data.Time (UTCTime, getCurrentTime)+import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)+import Keiro.Timer qualified as Timer import Kioku.Api.Access ( MemoryAccessContext, MemoryActor (..),@@ -35,6 +38,7 @@ import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (Confidence (..), MemoryType (..)) import Kioku.App (runAppIO, withNoopAppEnv)+import Kioku.Distill.L2 (SceneTimerPayload (..), l2SceneProcessManagerName) import Kioku.Id (genMemoryId) import Kioku.Memory qualified as Memory import Kioku.Memory.Domain (RecordMemoryData (..))@@ -43,8 +47,11 @@ import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource) import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction) import System.Environment (getEnvironment) import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory) import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))@@ -53,9 +60,27 @@ tests = testGroup "kioku recall end to end"- [ testCase "each flag reaches the database as its own target, inside one space" targetsReachPostgres+ [ testCase "deferred commands list, refuse disabled execution, and complete original work" deferredCommands,+ testCase "explicit AI file overrides environment and credentials do not enable AI" aiFilePrecedence,+ testCase "each flag reaches the database as its own target, inside one space" targetsReachPostgres ] +aiFilePrecedence :: IO ()+aiFilePrecedence = withSystemTempDirectory "kioku-ai-config-test" $ \dir -> do+ let path = dir </> "disabled.json"+ writeFile path "{\"version\":1}"+ inherited <- getEnvironment+ let variables =+ filter (\(name, _) -> name `notElem` ["KIOKU_AI_CONFIG", "PG_CONNECTION_STRING", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"]) inherited+ <> [("KIOKU_AI_CONFIG", dir </> "missing.json"), ("PG_CONNECTION_STRING", "host=/nonexistent connect_timeout=1"), ("ANTHROPIC_API_KEY", "sentinel-not-a-credential")]+ (code, _, err) <- readCreateProcessWithExitCode (proc "kioku" ["worker", "--backfill", "--ai-config", path]) {env = Just variables} ""+ assertBool "disabled backfill fails" (code /= ExitSuccess)+ assertContains "explicit file wins" "AIDisabled MemoryEmbedding" err+ assertMissing "credential redaction" "sentinel-not-a-credential" err+ (environmentCode, _, environmentError) <- readCreateProcessWithExitCode (proc "kioku" ["worker", "--backfill"]) {env = Just variables} ""+ assertBool "environment file is used without flag" (environmentCode /= ExitSuccess)+ assertContains "environment fallback" "missing.json" environmentError+ -- | Two spaces holding the same namespace and the same two scopes, with content that names which -- space and which scope it came from — so a row appearing in the wrong answer is unmistakable. alphaSpace :: MemorySpaceId@@ -167,17 +192,20 @@ -- The connection string and memory space are replaced rather than added, so a developer's -- exported @PG_CONNECTION_STRING@ cannot redirect the test at their own database. runKioku :: Text -> MemorySpaceId -> [String] -> IO (ExitCode, String, String)-runKioku connStr space args = do+runKioku = runKiokuAt Nothing++runKiokuAt :: Maybe FilePath -> Text -> MemorySpaceId -> [String] -> IO (ExitCode, String, String)+runKiokuAt directory connStr space args = do inherited <- getEnvironment let overridden = [ (name, value) | (name, value) <- inherited,- name `notElem` ["PG_CONNECTION_STRING", "KIOKU_MEMORY_SPACE"]+ name `notElem` ["PG_CONNECTION_STRING", "KIOKU_MEMORY_SPACE", "KIOKU_AI_CONFIG", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"] ] <> [ ("PG_CONNECTION_STRING", Text.unpack connStr), ("KIOKU_MEMORY_SPACE", Text.unpack (memorySpaceIdText space)) ]- readCreateProcessWithExitCode (proc "kioku" args) {env = Just overridden} ""+ readCreateProcessWithExitCode (proc "kioku" args) {env = Just overridden, cwd = directory} "" assertContains :: String -> String -> String -> IO () assertContains label needle haystack =@@ -200,3 +228,45 @@ spaceNamed :: Text -> MemorySpaceId spaceNamed raw = either (error . Text.unpack) id (mkMemorySpaceId raw)++-- No memories exist in this scope, so valid foreground scene work completes+-- without invoking a model. The core timer suite separately exercises the real+-- interactive manifest/result handoff during a contested foreground claim.+deferredCommands :: IO ()+deferredCommands = withKiokuMigratedDatabase $ \connStr ->+ withSystemTempDirectory "kioku-deferred-cli" $ \dir -> do+ let tid = Timer.TimerId UUID.nil+ disabled = dir </> "disabled.json"+ interactive = dir </> "interactive.json"+ writeFile disabled "{\"version\":1}"+ writeFile interactive "{\"version\":1,\"permissions\":[\"interactive\"],\"distillation\":{\"mode\":\"interactive\",\"provider\":\"claude\",\"model\":\"fixture\",\"workingDir\":\".\"}}"+ now <- getCurrentTime+ withNoopAppEnv (defaultConnectionSettings connStr) $ \app -> do+ seeded <- runAppIO app $ do+ runTransaction+ ( Timer.scheduleTimerTx+ ( Timer.TimerRequest+ tid+ l2SceneProcessManagerName+ "scene-fixture"+ now+ (toJSON (SceneTimerPayload alphaSpace globalScope))+ )+ )+ Timer.deadLetterTimer tid "kioku:deferred:interactive-unavailable feature=scene"+ seeded @?= Right True+ (listed, output, _) <- runKiokuAt (Just dir) connStr alphaSpace ["worker", "deferred", "list", "--ai-config", disabled]+ listed @?= ExitSuccess+ assertContains "list timer" (UUID.toString UUID.nil) output+ assertContains "list space" (Text.unpack (memorySpaceIdText alphaSpace)) output+ (refused, _, diagnostic) <- runKiokuAt (Just dir) connStr alphaSpace ["worker", "deferred", "resume", UUID.toString UUID.nil, "--ai-config", disabled]+ assertBool "disabled resume refuses" (refused /= ExitSuccess)+ assertContains "disabled reason" "AIDisabled Scene" diagnostic+ afterRefusal <- runAppIO app (Timer.lookupTimer tid)+ fmap (fmap (.attempts)) afterRefusal @?= Right (Just 0)+ (completed, done, err) <- runKiokuAt (Just dir) connStr alphaSpace ["worker", "deferred", "resume", UUID.toString UUID.nil, "--ai-config", interactive]+ assertBool ("foreground failed: " <> err) (completed == ExitSuccess)+ assertContains "completion" "Completed the original deferred timer" done+ final <- runAppIO app (Timer.lookupTimer tid)+ fmap (fmap (.status)) final @?= Right (Just Timer.Fired)+ fmap (fmap (.attempts)) final @?= Right (Just 1)