Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ TRINITY_LIVE_MODEL=
OLLAMA_BASE_URL=http://localhost:11434/v1
OLLAMA_MODEL=

# ---- Telegram gateway (slice 071), optional ---------------------------------------------
# The bot token from @BotFather. Read through Trinity.Config.secret/1 at each Bot API call and
# never stored. The gateway runs only when it is named: TRINITY_GATEWAYS=telegram. Without the
# token it stays idle and /gateways says so. Leave both empty to keep Telegram off.
TELEGRAM_BOT_TOKEN=
# TRINITY_GATEWAYS=telegram

# ---- App runtime (config/runtime.exs reads these; all have defaults) ------------------
# PORT=4000
# DATABASE_PATH=
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,30 @@ evidence for each increment is retained by the maintainers and summarised here.
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
the public record of delivered work.

### `slice/071`: Trinity answers on Telegram

A Telegram bot is now a channel Trinity can be reached from, alongside the console. It answers in
private chats and in groups where it is mentioned or replied to, streams its answer by editing one
message no more than once a second, converts the model's markdown to Telegram's strict MarkdownV2
so a half-written answer never fails to send, offers approvals as Approve and Deny buttons within the
channel's existing cap, and passes a photo to a model that declares it can see images. A restarted
bot does not process the same message twice: where it is up to is written to disk before a message
is handled. It is off until named (`TRINITY_GATEWAYS=telegram`) and given a token
(`TELEGRAM_BOT_TOKEN`); the application now starts the gateways it is configured with.

No Telegram library is used. Both candidates were measured: one cannot be pinned under this
project's version rule, and the other keeps its polling position in memory and cannot be pointed at a
test server. The Bot API is a handful of JSON calls over the HTTP client the project already uses.

Two defects in the gateway core were found and fixed on the way. The pairing prompt told an unknown
sender the very code it asked them to prove they could read on the desktop, which on a public
platform would let anyone pair; it now says only where the code is. And the short approval id a
channel shows was shared by every request raised in the same minute, so typing it decided whichever
came first; an ambiguous id is now refused, and buttons carry the full id.

The live checks (a real bot, a real phone, a vision model, a scheduled delivery) are listed for the
maintainer to run; everything else runs in the suite against a fake Bot API with no network.

## 2026-10-08

