Skip to content
Merged
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ standards register names the rows that ask for them.
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | approved |
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
| 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned |
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | planned |
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | done |
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved |
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned |
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | planned |
Expand Down
4 changes: 3 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,16 @@ config :trinity, :tools,
Trinity.Tools.Web.Search,
# Slice 031: full-text search over past messages.
Trinity.Tools.SessionSearch,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
],
toolsets: %{
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 031: search over past conversations.
memory: ["session_search"]
memory: ["session_search", "memory"]
}

# Slice 022: the filesystem roots beside the data directory (always a root) and the session's
Expand Down
4 changes: 3 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ config :trinity, :tools,
Trinity.Tools.Web.Search,
# Slice 031: full-text search over past messages.
Trinity.Tools.SessionSearch,
# Slice 030: the always-on memory tiers.
Trinity.Tools.Memory,
Trinity.Tools.Shell.Run
],
toolsets: %{
Expand All @@ -35,7 +37,7 @@ config :trinity, :tools,
web: ["web_fetch", "web_search"],
shell: ["shell"],
# Slice 031: search over past conversations.
memory: ["session_search"]
memory: ["session_search", "memory"]
},
timeout_ms: 2_000

Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ slice_id percent sha date
003 75.39 39518c2 2026-09-20
024 76.55 f977b84 2026-09-20
031 76.96 866dc3f 2026-09-20
030 78.40 c657da8 2026-09-21
4 changes: 2 additions & 2 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ without anything failing.

