Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ standards register names the rows that ask for them.
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | approved |
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | approved |
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | approved |
| 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | planned |
| 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | done |
| 041 | Skill self-management with staged approval + scanner | 4 Skills | M | 040, 021 | planned |
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned |
| 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | planned |
Expand Down
2 changes: 2 additions & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ never pin a version hex marks as retired or vulnerable.
|---|---|---|---|
| `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). |
| `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. |
| `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). |
| `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). |
| `luerl (+ sandbox)` | latest | 🔍 not a single package | Slice 110 only. Two packages, so no single lock key. |
| `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. |
| `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. |
Expand Down
13 changes: 12 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ config :trinity, :tools,
Trinity.Tools.SessionSearch,
# Slice 032: hybrid recall over semantic memories and past messages.
Trinity.Tools.Recall,
# Slice 040: the skills' progressive disclosure.
Trinity.Skills.Tools.List,
Trinity.Skills.Tools.View,
Trinity.Skills.Tools.File,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
Expand All @@ -84,9 +88,16 @@ config :trinity, :tools,
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 031: search over past conversations.
memory: ["session_search", "recall", "memory"]
memory: ["session_search", "recall", "memory"],
# Slice 040: the skill tools.
skills: ["skills_list", "skill_view", "skill_file"]
}

# Slice 040: the skill roots. `user_dir:` and `bundled_dir:` default to the data directory's
# `skills` and `priv/skills`; `watch: false` turns the filesystem watchers off (the reindex
# button and `mix trinity.skills.reindex` remain); `index_tokens:` caps the prompt's index.
config :trinity, :skills, watch: true, index_tokens: 338

# Slice 022: the filesystem roots beside the data directory (always a root) and the session's
# working directory. Empty here; config/runtime.exs reads TRINITY_FS_ROOTS (colon-separated).
config :trinity, :fs, roots: []
Expand Down
16 changes: 15 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ config :trinity, :tools,
Trinity.Tools.SessionSearch,
# Slice 032: hybrid recall.
Trinity.Tools.Recall,
# Slice 040: the skills' progressive disclosure.
Trinity.Skills.Tools.List,
Trinity.Skills.Tools.View,
Trinity.Skills.Tools.File,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
Expand All @@ -39,7 +43,9 @@ config :trinity, :tools,
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 031: search over past conversations.
memory: ["session_search", "recall", "memory"]
memory: ["session_search", "recall", "memory"],
# Slice 040: the skill tools.
skills: ["skills_list", "skill_view", "skill_file"]
},
timeout_ms: 2_000

Expand Down Expand Up @@ -171,3 +177,11 @@ config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__)
# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
# model is needed.
config :trinity, :memory, embedder: :fake, observer: false

