|
| 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