packages feed

llm-simple-0.1.0.1: src/LLM/Tools/FileInfo.hs

module LLM.Tools.FileInfo
  ( fileInfoToolTyped,
    FileInfoToolArgs (..),
  )
where

import Autodocodec qualified as AC
import Control.Exception (IOException, try)
import Data.Aeson (FromJSON)
import Data.ByteString qualified as BS
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Time.Clock (UTCTime)
import GHC.Generics (Generic)
import LLM.Core.Types (TypedTool (..))
import LLM.Tools.FsConfig (FsConfig, sandboxPath)
import System.Directory
  ( Permissions (..),
    doesDirectoryExist,
    doesFileExist,
    doesPathExist,
    getFileSize,
    getModificationTime,
    getPermissions,
    listDirectory,
    pathIsSymbolicLink,
  )
import System.IO (IOMode (ReadMode), withBinaryFile)

-- | Number of leading bytes inspected when sniffing for binary content.
binarySniffBytes :: Int
binarySniffBytes = 8192

-- | Skip line-counting on files larger than this (bytes). For large text
-- files the line count is reported as "unknown (file too large)" so the
-- tool stays cheap regardless of input.
maxLineCountBytes :: Integer
maxLineCountBytes = 5 * 1024 * 1024 -- 5 MB

-- | Cap on directory entry counts in the summary, so listing a huge
-- directory doesn't itself burn the context window.
maxDirEntriesCounted :: Int
maxDirEntriesCounted = 10000

newtype FileInfoToolArgs = FileInfoToolArgs
  { _fiPath :: Text
  }
  deriving (Generic)
  deriving (FromJSON) via (AC.Autodocodec FileInfoToolArgs)

instance AC.HasCodec FileInfoToolArgs where
  codec :: AC.JSONCodec FileInfoToolArgs
  codec =
    AC.object "get metadata about a file or directory" $
      FileInfoToolArgs
        <$> AC.requiredField "path" "Relative path to inspect"
          AC..= (._fiPath)

fileInfoToolTyped :: FsConfig -> TypedTool ctx FileInfoToolArgs
fileInfoToolTyped cfg =
  TypedTool
    { ttoolName = "file_info",
      ttoolDescription =
        "Get metadata about a file or directory at a workspace-relative path. "
          <> "For files: kind (text/binary), size in bytes, line count (text files up to 5 MB), "
          <> "permissions, modification time, and whether it is a symbolic link. "
          <> "For directories: entry count (visible vs. total), permissions, modification time. "
          <> "Reports clearly if the path does not exist. "
          <> "Use this before read_file_paginated to decide whether a file is worth reading.",
      ttoolReadonly = True,
      ttoolExecute = const (execute cfg)
    }

execute :: FsConfig -> FileInfoToolArgs -> IO Text
execute cfg args = do
  let rel = args._fiPath
  resolved <- sandboxPath cfg (T.unpack rel)
  exists <- doesPathExist resolved
  if not exists
    then pure $ "Error: path does not exist: " <> rel
    else do
      isDir <- doesDirectoryExist resolved
      isFile <- doesFileExist resolved
      isLink <- safePathIsSymbolicLink resolved
      perms <- safeGetPermissions resolved
      mtime <- safeGetModificationTime resolved
      case (isDir, isFile) of
        (True, _) -> renderDirInfo rel resolved isLink perms mtime
        (_, True) -> renderFileInfo rel resolved isLink perms mtime
        _ -> pure $ "Error: path is neither a regular file nor a directory: " <> rel

-- ---------------------------------------------------------------------------
-- File rendering
-- ---------------------------------------------------------------------------

renderFileInfo ::
  Text ->
  FilePath ->
  Bool ->
  Maybe Permissions ->
  Maybe Text ->
  IO Text
