Skip to content

Commit fccfc8b

Browse files
committed
feat(s030): the personas pages, the memory page, the persona picker; docs/05 and docs/01 as built
Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 168fa44 commit fccfc8b

8 files changed

Lines changed: 632 additions & 6 deletions

File tree

‎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
|---|---|---|
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule TrinityWeb.MemoryLive do
4+
@moduledoc """
5+
`/memory` (slice 030): a persona's always-on tiers (`profile`, `always_on`) over every
6+
scope, each entry editable in place and deletable, an add form, the budget, and the
7+
consolidation proposals waiting for a decision. Every write is `Trinity.Memory.AlwaysOn`'s
8+
with `by: "ui"`, so it is in the change log like the tool's; a proposal is applied or
9+
rejected through `Trinity.Memory.Consolidator`. Running sessions keep their snapshot until
10+
their next start or refresh.
11+
"""
12+
use TrinityWeb, :live_view
13+
14+
alias Trinity.Memory.{AlwaysOn, Budget, Consolidator}
15+
alias Trinity.Personas
16+
17+
@impl true
18+
def mount(params, _session, socket) do
19+
personas = Personas.list()
20+
persona = Enum.find(personas, &(&1.id == params["persona_id"])) || Personas.default()
21+
22+
{:ok,
23+
socket
24+
|> assign(page_title: gettext("Memory"), personas: personas, persona: persona, editing: nil)
25+
|> load()}
26+
end
27+
28+
defp load(%{assigns: %{persona: persona}} = socket) do
29+
assign(socket,
30+
entries: AlwaysOn.all(persona.id),
31+
budget: Budget.status(persona.id),
32+
proposals: Consolidator.pending(persona.id),
33+
changes: AlwaysOn.changes(persona.id, limit: 20)
34+
)
35+
end
36+
37+
@impl true
38+
def handle_event("pick_persona", %{"persona_id" => id}, socket) do
39+
persona = Enum.find(socket.assigns.personas, &(&1.id == id)) || socket.assigns.persona
40+
{:noreply, socket |> assign(persona: persona, editing: nil) |> load()}
41+
end
42+
43+
def handle_event(
44+
"add",
45+
%{"tier" => tier, "key" => key, "body" => body},
46+
%{assigns: %{persona: persona}} = socket
47+
) do
48+
attrs = %{
49+
persona_id: persona.id,
50+
tier: tier,
51+
scope: AlwaysOn.persona_scope(persona.id),
52+
key: String.trim(key),
53+
body: String.trim(body)
54+
}
55+
56+
case AlwaysOn.add(attrs, by: "ui") do
57+
{:ok, _} ->
58+
{:noreply, load(socket)}
59+
60+
{:error, :exists} ->
61+
{:noreply, put_flash(socket, :error, gettext("That key exists in this tier."))}
62+
63+
{:error, cs} ->
64+
{:noreply, put_flash(socket, :error, gettext("Not added: %{e}", e: inspect(cs.errors)))}
65+
end
66+
end
67+
68+
def handle_event("edit", %{"id" => id}, socket), do: {:noreply, assign(socket, editing: id)}
69+
def handle_event("cancel_edit", _params, socket), do: {:noreply, assign(socket, editing: nil)}
70+
71+
def handle_event("save", %{"entry_id" => id, "body" => body}, socket) do
72+
with %{} = entry <- Enum.find(socket.assigns.entries, &(&1.id == id)),
73+
{:ok, _} <- AlwaysOn.replace(entry, String.trim(body), by: "ui") do
74+
{:noreply, socket |> assign(editing: nil) |> load()}
75+
else
76+
_ -> {:noreply, put_flash(socket, :error, gettext("Not saved."))}
77+
end
78+
end
79+
80+
def handle_event("delete", %{"id" => id}, socket) do
81+
with %{} = entry <- Enum.find(socket.assigns.entries, &(&1.id == id)),
82+
{:ok, _} <- AlwaysOn.remove(entry, by: "ui") do
83+
{:noreply, load(socket)}
84+
else
85+
_ -> {:noreply, put_flash(socket, :error, gettext("Not deleted."))}
86+
end
87+
end
88+
89+
def handle_event("apply_proposal", %{"id" => id}, socket) do
90+
with %{} = proposal <- Consolidator.get(id),
91+
{:ok, _} <- Consolidator.apply_proposal(proposal, by: "ui") do
92+
{:noreply, load(socket)}
93+
else
94+
_ -> {:noreply, put_flash(socket, :error, gettext("Not applied."))}
95+
end
96+
end
97+
98+
def handle_event("reject_proposal", %{"id" => id}, socket) do
99+
with %{} = proposal <- Consolidator.get(id),
100+
{:ok, _} <- Consolidator.reject_proposal(proposal) do
101+
{:noreply, load(socket)}
102+
else
103+
_ -> {:noreply, put_flash(socket, :error, gettext("Not rejected."))}
104+
end
105+
end
106+
107+
def handle_event("new_session", _params, socket),
108+
do: {:noreply, TrinityWeb.SessionLive.Index.new_session(socket)}
109+
110+
def handle_event("cancel", _params, socket), do: {:noreply, socket}
111+
112+
@impl true
113+
def render(assigns) do
114+
~H"""
115+
<Layouts.app flash={@flash}>
116+
<:bar>
117+
<span class="opacity-70">{gettext("Memory")}</span>
118+
<form id="memory-persona" phx-change="pick_persona" class="contents">
119+
<select
120+
name="persona_id"
121+
class="rounded-field border border-base-300 bg-base-100 px-2 py-1 text-meta"
122+
>
123+
<option :for={p <- @personas} value={p.id} selected={p.id == @persona.id}>
124+
{p.name}
125+
</option>
126+
</select>
127+
</form>
128+
<span
129+
id="budget"
130+
class={[
131+
"rounded-pill px-2 py-0.5 font-mono text-meta",
132+
@budget.over? && "bg-error/20 text-error",
133+
!@budget.over? && "bg-base-300"
134+
]}
135+
>
136+
{@budget.used} / {@budget.budget} {gettext("bytes")}
137+
</span>
138+
</:bar>
139+
<div
140+
id="memory"
141+
phx-hook="Shortcuts"
142+
class="mx-auto flex h-full max-w-4xl flex-col gap-6 overflow-y-auto px-4 py-6"
143+
>
144+
<section :if={@proposals != []} class="flex flex-col gap-2">
145+
<h2 class="text-lg font-semibold">{gettext("Consolidation waiting for you")}</h2>
146+
<div
147+
:for={p <- @proposals}
148+
id={"proposal-#{p.id}"}
149+
class="flex flex-col gap-2 rounded-field border border-warning/50 px-3 py-2"
150+
>
151+
<p class="text-meta opacity-70">
152+
{gettext("%{before} bytes now, %{after} proposed, budget %{budget}",
153+
before: p.bytes_before,
154+
after: p.bytes_after,
155+
budget: p.budget
156+
)}
157+
</p>
158+
<ul class="font-mono text-meta">
159+
<li :for={e <- p.entries["entries"] || []}>[{e["tier"]}] {e["key"]}: {e["body"]}</li>
160+
</ul>
161+
<div class="flex gap-2">
162+
<button phx-click="apply_proposal" phx-value-id={p.id} class="btn btn-sm btn-primary">{gettext(
163+
"Apply"
164+
)}</button>
165+
<button phx-click="reject_proposal" phx-value-id={p.id} class="btn btn-sm btn-ghost">{gettext(
166+
"Reject"
167+
)}</button>
168+
</div>
169+
</div>
170+
</section>
171+
172+
<section :for={tier <- ["profile", "always_on"]} class="flex flex-col gap-2">
173+
<h2 class="text-lg font-semibold">
174+
{if tier == "profile", do: gettext("About the person"), else: gettext("Always in mind")}
175+
</h2>
176+
<p :if={Enum.filter(@entries, &(&1.tier == tier)) == []} class="opacity-70">
177+
{gettext("Nothing kept in this tier.")}
178+
</p>
179+
<ul class="flex flex-col gap-1">
180+
<li
181+
:for={e <- Enum.filter(@entries, &(&1.tier == tier))}
182+
id={"entry-#{e.id}"}
183+
class="flex items-start gap-2 rounded-field border border-base-300 px-3 py-2 text-ui"
184+
>
185+
<span class="font-mono text-meta opacity-70" title={e.scope}>{short_scope(e.scope)}</span>
186+
<span class="font-mono font-semibold">{e.key}</span>
187+
<form
188+
:if={@editing == e.id}
189+
id={"edit-#{e.id}"}
190+
phx-submit="save"
191+
class="flex flex-1 items-center gap-2"
192+
>
193+
<input type="hidden" name="entry_id" value={e.id} />
194+
<input
195+
name="body"
196+
value={e.body}
197+
class="flex-1 rounded-field border border-base-300 bg-base-100 px-2 py-1"
198+
/>
199+
<button type="submit" class="btn btn-sm btn-primary">{gettext("Save")}</button>
200+
<button type="button" phx-click="cancel_edit" class="btn btn-sm btn-ghost">{gettext(
201+
"Cancel"
202+
)}</button>
203+
</form>
204+
<span :if={@editing != e.id} class="flex-1">{e.body}</span>
205+
<button
206+
:if={@editing != e.id}
207+
phx-click="edit"
208+
phx-value-id={e.id}
209+
class="btn btn-xs btn-ghost"
210+
>{gettext("edit")}</button>
211+
<button
212+
phx-click="delete"
213+
phx-value-id={e.id}
214+
data-confirm={gettext("Delete this entry?")}
215+
class="btn btn-xs btn-ghost text-error"
216+
>{gettext("delete")}</button>
217+
</li>
218+
</ul>
219+
</section>
220+
221+
<form id="memory-add" phx-submit="add" class="flex items-center gap-2">
222+
<select
223+
name="tier"
224+
class="rounded-field border border-base-300 bg-base-100 px-2 py-2 text-meta"
225+
>
226+
<option value="always_on">{gettext("always in mind")}</option>
227+
<option value="profile">{gettext("about the person")}</option>
228+
</select>
229+
<input
230+
name="key"
231+
placeholder={gettext("key")}
232+
class="w-40 rounded-field border border-base-300 bg-base-100 px-2 py-2 font-mono text-ui"
233+
/>
234+
<input
235+
name="body"
236+
placeholder={gettext("what to keep")}
237+
class="flex-1 rounded-field border border-base-300 bg-base-100 px-2 py-2 text-ui"
238+
/>
239+
<button type="submit" class="btn btn-sm btn-primary">{gettext("Add")}</button>
240+
</form>
241+
242+
<section class="flex flex-col gap-1">
243+
<h2 class="text-ui font-semibold opacity-70">{gettext("Recent changes")}</h2>
244+
<ul id="changes" class="font-mono text-meta opacity-70">
245+
<li :for={c <- @changes}>{c.action} {c.tier}/{c.key} {gettext("by")} {c.by}</li>
246+
</ul>
247+
</section>
248+
</div>
249+
</Layouts.app>
250+
"""
251+
end
252+
253+
defp short_scope("global"), do: "global"
254+
defp short_scope("persona:" <> _), do: "persona"
255+
defp short_scope("session:" <> _), do: "session"
256+
defp short_scope(o), do: o
257+
end

0 commit comments

Comments
 (0)