packages feed

okf-cli-0.11.0.0: src/Okf/Cli/Agent/Context.hs

-- | Read-only local context collection, with deterministic pure renderers.
-- No registry refresh, config evaluation, provider invocation or clock access.
module Okf.Cli.Agent.Context
  ( AgentContext (..),
    ProfileContext (..),
    AssetContext (..),
    collectAgentContext,
    renderContextMarkdown,
    renderContextJson,
  )
where

import Baikai.Interactive (InteractiveProvider (..))
import Baikai.Kit.Config qualified as Kit
import Baikai.Kit.Manifest (kindLabel)
import Baikai.Kit.Sidecar (sidecarPath)
import Baikai.Kit.Status (InstalledCopy (..), installedCopies)
import Control.Exception (IOException, try)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (filterM)
import Data.Aeson (ToJSON, encode)
import Data.ByteString.Lazy (ByteString)
import Data.Generics.Labels ()
import Data.List (nub, sort, sortOn)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)
import Okf.Cli.BundleDiscovery (bundleSearchRoots)
import Okf.Cli.CommandMetadata (CommandGuide (..), commandGuides)
import Okf.Cli.Config (defaultOkfConfig)
import Okf.Cli.Kit.Config (kitConfig)
import Okf.Cli.ProfileDiscovery (profileSearchRoots)
import Okf.Discovery (defaultDiscoveryOptions, discoverBundleRoots)
import Okf.Profile (ProfileSpec, TypeRule)
import Okf.Profile.Discovery (defaultProfileDiscoveryOptions, discoverProfileDescriptors, loadProfileDescriptorWithoutNetwork)
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, readable)

-- | Version 1 uses absolute paths throughout, independent of invocation depth.
data AgentContext = AgentContext
  { schemaVersion :: !Int,
    workingDirectory :: !FilePath,
    projectRoot :: !FilePath,
    bundles :: ![FilePath],
    profiles :: ![ProfileContext],
    commands :: ![CommandGuide],
    installedAssets :: ![AssetContext],
    warnings :: ![Text]
  }
  deriving stock (Show, Eq, Generic)
  deriving anyclass (ToJSON)

data ProfileContext = ProfileContext
  { path :: !FilePath,
    name :: !Text,
    description :: !(Maybe Text),
    guidance :: !(Maybe Text),
    typeGuidance :: ![(Text, Text)]
  }
  deriving stock (Show, Eq, Generic)
  deriving anyclass (ToJSON)

data AssetContext = AssetContext
  { path :: !FilePath,
    name :: !Text,
    kind :: !Text,
    provider :: !Text,
    scope :: !Text
  }
  deriving stock (Show, Eq, Generic)
  deriving anyclass (ToJSON)

collectAgentContext :: IO AgentContext
collectAgentContext = do
  workingDirectory <- getCurrentDirectory
  -- Configuration cannot change the tool identity or root policy. Inspect both
  -- providers without evaluating potentially remote/secret-bearing Dhall config.
  let kit = kitConfig defaultOkfConfig & #providers .~ [InteractiveClaude, InteractiveCodex]
  projectRoot <- kit ^. #projectRoot
  bundleRoots <- rootsAt projectRoot <$> bundleSearchRoots
  profileRoots <- rootsAt projectRoot <$> profileSearchRoots
  (bundlePaths, bundleWarnings) <- discover "bundles" (discoverBundleRoots defaultDiscoveryOptions) bundleRoots
  (profilePaths, profileWarnings) <- discover "profiles" (discoverProfileDescriptors defaultProfileDiscoveryOptions) profileRoots
  loadedProfiles <- traverse loadProfile profilePaths
  (assets, assetWarnings) <- optionalSource "installed kit assets" [] $ do
    copies <- installedCopies kit >>= filterM (ownedCopy kit)
    traverse assetContext copies
  pure
    AgentContext
      { schemaVersion = 1,
        workingDirectory,
        projectRoot,
        bundles = sort (nub bundlePaths),
        profiles = sortOn (\ProfileContext {path} -> path) [p | (Just p, _) <- loadedProfiles],
        commands = sortOn (\CommandGuide {commandPath} -> commandPath) commandGuides,
        installedAssets = sortOn (\AssetContext {path, provider, scope} -> (path, provider, scope)) assets,
        warnings = sort (nub (bundleWarnings <> profileWarnings <> concatMap snd loadedProfiles <> assetWarnings))
      }
  where
    rootsAt root ["."] = [root]
    rootsAt _ roots = roots
    ownedCopy kit InstalledCopy {name, kind, scope, provider} = do
      base <- Kit.providerAgentsBase kit provider scope
      doesFileExist (sidecarPath provider kind name base (Kit.sidecarFileName kit))
    assetContext InstalledCopy {path, name, kind, provider, scope} = do
      absolute <- canonicalizePath path
      pure AssetContext {path = absolute, name, kind = kindLabel kind, provider = Kit.providerLabel provider, scope = Kit.scopeLabel scope}