| Context (module) | Owns | May depend on |
|---|---|---|
| `Trinity.Sessions` | Session process, turn loop, message log | LLM, Tools, **Effects**, Permissions, Memory, Skills, Repo, PubSub |
| `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) |
| `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 tier, episodic FTS, semantic store, retrieval, compaction | LLM (summaries/embeddings), Repo |
| `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.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** |
| `Trinity.MCP` | Client manager, tool bridge, server | Tools, **Effects**, **Permissions**, Memory |
Expand Down
16 changes: 16 additions & 0 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ every term for FTS5, so operators are text.
Invariant: total bytes of `always_on` + `profile` for a persona ≤ configurable budget (default 8 KB), enforced by
`Trinity.Memory.Budget`, which triggers consolidation instead of silent truncation.

As built at slice 030: `memories` carries `persona_id` (the budget's unit) beside the columns above, the scope
vocabulary is `global | persona:<id> | project:<path> | session:<id>` (a session reads the chain `session`,
`persona`, `global`), `key` is required for the two always-on tiers (`^[a-z0-9][a-z0-9_.-]{0,63}$`, unique with
tier and scope), and the semantic tier's columns wait for 032. Two tables beside it:

### 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
always-on tiers appends one; a consolidation's writes carry its proposal id, so no entry leaves the tiers
without a row here.

### memory_proposals (Slice 030)
`persona_id`, `entries` (the proposed set), `bytes_before`, `bytes_after`, `budget`, `status` (applied | pending |
rejected), `decided_at`. The consolidator applies a proposal under budget at once and holds one over budget for
the owner (the memory page).

### skills (Slice 040)
| column | type | notes |
|---|---|---|
Expand Down
7 changes: 7 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,17 @@ defmodule Trinity do
Sessions.Message,
Sessions.SessionRow,
Sessions.Prompt,
Personas,
LLM,
Memory,
Memory.Tokens,
Memory.Search,
Memory.AlwaysOn,
Memory.Budget,
Memory.Consolidator,
Memory.Entry,
Memory.Proposal,
Memory.Change,
Tools,
Permissions,
Permissions.Approval,
Expand Down
19 changes: 12 additions & 7 deletions lib/trinity/effects/runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,16 @@ defmodule Trinity.Effects.Runner do
@doc "The executor: decide and receipt, then read directly or cross the membrane."
@spec execute(map(), map(), Context.t()) :: {:ok, Result.t()} | {:error, term()}
def execute(entry, args, %Context{} = ctx) do
{decision, fp, reason} =
{decision, fp, reason, basis} =
case Runner.decide(entry, args, ctx) do
{:allow, fp} -> {:allow, fp, nil}
{:deny, fp} -> {:deny, fp, :denied}
{:ask, why, fp} -> {:ask, fp, why}
{:allow, fp, basis} -> {:allow, fp, nil, basis}
{:deny, fp, basis} -> {:deny, fp, :denied, basis}
{:ask, why, fp, basis} -> {:ask, fp, why, basis}
end

scope = scope(ctx)

case decision_receipt(scope, entry, ctx, decision, fp, reason) do
case decision_receipt(scope, entry, ctx, decision, fp, reason, basis) do
{:ok, _} -> dispatch(decision, entry, args, ctx, scope, fp, reason)
{:error, why} -> {:error, {:decision_not_receipted, why}}
end
Expand Down Expand Up @@ -83,7 +83,8 @@ defmodule Trinity.Effects.Runner do
ctx,
decision,
fp,
reason
reason,
basis
) do
Receipts.append(scope, %{
kind: "decision",
Expand All @@ -93,7 +94,11 @@ defmodule Trinity.Effects.Runner do
"tool" => name,
"effect" => Atom.to_string(effect)
},
decision: %{"outcome" => Atom.to_string(decision), "reason" => reason && inspect(reason)},
decision: %{
"outcome" => Atom.to_string(decision),
"basis" => basis,
"reason" => reason && inspect(reason)
},
fingerprint: fp,
subject_ref: "decision:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}",
meta: %{"tool_definition_digest" => digest}
Expand Down
4 changes: 3 additions & 1 deletion lib/trinity/memory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ defmodule Trinity.Memory do
to 032 add the tiers, the search and the semantic recall. It depends on the LLM and on the
core, never on Sessions: the Session calls it and writes what it returns.
"""
use Boundary, deps: [Trinity, Trinity.LLM], exports: [Tokens, Compactor, Search]
use Boundary,
deps: [Trinity, Trinity.LLM],
exports: [Tokens, Compactor, Search, AlwaysOn, Budget, Consolidator, Entry, Proposal, Change]
end
197 changes: 197 additions & 0 deletions lib/trinity/memory/always_on.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Memory.AlwaysOn do
@moduledoc """
The always-on memory tiers (slice 030): `profile` (who the person is) and `always_on` (what
Trinity keeps in mind), rendered as a deterministic block into every prompt of the persona.

**Scopes.** A session reads its scope chain, `session:<id>`, `persona:<id>`, `global`, and
nothing else: a memory written for one session is invisible to another until promoted
(SLICE.md M6, AC7). Promotion is a change like any other, logged; through the `memory` tool
it is an artifact effect with a receipt.

**Every write is logged** in `memory_changes` with the body before and after and who wrote
it (`tool`, `ui`, `consolidator`). After a write the budget is checked and, over it,
`Trinity.Memory.Consolidator.run/2` is asked to bring the tiers under it.

**The snapshot** is computed once per session start and on explicit refresh; the Session
keeps it in state, so a mid-session edit takes effect next session or on refresh.
"""

import Ecto.Query

alias Trinity.Memory.{Budget, Change, Entry}
alias Trinity.Repo

@type scope :: String.t()
@type write_opts :: [
by: String.t(),
session_id: String.t() | nil,
proposal_id: String.t() | nil
]

@doc "The scope chain a session reads, innermost first."
@spec chain(String.t(), String.t() | nil) :: [scope()]
def chain(persona_id, nil), do: ["persona:" <> persona_id, "global"]

def chain(persona_id, session_id),
do: ["session:" <> session_id, "persona:" <> persona_id, "global"]

@doc "The persona scope."
@spec persona_scope(String.t()) :: scope()
def persona_scope(persona_id), do: "persona:" <> persona_id

@doc "A session's scope."
@spec session_scope(String.t()) :: scope()
def session_scope(session_id), do: "session:" <> session_id

@doc "The entries a session sees: its chain, always-on tiers, sorted by tier then key."
@spec entries(String.t(), String.t() | nil) :: [Entry.t()]
def entries(persona_id, session_id) do
scopes = chain(persona_id, session_id)

from(e in Entry,
where:
e.persona_id == ^persona_id and e.scope in ^scopes and e.tier in ^Entry.always_on_tiers(),
order_by: [e.tier, e.key]
)
|> Repo.all()
|> Enum.sort_by(&{tier_rank(&1.tier), &1.key})
end

@doc "Every always-on entry of a persona, over every scope (the pages and the consolidator)."
@spec all(String.t()) :: [Entry.t()]
def all(persona_id) do
from(e in Entry, where: e.persona_id == ^persona_id and e.tier in ^Entry.always_on_tiers())
|> Repo.all()
|> Enum.sort_by(&{tier_rank(&1.tier), &1.scope, &1.key})
end

@doc """
The block the prompt carries: profile then always-on, one `- key: body` per entry, sorted, or
`""` when there is nothing. Deterministic for the same rows.
"""
@spec snapshot(String.t(), String.t() | nil) :: String.t()
def snapshot(persona_id, session_id) do
persona_id |> entries(session_id) |> render()
end

@doc "Renders entries as the snapshot block."
@spec render([Entry.t()]) :: String.t()
def render([]), do: ""

def render(entries) do
entries
|> Enum.group_by(& &1.tier)
|> Enum.sort_by(fn {tier, _} -> tier_rank(tier) end)
|> Enum.map_join("\n\n", fn {tier, es} ->
heading = if tier == "profile", do: "## About the person", else: "## Always in mind"
heading <> "\n" <> Enum.map_join(es, "\n", &"- #{&1.key}: #{&1.body}")
end)
end

@doc "An entry by tier, scope and key."
@spec get(String.t(), scope(), String.t()) :: Entry.t() | nil
def get(tier, scope, key), do: Repo.get_by(Entry, tier: tier, scope: scope, key: key)

@doc "Adds an entry (refused if the key exists in that tier and scope); logged; budget checked."
@spec add(map(), write_opts()) :: {:ok, Entry.t()} | {:error, Ecto.Changeset.t() | :exists}
def add(attrs, opts) do
attrs = Map.new(attrs, fn {k, v} -> {to_atom(k), v} end)

if get(attrs[:tier], attrs[:scope], attrs[:key]) do
{:error, :exists}
else
with {:ok, entry} <- %Entry{} |> Entry.changeset(attrs) |> Repo.insert() do
log(entry, "add", nil, entry.body, opts)
after_write(entry.persona_id, opts)
{:ok, entry}
end
end
end

@doc "Replaces an entry's body; logged; budget checked."
@spec replace(Entry.t(), String.t(), write_opts()) ::
{:ok, Entry.t()} | {:error, Ecto.Changeset.t()}
def replace(%Entry{} = entry, body, opts) do
with {:ok, updated} <- entry |> Entry.changeset(%{body: body}) |> Repo.update() do
log(updated, "replace", entry.body, body, opts)
after_write(entry.persona_id, opts)
{:ok, updated}
end
end

@doc "Removes an entry; logged."
@spec remove(Entry.t(), write_opts()) :: {:ok, Entry.t()} | {:error, Ecto.Changeset.t()}
def remove(%Entry{} = entry, opts) do
with {:ok, deleted} <- Repo.delete(entry) do
log(entry, "remove", entry.body, nil, opts)
{:ok, deleted}
end
end

@doc "Moves an entry to another scope (a session's memory to the persona's, or to global); logged as a promotion."
@spec promote(Entry.t(), scope(), write_opts()) ::
{:ok, Entry.t()} | {:error, Ecto.Changeset.t() | :exists}
def promote(%Entry{} = entry, to_scope, opts) do
if get(entry.tier, to_scope, entry.key) do
{:error, :exists}
else
with {:ok, moved} <- entry |> Entry.changeset(%{scope: to_scope}) |> Repo.update() do
log(moved, "promote", entry.scope, to_scope, opts)
{:ok, moved}
end
end
end

@doc "The change log of a persona, newest first (`limit:`)."
@spec changes(String.t(), keyword()) :: [Change.t()]
def changes(persona_id, opts \\ []) do
from(c in Change,
where: c.persona_id == ^persona_id,
order_by: [desc: c.inserted_at],
limit: ^Keyword.get(opts, :limit, 200)
)
|> Repo.all()
end

@doc "The change rows of one consolidation proposal."
@spec changes_of_proposal(String.t()) :: [Change.t()]
def changes_of_proposal(proposal_id),
do: Repo.all(from c in Change, where: c.proposal_id == ^proposal_id, order_by: c.inserted_at)

@doc false
@spec log(Entry.t(), String.t(), String.t() | nil, String.t() | nil, write_opts()) :: Change.t()
def log(%Entry{} = e, action, before, after_, opts) do
Repo.insert!(%Change{
persona_id: e.persona_id,
action: action,
tier: e.tier,
scope: e.scope,
key: e.key,
before: before,
after: after_,
by: Keyword.get(opts, :by, "unknown"),
session_id: Keyword.get(opts, :session_id),
proposal_id: Keyword.get(opts, :proposal_id),
inserted_at: DateTime.utc_now()
})
end

# The consolidator runs after a write that leaves the persona over budget, unless the write
# is the consolidator's own.
defp after_write(persona_id, opts) do
if Keyword.get(opts, :by) != "consolidator" and Budget.status(persona_id).over? do
Trinity.Memory.Consolidator.run(persona_id, opts)
end

:ok
end

defp tier_rank("profile"), do: 0
defp tier_rank("always_on"), do: 1
defp tier_rank(_), do: 2

defp to_atom(k) when is_atom(k), do: k
defp to_atom(k) when is_binary(k), do: String.to_existing_atom(k)
end
Loading
Loading