### `slice/100`: Trinity as a desktop application
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ enforces it.
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.
refusal is receipted. The console adapter ships (`mix trinity.console`), and so does Telegram:
a bot that answers in private chats and in groups where it is mentioned, streams its reply by
editing one message, offers approvals as buttons (within the same cap), and passes a photo to a
model that can see. Set `TELEGRAM_BOT_TOKEN` and `TRINITY_GATEWAYS=telegram` to turn it on.
- **Delegates.** The assistant can hand a bounded piece of work to a child session with a
`delegate` tool: its own context, its own history, and a result handed back as text. The
parent's conversation never contains the child's messages, which is the point:
Expand Down Expand Up @@ -171,10 +174,9 @@ enforces it.
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: the messaging platforms themselves (071 Telegram and 072 Discord, which need a bot
token the project does not yet hold), the native desktop shell and signed releases (the rest of M6),
store-and-forward receipts for disconnected operation (026, waiting on an answer from the external
authority plane). The [Milestones](#milestones) section below sets out the order the remaining work
Not there yet: the second messaging platform (072), the native desktop shell and signed releases
(the rest of M6), store-and-forward receipts for disconnected operation (026, waiting on an answer
from the external authority plane). The [Milestones](#milestones) section below sets out the order the remaining work
is being built in.

The interface is a local web page; the desktop shell exists as a packaging spike, not a product.
Expand Down
5 changes: 3 additions & 2 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ never pin a version hex marks as retired or vulnerable.
| `stream_data` | ~> 1.4 | ✅ in `mix.lock` | Property-based testing: generated input for `test/property`. Apache-2.0; 1.4.0 measured on hex.pm 2026-09-24, released 2026-07-14. Test only, so it ships in no artifact. Elixir has no focused fuzzing library and property-based testing is what the OpenSSF gold criterion `dynamic_analysis` accepts in its place, which is why this is a dependency rather than a habit. Added at Slice 004. |
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. Since Slice 071 also the Telegram Bot API client, in place of a Telegram library. |
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
| `beam_mcp` | ~> 0.9 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
| `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. |
Expand Down Expand Up @@ -141,7 +141,8 @@ never pin a version hex marks as retired or vulnerable.
| `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. |
| `nostrum` | ~> 0.10 | 🔍 not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. |
| `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. |
| `telegex` | not used (measured at Slice 071, 2026-10-08) | 🔍 not a single package | Telegram. Measured at Slice 071: the latest release on hex is `1.9.0-rc.0` (2024-09-18) and the last stable `1.8.0` (2024-05-28); this file's own rule forbids pinning an `-rc`. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` (slice 071 NOTES, "The library, measured"). The row stays so the decision is visible where a reader would look for the package. |
| `ex_gram` | not used (measured at Slice 071, 2026-10-08) | 🔍 not a single package | Telegram. Measured at Slice 071 on `0.71.0` (2026-10-01): its poller keeps the `getUpdates` offset in process state and starts at `-1`, so a restarted poller reprocesses or skips unconfirmed updates (Slice 071's AC5 is that property); its file download host is a module attribute that cannot be pointed at a test server; Beerware licence; 18,054 lines for what is here a handful of JSON calls. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` and owns its poller. |
| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. |
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. |
| `jsv` | ~> 0.23 | ✅ in `mix.lock` | JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas). |
Expand Down
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,9 @@ 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}

# Slice 071: the gateways this build carries, by module. None runs unless it is named:
# `config :trinity, :gateways, adapters: [...]` names modules, and `TRINITY_GATEWAYS`
# (config/runtime.exs) names adapters from this list by their name ("telegram", "console"), so an
# environment variable never mints an atom. A new platform is a module and a line here.
config :trinity, :gateways, available: [Trinity.Gateways.Console, Trinity.Gateways.Telegram]
14 changes: 10 additions & 4 deletions config/llm.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import Config
# are US dollars per million tokens and are the source of every recorded cost. Free-tier
# models carry 0.0. The default model is the OpenRouter one the owner picked; the NVIDIA
# endpoint is reached as an OpenAI-compatible base_url. Test config replaces all of this.
# Slice 071: the default OpenRouter model takes images. llm_db's snapshot (the dependency in
# mix.lock) lists `openrouter` / `inclusionai/ling-3.0-flash-vl:free` with input modalities
# ["text", "image", "video"]. `:vision` is declared for that id only: a TRINITY_LIVE_MODEL naming
# another model gets no vision until someone checks that one and says so here.
ling_default = "inclusionai/ling-3.0-flash-vl:free"
ling_model = System.get_env("TRINITY_LIVE_MODEL", ling_default)
ling_vision = if ling_model == ling_default, do: [:vision], else: []

config :trinity, :llm,
default_model: "openrouter:ling",
providers: %{req_llm: Trinity.LLM.Providers.ReqLLM},
Expand All @@ -15,11 +23,9 @@ config :trinity, :llm,
%{
id: "openrouter:ling",
provider: :req_llm,
model:
"openrouter:" <>
System.get_env("TRINITY_LIVE_MODEL", "inclusionai/ling-3.0-flash-vl:free"),
model: "openrouter:" <> ling_model,
api_key_env: "OPENROUTER_API_KEY",
caps: [:stream, :tools, :json],
caps: [:stream, :tools, :json] ++ ling_vision,
price: %{input: 0.0, output: 0.0}
},
%{
Expand Down
15 changes: 15 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ if config_env() != :test and auth_profile not in [nil, ""] do
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
end

# Slice 071: which gateways run. `TRINITY_GATEWAYS=telegram` (comma separated) names adapters from
# `config :trinity, :gateways, available:` by name; `Trinity.Gateways.adapters/0` matches them, so
# a name that is not available matches nothing and mints no atom. Unset, no gateway runs. The
# suite configures its own and never reads this.
if config_env() != :test do
case System.get_env("TRINITY_GATEWAYS") do
names when names in [nil, ""] ->
:ok

names ->
config :trinity, :gateways,
enabled: names |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
end
end

# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development
Expand Down
Loading
Loading