packages feed

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 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)