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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
Trinity issues no production authority: the personal issuer refuses to start under an
external authority adapter, by construction (`docs/07-security-model.md`).
- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries
text, and everything else (the session, the gate, the receipts) is the same machinery the
desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page
and nothing else, no session and no model; once paired they get slash commands, the answer
streaming back as the turn runs, and any approval the turn raises rendered into the same
conversation. What a channel may approve is capped below what the desktop may: a `write` at
most by default, so an `exec` or a `destructive` request is decided at the machine, and the
refusal is receipted. The console adapter ships (`mix trinity.console`); the platforms are next.
- **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint,
and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the
model turns into cron). Each run is a fresh conversation you can open, its result waits on
Expand All @@ -65,7 +73,7 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice
curator that marks old memories stale and archives the untouched ones (never deleting)
run on the same queues; `/oban` shows the jobs.

Not there yet: messaging gateways and subagents (M5b),
Not there yet: the messaging platforms themselves and subagents (M5b),
the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every
slice, and the [Milestones](#milestones) section below explains how to read it.

Expand Down Expand Up @@ -107,7 +115,7 @@ model registry is `config/llm.exs`; keys come from the environment (`.env` is gi
pages: `/` conversations, `/s/:id` a conversation and `/s/:id/receipts` its receipts, `/search`
full-text search, `/personas` and `/memory` the persona and its memory (with the semantic tab and
the embedding model's download), `/skills` the skills, the changes waiting for your decision and
the learn form, `/mcp` the MCP servers you connect to (their health and the tools they
the learn form, `/gateways` the channels you can reach Trinity from, `/mcp` the MCP servers you connect to (their health and the tools they
contribute), `/permissions` the rules and pending approvals (a server's question to you, when
one of its tools asks for input mid-call, is answered there too), `/tasks` the scheduled tasks and
their results, `/oban` the jobs (in development, or when configured), `/settings` the export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal;
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ standards register names the rows that ask for them.
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved |
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved |
| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | approved |
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned |
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | done |
| 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned |
| 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned |
| 080 | Subagents + delegation | 8 Orchestration | M | 020, 023 | planned |
Expand Down
4 changes: 4 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,7 @@ config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

# Slice 070: a scheduled run can be delivered to a gateway conversation; `deliver_to`'s "kind"
# names the implementation (slice 050's `Trinity.Scheduler.Delivery.for/1`).
config :trinity, :deliveries, %{"gateway" => Trinity.Scheduler.Delivery.Gateway}
5 changes: 3 additions & 2 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Trinity.Application
│ # 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+
├── Trinity.Gateways.Router # one road from a channel to a session. Slice 070
├── Trinity.Gateways.Console # the in-process adapter; platforms at 071, 072
├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080
├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110
├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100
Expand Down Expand Up @@ -91,7 +92,7 @@ without anything failing.
| `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) |
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery (as built at 050: the `tasks` and `task_runs` rows, `next_run_at` computed from a cron expression or a one-shot datetime, `Workers.Tick` (the one Cron plugin entry, every minute, enqueuing `Workers.RunTask` for what is due), `RunTask` (one turn in a fresh `origin: "cron"` session, the summary, the delivery), `Delivery` with `Delivery.Desktop`, `Parse.human/2` (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues `agent_tasks`, `memory`, `maintenance`; the observer as `Trinity.Memory.ObserverWorker` and the curator as `Trinity.Memory.Curator` live in Memory) | Sessions, LLM, **Repo** (Gateways at 070) |
| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`; at 062: `Trinity.MCP.Auth`, a boundary of its own under `Trinity.MCP` with `deps: []` (the behaviour `authorize/2` answering a `Principal`, never the token; `Config`, `Token`, `JWKS`, `Discovery`, `Scopes`; the profiles `Local`, `External` and `Embedded` with `Embedded.Keys`; the client role `Client` with `Client.Store`), and `AuthHost` in `Trinity.MCP` (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); `Server.Auth.Local` is gone, `Server.Plug` authorizes ahead of the transport and answers `401` with a challenge, `Server` checks scope before the gate) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) |
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub |
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:<id>` and `approvals:<id>` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt)) | Sessions, **Permissions**, PubSub |
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
| `Trinity.Desktop` | ex_tauri bridge | PubSub |
Expand Down
22 changes: 21 additions & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ One row per request, the audit trail this slice owns; slice 024 reads it for dec
| prompt | text | the user message of the run's turn |
| persona_id | fk personas, nullable | the default persona when unset |
| skill_names | {array, string} | hinted to the model in the prompt |
| deliver_to | map | `%{"kind" => "desktop"}` at 050; gateways name their kind at 070 |
| deliver_to | map | `%{"kind" => "desktop"}` at 050; at 070 `%{"kind" => "gateway", "adapter" => "console", "conversation" => "c-1"}` names a channel |
| enabled | boolean | the tick enqueues enabled tasks only; a `once` task is disabled once enqueued |
| timeout_ms | integer | one run's bound (600 000 by default) |
| last_run_at, next_run_at | timestamp | `next_run_at` is computed by `Trinity.Scheduler` from the schedule, not mirrored from Oban: the tick (one Cron plugin entry a minute) enqueues what is due and advances it |
Expand All @@ -194,6 +194,26 @@ Oban's own `oban_jobs` (and `oban_peers`) carry the jobs; the pruner keeps a wee
The curator (slice 050) adds `stale_at` and `archived_at` to `memories` (docs above): stale is still
recalled and shown as such; archived leaves recall and stays in the row.

### gateway_identities (Slice 070, as built)
Who outside this machine may talk to Trinity. The key is the pair, because an external id means
nothing outside the platform that issued it.

| column | type | notes |
|---|---|---|
| adapter | string | the adapter's own name (`Trinity.Gateways.Adapter.name/1`: "console"), half the key |
| external_user_id | string | the platform's id for the person; unique with `adapter` |
| display_name | string, nullable | whatever the platform offered, for the page |
| state | string | "pending" (a code was shown) \| "paired" \| "revoked"; a revoked row is kept, so turning someone away is recorded rather than forgotten |
| code | string, nullable | the six-character pairing code, shown on `/gateways` and nowhere else; cleared once spent |
| code_expires_at | timestamp, nullable | ten minutes; a pending sender who comes back later is shown a new code rather than locked out |
| paired_at, revoked_at | timestamp, nullable | |
| last_conversation | string, nullable | where the pairing was asked from, so the answer goes back there |

A gateway session is an ordinary session with `origin` = the adapter's name and `origin_ref`
`%{"adapter", "conversation", "external_user_id"}` (010's columns; this slice adds no column to
`sessions`). A scheduled task delivers to one with
`deliver_to: %{"kind" => "gateway", "adapter" => …, "conversation" => …}`.

### usage_events (Slice 011; the ledger and budgets that read it are Slice 090)
One row per completed call, as built at slice 011:

Expand Down
36 changes: 29 additions & 7 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,17 +282,39 @@ regulated effect.
- Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff.
- Provider keys are read at call time by the provider module; not held in Session state.

## Gateways

- Allowlist of external user ids; DM pairing code flow (Slice 070). Unknown senders get a pairing prompt, nothing else.
- Rate limits per external user.
## Gateways (Slice 070, as built)

- **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is
called and nothing of the message is read but the code it might be: `Trinity.Gateways.Router`
asks `Identities.admit/3` before anything else happens. The code is six characters from an
alphabet without the pairs a person misreads, lives ten minutes, and is compared in constant
time. It is shown on `/gateways` and nowhere else, which is the whole of the proof: a sender
who can read it is at the owner's desktop. A configured allowlist
(`config :trinity, :gateways, allowlist: [{adapter, id}]`) pairs a known id at first sight.
- **An identity is `(adapter, external_user_id)`**, because an id means nothing outside the
platform that issued it, and a revoked row is kept rather than deleted: turning someone away is
a thing the page records rather than a thing it forgets.
- **Rate limits per external user**: a token bucket in the router's own state, above the session,
so a flood costs a row lookup and nothing more. A throttled sender is told, not dropped.
- **An adapter carries text and nothing else.** It never calls the LLM, never touches a session
and never decides an approval; that rule is what lets a platform be added as one module and a
configuration entry. A command that raises costs its own message: the router holds every
conversation's binding and rescues rather than dying with them.
- **Channel trust cap.** The allowlist, pairing and rate limits control *who may talk to the bot*. They do not
control *what an approval arriving from that channel may authorise*, and an approval surface is exactly as
trustworthy as the account behind it. So each channel carries a cap on the risk tier it can approve, and the
cap is applied after the gate's own decision, never instead of it. Default: `:read`, `:network` and `:write`
are approvable from any paired gateway; `:exec` and `:destructive` are **desktop or console only**. The cap is
configurable and the default is the safe one. A capped request is not silently dropped: the requester is told
the decision must be made on the desktop, and the refusal is receipted like any other.
are approvable from any paired gateway; `:exec` and `:destructive` are **desktop only**. The cap is
configurable per adapter (`config :trinity, :gateways, caps: %{"console" => :write}`) and the default is the
safe one. A capped request is not silently dropped: the requester is told the decision must be made on the
desktop, and the refusal is receipted like any other (`Trinity.Gateways.Receipts`, basis `channel_cap`, on the
session's own chain).

**Amended at 070**: this paragraph read "desktop or console only" when it was written, before there was a
console adapter. There is one now, and it is capped like every other channel rather than exempted: an
in-process adapter is still a channel, and making it the one that may approve a destructive effect would be a
hole shaped exactly like the thing the cap exists for. `Trinity.Gateways.Cap` applies the ceiling to every
adapter, `Console` included, and an unknown tier is refused rather than waved through.

## Effects and receipts (Slice 024, as built)

Expand Down
94 changes: 94 additions & 0 deletions lib/mix/tasks/trinity.console.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Console do
@shortdoc "Talks to Trinity from this terminal through the console gateway"
@moduledoc """
Slice 070. Starts the application and talks to it through `Trinity.Gateways.Console`, the
in-process adapter, so the gateway layer can be used and watched without a platform account:

mix trinity.console

It is a real channel and not a back door. The first message is answered with a pairing code,
as any unknown sender's is; the code is shown on the desktop's `/gateways` page, and typing it
here pairs this terminal. Every slash command works (`/help` lists them), an approval raised by
a turn appears here, and the channel's tier ceiling applies to `/approve` exactly as it would
on a messaging platform: an `exec` or `destructive` request is decided on the desktop.

Two things to know, as with `mix trinity.mcp.stdio`: this is a whole Trinity and the data
directory admits one at a time (`Trinity.DataDir.Lock`), so run it when the desktop does not,
or point it at its own directory with `TRINITY_DATA_DIR`. Ctrl-C twice leaves.
"""
use Boundary, classify_to: Trinity.Gateways
use Mix.Task

alias Trinity.Gateways.{Console, Router}

@conversation "terminal"

@impl Mix.Task
def run(argv) do
Mix.Task.run("app.start")
user = Enum.at(argv, 0, System.get_env("USER") || "owner")
{:ok, _} = ensure_started(Console)
{:ok, _} = ensure_started(Router)

IO.puts("Trinity console. The first message will be answered with a pairing code.")
IO.puts("Say /help for the commands, or Ctrl-C twice to leave.\n")
loop(user)
end

defp ensure_started(module) do
case module.start_link([]) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
other -> other
end
end

defp loop(user) do
case IO.gets("> ") do
:eof ->
:ok

{:error, reason} ->
Mix.shell().error("console: #{inspect(reason)}")

line ->
line |> String.trim() |> send_line(user)
loop(user)
end
end

defp send_line("", _user), do: :ok

defp send_line(text, user) do
before = length(Console.delivered(@conversation))
_ = Router.inbound(Console, @conversation, user, text)
# The reply streams, so the terminal waits a moment for what the channel was shown rather
# than printing nothing and looking broken.
print_new(before, 0)
end

defp print_new(before, waited) when waited < 30_000 do
delivered = Console.delivered(@conversation)

if length(delivered) > before do
Process.sleep(200)
Console.text(@conversation) |> Enum.drop(visible_before(before)) |> Enum.each(&IO.puts/1)
else
Process.sleep(100)
print_new(before, waited + 100)
end
end

defp print_new(_before, _waited), do: IO.puts("(no answer yet)")

# How many visible messages the log held before this turn: an edit replaces a message rather
# than adding one, so counting the log is not counting what a person has seen.
defp visible_before(before) do
@conversation
|> Console.delivered()
|> Enum.take(before)
|> Enum.count(&match?({:message, _}, &1))
end
end
Loading
Loading