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
7 changes: 5 additions & 2 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,12 @@ jobs:
- run: mix deps.get

# The adapter is chosen at compile time (config/config.exs); this proves the build
# under TRINITY_DB=postgres links the Postgres adapter and nothing SQLite.
# under TRINITY_DB=postgres links the Postgres adapter and nothing SQLite. `--no-start`
# since slice 050: the check needs the compiled module, not a booted application, and
# Oban refuses to start before its table is migrated (run 35711666503, this step booted
# the application before `ecto.reset`).
- name: The compiled adapter is Postgres
run: mix run -e 'Ecto.Adapters.Postgres = Trinity.Repo.__adapter__()'
run: mix run --no-start -e 'Ecto.Adapters.Postgres = Trinity.Repo.__adapter__()'

- run: mix ecto.reset
- run: mix test --exclude sqlite
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,15 @@ merged with a merge commit and tagged `slice/NNN` (`git tag -l 'slice/*' | wc -l
the assistant's own, and one that needs your approval waits for it on the permissions page
while the client carries a sealed state it can retry with. A headless release runs the same
tree as a server, in a container or under systemd (`docs/mcp-server.md`).

Not there yet: scheduled tasks and MCP's authorization roles (M5a), messaging gateways and subagents (M5b),
- **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
the page until you have read it, and the work is a durable job (Oban on the app's own
database) that survives a restart and retries a failed turn. The memory observer and a
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: MCP's authorization roles (M5a), messaging gateways 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 @@ -91,7 +98,8 @@ full-text search, `/personas` and `/memory` the persona and its memory (with the
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
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), `/settings` the export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal;
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;
`docs/backup.md` explains the archive.

Semantic recall needs the local embedding model (91 MB, downloaded from the memory page on your
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ standards register names the rows that ask for them.
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | approved |
| 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | approved |
| 041 | Skill self-management with staged approval + scanner | 4 Skills | M | 040, 021 | approved |
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned |
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | done |
| 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved |
| 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 |
Expand Down
3 changes: 2 additions & 1 deletion VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ never pin a version hex marks as retired or vulnerable.
| `ecto_sqlite3` | >= 0.0.0 | ✅ in `mix.lock` | Primary DB. FTS5 available. |
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
| `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. |
| `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). |
| `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_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. |
Expand Down
24 changes: 24 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ config :trinity, :fs, roots: []
# without evaluating the environment-specific imports below.
import_config "llm.exs"

# Slice 050: Oban on the app's repo. The engine follows the compile-time adapter (Lite on
# SQLite, Basic on Postgres); the queues are small because the machine's model is one; the
# tick is the scheduler's one Cron entry and the curator its second; the pruner keeps the jobs
# table to a week; the lifeline rescues a job orphaned by a crash. config/test.exs sets
# `testing: :manual` so nothing runs on its own in the suite.
config :trinity, Oban,
repo: Trinity.Repo,
engine:
if(System.get_env("TRINITY_DB", "sqlite") == "postgres",
do: Oban.Engines.Basic,
else: Oban.Engines.Lite
),
notifier: Oban.Notifiers.PG,
queues: [agent_tasks: 1, memory: 2, maintenance: 1],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"* * * * *", Trinity.Scheduler.Workers.Tick},
{"0 3 * * *", Trinity.Memory.Curator}
]},
{Oban.Plugins.Pruner, max_age: 7 * 24 * 60 * 60},
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)}
]

