Skip to content

Commit ccb3747

Browse files
committed
feat(s040): skills: parser, sources, registry with index rows and watchers, the three tools, the prompt index, the /skills page, three bundled skills
SKILL.md parsed to the agentskills.io specification with Trinity's keys under trinity:; three roots in precedence order with a per-file manifest check; a GenServer over ETS that scans at boot, writes the index rows on the first read, keeps statuses and versions across rescans, and watches the roots (inotify, polling without inotifywait); skills_list, skill_view and skill_file (a symlink-resolving jail) as reads; the index in the context tier under 033's 338-token reserve; conditional activation by registered tools and toolsets; /skills with badges, view, enable, disable, reindex and the load errors; mix trinity.skills.reindex; VERSIONS rows for yaml_elixir and file_system. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent fc8282b commit ccb3747

53 files changed

Lines changed: 2424 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎VERSIONS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ never pin a version hex marks as retired or vulnerable.
130130
|---|---|---|---|
131131
| `muontrap` | ~> 2.0 | ✅ in `mix.lock` | The shell tool's process wrapper (`Trinity.Tools.Shell.Run`, Slice 022): a C port, SIGTERM then SIGKILL, the child dies with the port. Read against 2.0.0 at Slice 022: `cmd/3` takes `:timeout` (SIGTERM at expiry, `:timeout` as the status), `:delay_to_sigkill`, `:cd`, `:env`, optional cgroup v2 limits. ⚠️ POSIX only: declared in mix.exs on a Unix host alone; the shell tool is unavailable on Windows (NOTES.md, the Windows decision). |
132132
| `floki` | ~> 0.38 | ✅ in `mix.lock` | HTML to text for `web_fetch` (Slice 022): script, style, nav, header, footer and aside dropped, the body's text taken. |
133+
| `yaml_elixir` | ~> 2.12 | ✅ in `mix.lock` | SKILL.md frontmatter (`Trinity.Skills.Parser`, Slice 040), over `yamerl` 0.10.0. Was in the lock as sobelow's dependency; declared at 040. Checked against hex.pm 2026-09-21: 2.12.2 (2026-05-30). |
134+
| `file_system` | ~> 1.1 | ✅ in `mix.lock` | The skill roots' watcher (`Trinity.Skills.Registry`, Slice 040): inotify on Linux (needs the `inotifywait` executable; polls once a second without it), fsevents on macOS, the Windows backend there. Was in the lock as phoenix_live_reload's dependency; declared at 040. Checked against hex.pm 2026-09-21: 1.1.1 (2025-09-08). |
133135
| `luerl (+ sandbox)` | latest | 🔍 not a single package | Slice 110 only. Two packages, so no single lock key. |
134136
| `burrito` | ~> 1.6 | ✅ in `mix.lock` | ⚠️ ERTS availability drives the OTP pin, and Slice 000 measured it: only the OTP 28 line is fetchable for macOS and Linux. Corrected 2026-09-05: this row previously read `~> 1.5 / 1.5.0 ✅`; that mark was not measured. Added at Slice 001. |
135137
| `ex_tauri` | ~> 0.2 | ✅ in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. |

‎config/config.exs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ config :trinity, :tools,
7575
Trinity.Tools.SessionSearch,
7676
# Slice 032: hybrid recall over semantic memories and past messages.
7777
Trinity.Tools.Recall,
78+
# Slice 040: the skills' progressive disclosure.
79+
Trinity.Skills.Tools.List,
80+
Trinity.Skills.Tools.View,
81+
Trinity.Skills.Tools.File,
7882
# Slice 030: the always-on memory tiers.
7983
Trinity.Tools.Memory,
8084
Trinity.Tools.Shell.Run
@@ -84,9 +88,16 @@ config :trinity, :tools,
8488
web: ["web_fetch", "web_search"],
8589
shell: ["shell"],
8690
# Slice 031: search over past conversations.
87-
memory: ["session_search", "recall", "memory"]
91+
memory: ["session_search", "recall", "memory"],
92+
# Slice 040: the skill tools.
93+
skills: ["skills_list", "skill_view", "skill_file"]
8894
}
8995