# Slice 040: the suite's skill roots are fixtures; the user root is a directory a test may
# point elsewhere with `Application.put_env` and a rescan.
config :trinity, :skills,
bundled_dir: Path.expand("../test/support/fixtures/skills/bundled", __DIR__),
user_dir: Path.expand("../test/support/fixtures/skills/user", __DIR__),
watch: true,
index_tokens: 338
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ slice_id percent sha date
033 78.50 3b5e89e 2026-09-21
034 78.83 3a5785f 2026-09-21
032 78.99 193800e 2026-09-21
040 79.86 dc9dc6b 2026-09-21
10 changes: 6 additions & 4 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ Trinity.Application
│ # :local and the model is in the cache; absent otherwise, the
│ # tier off and the reason logged once. ensure_embedding/0
│ # starts it after a download.
├── Trinity.Skills.Registry # hot-loaded skills index. Slice 040
├── Trinity.Skills.Registry # Slice 040, as built: a GenServer over an ETS table; scans the
│ # roots at boot and on change (file_system watchers, linked,
│ # a dead one dropped), writes the index rows on first read
├── Oban # cron + durable jobs. Slice 050
├── Trinity.MCP.Supervisor # MCP clients (one per server, lib per 059) + server. Slice 059-062
├── Trinity.Gateways.Supervisor # adapters (Telegram, Discord…). Slice 070+
Expand Down Expand Up @@ -78,15 +80,15 @@ without anything failing.
| Context (module) | Owns | May depend on |
|---|---|---|
| `Trinity.Sessions` | Session process, turn loop, message log; the persona row and its store (since 010; `Trinity.Personas` is the context over them, as built at 030) | LLM, Tools, **Effects**, Permissions, Memory, Skills, Repo, PubSub, Receipts (as built at 030: the prompt truncation receipt), Context (as built at 033: AGENTS.md every turn) |
| `Trinity.Context` | What the project tells the prompt: `AgentsMd` (033); the skills index joins it at 040 | none beyond the core |
| `Trinity.Context` | What the project tells the prompt: `AgentsMd` (033), `SkillsIndex` (040) | Skills (as built at 040) |
| `Trinity.LLM` | Provider behaviour, req_llm adapter, model registry, streaming, usage | Repo (usage), Telemetry |
| `Trinity.Tools` | Tool behaviour, registry, execution runtime, core tools, and (as built at 024) the compile-time effect catalog `Trinity.Tools.Catalog`, because the registry reads it and Effects depends on Tools | Permissions, Sandbox, Repo, **Memory** (as built at 031: `session_search` reads the index; Memory never depends on Tools) |
| `Trinity.Permissions` | Policy, tier/1 (name-only), fingerprint-bound approvals, override adjudication | Repo, PubSub |
| `Trinity.Effects` | The membrane; the runner in force (`Effects.Runner`, the executor `Tools.Runner` takes as a function); decision and query receipts; the boot receipt | **Tools**, Permissions, Authority, Receipts, Repo |
| `Trinity.Authority` | Behaviour; `Local` implementation (the one caller of `execute/2` for effectful tools); selection at boot; `Staged` | Receipts, Repo |
| `Trinity.Receipts` | Local chain (one supervised writer per scope, ADR-0013), the signer seam (Ed25519, P-384, ML-DSA-87), key custody and the registry, checkpoints, the verifier, the alarm | Repo (`Repo.Receipts`) |
| `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo |
| `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner | Repo, Permissions, **Effects**, **Receipts**, Sandbox |
| `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 040: Tools, for conditional activation and the tool behaviour, and Memory, for the token estimator; Tools never depends on Skills) |
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** |
| `Trinity.MCP` | Client manager, tool bridge, server | Tools, **Effects**, **Permissions**, Memory |
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub |
Expand All @@ -113,7 +115,7 @@ through those modules only. `boundary` `exports:` lists enforce this.
| `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0` | config `:gateways` list |
| `Trinity.Memory.VectorStore` | `upsert/3`, `search/3`, `delete/1`, `count/1` (as built at 032: the scope filter is `search/3`'s required argument) | the database adapter, at compile time (`Brute` on SQLite, `Pgvector` on Postgres; `Hnswlib` when a slice measures past 10^5 rows) |
| `Trinity.Memory.Embedder` | `embed/1`, `dim/0`, `model_id/0`, `availability/0` | `config :trinity, :memory, embedder:` (`:local` default, `:hosted` opt-in only, `:fake` in the suite) |
| `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` |
| `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` (not built at 040: the three roots are fixed and `Trinity.Skills.Sources` scans them; a loader behaviour waits for a fourth kind of source) |
| `Trinity.Permissions.Policy` | `decide/3` → `:allow | :deny | {:ask, prompt}` | config |

Adding any of these = new module + config entry. No core edits. A test in Slice 020 asserts this
Expand Down
10 changes: 10 additions & 0 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ the owner (the memory page).
| embedding | vector(384), nullable | for skill retrieval |
Filesystem is canonical for content; DB is the index (rebuildable via `mix trinity.skills.reindex`).

As built at slice 040: `skills` carries `id`, `name`, `version` (integer, bumped when the body's digest
changes), `source` (`bundled | user | project`; `agent` and `hub:<url>` arrive with 041), `scope` (`global |
account | project`, the M6 tag from the root), `path`, `frontmatter` (the parsed keys: description, category,
license, compatibility, metadata, allowed-tools, trinity), `body_hash`, `status` (`active | disabled`, the
owner's, kept across rescans; `pending_approval` and `rejected` are 041's), `scan_result` (the per-file
SHA-256 manifest of the directory and the `references/` and `scripts/` listings) and timestamps, unique on
(`name`, `source`). No `embedding` column: nothing retrieves skills by vector yet. The rows are written on
the registry's first read after a scan, not at boot, and a row whose skill is gone from every root is removed
on the next scan.

### skill_changes (Slice 041)
Staged proposals by the agent: `skill_id`, `diff`, `rationale`, `status`, `decided_by`, `decided_at`.

Expand Down
32 changes: 32 additions & 0 deletions lib/mix/tasks/trinity.skills.reindex.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Skills.Reindex do
@shortdoc "Rescans the skill roots and rewrites the skills index"

@moduledoc """
Slice 040. Scans the user root (`<data dir>/skills`) and the bundled root (`priv/skills`),
rewrites the `skills` table from what is on disk (statuses kept), and prints every skill
with its source, version and status, then every directory that did not load and why.

mix trinity.skills.reindex
"""
use Boundary, classify_to: Trinity
use Mix.Task

@impl Mix.Task
def run(_argv) do
Mix.Task.run("app.start")
{us, :ok} = :timer.tc(fn -> Trinity.Skills.rescan() end)
skills = Trinity.Skills.list()
Mix.shell().info("reindexed #{length(skills)} skills in #{div(us, 1000)} ms")

for s <- skills,
do:
Mix.shell().info(
" #{s.name} #{s.source}/#{s.scope} v#{s.version} #{s.status} #{s.path}"
)

for e <- Trinity.Skills.Registry.errors(),
do: Mix.shell().info(" not loaded: #{e.dir} (#{e.source}): #{inspect(e.reason)}")
end
end
5 changes: 5 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defmodule Trinity do
Personas,
Context,
Context.AgentsMd,
Context.SkillsIndex,
Archive,
Archive.Layout,
Archive.Manifest,
Expand All @@ -50,6 +51,10 @@ defmodule Trinity do
Permissions.Approval,
Permissions.Rule,
Effects,
Skills,
Skills.Skill,
Skills.Registry,
Skills.Sources,
CorePolicy,
Receipts,
Receipts.Receipt,
Expand Down
3 changes: 3 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ defmodule Trinity.Application do
Trinity.Tools.Supervisor,
# Slice 021: approval requests and their decisions, with pending rows reloaded.
Trinity.Permissions.Gate,
# Slice 040: the skills index, scanned from its roots and watched; after the tools
# (conditional activation asks which exist) and before the sessions that read it.
Trinity.Skills.Registry,
Trinity.Sessions.Supervisor
] ++
Trinity.Smoke.probe(Trinity.Smoke.argv()) ++
Expand Down
2 changes: 1 addition & 1 deletion lib/trinity/context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ defmodule Trinity.Context do
content, read from the project root every turn, untrusted by provenance. `AgentsMd` is the
first source; slice 040's skills index joins it in the same tier.
"""
use Boundary, deps: [Trinity], exports: [AgentsMd]
use Boundary, deps: [Trinity, Trinity.Skills], exports: [AgentsMd, SkillsIndex]
end
19 changes: 19 additions & 0 deletions lib/trinity/context/skills_index.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Context.SkillsIndex do
@moduledoc """
The skills index in the context tier (slice 040): the active skills for the session's
project, rendered by `Trinity.Skills.Index` under the prompt cap. Read every turn like the
AGENTS.md block beside it, so a skill added on disk is in the next turn.
"""

alias Trinity.Skills.Index

@doc "The block for a project root (`nil` for none): `\"\"` when no skill is active."
@spec render(String.t() | nil) :: String.t()
def render(project_root) do
project_root
|> then(&Trinity.Skills.active(project_root: &1))
|> Index.render(Index.default_tokens())
end
end
9 changes: 8 additions & 1 deletion lib/trinity/sessions/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,14 @@ defmodule Trinity.Sessions.Session do
tools = Trinity.Tools.to_llm_tools()
history = Trinity.Sessions.history(id, limit: 500)
# Slice 033: the project's AGENTS.md, read now, so a change is in this turn (live reload).
context = Trinity.Context.AgentsMd.render(session.project_root, session.project_root)
context =
[
Trinity.Context.AgentsMd.render(session.project_root, session.project_root),
# Slice 040: the skills index, under its own cap inside the same tier.
Trinity.Context.SkillsIndex.render(session.project_root)
]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n\n")

