Skip to content

Commit 91c673c

Browse files
authored
Merge slice/030-persona-always-on-memory
Slice 030: persona (SOUL) and the always-on memory tier. PROOF.md at slices/030-persona-always-on-memory/PROOF.md; gate, postgres and fips jobs green on the closing tree. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents df4b567 + f458f8c commit 91c673c

44 files changed

Lines changed: 2637 additions & 62 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ standards register names the rows that ask for them.
4444
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | approved |
4545
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
4646
| 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned |
47-
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | planned |
47+
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | done |
4848
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved |
4949
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned |
5050
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | planned |

config/config.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,16 @@ config :trinity, :tools,
7373
Trinity.Tools.Web.Search,
7474
# Slice 031: full-text search over past messages.
7575
Trinity.Tools.SessionSearch,
76+
# Slice 030: the always-on memory tiers.
77+
Trinity.Tools.Memory,
7678
Trinity.Tools.Shell.Run
7779
],
7880
toolsets: %{
7981
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
8082
web: ["web_fetch", "web_search"],
8183
shell: ["shell"],
8284
# Slice 031: search over past conversations.
83-
memory: ["session_search"]
85+
memory: ["session_search", "memory"]
8486
}
8587

8688
# Slice 022: the filesystem roots beside the data directory (always a root) and the session's

config/test.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ config :trinity, :tools,
2727
Trinity.Tools.Web.Search,
2828
# Slice 031: full-text search over past messages.
2929
Trinity.Tools.SessionSearch,
30+
# Slice 030: the always-on memory tiers.
31+
Trinity.Tools.Memory,
3032
Trinity.Tools.Shell.Run
3133
],
3234
toolsets: %{
@@ -35,7 +37,7 @@ config :trinity, :tools,
3537
web: ["web_fetch", "web_search"],
3638
shell: ["shell"],
3739
# Slice 031: search over past conversations.
38-
memory: ["session_search"]
40+
memory: ["session_search", "memory"]
3941
},
4042
timeout_ms: 2_000
4143

coverage.tsv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ slice_id percent sha date
1212
003 75.39 39518c2 2026-09-20
1313
024 76.55 f977b84 2026-09-20
1414
031 76.96 866dc3f 2026-09-20
15+
030 78.40 c657da8 2026-09-21

docs/01-architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,14 @@ without anything failing.
7272