96+
# Slice 040: the skill roots. `user_dir:` and `bundled_dir:` default to the data directory's
97+
# `skills` and `priv/skills`; `watch: false` turns the filesystem watchers off (the reindex
98+
# button and `mix trinity.skills.reindex` remain); `index_tokens:` caps the prompt's index.
99+
config :trinity, :skills, watch: true, index_tokens: 338
100+
90101
# Slice 022: the filesystem roots beside the data directory (always a root) and the session's
91102
# working directory. Empty here; config/runtime.exs reads TRINITY_FS_ROOTS (colon-separated).
92103
config :trinity, :fs, roots: []

‎config/test.exs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ config :trinity, :tools,
2929
Trinity.Tools.SessionSearch,
3030
# Slice 032: hybrid recall.
3131
Trinity.Tools.Recall,
32+
# Slice 040: the skills' progressive disclosure.
33+
Trinity.Skills.Tools.List,
34+
Trinity.Skills.Tools.View,
35+
Trinity.Skills.Tools.File,
3236
# Slice 030: the always-on memory tiers.
3337
Trinity.Tools.Memory,
3438
Trinity.Tools.Shell.Run
@@ -39,7 +43,9 @@ config :trinity, :tools,
3943
web: ["web_fetch", "web_search"],
4044
shell: ["shell"],
4145
# Slice 031: search over past conversations.
42-
memory: ["session_search", "recall", "memory"]
46+
memory: ["session_search", "recall", "memory"],
47+
# Slice 040: the skill tools.
48+
skills: ["skills_list", "skill_view", "skill_file"]
4349
},
4450
timeout_ms: 2_000
4551

@@ -171,3 +177,11 @@ config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__)
171177
# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
172178
# model is needed.
173179
config :trinity, :memory, embedder: :fake, observer: false
180+
181+
# Slice 040: the suite's skill roots are fixtures; the user root is a directory a test may
182+
# point elsewhere with `Application.put_env` and a rescan.
183+
config :trinity, :skills,
184+
bundled_dir: Path.expand("../test/support/fixtures/skills/bundled", __DIR__),
185+
user_dir: Path.expand("../test/support/fixtures/skills/user", __DIR__),
186+
watch: true,
187+
index_tokens: 338
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Mix.Tasks.Trinity.Skills.Reindex do
4+
@shortdoc "Rescans the skill roots and rewrites the skills index"
5+
6+
@moduledoc """
7+
Slice 040. Scans the user root (`<data dir>/skills`) and the bundled root (`priv/skills`),
8+
rewrites the `skills` table from what is on disk (statuses kept), and prints every skill
9+
with its source, version and status, then every directory that did not load and why.
10+
11+
mix trinity.skills.reindex
12+
"""
13+
use Boundary, classify_to: Trinity
14+
use Mix.Task
15+
16+
@impl Mix.Task
17+
def run(_argv) do
18+
Mix.Task.run("app.start")
19+
{us, :ok} = :timer.tc(fn -> Trinity.Skills.rescan() end)
20+
skills = Trinity.Skills.list()
21+
Mix.shell().info("reindexed #{length(skills)} skills in #{div(us, 1000)} ms")
22+
23+
for s <- skills,
24+
do:
25+
Mix.shell().info(
26+
" #{s.name} #{s.source}/#{s.scope} v#{s.version} #{s.status} #{s.path}"
27+
)
28+
29+
for e <- Trinity.Skills.Registry.errors(),
30+
do: Mix.shell().info(" not loaded: #{e.dir} (#{e.source}): #{inspect(e.reason)}")
31+
end
32+
end

‎lib/trinity.ex‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ defmodule Trinity do
2929
Personas,
3030
Context,
3131
Context.AgentsMd,
32+
Context.SkillsIndex,
3233
Archive,
3334
Archive.Layout,
3435
Archive.Manifest,
@@ -50,6 +51,10 @@ defmodule Trinity do
5051
Permissions.Approval,
5152
Permissions.Rule,
5253
Effects,
54+
Skills,
55+
Skills.Skill,
56+
Skills.Registry,
57+
Skills.Sources,
5358
CorePolicy,
5459
Receipts,
5560
Receipts.Receipt,

‎lib/trinity/application.ex‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ defmodule Trinity.Application do
5454
Trinity.Tools.Supervisor,
5555
# Slice 021: approval requests and their decisions, with pending rows reloaded.
5656
Trinity.Permissions.Gate,
57+
# Slice 040: the skills index, scanned from its roots and watched; after the tools
58+
# (conditional activation asks which exist) and before the sessions that read it.
59+
Trinity.Skills.Registry,
5760
Trinity.Sessions.Supervisor
5861
] ++
5962
Trinity.Smoke.probe(Trinity.Smoke.argv()) ++