-- Expected IO failures are warnings; cancellation is never converted to data.
optionalSource :: Text -> a -> IO a -> IO (a, [Text])
optionalSource label fallback action = do
  result <- try @IOException action
  pure $ case result of
    Left _ -> (fallback, [label <> " is inaccessible; inspect it directly for diagnostics."])
    Right value -> (value, [])

discover :: Text -> (FilePath -> IO [FilePath]) -> [FilePath] -> IO ([FilePath], [Text])
discover label scanner roots = do
  results <- traverse scan (sort (nub roots))
  pure (concatMap fst results, concatMap snd results)
  where
    scan root = optionalSource (label <> " source " <> Text.pack root) [] $ do
      exists <- doesDirectoryExist root
      if not exists then ioError (userError "missing directory") else pure ()
      permissions <- getPermissions root
      if not (readable permissions) then ioError (userError "unreadable directory") else pure ()
      scanner root >>= traverse canonicalizePath

loadProfile :: FilePath -> IO (Maybe ProfileContext, [Text])
loadProfile path = do
  loaded <- loadProfileDescriptorWithoutNetwork path
  pure $ case loaded of
    Left _ -> (Nothing, ["Profile became unavailable: " <> Text.pack path])
    Right (spec :: ProfileSpec) ->
      ( Just
          ProfileContext
            { path,
              name = spec ^. #name,
              description = spec ^. #description,
              guidance = spec ^. #guidance,
              typeGuidance = sort [(rule ^. #type_, text) | (rule :: TypeRule) <- spec ^. #types, Just text <- [rule ^. #guidance]]
            },
        []
      )

renderContextJson :: AgentContext -> ByteString
renderContextJson = encode

renderContextMarkdown :: AgentContext -> Text
renderContextMarkdown AgentContext {workingDirectory, projectRoot, bundles, profiles, commands, installedAssets, warnings} =
  Text.unlines $
    [ "# OKF project context",
      "Local discovery is read-only and bounded to depth four; no registry was refreshed.",
      "This is context, not a user task. Wait for the user's instructions when no task was supplied.",
      "Working directory: " <> Text.pack workingDirectory,
      "Project root: " <> Text.pack projectRoot,
      "All discovered paths below are absolute. Command example paths are relative to the working directory.",
      "",
      "## Bundles"
    ]
      <> nonempty "No bundles discovered; use `okf bundles --json` or set OKF_BUNDLE_ROOTS." (map Text.pack bundles)
      <> ["", "## Local profiles"]
      <> nonempty "No local profiles discovered; use `okf profiles --json` or `okf profile list`." (concatMap profileLines profiles)
      <> ["", "## Installed OKF kit assets"]
      <> nonempty "No OKF kit assets discovered; use `okf kit list` and `okf kit install NAME`." (map assetLine installedAssets)
      <> [ "",
           "## Commands",
           "Examples use placeholders BUNDLE, CONCEPT, PROFILE, EXPORT and NAME; replace them with discovered values.",
           "Commands marked 'can write' have writing subcommands or flags; inspect --help and preview before writing."
         ]
      <> concatMap commandLines commands
      <> ["", "## Discovery warnings"]
      <> nonempty "None." warnings
  where
    nonempty fallback [] = [fallback]
    nonempty _ values = map ("- " <>) values
    profileLines ProfileContext {path, name, description, guidance, typeGuidance} =
      [name <> " — " <> Text.pack path]
        <> maybe [] pure description
        <> maybe [] pure guidance
        <> [typ <> ": " <> text | (typ, text) <- typeGuidance]
    assetLine AssetContext {path, name, kind, provider, scope} =
      name <> " (" <> kind <> ", " <> provider <> ", " <> scope <> ") — " <> Text.pack path
    commandLines CommandGuide {commandPath, description, examples, writesFiles} =
      ["- `okf " <> commandPath <> "`: " <> description <> if writesFiles then " (can write)" else " (read-only)"]
        <> map (\example -> "  - `" <> example <> "`") examples