7373
| Context (module) | Owns | May depend on |
7474
|---|---|---|
75-
| `Trinity.Sessions` | Session process, turn loop, message log | LLM, Tools, **Effects**, Permissions, Memory, Skills, Repo, PubSub |
75+
| `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) |
7676
| `Trinity.LLM` | Provider behaviour, req_llm adapter, model registry, streaming, usage | Repo (usage), Telemetry |
7777
| `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) |
7878
| `Trinity.Permissions` | Policy, tier/1 (name-only), fingerprint-bound approvals, override adjudication | Repo, PubSub |
7979
| `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 |
8080
| `Trinity.Authority` | Behaviour; `Local` implementation (the one caller of `execute/2` for effectful tools); selection at boot; `Staged` | Receipts, Repo |
8181
| `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`) |
82-
| `Trinity.Memory` | Always-on tier, episodic FTS, semantic store, retrieval, compaction | LLM (summaries/embeddings), Repo |
82+
| `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo |
8383
| `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner | Repo, Permissions, **Effects**, **Receipts**, Sandbox |
8484
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** |
8585
| `Trinity.MCP` | Client manager, tool bridge, server | Tools, **Effects**, **Permissions**, Memory |

docs/05-data-model.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ every term for FTS5, so operators are text.
6969
Invariant: total bytes of `always_on` + `profile` for a persona ≤ configurable budget (default 8 KB), enforced by
7070
`Trinity.Memory.Budget`, which triggers consolidation instead of silent truncation.
7171

72+
As built at slice 030: `memories` carries `persona_id` (the budget's unit) beside the columns above, the scope
73+
vocabulary is `global | persona:<id> | project:<path> | session:<id>` (a session reads the chain `session`,
74+
`persona`, `global`), `key` is required for the two always-on tiers (`^[a-z0-9][a-z0-9_.-]{0,63}$`, unique with
75+
tier and scope), and the semantic tier's columns wait for 032. Two tables beside it:
76+
77+
### memory_changes (Slice 030)
78+
`persona_id`, `action` (add | replace | remove | promote | consolidate), `tier`, `scope`, `key`, `before`,
79+
`after`, `by` (tool | ui | consolidator), `session_id`, `proposal_id`, `inserted_at`. Every write to the
80+
always-on tiers appends one; a consolidation's writes carry its proposal id, so no entry leaves the tiers
81+
without a row here.
82+
83+
### memory_proposals (Slice 030)
84+
`persona_id`, `entries` (the proposed set), `bytes_before`, `bytes_after`, `budget`, `status` (applied | pending |
85+
rejected), `decided_at`. The consolidator applies a proposal under budget at once and holds one over budget for
86+
the owner (the memory page).
87+
7288
### skills (Slice 040)
7389
| column | type | notes |
7490
|---|---|---|

lib/trinity.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,17 @@ defmodule Trinity do
2525
Sessions.Message,
2626
Sessions.SessionRow,
2727
Sessions.Prompt,
28+
Personas,
2829
LLM,
2930
Memory,
3031
Memory.Tokens,
3132
Memory.Search,
33+
Memory.AlwaysOn,
34+
Memory.Budget,
35+
Memory.Consolidator,
36+
Memory.Entry,
37+
Memory.Proposal,
38+
Memory.Change,
3239
Tools,
3340
Permissions,
3441
Permissions.Approval,

lib/trinity/effects/runner.ex

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ defmodule Trinity.Effects.Runner do
3131
@doc "The executor: decide and receipt, then read directly or cross the membrane."
3232
@spec execute(map(), map(), Context.t()) :: {:ok, Result.t()} | {:error, term()}
3333
def execute(entry, args, %Context{} = ctx) do
34-
{decision, fp, reason} =
34+
{decision, fp, reason, basis} =
3535
case Runner.decide(entry, args, ctx) do
36-
{:allow, fp} -> {:allow, fp, nil}
37-
{:deny, fp} -> {:deny, fp, :denied}
38-
{:ask, why, fp} -> {:ask, fp, why}
36+
{:allow, fp, basis} -> {:allow, fp, nil, basis}
37+
{:deny, fp, basis} -> {:deny, fp, :denied, basis}
38+
{:ask, why, fp, basis} -> {:ask, fp, why, basis}
3939
end
4040

4141
scope = scope(ctx)
4242

43-
case decision_receipt(scope, entry, ctx, decision, fp, reason) do
43+
case decision_receipt(scope, entry, ctx, decision, fp, reason, basis) do
4444
{:ok, _} -> dispatch(decision, entry, args, ctx, scope, fp, reason)
4545
{:error, why} -> {:error, {:decision_not_receipted, why}}
4646
end
@@ -83,7 +83,8 @@ defmodule Trinity.Effects.Runner do
8383
ctx,
8484
decision,
8585
fp,
86-
reason
86+
reason,
87+
basis
8788
) do
8889
Receipts.append(scope, %{
8990
kind: "decision",
@@ -93,7 +94,11 @@ defmodule Trinity.Effects.Runner do
9394
"tool" => name,
9495
"effect" => Atom.to_string(effect)
9596
},
96-
decision: %{"outcome" => Atom.to_string(decision), "reason" => reason && inspect(reason)},
97+
decision: %{
98+
"outcome" => Atom.to_string(decision),
99+
"basis" => basis,
100+
"reason" => reason && inspect(reason)
101+
},
97102
fingerprint: fp,
98103
subject_ref: "decision:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}",
99104
meta: %{"tool_definition_digest" => digest}

lib/trinity/memory.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ defmodule Trinity.Memory do
66
to 032 add the tiers, the search and the semantic recall. It depends on the LLM and on the
77
core, never on Sessions: the Session calls it and writes what it returns.
88
"""
9-
use Boundary, deps: [Trinity, Trinity.LLM], exports: [Tokens, Compactor, Search]
9+
use Boundary,
10+
deps: [Trinity, Trinity.LLM],
11+
exports: [Tokens, Compactor, Search, AlwaysOn, Budget, Consolidator, Entry, Proposal, Change]
1012
end

lib/trinity/memory/always_on.ex

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Memory.AlwaysOn do
4+
@moduledoc """
5+
The always-on memory tiers (slice 030): `profile` (who the person is) and `always_on` (what
6+
Trinity keeps in mind), rendered as a deterministic block into every prompt of the persona.
7+
8+
**Scopes.** A session reads its scope chain, `session:<id>`, `persona:<id>`, `global`, and
9+
nothing else: a memory written for one session is invisible to another until promoted
10+
(SLICE.md M6, AC7). Promotion is a change like any other, logged; through the `memory` tool
11+
it is an artifact effect with a receipt.
12+
13+
**Every write is logged** in `memory_changes` with the body before and after and who wrote
14+
it (`tool`, `ui`, `consolidator`). After a write the budget is checked and, over it,
15+
`Trinity.Memory.Consolidator.run/2` is asked to bring the tiers under it.
16+
17+
**The snapshot** is computed once per session start and on explicit refresh; the Session
18+
keeps it in state, so a mid-session edit takes effect next session or on refresh.
19+
"""
20+
21+
import Ecto.Query
22+
23+
alias Trinity.Memory.{Budget, Change, Entry}
24+
alias Trinity.Repo
25+
26+
@type scope :: String.t()
27+
@type write_opts :: [
28+
by: String.t(),
29+
session_id: String.t() | nil,
30+
proposal_id: String.t() | nil
31+
]
32+
33+
@doc "The scope chain a session reads, innermost first."
34+
@spec chain(String.t(), String.t() | nil) :: [scope()]
35+
def chain(persona_id, nil), do: ["persona:" <> persona_id, "global"]
36+
37+
def chain(persona_id, session_id),
38+
do: ["session:" <> session_id, "persona:" <> persona_id, "global"]
39+
40+
@doc "The persona scope."
41+
@spec persona_scope(String.t()) :: scope()
42+
def persona_scope(persona_id), do: "persona:" <> persona_id
43+
44+
@doc "A session's scope."
45+
@spec session_scope(String.t()) :: scope()
46+
def session_scope(session_id), do: "session:" <> session_id
47+
48+
@doc "The entries a session sees: its chain, always-on tiers, sorted by tier then key."
49+
@spec entries(String.t(), String.t() | nil) :: [Entry.t()]
50+
def entries(persona_id, session_id) do
51+
scopes = chain(persona_id, session_id)
52+
53+
from(e in Entry,
54+
where:
55+
e.persona_id == ^persona_id and e.scope in ^scopes and e.tier in ^Entry.always_on_tiers(),
56+
order_by: [e.tier, e.key]
57+
)
58+
|> Repo.all()
59+
|> Enum.sort_by(&{tier_rank(&1.tier), &1.key})
60+
end
61+
62+
@doc "Every always-on entry of a persona, over every scope (the pages and the consolidator)."
63+
@spec all(String.t()) :: [Entry.t()]
64+
def all(persona_id) do
65+
from(e in Entry, where: e.persona_id == ^persona_id and e.tier in ^Entry.always_on_tiers())
66+
|> Repo.all()
67+
|> Enum.sort_by(&{tier_rank(&1.tier), &1.scope, &1.key})
68+
end
69+
70+
@doc """
71+
The block the prompt carries: profile then always-on, one `- key: body` per entry, sorted, or
72+
`""` when there is nothing. Deterministic for the same rows.
73+
"""
74+
@spec snapshot(String.t(), String.t() | nil) :: String.t()
75+
def snapshot(persona_id, session_id) do
76+
persona_id |> entries(session_id) |> render()
77+
end
78+
79+
@doc "Renders entries as the snapshot block."
80+
@spec render([Entry.t()]) :: String.t()
81+
def render([]), do: ""
82+
83+
def render(entries) do
84+
entries
85+
|> Enum.group_by(& &1.tier)
86+
|> Enum.sort_by(fn {tier, _} -> tier_rank(tier) end)
87+
|> Enum.map_join("\n\n", fn {tier, es} ->
88+
heading = if tier == "profile", do: "## About the person", else: "## Always in mind"
89+
heading <> "\n" <> Enum.map_join(es, "\n", &"- #{&1.key}: #{&1.body}")
90+
end)
91+
end
92+
93+
@doc "An entry by tier, scope and key."
94+
@spec get(String.t(), scope(), String.t()) :: Entry.t() | nil
95+
def get(tier, scope, key), do: Repo.get_by(Entry, tier: tier, scope: scope, key: key)
96+
97+
@doc "Adds an entry (refused if the key exists in that tier and scope); logged; budget checked."
98+
@spec add(map(), write_opts()) :: {:ok, Entry.t()} | {:error, Ecto.Changeset.t() | :exists}
99+
def add(attrs, opts) do
100+
attrs = Map.new(attrs, fn {k, v} -> {to_atom(k), v} end)
101+
102+
if get(attrs[:tier], attrs[:scope], attrs[:key]) do
103+
{:error, :exists}
104+
else
105+
with {:ok, entry} <- %Entry{} |> Entry.changeset(attrs) |> Repo.insert() do
106+
log(entry, "add", nil, entry.body, opts)
107+
after_write(entry.persona_id, opts)
108+
{:ok, entry}
109+
end
110+
end
111+
end
112+
113+
@doc "Replaces an entry's body; logged; budget checked."
114+
@spec replace(Entry.t(), String.t(), write_opts()) ::
115+
{:ok, Entry.t()} | {:error, Ecto.Changeset.t()}
116+
def replace(%Entry{} = entry, body, opts) do
117+
with {:ok, updated} <- entry |> Entry.changeset(%{body: body}) |> Repo.update() do
118+
log(updated, "replace", entry.body, body, opts)
119+
after_write(entry.persona_id, opts)
120+
{:ok, updated}
121+
end
122+
end
123+
124+
@doc "Removes an entry; logged."
125+
@spec remove(Entry.t(), write_opts()) :: {:ok, Entry.t()} | {:error, Ecto.Changeset.t()}
126+
def remove(%Entry{} = entry, opts) do
127+
with {:ok, deleted} <- Repo.delete(entry) do
128+
log(entry, "remove", entry.body, nil, opts)
129+
{:ok, deleted}
130+
end
131+
end
132+
133+
@doc "Moves an entry to another scope (a session's memory to the persona's, or to global); logged as a promotion."
134+
@spec promote(Entry.t(), scope(), write_opts()) ::
135+
{:ok, Entry.t()} | {:error, Ecto.Changeset.t() | :exists}
136+
def promote(%Entry{} = entry, to_scope, opts) do
137+
if get(entry.tier, to_scope, entry.key) do
138+
{:error, :exists}
139+
else
140+
with {:ok, moved} <- entry |> Entry.changeset(%{scope: to_scope}) |> Repo.update() do
141+
log(moved, "promote", entry.scope, to_scope, opts)
142+
{:ok, moved}
143+
end
144+
end
145+
end
146+
147+
@doc "The change log of a persona, newest first (`limit:`)."
148+
@spec changes(String.t(), keyword()) :: [Change.t()]
149+
def changes(persona_id, opts \\ []) do
150+
from(c in Change,
151+
where: c.persona_id == ^persona_id,
152+
order_by: [desc: c.inserted_at],
153+
limit: ^Keyword.get(opts, :limit, 200)
154+
)
155+
|> Repo.all()
156+
end
157+
158+
@doc "The change rows of one consolidation proposal."
159+
@spec changes_of_proposal(String.t()) :: [Change.t()]
160+
def changes_of_proposal(proposal_id),
161+
do: Repo.all(from c in Change, where: c.proposal_id == ^proposal_id, order_by: c.inserted_at)
162+
163+
@doc false
164+
@spec log(Entry.t(), String.t(), String.t() | nil, String.t() | nil, write_opts()) :: Change.t()
165+
def log(%Entry{} = e, action, before, after_, opts) do
166+
Repo.insert!(%Change{
167+
persona_id: e.persona_id,
168+
action: action,
169+
tier: e.tier,
170+
scope: e.scope,
171+
key: e.key,
172+
before: before,
173+
after: after_,
174+
by: Keyword.get(opts, :by, "unknown"),
175+
session_id: Keyword.get(opts, :session_id),
176+
proposal_id: Keyword.get(opts, :proposal_id),
177+
inserted_at: DateTime.utc_now()
178+
})
179+
end
180+
181+
# The consolidator runs after a write that leaves the persona over budget, unless the write
182+
# is the consolidator's own.
183+
defp after_write(persona_id, opts) do
184+
if Keyword.get(opts, :by) != "consolidator" and Budget.status(persona_id).over? do
185+
Trinity.Memory.Consolidator.run(persona_id, opts)
186+
end
187+
188+
:ok
189+
end
190+
191+
defp tier_rank("profile"), do: 0
192+
defp tier_rank("always_on"), do: 1
193+
defp tier_rank(_), do: 2
194+
195+
defp to_atom(k) when is_atom(k), do: k
196+
defp to_atom(k) when is_binary(k), do: String.to_existing_atom(k)
197+
end

0 commit comments

Comments
 (0)