‎lib/trinity/context.ex‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ defmodule Trinity.Context do
66
content, read from the project root every turn, untrusted by provenance. `AgentsMd` is the
77
first source; slice 040's skills index joins it in the same tier.
88
"""
9-
use Boundary, deps: [Trinity], exports: [AgentsMd]
9+
use Boundary, deps: [Trinity, Trinity.Skills], exports: [AgentsMd, SkillsIndex]
1010
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Context.SkillsIndex do
4+
@moduledoc """
5+
The skills index in the context tier (slice 040): the active skills for the session's
6+
project, rendered by `Trinity.Skills.Index` under the prompt cap. Read every turn like the
7+
AGENTS.md block beside it, so a skill added on disk is in the next turn.
8+
"""
9+
10+
alias Trinity.Skills.Index
11+
12+
@doc "The block for a project root (`nil` for none): `\"\"` when no skill is active."
13+
@spec render(String.t() | nil) :: String.t()
14+
def render(project_root) do
15+
project_root
16+
|> then(&Trinity.Skills.active(project_root: &1))
17+
|> Index.render(Index.default_tokens())
18+
end
19+
end

‎lib/trinity/sessions/session.ex‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,14 @@ defmodule Trinity.Sessions.Session do
355355
tools = Trinity.Tools.to_llm_tools()
356356
history = Trinity.Sessions.history(id, limit: 500)
357357
# Slice 033: the project's AGENTS.md, read now, so a change is in this turn (live reload).
358-
context = Trinity.Context.AgentsMd.render(session.project_root, session.project_root)
358+
context =
359+
[
360+
Trinity.Context.AgentsMd.render(session.project_root, session.project_root),
361+
# Slice 040: the skills index, under its own cap inside the same tier.
362+
Trinity.Context.SkillsIndex.render(session.project_root)
363+
]
364+
|> Enum.reject(&(&1 == ""))
365+
|> Enum.join("\n\n")
359366

360367
# Slice 032: what the semantic tier and past conversations hold about the latest user
361368
# message, fused and capped; "" when there is no persona or nothing relevant.

‎lib/trinity/skills.ex‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Skills do
4+
@moduledoc """
5+
Procedural memory (slice 040, ADR-0006): skills are directories with a `SKILL.md` in the
6+
agentskills.io format, found under three roots in precedence order (a project's
7+
`.trinity/skills`, the data directory's `skills`, the bundled `priv/skills`), parsed into
8+
`Trinity.Skills.Skill`, indexed in the `skills` table and held by `Trinity.Skills.Registry`,
9+
and shown to the model by progressive disclosure: the index in the prompt, the body on
10+
`skill_view`, a reference file on `skill_file`. The filesystem is canonical; the table is
11+
an index a reindex rebuilds. Nothing here writes a skill: that is slice 041's.
12+
13+
Depends on Tools because conditional activation asks the registry which tools and
14+
toolsets exist, and because the three skill tools implement `Trinity.Tools.Tool`; Tools
15+
never depends on Skills.
16+
"""
17+
use Boundary,
18+
deps: [Trinity, Trinity.Tools, Trinity.Memory],
19+
exports: [Skill, Parser, Sources, Registry, Row, Index, Tools.List, Tools.View, Tools.File]
20+
21+
alias Trinity.Skills.Registry
22+
23+
@doc "The effective skills (one per name, by precedence); `project_root:` adds a project's."
24+
@spec list(keyword()) :: [Trinity.Skills.Skill.t()]
25+
defdelegate list(opts \\ []), to: Registry
26+
27+
@doc "The skills the model may see now: active, and their requirements met."
28+
@spec active(keyword()) :: [Trinity.Skills.Skill.t()]
29+
defdelegate active(opts \\ []), to: Registry
30+
31+
@doc "A skill by name, among the effective ones."
32+
@spec get(String.t(), keyword()) :: Trinity.Skills.Skill.t() | nil
33+
defdelegate get(name, opts \\ []), to: Registry
34+
35+
@doc "Rescans every root and rewrites the index."
36+
@spec rescan() :: :ok
37+
defdelegate rescan(), to: Registry
38+
39+
@doc "Enables or disables a skill by name and source; persisted in the index."
40+
@spec set_status(String.t(), String.t(), String.t()) :: :ok | {:error, term()}
41+
defdelegate set_status(name, source, status), to: Registry
42+
end

0 commit comments

Comments
 (0)