# Slice 032: what the semantic tier and past conversations hold about the latest user
# message, fused and capped; "" when there is no persona or nothing relevant.
Expand Down
42 changes: 42 additions & 0 deletions lib/trinity/skills.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Skills do
@moduledoc """
Procedural memory (slice 040, ADR-0006): skills are directories with a `SKILL.md` in the
agentskills.io format, found under three roots in precedence order (a project's
`.trinity/skills`, the data directory's `skills`, the bundled `priv/skills`), parsed into
`Trinity.Skills.Skill`, indexed in the `skills` table and held by `Trinity.Skills.Registry`,
and shown to the model by progressive disclosure: the index in the prompt, the body on
`skill_view`, a reference file on `skill_file`. The filesystem is canonical; the table is
an index a reindex rebuilds. Nothing here writes a skill: that is slice 041's.

Depends on Tools because conditional activation asks the registry which tools and
toolsets exist, and because the three skill tools implement `Trinity.Tools.Tool`; Tools
never depends on Skills.
"""
use Boundary,
deps: [Trinity, Trinity.Tools, Trinity.Memory],
exports: [Skill, Parser, Sources, Registry, Row, Index, Tools.List, Tools.View, Tools.File]

alias Trinity.Skills.Registry

@doc "The effective skills (one per name, by precedence); `project_root:` adds a project's."
@spec list(keyword()) :: [Trinity.Skills.Skill.t()]
defdelegate list(opts \\ []), to: Registry

@doc "The skills the model may see now: active, and their requirements met."
@spec active(keyword()) :: [Trinity.Skills.Skill.t()]
defdelegate active(opts \\ []), to: Registry

@doc "A skill by name, among the effective ones."
@spec get(String.t(), keyword()) :: Trinity.Skills.Skill.t() | nil
defdelegate get(name, opts \\ []), to: Registry

@doc "Rescans every root and rewrites the index."
@spec rescan() :: :ok
defdelegate rescan(), to: Registry

@doc "Enables or disables a skill by name and source; persisted in the index."
@spec set_status(String.t(), String.t(), String.t()) :: :ok | {:error, term()}
defdelegate set_status(name, source, status), to: Registry
end
Loading
Loading