Packages

fnord

0.9.20

AI code archaeology

Current section

Files

Jump to
fnord lib ai tools save_skill.ex
Raw

lib/ai/tools/save_skill.ex

defmodule AI.Tools.SaveSkill do
@moduledoc """
Save a new skill definition either into the current project's skills directory or into the user-global skills directory.
The coordinator can use this tool to persist skill TOML files under:
project scope: `~/.fnord/projects/<project>/skills/<name>.toml`
global scope: `~/.fnord/skills/<name>.toml`
Safety rules:
- The tool is synchronous.
- It requires explicit user confirmation via `UI.confirm/2`.
- For project scope, it refuses to create a project skill if a user-defined skill with the same name already exists in `~/fnord/skills` (because user definitions override project definitions).
- For global scope, it writes directly to the user skills directory (overwriting existing entries if any).
"""
@behaviour AI.Tools
@tool_name "save_skill"
@impl AI.Tools
def async?, do: false
@impl AI.Tools
def is_available?, do: true
@impl AI.Tools
def ui_note_on_request(%{"name" => name}) do
{"Save skill", name}
end
@impl AI.Tools
def ui_note_on_result(%{"name" => name}, _result) do
{"Saved skill", name}
end
@impl AI.Tools
def tool_call_failure_message(_args, _reason), do: :default
@impl AI.Tools
def read_args(%{} = args) do
with {:ok, name} <- get_string(args, "name"),
{:ok, description} <- get_string(args, "description"),
{:ok, model} <- get_string(args, "model"),
{:ok, tools} <- get_string_list(args, "tools"),
{:ok, system_prompt} <- get_string(args, "system_prompt"),
{:ok, response_format} <- get_optional_map(args, "response_format"),
{:ok, scope} <- get_scope(args) do
{:ok,
%{
"name" => name,
"description" => description,
"model" => model,
"tools" => tools,
"system_prompt" => system_prompt,
"response_format" => response_format,
"scope" => scope
}}
end
end
defp get_string(args, key) do
case Map.fetch(args, key) do
{:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
_ -> {:error, "Missing or empty required argument: #{key}"}
end
end
defp get_string_list(args, key) do
case Map.fetch(args, key) do
{:ok, list} when is_list(list) ->
list
|> Enum.filter(&is_binary/1)
|> case do
[] -> {:error, "Missing or empty required argument: #{key}"}
strings -> {:ok, strings}
end
_ ->
{:error, "Missing or empty required argument: #{key}"}
end
end
defp get_optional_map(args, key) do
case Map.fetch(args, key) do
:error -> {:ok, nil}
{:ok, nil} -> {:ok, nil}
{:ok, value} when is_map(value) -> {:ok, value}
_ -> {:error, {:invalid_arg, key}}
end
end
@spec get_scope(map) :: {:ok, String.t()} | {:error, String.t()}
defp get_scope(args) do
case Map.fetch(args, "scope") do
:error ->
{:ok, "project"}
{:ok, val} when val in ["project", "global"] ->
{:ok, val}
{:ok, invalid} ->
{:error, "Invalid scope '#{invalid}'. Allowed values are \"project\" or \"global\"."}
end
end
@impl AI.Tools
def spec() do
%{
type: "function",
function: %{
name: @tool_name,
description:
"Save a skill into either the current project's skills directory (~/.fnord/projects/<project>/skills) or the user-global skills directory (~/.fnord/skills).",
parameters: %{
type: "object",
additionalProperties: false,
properties: %{
name: %{type: "string", description: "Skill name"},
description: %{type: "string", description: "Brief description"},
model: %{
type: "string",
description: "Model preset (smart/balanced/fast/web/large_context...)"
},
tools: %{type: "array", items: %{type: "string"}, description: "Tool tags"},
system_prompt: %{type: "string", description: "Base system prompt"},
response_format: %{
anyOf: [%{type: "object"}, %{type: "null"}],
description: "Optional response_format map"
},
scope: %{
type: "string",
description: "Scope of the skill: \"project\" or \"global\" (default \"project\")"
}
},
required: ["name", "description", "model", "tools", "system_prompt"]
}
}
}
end
@valid_name_pattern ~r/^[a-z0-9][a-z0-9_-]*$/
@spec resolve_target_dir(binary, binary) :: {:ok, binary} | {:error, any}
defp resolve_target_dir("project", name) do
case Skills.project_skills_dir() do
{:ok, dir} ->
case check_user_collision(name) do
:ok -> {:ok, dir}
{:error, reason} -> {:error, reason}
end
{:error, :no_project_selected} ->
{:error, "No project selected. Select a project or set scope=\"global\"."}
end
end
defp resolve_target_dir("global", _name) do
{:ok, Skills.user_skills_dir()}
end
@impl AI.Tools
def call(%{
"name" => name,
"description" => description,
"model" => model,
"tools" => tools,
"system_prompt" => system_prompt,
"response_format" => response_format,
"scope" => scope
}) do
with :ok <- validate_name(name),
{:ok, target_dir} <- resolve_target_dir(scope, name),
{:ok, _model_preset} <- Skills.Runtime.model_from_string(model),
{:ok, _toolbox} <- Skills.Runtime.toolbox_from_tags(tools),
{:ok, _rf} <- Skills.Runtime.validate_response_format(response_format),
{:ok, toml} <-
Skills.Toml.encode_skill(%Skills.Skill{
name: name,
description: description,
model: model,
tools: tools,
system_prompt: system_prompt,
response_format: response_format
}),
{:ok, path} <- skill_path(target_dir, name),
:ok <- confirm_write(path),
:ok <- write_skill_file(path, toml) do
{:ok, "Saved skill #{name} to #{path}"}
end
end
defp validate_name(name) do
case Regex.match?(@valid_name_pattern, name) do
true -> :ok
false -> {:error, "Invalid skill name '#{name}'. Names must match [a-z0-9][a-z0-9_-]*."}
end
end
defp check_user_collision(name) do
user_dir = Skills.user_skills_dir()
# Check only whether a file with this name exists in the user dir, rather
# than loading the entire directory (which can fail on unrelated issues
# like duplicate names among other skills).
path = Path.join(user_dir, "#{name}.toml")
if File.exists?(path) do
{:error,
"A user-defined skill named '#{name}' already exists at #{path}. " <>
"User skills override project skills, so this save would be shadowed."}
else
:ok
end
end
defp skill_path(project_dir, name) do
File.mkdir_p(project_dir)
|> case do
:ok -> {:ok, Path.join(project_dir, "#{name}.toml")}
{:error, reason} -> {:error, reason}
end
end
defp confirm_write(path) do
case UI.confirm("Write skill to #{path}?", false) do
true -> :ok
false -> {:error, :aborted}
end
end
defp write_skill_file(path, toml) do
lock_path = path <> ".lock"
FileLock.with_lock(lock_path, fn ->
Settings.write_atomic!(path, toml)
:ok
end)
|> case do
{:ok, :ok} -> :ok
{:error, reason} -> {:error, reason}
end
rescue
e -> {:error, Exception.message(e)}
end
end