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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ 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/133`: semantic memory without native code, and vectors that know where they came from

Semantic recall used to need EXLA, a 463 MB native library that does not load in the Linux desktop
bundle and has no Windows build, so on those builds it was simply off. This adds a floor that needs
no native code at all: a static embedding model (`static-retrieval-mrl-en-v1`, 256 dimensions, int8,
7.8 MB of weights) run in plain Elixir, with its own implementation of the BERT tokenizer that gives
the reference library's token ids on 5,000 natural sentences and 1,000 hostile ones. Its vectors
match the reference implementation's to a cosine of 0.9999 or better on 500 fixtures, and scoring
10,000 of them takes 10 to 25 ms at the 95th percentile on the owner's machine, depending on the
method. A release built without the neural libraries boots, embeds and recalls through it. The
weights are not distributed with Trinity while their licence and training-data terms are reviewed.

Every stored vector now belongs to a named embedding space, identified by a digest over fourteen
fields (model, revision, weights and tokenizer digests, width, pooling, quantization, prompts,
runtime, declared locality and more), in a table of its own. A store answers from exactly one space,
chosen by the operator: configuring a different model turns semantic recall off with a reason
instead of quietly finding nothing, moving to a new model is an explicit re-embed built beside the
old vectors, and the old vectors stay until the operator confirms their removal. A store row that
does not fit its space makes recall refuse rather than rank. An embedder that reaches an endpoint
must declare whether it is inside the deployment's boundary; a regulated node refuses to boot on a
missing or wrong declaration, and full-text recall keeps working through every runtime fault.

The evaluation that decides the default model is built and waiting: a generated corpus, 300 queries,
and labels that the scored run will not read until 100 of them have been audited and frozen.

## 2026-10-08

### `slice/100`: Trinity as a desktop application
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,12 @@ one of its tools asks for input mid-call, is answered there too), `/tasks` the s
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
say-so, never on its own). Without it the tier is off and full-text search still works;
`docs/perf.md` has what it costs.
Semantic recall needs a local embedding model: all-MiniLM-L6-v2 (91 MB, downloaded from the memory page on your
say-so, never on its own) or the static floor (a 7.8 MB weights file, pure Elixir, no native code, installed
under the data directory; not yet distributed with Trinity while its licence is reviewed). Without one the tier
is off and full-text search still works. Every stored vector belongs to one named embedding space, and a store
answers only from the space it is pinned to: changing models is an explicit re-embed (`mix trinity.space.retier`),
never a silent switch. `docs/perf.md` has what it costs.

`mix gate` runs the full quality gate and has to pass before every commit: format check, compile
with warnings as errors, Credo, Sobelow, dependency audits, version verification, the naming and
Expand Down
13 changes: 13 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,19 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

# Slice 133: the static floor is the embedder where the neural group cannot run. A build without
# it (`TRINITY_WITHOUT_ML=1`, mix.exs) has nothing else in process; the Linux desktop bundle
# leaves exla out (its NIF never loaded in Burrito's musl ERTS) and a Windows host never declares
# it. Chosen at build time, the way mix.exs composes exla per `BURRITO_TARGET`: this is the
# configuration such a build ships with, not a fallback, and the store still answers only from
# the space it is pinned to. Until the weights may be bundled (slice 133 NOTES, D7) these builds
# report `:weights_missing` unless an operator installs the artifact (`Embedders.Static`).
# Everywhere else the default stays `:local` (MiniLM) until the eval decides (D5).
if System.get_env("TRINITY_WITHOUT_ML") == "1" or
System.get_env("BURRITO_TARGET") in ["linux_x86_64", "windows_x86_64"] do
config :trinity, :memory, embedder: :static
end

# Slice 100: the Settings page and the setup path take provider keys in a form whose parameter is
# `secret`. Phoenix and LiveView log a request's or an event's parameters at debug level; anything
# whose name contains one of these is logged as "[FILTERED]". Phoenix's own default is the first.
Expand Down
14 changes: 11 additions & 3 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ without anything failing.
| `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.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023); at 133 the embedding spaces (`Space`, the fourteen-field identity; `Spaces`, the one active space, re-tier, drop), the static floor (`Embedders.Static`, `WordPiece`, `StaticArtifact`), the int8 `Scorer`, the embedder configuration's faults (`EmbedderConfig`) and the operator's `ReEmbedWorker` | 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 (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) |
Expand All @@ -120,8 +120,8 @@ through those modules only. `boundary` `exports:` lists enforce this.
| `Trinity.LLM.Provider` | `stream/3`, `generate/3`, `embed/2`, `models/0`, `capabilities/1` | config `:providers` list |
| `Trinity.Tools.Tool` | `name/0`, `description/0`, `schema/0`, `risk/0`, `execute/2` | config `:tools` list + MCP dynamic registration |
| `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.Memory.VectorStore` | `upsert/3`, `search/3`, `delete/1`, `count/1` (as built at 032: the scope filter is `search/3`'s required argument; as built at 133: `search/3` and `count/1` over one named space, `{:error, {:mixed_space, n}}` rather than a ranking when a row does not fit it, writes through `Spaces.put_vector/5`) | 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`; at 133 `space/0` and `thresholds/0` | `config :trinity, :memory, embedder:` (`:local` default, `:static` the floor, `:hosted` opt-in only with a declared `locality:`, `:fake` in the suite, or a module; a list names several, and the store is served by the one whose space is pinned) |
| `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 |

Expand All @@ -136,6 +136,14 @@ UI/Gateway ──user_message──▶ Session(gen_statem)
→ LLM.stream/3 ──chunks──▶ PubSub "session:<id>" ──▶ LiveView + gateways
→ on tool_call: Permissions.decide → (ask → approval_wait) → Tools.execute in Task → result appended
→ loop until final text → persist → Memory.Observer.observe(turn) (a Task under Trinity.Memory.TaskSupervisor, as built at 032) → idle

**Embedding spaces (Slice 133, as built):** every vector is a `memory_embeddings` row under the space that produced
it, and the store has one active space, moved only by the operator's re-tier (`Spaces.retier/2`, built beside the
old space, the pointer moved in one transaction when it is complete, the old space kept until `mix
trinity.space.drop <id> --confirm`). `Semantic.status/0` serves the store through the configured embedder whose
space is active and answers OFF with a named reason otherwise: a configuration fault (`EmbedderConfig`; under
`:regulated` the boot is refused instead), a space mismatch, the embedder's availability, or the last runtime
fault (an unreachable endpoint, a store row that does not fit its space). Full-text recall goes on in every case.
```

Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe
Expand Down
1 change: 1 addition & 0 deletions docs/02-tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Versions live in `VERSIONS.md`. This file explains *why* each choice was made an
| Embeddings | Bumblebee + EXLA (all-MiniLM-L6-v2) | Local, private, 384-dim | If EXLA binary size is unacceptable on desktop → hosted embeddings via req_llm, or Ortex (risk: stalled) |
| Vector search | sqlite_vec (brute force) behind `VectorStore` behaviour | Fine to ~10^5 vectors; no extra process | Scale → hnswlib (pre-1.0) or pgvector HNSW |
| Vector search, as built at 032 | brute force in Elixir over the row's `embedding` bytes (SQLite); pgvector HNSW (Postgres) | sqlite_vec's one release needs `nx ~> 0.9` and cannot share a tree with nx 0.13 (VERSIONS.md); measured in docs/perf.md | Scale → hnswlib 0.1.10 |
| Embeddings and vector search, as built at 133 | the static floor: `static-retrieval-mrl-en-v1` at 256 dimensions int8 in pure Elixir (`Embedders.Static`, its own WordPiece), scored by the int8 scorer in Elixir; MiniLM through Bumblebee and EXLA stays the default where EXLA loads; every vector under a named, operator-pinned embedding space (`memory_embeddings`) | No NIF on the floor's path, so semantic memory can run on Windows, Burrito's musl and a build without the neural group (`TRINITY_WITHOUT_ML=1`); the eval (slice 133, AC14) decides the default; the weights wait on legal review | Scorer p95 over 50 ms at 10^4 x 256 (measured 10.3 ms with the prefilter, 24.5 ms exact: docs/perf.md) → a scoring-only Rustler crate |
| Shell tool | MuonTrap | Guaranteed child kill on process death; cgroups on Linux | none |
| Sandbox | Luerl (`sandbox` pkg) | In-VM, reduction-limited, no OS access | Untrusted native code → container/microVM (out of scope) |
| Modularity | `boundary` + behaviours + `Registry` | Compile-time enforcement of context deps | none |
Expand Down
28 changes: 27 additions & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ every term for FTS5, so operators are text.
| scope | string | "global" \| "persona:<id>" \| "project:<path>" |
| key | string, nullable | for always_on/profile: short stable key, unique within (tier, scope) |
| body | text | |
| embedding | vector(384), nullable | semantic tier only; sqlite_vec virtual table `memories_vec` on SQLite |
| embedding | vector(384), nullable | semantic tier only; sqlite_vec virtual table `memories_vec` on SQLite (as planned; as built see below: slice 032 put it on the row, slice 133 moved it to `memory_embeddings`) |
| source_message_id | fk, nullable | provenance |
| confidence | float | agent-assigned |
| last_used_at | utc_datetime_usec | for decay/pruning |
Expand Down Expand Up @@ -91,6 +91,32 @@ The change log's `action` vocabulary grows by `pin` (a semantic memory promoted
`AlwaysOn.add/2`, so the budget applies) and `by` by `observer`. Recall marks its memory hits' `last_used_at`,
which the retriever's recency decay reads.