# Slice 010, every environment, SQLite only (the Postgres adapter ignores keys it does not
# know, and the CI matrix proves that). One writer: the pool has exactly one connection, so the
# single-writer rule SQLite imposes is the pool's shape rather than a hope. Each pragma is named
Expand Down
12 changes: 12 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,16 @@ if System.get_env("TRINITY_DB") == "postgres" do
queue_target: 5_000,
queue_interval: 30_000
else
# Slice 050: two connections, not one. At boot the sandbox is still in auto mode and the
# first long-lived process to query holds a connection until it exits; Oban verifies its
# migration at start through a raw checkout (`unboxed_run`) and waited 90 s on the one
# connection (run of 2026-09-22). Every test still shares its owner's single connection
# with every process it starts (shared mode), so 010's "writers queue on one connection"
# holds within a test; the second serves the boot-time check alone.
config :trinity, Trinity.Repo,
database: Path.expand("../trinity_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 2,
queue_target: 5_000,
queue_interval: 30_000

Expand Down Expand Up @@ -196,3 +203,8 @@ config :trinity, :skills,
# needs); a short backoff so the reconnect test runs in seconds.
config :trinity, :mcp_boot, false
config :trinity, :mcp_client, backoff_ms: 50, max_backoff_ms: 400, connect_timeout: 5_000

# Slice 050: Oban runs nothing on its own in the suite; each test drives its jobs.
config :trinity, Oban, testing: :manual
# Slice 050: the dashboard route is mounted in the suite so its mount is a test.
config :trinity, :oban_web, true
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ slice_id percent sha date
059 80.54 53c9091 2026-09-21
060 80.32 49f40a4 2026-09-21
061 80.05 c6b6faa 2026-09-22
050 80.31 c01466d 2026-09-22
2 changes: 1 addition & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ without anything failing.
| `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 (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 | Sessions, Gateways, **Repo** |
| `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`) | 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.Subagents` | Delegation, result collection | Sessions, Tools |
Expand Down
35 changes: 26 additions & 9 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,18 +164,35 @@ One row per request, the audit trail this slice owns; slice 024 reads it for dec
| request | map, nullable | slice 060: a server's input request as sent (`kind`, `server`, `inputRequests`), when the approval is a multi-round-trip question and not a yes or no; the server's `requestState` is never here (it lives in the client process) |
| answer | map, nullable | slice 060: the answer the decision carried, the revision's `inputResponses` map keyed as `inputRequests` were |

### tasks (Slice 050)
### tasks (Slice 050, as built)
| column | type | notes |
|---|---|---|
| name | string | |
| schedule | string | cron expr or ISO one-shot |
| prompt | text | |
| persona_id | fk | |
| skill_names | {array, string} | |
| deliver_to | map | `{"gateway": "telegram", "ref": {...}}` or desktop |
| enabled | boolean | |
| last_run_at, next_run_at | | mirrors Oban state |
Execution history is in `oban_jobs` + a `task_runs` table (status, session_id, summary).
| kind | string | "cron" \| "once" |
| schedule | string | a five-field cron expression Oban's parser accepts (`@daily` and its kin too), or an ISO 8601 datetime for `once` |
| 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 |
| 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 |

### task_runs (Slice 050, as built)
| column | type | notes |
|---|---|---|
| task_id | fk tasks | |
| scheduled_at | timestamp | unique with `task_id`: a tick that fires twice enqueues once |
| session_id | fk sessions, nullable | the `origin: "cron"` session the turn ran in |
| status | string | "queued" \| "running" \| "retrying" \| "ok" \| "failed" |
| attempt | integer | Oban's attempt number |
| summary | text | the assistant's answer, its first 2 000 bytes |
| error | text | on `retrying` and `failed` |
| started_at, finished_at, delivered_at, seen_at | timestamp | `seen_at` is set from the tasks page |
Oban's own `oban_jobs` (and `oban_peers`) carry the jobs; the pruner keeps a week of them.

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.

### 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
14 changes: 14 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES).
- Luerl with reduction limits, no `os`/`io`/`require`, no filesystem; explicit host functions only.
- Native/shell code is never "sandboxed" by the BEAM: the UI says so plainly when approving `:exec`.

## Scheduled tasks (Slice 050, as built)

- **A run is an ordinary turn** in a fresh session with `origin: "cron"`, the persona the task names, the
task's prompt as the user message. It reaches tools through the same gate and the same membrane, and
every call it makes leaves the same receipts; the session's history is the run's record and the tasks
page links to it.
- **Nobody is at the desk.** A tool call that asks for approval in a cron session waits its expiry on the
permissions page (021, ten minutes by default) and is denied when it passes; the turn goes on and the
run's summary shows the denial. A rule the owner writes beforehand is what lets a scheduled task write.
- **The curator deletes nothing.** Marking stale is a query receipt; archiving is an effect receipt written
by the curator itself (as 041's promotion writes its own); both on the persona's memory scope.
- **Oban's dashboard** at `/oban` is in the browser pipeline with no authentication, as every page is
until 062; it is mounted in development and where `config :trinity, :oban_web` says so.

## MCP client (Slice 060, as built)

- **Every tool a server lists is a dynamic tool under `mcp:<server>:<tool>`**, registered through one bridge
Expand Down
5 changes: 5 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ defmodule Trinity do
Skills.Promotion,
Skills.Manager,
Skills.Learn,
# Slice 050: the tasks page reads and writes the scheduler.
Scheduler,
Scheduler.Task,
Scheduler.Run,
Scheduler.Parse,
CorePolicy,
Receipts,
Receipts.Receipt,
Expand Down
5 changes: 4 additions & 1 deletion lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ defmodule Trinity.Application do
# Slice 061: the server's replay table, before the endpoint that serves /mcp.
Trinity.MCP.Server.Replay,
Trinity.MCP.Boot,
Trinity.Sessions.Supervisor
Trinity.Sessions.Supervisor,
# Slice 050: Oban after the sessions its workers drive (a run is a turn in a session),
# the engine chosen by the adapter (config.exs).
{Oban, Application.fetch_env!(:trinity, Oban)}
] ++
Trinity.Smoke.probe(Trinity.Smoke.argv()) ++
[
Expand Down
4 changes: 3 additions & 1 deletion lib/trinity/memory/always_on.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ defmodule Trinity.Memory.AlwaysOn do
def entries(persona_id, session_id) do
scopes = chain(persona_id, session_id)

# Slice 050: an archived entry (the curator's mark) is not part of what a session sees.
from(e in Entry,
where:
e.persona_id == ^persona_id and e.scope in ^scopes and e.tier in ^Entry.always_on_tiers(),
e.persona_id == ^persona_id and e.scope in ^scopes and e.tier in ^Entry.always_on_tiers() and
is_nil(e.archived_at),
order_by: [e.tier, e.key]
)
|> Repo.all()
Expand Down
Loading
Loading