Current section

Files

Jump to
planck_agent lib planck agent skill_usage.ex
Raw

lib/planck/agent/skill_usage.ex

defmodule Planck.Agent.SkillUsage do
@moduledoc """
Per-project SQLite database tracking skill usage per agent type.
The DB lives at `.planck/skills.db` inside the project directory. It is
created on first use; callers pass the path explicitly so the module stays
stateless and testable.
The PK is `(team_name, agent_name, skill_name)`. `agent_type` is stored as a
plain column and is used only for the dynamic-orchestrator union query.
- `team_name` — team directory alias, e.g. `"my-team"`
- `agent_name``spec.name || spec.type`, e.g. `"Builder"`, `"orchestrator"`
- `agent_type``spec.type`, e.g. `"orchestrator"`, `"builder"`
Fixed agents are queried by `(team_name, agent_name)`. Dynamic orchestrators
(spawned via `spawn_agent`, no history of their own) are seeded by querying
`agent_type = 'orchestrator'` across the team.
## Schema
CREATE TABLE skill_usage (
team_name TEXT NOT NULL,
agent_name TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_name TEXT NOT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
PRIMARY KEY (team_name, agent_name, skill_name)
);
"""
require Logger
@db_filename "skills.db"
@doc """
Record one use of `skill_name` by `(team_name, agent_name, agent_type)`.
Creates the DB and table if they do not exist. Increments `use_count` and
updates `last_used` to the current UTC timestamp. No-ops silently on error.
"""
@spec record_use(Path.t(), String.t(), String.t(), String.t(), String.t()) :: :ok
def record_use(project_dir, team_name, agent_name, agent_type, skill_name) do
path = db_path(project_dir)
with {:ok, conn} <- open(path),
:ok <- ensure_table(conn) do
upsert(conn, team_name, agent_name, agent_type, skill_name)
Exqlite.Sqlite3.close(conn)
else
{:error, reason} ->
Logger.warning("[Planck.Agent.SkillUsage] record_use failed: #{inspect(reason)}")
end
:ok
end
@doc """
Return the ranked skill names for an agent, with a cold-start fallback.
Queries the SQLite DB for the top `n` most recently used skills for
`(team_name, agent_name)`. When no history exists (first session), falls back
to the `n` most recently modified skill files — ensuring the index is always
populated even before any skills have been loaded.
"""
@spec ranked_names(Path.t(), String.t(), String.t(), [Planck.Agent.Skill.t()], pos_integer()) ::
[String.t()]
def ranked_names(project_dir, team_name, agent_name, skills, n) do
ranked = top_n(project_dir, team_name, agent_name, n)
if ranked == [], do: fallback_by_mtime(skills, n), else: ranked
end
@spec fallback_by_mtime([Planck.Agent.Skill.t()], pos_integer()) :: [String.t()]
defp fallback_by_mtime(skills, n) do
skills
|> Enum.sort_by(&skill_mtime/1, :desc)
|> Enum.take(n)
|> Enum.map(& &1.name)
end
@spec skill_mtime(Planck.Agent.Skill.t()) :: :calendar.datetime()
defp skill_mtime(skill) do
case File.stat(skill.skill_file) do
{:ok, %{mtime: mtime}} -> mtime
_ -> {{0, 0, 0}, {0, 0, 0, 0}}
end
end
@doc """
Return the top `n` skill names for `(team_name, agent_name)`, ordered by
recency then count.
Returns an empty list when the DB does not exist or the agent has no history.
"""
@spec top_n(Path.t(), String.t(), String.t(), pos_integer()) :: [String.t()]
def top_n(project_dir, team_name, agent_name, n) do
query_names(db_path(project_dir), fn conn ->
{:ok, stmt} =
Exqlite.Sqlite3.prepare(conn, """
SELECT skill_name FROM skill_usage
WHERE team_name = ?1 AND agent_name = ?2
ORDER BY last_used DESC, use_count DESC
LIMIT ?3
""")
:ok = Exqlite.Sqlite3.bind(stmt, [team_name, agent_name, n])
collect_names(conn, stmt)
end)
end
@doc """
Return the top `n` skill names across all orchestrators in a given team.
Used to seed the skill ranking for dynamic orchestrators (spawned via
`spawn_agent`), which have no usage history of their own. Queries by
`agent_type = 'orchestrator'` so all fixed orchestrators in the team
contribute. Deduplicates and orders by recency then count.
"""
@spec top_n_for_orchestrators(Path.t(), String.t(), pos_integer()) :: [String.t()]
def top_n_for_orchestrators(project_dir, team_name, n) do
query_names(db_path(project_dir), fn conn ->
{:ok, stmt} =
Exqlite.Sqlite3.prepare(conn, """
SELECT skill_name FROM skill_usage
WHERE team_name = ?1 AND agent_type = 'orchestrator'
GROUP BY skill_name
ORDER BY MAX(last_used) DESC, SUM(use_count) DESC
LIMIT ?2
""")
:ok = Exqlite.Sqlite3.bind(stmt, [team_name, n])
collect_names(conn, stmt)
end)
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
@spec db_path(Path.t()) :: Path.t()
defp db_path(project_dir), do: Path.join([project_dir, ".planck", @db_filename])
@spec open(Path.t()) :: {:ok, Exqlite.Sqlite3.db()} | {:error, term()}
defp open(path) do
:ok = path |> Path.dirname() |> File.mkdir_p()
Exqlite.Sqlite3.open(path)
end
@spec ensure_table(Exqlite.Sqlite3.db()) :: :ok
defp ensure_table(conn) do
Exqlite.Sqlite3.execute(conn, """
CREATE TABLE IF NOT EXISTS skill_usage (
team_name TEXT NOT NULL,
agent_name TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_name TEXT NOT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
PRIMARY KEY (team_name, agent_name, skill_name)
)
""")
end
@spec upsert(Exqlite.Sqlite3.db(), String.t(), String.t(), String.t(), String.t()) :: :ok
defp upsert(conn, team_name, agent_name, agent_type, skill_name) do
now = DateTime.utc_now() |> DateTime.to_iso8601()
{:ok, stmt} =
Exqlite.Sqlite3.prepare(conn, """
INSERT INTO skill_usage (team_name, agent_name, agent_type, skill_name, use_count, last_used)
VALUES (?1, ?2, ?3, ?4, 1, ?5)
ON CONFLICT (team_name, agent_name, skill_name)
DO UPDATE SET use_count = use_count + 1, last_used = ?5
""")
:ok = Exqlite.Sqlite3.bind(stmt, [team_name, agent_name, agent_type, skill_name, now])
:done = Exqlite.Sqlite3.step(conn, stmt)
:ok = Exqlite.Sqlite3.release(conn, stmt)
end
@spec query_names(Path.t(), (Exqlite.Sqlite3.db() -> [String.t()])) :: [String.t()]
defp query_names(path, fun) do
if File.exists?(path) do
case Exqlite.Sqlite3.open(path) do
{:ok, conn} ->
names = fun.(conn)
Exqlite.Sqlite3.close(conn)
names
{:error, _} ->
[]
end
else
[]
end
end
@spec collect_names(Exqlite.Sqlite3.db(), Exqlite.Sqlite3.statement()) :: [String.t()]
defp collect_names(conn, stmt) do
collect_names(conn, stmt, [])
end
defp collect_names(conn, stmt, acc) do
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, [name]} -> collect_names(conn, stmt, [name | acc])
:done -> Enum.reverse(acc)
end
end
end