lib/planck/agent/skill.ex

defmodule Planck.Agent.Skill do
  @moduledoc """
  Filesystem-based agent skills.

  A skill is a subdirectory under a skills directory (e.g. `.planck/skills/`)
  containing a `SKILL.md` file. The file has YAML frontmatter with `name` and
  `description`, followed by usage instructions, resource references, and any
  other context the agent needs.

  ## Directory layout

      .planck/skills/
      └── n8n-expert/
          ├── SKILL.md            ← required
          ├── docs/
          │   └── node-types.md   ← lazily loaded by the agent via `read`
          └── scripts/
              └── validate.sh     ← runnable via `bash`

  ## SKILL.md format

      ---
      name: n8n-expert
      description: Expert at building n8n workflows and automation.
      ---

      # N8N Expert

      You are an expert at n8n...

      ## Resources

      - `docs/node-types.md` — reference for all n8n node types
      - `scripts/validate.sh` — validates a workflow JSON file

  Only `name` and `description` are parsed from the frontmatter. The rest of
  the file is plain Markdown consumed by the agent when it loads the skill.

  ## Usage

      skills = Planck.Agent.Skill.load_all(["~/.planck/skills"])

      # Skills are typically threaded through AgentSpec.to_start_opts/2 via
      # skill_pool:, which resolves spec.skills names and appends the skill
      # section to system_prompt for the relevant agent.
      start_opts = Planck.Agent.AgentSpec.to_start_opts(spec, skill_pool: skills)

  The agent discovers skills from the system prompt index and loads `SKILL.md`
  via the `read` tool when a skill is relevant. Scripts are run via `bash`.
  No special runtime support is required — skills are just files.
  """

  require Logger

  alias Planck.Agent.Tool

  @skill_file "SKILL.md"
  @frontmatter_re ~r/\A---\n(.*?)\n---/s

  @typedoc """
  A loaded skill.

  - `:name` — identifier used in the system prompt index
  - `:description` — one-line summary shown to the agent
  - `:path` — absolute path to the skill directory
  - `:skill_file` — absolute path to `SKILL.md`
  """
  @type t :: %__MODULE__{
          name: String.t(),
          description: String.t(),
          path: Path.t(),
          skill_file: Path.t()
        }

  @enforce_keys [:name, :description, :path, :skill_file]
  defstruct [:name, :description, :path, :skill_file]

  @doc """
  Load all skills from a list of directories.

  Each directory is scanned for subdirectories containing a `SKILL.md` file.
  Directories that do not exist are silently skipped. Invalid `SKILL.md` files
  are skipped with a warning.

  Paths are expanded via `Path.expand/1` so `~` and relative paths resolve
  correctly.
  """
  @spec load_all([Path.t()]) :: [t()]
  def load_all(dirs) when is_list(dirs) do
    Enum.flat_map(dirs, &load_dir/1)
  end

  @doc """
  Load a single skill from a `SKILL.md` file path.

  Returns `{:ok, skill}` or `{:error, reason}`.
  """
  @spec from_file(Path.t()) :: {:ok, t()} | {:error, String.t()}
  def from_file(skill_file) do
    with {:ok, content} <- read_file(skill_file),
         {:ok, name, description} <- parse_frontmatter(content, skill_file) do
      {:ok,
       %__MODULE__{
         name: name,
         description: description,
         path: Path.dirname(skill_file),
         skill_file: skill_file
       }}
    end
  end

  @doc """
  Generate a skills section for injection into an agent's system prompt.

  Produces a compact index of skill names and descriptions, followed by an
  instruction to load skills via the `load_skill` tool. Returns `nil` when
  the list is empty.
  """
  @spec system_prompt_section([t()]) :: String.t() | nil
  def system_prompt_section([]), do: nil

  def system_prompt_section(skills) do
    entries =
      Enum.map_join(skills, "\n", fn %__MODULE__{name: name, description: desc} ->
        "- **#{name}** — #{desc}"
      end)

    """
    ## Available skills

    #{entries}

    Use `load_skill` tool with the skill name to load its full instructions when relevant.
    Resource files referenced inside a skill are loaded via the `read` tool using
    the absolute paths provided in the skill's content.
    """
  end

  @doc """
  Build a `load_skill` tool that loads a skill's `SKILL.md` by name.

  The tool is a closure over the given skill pool. It is automatically added to
  every agent when skills are available — agents do not need to declare it in
  their TEAM.json `"tools"` array.
  """
  @spec load_skill_tool([t()]) :: Tool.t()
  def load_skill_tool(skills) do
    skill_map = Map.new(skills, &{&1.name, &1})

    Tool.new(
      name: "load_skill",
      description:
        "Load a skill's full instructions by name. Use this when a skill is relevant to the current task.",
      parameters: %{
        "type" => "object",
        "properties" => %{
          "name" => %{"type" => "string", "description" => "The skill name (e.g. \"elixir-dev\")"}
        },
        "required" => ["name"]
      },
      execute_fn: fn _agent_id, _id, %{"name" => name} ->
        case Map.get(skill_map, name) do
          nil ->
            available = skill_map |> Map.keys() |> Enum.sort() |> Enum.join(", ")
            {:error, "Unknown skill: #{name}. Available: #{available}"}

          skill ->
            File.read(skill.skill_file)
        end
      end
    )
  end

  @doc """
  Build a `list_skills` tool that returns all available skill names and descriptions.

  This is an opt-in discovery tool. Add `"list_skills"` to an agent's TEAM.json
  `"tools"` array when that agent needs to autonomously discover what skills exist.
  Workers that receive skill names from the orchestrator do not need this tool.
  """
  @spec list_skills_tool([t()]) :: Tool.t()
  def list_skills_tool(skills) do
    entries =
      Enum.map_join(skills, "\n", fn %__MODULE__{name: name, description: desc} ->
        "- **#{name}**: #{desc}"
      end)

    Tool.new(
      name: "list_skills",
      description: "List all available skills with their names and descriptions.",
      parameters: %{"type" => "object", "properties" => %{}, "required" => []},
      execute_fn: fn _agent_id, _id, _args ->
        if entries == "",
          do: {:ok, "No skills available."},
          else: {:ok, entries}
      end
    )
  end

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  @spec load_dir(Path.t()) :: [t()]
  defp load_dir(dir) do
    expanded = Path.expand(dir)

    if File.dir?(expanded) do
      expanded |> File.ls!() |> Enum.flat_map(&load_entry(expanded, &1))
    else
      []
    end
  end

  @spec load_entry(Path.t(), String.t()) :: [t()]
  defp load_entry(dir, entry) do
    skill_path = Path.join(dir, entry)
    skill_file = Path.join(skill_path, @skill_file)

    if File.dir?(skill_path) and File.regular?(skill_file) do
      case from_file(skill_file) do
        {:ok, skill} ->
          [skill]

        {:error, reason} ->
          Logger.warning("[Planck.Agent.Skill] skipping #{skill_file}: #{reason}")
          []
      end
    else
      []
    end
  end

  @spec read_file(Path.t()) :: {:ok, String.t()} | {:error, String.t()}
  defp read_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "cannot read #{path}: #{:file.format_error(reason)}"}
    end
  end

  @spec parse_frontmatter(String.t(), Path.t()) ::
          {:ok, String.t(), String.t()} | {:error, String.t()}
  defp parse_frontmatter(content, path) do
    content = String.replace(content, "\r\n", "\n")

    case Regex.run(@frontmatter_re, content, capture: :all_but_first) do
      nil ->
        {:error, "#{path} has no frontmatter (expected --- ... --- at the top)"}

      [frontmatter] ->
        with {:ok, name} <- extract_field(frontmatter, "name", path),
             {:ok, description} <- extract_field(frontmatter, "description", path) do
          {:ok, name, description}
        end
    end
  end

  @spec extract_field(String.t(), String.t(), Path.t()) ::
          {:ok, String.t()} | {:error, String.t()}
  defp extract_field(frontmatter, field, path) do
    pattern = ~r/^#{Regex.escape(field)}:\s*(.+)$/m

    case Regex.run(pattern, frontmatter, capture: :all_but_first) do
      [value] ->
        trimmed = String.trim(value)
        if trimmed != "", do: {:ok, trimmed}, else: {:error, "#{path}: #{field} is blank"}

      nil ->
        {:error, "#{path}: missing required frontmatter field '#{field}'"}
    end
  end
end