renderFileInfo rel resolved isLink perms mtime = do
  size <- safeGetFileSize resolved
  isBin <- detectBinary resolved
  lineCount <-
    if isBin
      then pure Nothing
      else case size of
        Just n | n <= maxLineCountBytes -> safeLineCount resolved
        _ -> pure Nothing
  let header =
        "=== "
          <> rel
          <> " | file"
          <> (if isLink then " (symlink)" else "")
          <> " ==="
      lns =
        [ "kind: " <> if isBin then "binary" else "text",
          "size: " <> maybe "unknown" (\n -> T.pack (show n) <> " bytes") size,
          "lines: "
            <> case (isBin, lineCount, size) of
              (True, _, _) -> "n/a (binary)"
              (False, Just n, _) -> T.pack (show n)
              (False, Nothing, Just n)
                | n > maxLineCountBytes ->
                    "unknown (file larger than " <> T.pack (show maxLineCountBytes) <> " bytes)"
              _ -> "unknown",
          permsLine perms,
          "modified: " <> fromMaybeText "unknown" mtime
        ]
  pure $ T.unlines (header : lns)

-- ---------------------------------------------------------------------------
-- Directory rendering
-- ---------------------------------------------------------------------------

renderDirInfo ::
  Text ->
  FilePath ->
  Bool ->
  Maybe Permissions ->
  Maybe Text ->
  IO Text
renderDirInfo rel resolved isLink perms mtime = do
  entries <- safeListDirectory resolved
  let total = length entries
      visible = length (filter (not . isHidden) entries)
      countText n
        | n >= maxDirEntriesCounted = T.pack (show maxDirEntriesCounted) <> "+"
        | otherwise = T.pack (show n)
      header =
        "=== "
          <> rel
          <> " | directory"
          <> (if isLink then " (symlink)" else "")
          <> " ==="
      lns =
        [ "entries (visible): " <> countText visible,
          "entries (total):   " <> countText total,
          permsLine perms,
          "modified: " <> fromMaybeText "unknown" mtime
        ]
  pure $ T.unlines (header : lns)

isHidden :: FilePath -> Bool
isHidden ('.' : _) = True
isHidden _ = False

permsLine :: Maybe Permissions -> Text
permsLine Nothing = "permissions: unknown"
permsLine (Just p) =
  let bit b c = if b then T.singleton c else "-"
   in "permissions: "
        <> bit (readable p) 'r'
        <> bit (writable p) 'w'
        <> bit (executable p) 'x'
        <> (if searchable p then " (searchable)" else "")

fromMaybeText :: Text -> Maybe Text -> Text
fromMaybeText d Nothing = d
fromMaybeText _ (Just t) = t

-- ---------------------------------------------------------------------------
-- Safe IO wrappers
-- ---------------------------------------------------------------------------

safeGetFileSize :: FilePath -> IO (Maybe Integer)
safeGetFileSize p = do
  r <- try (getFileSize p) :: IO (Either IOException Integer)
  pure (eitherToMaybe r)

safeGetModificationTime :: FilePath -> IO (Maybe Text)
safeGetModificationTime p = do
  r <- try (getModificationTime p) :: IO (Either IOException UTCTime)
  pure $ case r of
    Right t -> Just (T.pack (show t))
    Left _ -> Nothing

safeGetPermissions :: FilePath -> IO (Maybe Permissions)
safeGetPermissions p = do
  r <- try (getPermissions p) :: IO (Either IOException Permissions)
  pure (eitherToMaybe r)

safePathIsSymbolicLink :: FilePath -> IO Bool
safePathIsSymbolicLink p = do
  r <- try (pathIsSymbolicLink p) :: IO (Either IOException Bool)
  pure $ case r of
    Right b -> b
    Left _ -> False

safeListDirectory :: FilePath -> IO [FilePath]
safeListDirectory p = do
  r <- try (listDirectory p) :: IO (Either IOException [FilePath])
  pure $ case r of
    Right xs -> xs
    Left _ -> []

safeLineCount :: FilePath -> IO (Maybe Int)
safeLineCount p = do
  r <- try (TIO.readFile p) :: IO (Either IOException Text)
  pure $ case r of
    Right content -> Just (length (T.lines content))
    Left _ -> Nothing

eitherToMaybe :: Either e a -> Maybe a
eitherToMaybe (Right a) = Just a
eitherToMaybe (Left _) = Nothing

-- | NUL byte in the first few KB is a robust, cheap binary heuristic
-- (the same one used by git diff and grep).
detectBinary :: FilePath -> IO Bool
detectBinary path = do
  r <-
    try (withBinaryFile path ReadMode (`BS.hGet` binarySniffBytes)) ::
      IO (Either IOException BS.ByteString)
  pure $ case r of
    Right bs -> BS.elem 0 bs
    Left _ -> True