As built at slice 133 (embedding spaces): the three `embedding*` columns, the index on them and Postgres's
`embedding_vector vector(384)` are gone from `memories`. Migration `20261008120000_create_memory_embeddings`
moved every row that had a vector (the count before equals the count after, held by
`test/trinity/memory/migration_test.exs`) into two tables:

- **`embedding_spaces`**: `id` (string, primary key: the space ID, 64 lowercase hex, the SHA-256 of the
manifest's RFC 8785 JSON), `manifest` (map: the fourteen identity fields of `Trinity.Memory.Space`), `dim`,
`quantization` (`f32` or `int8`), `state` (`building` while a re-tier fills it, `complete` after), `active`
(boolean), `completed_at`, timestamps. A unique partial index (`embedding_spaces_one_active`, `WHERE active`)
lets the database hold at most one active space: the store's pinned space. A row written before slice 133 is
under a legacy space whose unrecorded fields read `"unrecorded"` (`Space.legacy/2`); the space of the most
recently embedded row was made active.
- **`memory_embeddings`**: `memory_id` (references `memories`, `ON DELETE CASCADE`) and `space_id` (references
`embedding_spaces`, `ON DELETE RESTRICT`) together the primary key, `dim`, `vector` (binary: float32
little-endian, or for an `int8` space one signed byte a component, the vector scaled to a largest magnitude
of 127), `inserted_at`, and an index on `space_id`. A memory may hold a vector in several spaces at once,
which is what a re-tier builds. On Postgres there is also `embedding_vector`, an **untyped** pgvector
column, and one partial expression HNSW index per space,
`((embedding_vector::vector(<dim>)) vector_cosine_ops) WHERE space_id = '<id>'`, named
`memory_embeddings_hnsw_<first 16 hex of the id>` and built when the space is registered.

Vectors are written by `Trinity.Memory.Spaces.put_vector/5` alone (bytes and, on Postgres, the pgvector value in
one statement, the width checked against the space). A search names one space; a space with a row whose width
or byte length does not fit it is refused rather than ranked. `mix trinity.space.list`, `mix
trinity.space.retier` and `mix trinity.space.drop <id> --confirm` are the operator's commands.

### memory_changes (Slice 030)
`persona_id`, `action` (add | replace | remove | promote | consolidate), `tier`, `scope`, `key`, `before`,
`after`, `by` (tool | ui | consolidator), `session_id`, `proposal_id`, `inserted_at`. Every write to the
Expand Down
Loading
Loading