|
1
|
+ |
defmodule OpenAgents.Memories do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
The account's memories: what it asked to have remembered, and what the server
|
|
4
|
+ |
learned on its behalf.
|
|
5
|
+ |
|
|
6
|
+ |
Memory lives where the account lives. This store is a table in this database,
|
|
7
|
+ |
not a file on whichever machine happened to run the session, because the CLI,
|
|
8
|
+ |
the web app, and the API are three clients of one account and a memory
|
|
9
|
+ |
written through any of them belongs to all three.
|
|
10
|
+ |
|
|
11
|
+ |
## Why this is a plane of its own
|
|
12
|
+ |
|
|
13
|
+ |
This repository already has memory planes, and a fourth one needs a reason
|
|
14
|
+ |
rather than a preference. The reason is that both existing planes are scoped
|
|
15
|
+ |
and sourced on axes this store cannot use.
|
|
16
|
+ |
|
|
17
|
+ |
* **Scope.** `OpenAgents.ProfileMemory` and `OpenAgents.ExperienceMemory` are
|
|
18
|
+ |
scoped to `OpenAgents.Conversations.Visitor` — a signed browser under the
|
|
19
|
+ |
account's one canonical conversation (DATA-002). This lane authenticates an
|
|
20
|
+ |
`OpenAgents.Accounts.User` over the API, and a CLI session has no browser to
|
|
21
|
+ |
be a visitor of. Scoping here is `memories.user_id`, and MEMORY-010 makes
|
|
22
|
+ |
that a database predicate rather than a filter someone remembered to write.
|
|
23
|
+ |
|
|
24
|
+ |
* **Source.** A profile-memory record is only active with a same-owner
|
|
25
|
+ |
complete user-message source or a host-recorded owner assertion
|
|
26
|
+ |
(MEMORY-003), and its sources are `messages` rows in that one conversation.
|
|
27
|
+ |
A memory written from a coding session comes out of a **thread**, and a
|
|
28
|
+ |
thread is explicitly not a conversation (THREAD-001). There is no
|
|
29
|
+ |
admissible profile-memory source for it.
|
|
30
|
+ |
|
|
31
|
+ |
* **The `learned` bucket cannot live there at all.** MEMORY-002 says
|
|
32
|
+ |
conversation evidence never enters the profile-memory plane through
|
|
33
|
+ |
repetition, model confidence, or recall classification, and MEMORY-003 says
|
|
34
|
+ |
candidates never activate through repetition or model confidence.
|
|
35
|
+ |
Consolidation-derived memory is exactly that, so admitting it to profile
|
|
36
|
+ |
memory would mean weakening two current invariants to fit a design.
|
|
37
|
+ |
|
|
38
|
+ |
What this store is **not** is a second home for browser profile claims. A
|
|
39
|
+ |
durable fact a reader states in the web conversation still belongs in
|
|
40
|
+ |
`OpenAgents.ProfileMemory`, under its consent and correction contract. The
|
|
41
|
+ |
two planes are expected to be reconciled once one scope can express the
|
|
42
|
+ |
other; that is a decision with its own issue, not a thing to assume here.
|
|
43
|
+ |
|
|
44
|
+ |
## What it holds
|
|
45
|
+ |
|
|
46
|
+ |
Two buckets, described on `OpenAgents.Memories.Memory`. `user` memories are
|
|
47
|
+ |
explicit: a reader said "remember that I prefer X" and something called
|
|
48
|
+ |
`create/2`. Nothing here infers a memory from what a turn contained, and
|
|
49
|
+ |
nothing should — a store that fills itself is a store nobody trusts.
|
|
50
|
+ |
`learned` memories come from server-side consolidation over thread events.
|
|
51
|
+ |
|
|
52
|
+ |
## Corrections supersede
|
|
53
|
+ |
|
|
54
|
+ |
`create/2` accepts `supersedes`, and the replacement points the old row at
|
|
55
|
+ |
itself rather than editing it. Recall reads live rows only, so the correction
|
|
56
|
+ |
takes effect immediately, and the row it corrected stays readable through
|
|
57
|
+ |
`list/2` with `include_superseded: true`. A wrong `learned` memory is traced
|
|
58
|
+ |
through `source_ref` to the work that taught it and superseded from there.
|
|
59
|
+ |
|
|
60
|
+ |
## Recall is bounded three ways
|
|
61
|
+ |
|
|
62
|
+ |
`recall/3` is what `POST /api/v1/responses` calls before the provider. It is
|
|
63
|
+ |
bounded by the store (`maximum_live_memories` per account, enforced at
|
|
64
|
+ |
write), by count (`maximum_attached` per turn), and by size
|
|
65
|
+ |
(`maximum_attached_characters` per turn). Whatever the bounds exclude is
|
|
66
|
+ |
counted into `OpenAgents.Memories.Recall`'s `dropped`, and the note says so —
|
|
67
|
+ |
a memory that did not fit is reported, never truncated into a half sentence.
|
|
68
|
+ |
|
|
69
|
+ |
The two buckets clear the bar differently, and the difference is the whole
|
|
70
|
+ |
point of the feature:
|
|
71
|
+ |
|
|
72
|
+ |
* A `user` memory attaches whenever the account has one. The reader asked for
|
|
73
|
+ |
it; "remember I use pnpm, not npm" has to reach "install the deps", and
|
|
74
|
+ |
those two sentences share no word.
|
|
75
|
+ |
* A `learned` memory must clear the retrieval backend's floor. Consolidation
|
|
76
|
+ |
writes these without being asked, so they earn attention by being about
|
|
77
|
+ |
this turn rather than by existing.
|
|
78
|
+ |
|
|
79
|
+ |
Retrieval itself is `OpenAgents.Memories.Retrieval`, which chooses between an
|
|
80
|
+ |
embedding backend and a lexical stand-in.
|
|
81
|
+ |
"""
|
|
82
|
+ |
|
|
83
|
+ |
import Ecto.Query
|
|
84
|
+ |
|
|
85
|
+ |
alias Ecto.Multi
|
|
86
|
+ |
alias OpenAgents.Accounts.User
|
|
87
|
+ |
alias OpenAgents.Memories.{Memory, Recall, Retrieval}
|
|
88
|
+ |
alias OpenAgents.Memories.Retrieval.Semantic
|
|
89
|
+ |
alias OpenAgents.Repo
|
|
90
|
+ |
|
|
91
|
+ |
@maximum_listed 200
|
|
92
|
+ |
|
|
93
|
+ |
@doc """
|
|
94
|
+ |
Writes one memory for `user`.
|
|
95
|
+ |
|
|
96
|
+ |
Attributes: `body` (required), `bucket` (`user` by default), `source_ref`,
|
|
97
|
+ |
and `supersedes` — the id of a memory this one replaces, which must belong to
|
|
98
|
+ |
the same account and must still be live.
|
|
99
|
+ |
|
|
100
|
+ |
The owner is set on the struct and never cast, so a request body cannot name
|
|
101
|
+ |
whose memory it is writing.
|
|
102
|
+ |
"""
|
|
103
|
+ |
@spec create(User.t(), map()) ::
|
|
104
|
+ |
{:ok, Memory.t()}
|
|
105
|
+ |
| {:error, Ecto.Changeset.t()}
|
|
106
|
+ |
| {:error, :quota_reached}
|
|
107
|
+ |
| {:error, :supersedes_not_found}
|
|
108
|
+ |
def create(%User{} = user, attrs) when is_map(attrs) do
|
|
109
|
+ |
attrs = normalize(attrs)
|
|
110
|
+ |
body = Map.get(attrs, "body")
|
|
111
|
+ |
|
|
112
|
+ |
embedding =
|
|
113
|
+ |
case body do
|
|
114
|
+ |
text when is_binary(text) and text != "" -> Semantic.embedding_for(text)
|
|
115
|
+ |
_absent -> nil
|
|
116
|
+ |
end
|
|
117
|
+ |
|
|
118
|
+ |
attrs =
|
|
119
|
+ |
case embedding do
|
|
120
|
+ |
{vector, model} -> Map.merge(attrs, %{"embedding" => vector, "embedding_model" => model})
|
|
121
|
+ |
nil -> attrs
|
|
122
|
+ |
end
|
|
123
|
+ |
|
|
124
|
+ |
changeset = Memory.changeset(%Memory{user_id: user.id}, attrs)
|
|
125
|
+ |
|
|
126
|
+ |
Multi.new()
|
|
127
|
+ |
|> Multi.run(:supersedes, fn _repo, _changes -> superseded(user, attrs) end)
|
|
128
|
+ |
|> Multi.run(:quota, fn _repo, changes -> quota(user, changes.supersedes) end)
|
|
129
|
+ |
|> Multi.insert(:memory, changeset)
|
|
130
|
+ |
|> Multi.run(:supersede, fn repo, changes -> link(repo, changes) end)
|
|
131
|
+ |
|> Repo.transaction()
|
|
132
|
+ |
|> case do
|
|
133
|
+ |
{:ok, %{memory: memory}} -> {:ok, memory}
|
|
134
|
+ |
{:error, :memory, changeset, _changes} -> {:error, changeset}
|
|
135
|
+ |
{:error, _step, reason, _changes} -> {:error, reason}
|
|
136
|
+ |
end
|
|
137
|
+ |
end
|
|
138
|
+ |
|
|
139
|
+ |
@doc """
|
|
140
|
+ |
The account's memories, newest first.
|
|
141
|
+ |
|
|
142
|
+ |
Live only unless `include_superseded: true`. Options: `bucket` to narrow to
|
|
143
|
+ |
one bucket, and `limit`, capped at #{@maximum_listed}.
|
|
144
|
+ |
"""
|
|
145
|
+ |
@spec list(User.t(), keyword()) :: [Memory.t()]
|
|
146
|
+ |
def list(%User{} = user, opts \\ []) do
|
|
147
|
+ |
user
|
|
148
|
+ |
|> scope(opts)
|
|
149
|
+ |
|> order_by([memory], desc: memory.inserted_at, desc: memory.id)
|
|
150
|
+ |
|> limit(^limit(opts))
|
|
151
|
+ |
|> Repo.all()
|
|
152
|
+ |
end
|
|
153
|
+ |
|
|
154
|
+ |
@doc "One of the account's memories by id, live or superseded."
|
|
155
|
+ |
@spec fetch(User.t(), String.t()) :: {:ok, Memory.t()} | {:error, :not_found}
|
|
156
|
+ |
def fetch(%User{id: user_id}, id) when is_binary(id) do
|
|
157
|
+ |
with {:ok, memory_id} <- Ecto.UUID.cast(id),
|
|
158
|
+ |
%Memory{} = memory <- Repo.get_by(Memory, id: memory_id, user_id: user_id) do
|
|
159
|
+ |
{:ok, memory}
|
|
160
|
+ |
else
|
|
161
|
+ |
_absent -> {:error, :not_found}
|
|
162
|
+ |
end
|
|
163
|
+ |
end
|
|
164
|
+ |
|
|
165
|
+ |
def fetch(%User{}, _id), do: {:error, :not_found}
|
|
166
|
+ |
|
|
167
|
+ |
@doc """
|
|
168
|
+ |
Points `memory` at the memory that replaced it.
|
|
169
|
+ |
|
|
170
|
+ |
Both must belong to the same account, and a memory cannot supersede itself.
|
|
171
|
+ |
"""
|
|
172
|
+ |
@spec supersede(Memory.t(), Memory.t()) :: {:ok, Memory.t()} | {:error, :not_supersedable}
|
|
173
|
+ |
def supersede(%Memory{user_id: owner} = memory, %Memory{user_id: owner} = replacement)
|
|
174
|
+ |
when memory.id != replacement.id do
|
|
175
|
+ |
memory
|
|
176
|
+ |
|> Memory.supersede_changeset(replacement)
|
|
177
|
+ |
|> Repo.update()
|
|
178
|
+ |
|> case do
|
|
179
|
+ |
{:ok, updated} -> {:ok, updated}
|
|
180
|
+ |
{:error, _changeset} -> {:error, :not_supersedable}
|
|
181
|
+ |
end
|
|
182
|
+ |
end
|
|
183
|
+ |
|
|
184
|
+ |
def supersede(%Memory{}, %Memory{}), do: {:error, :not_supersedable}
|
|
185
|
+ |
|
|
186
|
+ |
@doc """
|
|
187
|
+ |
Removes one memory outright.
|
|
188
|
+ |
|
|
189
|
+ |
Deletion is deletion: unlike a correction, nothing is kept. A memory that
|
|
190
|
+ |
points at this one as its replacement keeps its own row and loses the
|
|
191
|
+ |
pointer, so removing a correction never removes the history behind it.
|
|
192
|
+ |
"""
|
|
193
|
+ |
@spec delete(User.t(), String.t()) :: {:ok, Memory.t()} | {:error, :not_found}
|
|
194
|
+ |
def delete(%User{} = user, id) do
|
|
195
|
+ |
with {:ok, memory} <- fetch(user, id),
|
|
196
|
+ |
{:ok, deleted} <- Repo.delete(memory) do
|
|
197
|
+ |
{:ok, deleted}
|
|
198
|
+ |
else
|
|
199
|
+ |
_absent -> {:error, :not_found}
|
|
200
|
+ |
end
|
|
201
|
+ |
end
|
|
202
|
+ |
|
|
203
|
+ |
@doc """
|
|
204
|
+ |
What this turn should be told, bounded.
|
|
205
|
+ |
|
|
206
|
+ |
`query` is the incoming input. Every live memory the account holds is ranked
|
|
207
|
+ |
against it; `user` memories are kept regardless of score and `learned` ones
|
|
208
|
+ |
only above the backend's floor; the result is cut to `maximum_attached`
|
|
209
|
+ |
memories and `maximum_attached_characters`, and what the cut excluded is
|
|
210
|
+ |
counted rather than dropped in silence.
|
|
211
|
+ |
|
|
212
|
+ |
Never raises. An unreadable store or an unavailable backend recalls nothing.
|
|
213
|
+ |
"""
|
|
214
|
+ |
@spec recall(User.t(), String.t(), keyword()) :: Recall.t()
|
|
215
|
+ |
def recall(user, query, opts \\ [])
|
|
216
|
+ |
|
|
217
|
+ |
def recall(%User{} = user, query, opts) when is_binary(query) and query != "" do
|
|
218
|
+ |
candidates = list(user, limit: maximum_live_memories())
|
|
219
|
+ |
{backend, ranked, floor} = Retrieval.rank(user.id, query, candidates)
|
|
220
|
+ |
|
|
221
|
+ |
eligible =
|
|
222
|
+ |
Enum.flat_map(ranked, fn {memory, score} ->
|
|
223
|
+ |
if memory.bucket == "user" or score > floor, do: [memory], else: []
|
|
224
|
+ |
end)
|
|
225
|
+ |
|
|
226
|
+ |
{kept, dropped} = bound(eligible, opts)
|
|
227
|
+ |
|
|
228
|
+ |
%Recall{memories: kept, dropped: dropped, backend: backend}
|
|
229
|
+ |
rescue
|
|
230
|
+ |
_error -> %Recall{memories: [], dropped: 0, backend: :lexical}
|
|
231
|
+ |
end
|
|
232
|
+ |
|
|
233
|
+ |
def recall(%User{}, _query, _opts),
|
|
234
|
+ |
do: %Recall{memories: [], dropped: 0, backend: :lexical}
|
|
235
|
+ |
|
|
236
|
+ |
@doc "The most live memories one account may hold."
|
|
237
|
+ |
@spec maximum_live_memories() :: pos_integer()
|
|
238
|
+ |
def maximum_live_memories, do: setting(:maximum_live_memories, 200)
|
|
239
|
+ |
|
|
240
|
+ |
@doc "The most memories one turn may attach."
|
|
241
|
+ |
@spec maximum_attached() :: pos_integer()
|
|
242
|
+ |
def maximum_attached, do: setting(:maximum_attached, 8)
|
|
243
|
+ |
|
|
244
|
+ |
@doc "The most characters of memory bodies one turn may attach."
|
|
245
|
+ |
@spec maximum_attached_characters() :: pos_integer()
|
|
246
|
+ |
def maximum_attached_characters, do: setting(:maximum_attached_characters, 2_000)
|
|
247
|
+ |
|
|
248
|
+ |
# ── internal ───────────────────────────────────────────────────────────────
|
|
249
|
+ |
|
|
250
|
+ |
# Count first, size second, and the count of what neither admitted. Taking
|
|
251
|
+ |
# the highest-ranked memories until the character budget is spent keeps the
|
|
252
|
+ |
# note about this turn rather than about whichever memory is longest.
|
|
253
|
+ |
defp bound(memories, opts) do
|
|
254
|
+ |
count = Keyword.get(opts, :maximum_attached, maximum_attached())
|
|
255
|
+ |
characters = Keyword.get(opts, :maximum_attached_characters, maximum_attached_characters())
|
|
256
|
+ |
|
|
257
|
+ |
{kept, _left} =
|
|
258
|
+ |
memories
|
|
259
|
+ |
|> Enum.take(count)
|
|
260
|
+ |
|> Enum.reduce({[], characters}, fn memory, {kept, remaining} ->
|
|
261
|
+ |
cost = String.length(memory.body)
|
|
262
|
+ |
|
|
263
|
+ |
if cost <= remaining, do: {[memory | kept], remaining - cost}, else: {kept, remaining}
|
|
264
|
+ |
end)
|
|
265
|
+ |
|
|
266
|
+ |
kept = Enum.reverse(kept)
|
|
267
|
+ |
{kept, length(memories) - length(kept)}
|
|
268
|
+ |
end
|
|
269
|
+ |
|
|
270
|
+ |
defp scope(%User{id: user_id}, opts) do
|
|
271
|
+ |
query = from(memory in Memory, where: memory.user_id == ^user_id)
|
|
272
|
+ |
|
|
273
|
+ |
query =
|
|
274
|
+ |
if Keyword.get(opts, :include_superseded, false) do
|
|
275
|
+ |
query
|
|
276
|
+ |
else
|
|
277
|
+ |
where(query, [memory], is_nil(memory.superseded_by_id))
|
|
278
|
+ |
end
|
|
279
|
+ |
|
|
280
|
+ |
case Keyword.get(opts, :bucket) do
|
|
281
|
+ |
bucket when bucket in ["user", "learned"] -> where(query, [m], m.bucket == ^bucket)
|
|
282
|
+ |
_all -> query
|
|
283
|
+ |
end
|
|
284
|
+ |
end
|
|
285
|
+ |
|
|
286
|
+ |
defp limit(opts) do
|
|
287
|
+ |
case Keyword.get(opts, :limit) do
|
|
288
|
+ |
value when is_integer(value) and value > 0 -> min(value, @maximum_listed)
|
|
289
|
+ |
_absent -> @maximum_listed
|
|
290
|
+ |
end
|
|
291
|
+ |
end
|
|
292
|
+ |
|
|
293
|
+ |
# A correction replaces one live row with another, so it is admitted at the
|
|
294
|
+ |
# ceiling. Refusing it there would leave an account that has filled its store
|
|
295
|
+ |
# unable to fix anything already in it.
|
|
296
|
+ |
defp quota(_user, %Memory{}), do: {:ok, :superseding}
|
|
297
|
+ |
|
|
298
|
+ |
defp quota(user, nil) do
|
|
299
|
+ |
live =
|
|
300
|
+ |
Repo.aggregate(
|
|
301
|
+ |
from(memory in Memory,
|
|
302
|
+ |
where: memory.user_id == ^user.id and is_nil(memory.superseded_by_id)
|
|
303
|
+ |
),
|
|
304
|
+ |
:count
|
|
305
|
+ |
)
|
|
306
|
+ |
|
|
307
|
+ |
if live < maximum_live_memories(), do: {:ok, live}, else: {:error, :quota_reached}
|
|
308
|
+ |
end
|
|
309
|
+ |
|
|
310
|
+ |
defp superseded(user, attrs) do
|
|
311
|
+ |
case Map.get(attrs, "supersedes") do
|
|
312
|
+ |
nil ->
|
|
313
|
+ |
{:ok, nil}
|
|
314
|
+ |
|
|
315
|
+ |
id when is_binary(id) ->
|
|
316
|
+ |
case fetch(user, id) do
|
|
317
|
+ |
{:ok, %Memory{superseded_by_id: nil} = memory} -> {:ok, memory}
|
|
318
|
+ |
_absent_or_already_superseded -> {:error, :supersedes_not_found}
|
|
319
|
+ |
end
|
|
320
|
+ |
|
|
321
|
+ |
_invalid ->
|
|
322
|
+ |
{:error, :supersedes_not_found}
|
|
323
|
+ |
end
|
|
324
|
+ |
end
|
|
325
|
+ |
|
|
326
|
+ |
defp link(_repo, %{supersedes: nil}), do: {:ok, nil}
|
|
327
|
+ |
|
|
328
|
+ |
defp link(repo, %{supersedes: previous, memory: memory}) do
|
|
329
|
+ |
previous
|
|
330
|
+ |
|> Memory.supersede_changeset(memory)
|
|
331
|
+ |
|> repo.update()
|
|
332
|
+ |
end
|
|
333
|
+ |
|
|
334
|
+ |
defp normalize(attrs) do
|
|
335
|
+ |
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
|
336
|
+ |
end
|
|
337
|
+ |
|
|
338
|
+ |
# `|| []` rather than a `get_env/3` default: the key can be present and nil,
|
|
339
|
+ |
# and a nil there would reach `Keyword.get/3` as a hard crash rather than as
|
|
340
|
+ |
# the configured fallback.
|
|
341
|
+ |
defp setting(key, fallback) do
|
|
342
|
+ |
(Application.get_env(:openagents, :memory_recall) || [])
|
|
343
|
+ |
|> Keyword.get(key, fallback)
|
|
344
|
+ |
end
|
|
345
|
+ |
end
|