Add cloud memories, their API, and server-side recall

c0bbb4dd9fd2 · AtlantisPleb · · parent 6651e503b653

Add cloud memories, their API, and server-side recall

Memory now lives where the account lives. Issue #51's redesign replaced the
local engram ledger with a store in this database, recalled server-side inside
`POST /api/v1/responses` so the CLI, the web app, and a direct API caller all
get memory attached to their turns without any of them implementing retrieval.

## The store

A `memories` table, account-scoped on `users.id`, with a `user` and a `learned`
bucket, an optional thread or session `source_ref`, and `superseded_by_id` so a
correction supersedes rather than edits. Bodies are immutable for the life of a
row, and the shape constraint bounds them in PostgreSQL rather than only in the
changeset.

This is a plane of its own rather than an extension of the existing memory
planes, and MEMORY-010 records why. Profile memory and experience memory are
scoped to a visitor under the account's one canonical conversation; this lane
authenticates a user over the API, and a CLI session has no browser to be a
visitor of. A profile-memory record also admits only a conversation-message
source or a host assertion (MEMORY-003), and a memory written from a coding
session comes out of a thread, which is not a conversation (THREAD-001).
MEMORY-002 rules the `learned` bucket out of the profile plane outright, so
admitting it there would mean weakening two current invariants to fit a design.

## Recall

`OpenAgents.Memories.recall/3` ranks the account's live memories against the
turn. A `user` memory attaches whenever the account holds one, because the
reader asked for it and "remember I use pnpm, not npm" shares no word with
"install the deps"; a `learned` memory must clear the backend's floor. The
result is bounded by count and by characters, and the `[From memory: …]` note's
last line says what did not fit rather than trailing off.

Retrieval is a swappable interface with two backends. The embedding backend is
the target. The PostgreSQL full-text backend is a marked stand-in, and it is
what a deployment runs unless `OPENAGENTS_FEATURE_MEMORY_EMBEDDINGS` and an
embedding credential are both set — no pgvector index exists for this plane, so
this is not the target met.

## Scope, and deletion

MEMORY-010 makes the account boundary a database predicate. Every query rooted
at the schema names `user_id`, in the context and in the backend that reads
PostgreSQL, and the test reads each module's own source AST so a query added
beside the scoped ones fails until it carries its scope.

Memories key on the retained account row rather than the visitor root, so the
DATA-004 visitor cascade does not reach them. `DataRights.delete/3` removes
them explicitly, in the same transaction, so an account that asked for
everything to be removed does not keep what it asked to have remembered.

## The API, and the auth it does not require

`POST`, `GET`, and `DELETE /api/v1/memories` on the `chat:account` lane. There
is no `PATCH`: a correction is a new row.

`POST /api/v1/responses` gains optional recognition, not authority.
`AmbientApiTokenAuth` refuses nobody, so an anonymous caller, an unreadable
credential, and a credential scoped elsewhere all reach the route exactly as
before — the dev lane keeps working. It deliberately does not adopt
`Context.Composer`, which is the browser conversation's prompt builder and
would replace an API caller's instructions with a description of a surface this
is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • modified docs/taxonomy.md
  • modified lib/openagents/data_rights.ex
  • modified lib/openagents/data_rights/export_inventory.ex
  • added lib/openagents/memories.ex
  • added lib/openagents/memories/memory.ex
  • added lib/openagents/memories/note.ex
  • added lib/openagents/memories/recall.ex
  • added lib/openagents/memories/retrieval.ex
  • added lib/openagents/memories/retrieval/lexical.ex
  • added lib/openagents/memories/retrieval/semantic.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/memory_controller.ex
  • modified lib/openagents_web/controllers/responses_controller.ex
  • added lib/openagents_web/plugs/ambient_api_token_auth.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825195623_create_memories.exs
  • added test/openagents/memories/retrieval_test.exs
  • added test/openagents/memories_test.exs
  • added test/openagents_web/controllers/memory_controller_test.exs
  • modified test/openagents_web/controllers/responses_controller_test.exs
  • added test/support/openagents/memories/synonym_embeddings_provider.ex

Diff

26 files changed, +2323 -15

INVARIANTS.md modified +71

@@ -1039,6 +1039,70 @@ Evidence: `OpenAgents.GraphMemory`, graph manifests/artifacts/memberships/outbox

1039 1039
database guards, `test/openagents/graph_memory_test.exs`, the committed graph comparison,
1040 1040
and `OpenAgents.GraphMemoryTest`.
1041 1041
1042
### MEMORY-010 — Cloud memories are account-scoped, explicit, and bounded at recall
1043
1044
Status: Current
1045
1046
Cloud memories (`OpenAgents.Memories`) are a plane of their own, distinct from
1047
the visitor-scoped memory planes MEMORY-001 through MEMORY-009 govern. They are
1048
account-scoped rather than conversation-scoped, thread-sourced rather than
1049
message-sourced, and authoritative rather than derived: dropping the table
1050
loses what an account asked to have remembered, which is the property that
1051
separates this store from every projection beside it. It is a separate plane
1052
because the existing ones cannot hold it — profile-memory records admit only a
1053
same-owner conversation-message source or host assertion (MEMORY-003), a thread
1054
is not a conversation (THREAD-001), and MEMORY-002 forbids consolidation-derived
1055
material from entering the profile plane at all.
1056
1057
Scope is a database predicate, never an application filter. Every Ecto query
1058
rooted at `OpenAgents.Memories.Memory` names `user_id`, in the context and in
1059
every retrieval backend that reads PostgreSQL, and the queries are read from
1060
each module's own source AST rather than remembered, so a query added beside
1061
the scoped ones fails until it carries its scope. No read offers a cross-account
1062
or unscoped fallback, and a memory of another account is refused as absent
1063
rather than as forbidden.
1064
1065
Writes are explicit. Nothing infers a memory from what a turn contained: a row
1066
exists because a caller asked for it through `POST /api/v1/memories`.
1067
Corrections supersede rather than edit — the replacement is a new row, the old
1068
row points at it, and recall reads live rows only — so a wrong memory is traced
1069
and replaced rather than overwritten. Bodies are immutable for the life of a
1070
row.
1071
1072
Recall is bounded three ways and reports what it excluded. The store caps live
1073
memories per account at write; each turn caps how many memories attach and how
1074
many characters they spend; and the `[From memory: …]` note states the count
1075
that did not fit rather than truncating. A `user` memory attaches whenever the
1076
account holds one, because the reader asked for it and it need not share
1077
vocabulary with the turn; a `learned` memory must clear the retrieval backend's
1078
floor. Recall never fails a turn: an empty store, an unavailable embedding
1079
provider, and an unreadable backend each recall nothing.
1080
1081
Retrieval is a swappable interface with two backends. The embedding backend is
1082
the target; the PostgreSQL full-text backend is a marked stand-in, and it is
1083
what a deployment runs unless `OPENAGENTS_FEATURE_MEMORY_EMBEDDINGS` and an
1084
embedding credential are both configured. No pgvector index exists for this
1085
plane — `message_semantic_embeddings` is bound to `message_id` — so the
1086
embedding backend compares vectors stored on the memory rows in process, the
1087
way the tool catalog does.
1088
1089
Recognition on `POST /api/v1/responses` grants no authority. The ambient plug
1090
refuses nobody, so an anonymous caller, an unreadable credential, and a
1091
credential scoped elsewhere all reach the route exactly as before recall
1092
existed, and the account it recognizes only widens the context of the answer.
1093
1094
Product-data deletion removes them. Memories key on the retained account row
1095
rather than the visitor root, so the DATA-004 visitor cascade does not reach
1096
them and `OpenAgents.DataRights.delete/3` removes them explicitly in the same
1097
transaction.
1098
1099
Evidence: `OpenAgents.Memories`, `OpenAgents.Memories.Retrieval`,
1100
`OpenAgentsWeb.MemoryController`, `OpenAgentsWeb.Plugs.AmbientApiTokenAuth`,
1101
the `memories` table's shape constraint and partial indexes,
1102
`test/openagents/memories_test.exs`,
1103
`test/openagents_web/controllers/memory_controller_test.exs`, and
1104
`test/openagents_web/controllers/responses_controller_test.exs`.
1105
1042 1106
### PRIVACY-001 — Secret-bearing profile memory is rejected, never scrub-stored
1043 1107
1044 1108
Status: Current

@@ -3261,6 +3325,12 @@ Derived graph manifests and artifacts cascade with the owner; standalone graph

3261 3325
memberships, outbox events, cascade plans, and operation receipts are likewise
3262 3326
removed in that transaction only after deletion of the visitor root.
3263 3327
3328
Cloud memories (MEMORY-010) are the one product-data family the visitor cascade
3329
cannot reach, because they key on the retained account row rather than on the
3330
visitor root. Deletion removes them by `user_id` explicitly, in the same
3331
transaction, so an account that asked for everything to be removed does not keep
3332
what it asked to have remembered.
3333
3264 3334
The minimal local account record (GitHub numeric ID, current login/avatar,
3265 3335
access status, authentication timestamps, and currently the encrypted GitHub
3266 3336
token ciphertext) is retained so deletion cannot erase a ban or bypass

@@ -5919,6 +5989,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5919 5989
| MEMORY-007 | `test/openagents/preferences_test.exs` |
5920 5990
| MEMORY-008 | `test/openagents/experience_memory_test.exs` |
5921 5991
| MEMORY-009 | `test/openagents/graph_memory_test.exs` |
5992
| MEMORY-010 | `test/openagents/memories_test.exs`, `test/openagents_web/controllers/memory_controller_test.exs`, `test/openagents_web/controllers/responses_controller_test.exs` |
5922 5993
| PRIVACY-001 | `test/openagents/memory/policy_and_redaction_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |
5923 5994
| TURN-001 | `test/openagents/conversations_test.exs` |
5924 5995
| TURN-002 | `test/openagents/conversations_test.exs` |
config/config.exs modified +15

@@ -290,6 +290,21 @@ config :openagents,

290 290
    maximum_export_artifacts: 500
291 291
  ],
292 292
  memory_portability: [enabled: false],
293
  # Cloud memories (`OpenAgents.Memories`). `embeddings_enabled` chooses the
294
  # target retrieval backend; with it off, recall runs on the lexical stand-in.
295
  # The three bounds are the store's ceiling per account and the per-turn
296
  # ceilings on how much memory may reach the model.
297
  memory_recall: [
298
    embeddings_enabled: false,
299
    provider: OpenAgents.Memory.OpenAIEmbeddings,
300
    model_id: "text-embedding-3-small",
301
    model_version: "2024-01",
302
    dimensions: 64,
303
    floor: 0.3,
304
    maximum_live_memories: 200,
305
    maximum_attached: 8,
306
    maximum_attached_characters: 2_000
307
  ],
293 308
  tool_discovery: [
294 309
    embeddings_enabled: false,
295 310
    provider: OpenAgents.Memory.OpenAIEmbeddings,
config/runtime.exs modified +7

@@ -271,6 +271,7 @@ if config_env() == :prod and runtime_role == :web do

271 271
  portability_enabled = feature.("MEMORY_PORTABILITY")
272 272
  shadow_enabled = feature.("SHADOW_PROGRAMS")
273 273
  tool_embeddings_enabled = feature.("TOOL_EMBEDDINGS")
274
  memory_embeddings_enabled = feature.("MEMORY_EMBEDDINGS")
274 275
  computers_enabled = feature.("COMPUTERS")
275 276
  conversation_reset_enabled = feature.("CONVERSATION_RESET")
276 277
  incident_fixer_enabled = feature.("INCIDENT_FIXER")

@@ -389,6 +390,11 @@ if config_env() == :prod and runtime_role == :web do

389 390
    |> Application.fetch_env!(:tool_discovery)
390 391
    |> Keyword.put(:embeddings_enabled, tool_embeddings_enabled)
391 392
393
  memory_recall =
394
    :openagents
395
    |> Application.fetch_env!(:memory_recall)
396
    |> Keyword.put(:embeddings_enabled, memory_embeddings_enabled)
397
392 398
  forge_repos = parse_csv.("OPENAGENTS_FORGE_REPOSITORIES")
393 399
  forge_owner = required_text.("OPENAGENTS_FORGE_OWNER")
394 400
  github_oauth_scopes = parse_csv.("GITHUB_OAUTH_SCOPES")

@@ -515,6 +521,7 @@ if config_env() == :prod and runtime_role == :web do

515 521
    memory_portability: memory_portability,
516 522
    shadow_programs: shadow_programs,
517 523
    tool_discovery: tool_discovery,
524
    memory_recall: memory_recall,
518 525
    computer_controller_enabled: computers_enabled,
519 526
    machine_token_ttl_seconds:
520 527
      parse_integer.("OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS", 300..2_592_000),
docs/taxonomy.md modified +10 -1

@@ -301,13 +301,22 @@ Architecture forbids treating Sarah as a service boundary.

301 301
inform Sarah's expression. It informs; it never grants capability or
302 302
authority.
303 303
304
**Memory planes** — account-scoped, consent-gated projections: conversation
304
**Memory planes** — visitor-scoped, consent-gated projections: conversation
305 305
recall (hybrid lexical + semantic), profile memory, learned preferences,
306 306
experience memory, graph memory. All disposable except the authoritative
307 307
messages and tool steps underneath them. *Search over thread history
308 308
(checkpoints, trailers, and receipts) is proposed and unclaimed as a use of
309 309
these planes, not a second index.*
310 310
311
**Memories** — `OpenAgents.Memories`, and a different thing from the memory
312
planes above. A memory is account-scoped (`memories.user_id`, not a visitor),
313
thread-sourced, and authoritative rather than derived: nothing can rebuild it,
314
because the sentence a reader asked to have remembered is the only copy. Two
315
buckets, `user` and `learned`. Recall runs server-side inside `POST
316
/api/v1/responses`, so every client gets it without implementing retrieval.
317
Use "memories" for this store and "memory planes" for the projections; a
318
durable fact a reader states in the web conversation is still profile memory.
319
311 320
### Threads
312 321
313 322
A **thread** is the unit of agent work. Everything in this section describes a
lib/openagents/data_rights.ex modified +10

@@ -14,6 +14,7 @@ defmodule OpenAgents.DataRights do

14 14
    SourceMembership
15 15
  }
16 16
17
  alias OpenAgents.Memories.Memory
17 18
  alias OpenAgents.Memory.SemanticDerivativeReceipt
18 19
  alias OpenAgents.{Accounts, ApiTokens, Conversations, ProfileMemory, Repo}
19 20
  alias OpenAgents.Voice.{ResponseContext, ResponseReceipt, Session, TranscriptItem}

@@ -152,6 +153,15 @@ defmodule OpenAgents.DataRights do

152 153
          from(record in ProfileMemory.Record, where: record.owner_visitor_id == ^visitor_id)
153 154
        )
154 155
156
      # Cloud memories are keyed on the account row, not on the visitor root,
157
      # and the account row is deliberately retained (DATA-004) so deletion
158
      # cannot erase a ban. The visitor cascade therefore does not reach them,
159
      # and leaving that to the cascade would quietly keep what an account
160
      # asked to have remembered after it asked for everything to be removed.
161
      # They are removed here, explicitly, in the same transaction.
162
      {_deleted_memories, nil} =
163
        Repo.delete_all(from(memory in Memory, where: memory.user_id == ^user_id))
164
155 165
      Repo.delete!(owner)
156 166
157 167
      {_deleted_receipts, nil} =
lib/openagents/data_rights/export_inventory.ex modified +14

@@ -284,6 +284,20 @@ defmodule OpenAgents.DataRights.ExportInventory do

284 284
          "carries the objective, the terminal report, usage, and the transcript, " <>
285 285
          "so the export ledger now reaches what the deletion cascade always did."
286 286
    },
287
    %{
288
      family: :memory,
289
      api?: true,
290
      status: :portable,
291
      mechanism: "GET /api/v1/memories",
292
      proof: {:test, "test/openagents_web/controllers/memory_controller_test.exs"},
293
      issue: nil,
294
      note:
295
        "The list route is the export: it returns every memory the account " <>
296
          "wrote, live and superseded both, to that account and to nobody " <>
297
          "else. Unlike the recall planes this store is authoritative — the " <>
298
          "sentence a reader typed once is the only copy — so it leaves " <>
299
          "through a route of its own rather than as a projection of messages."
300
    },
287 301
    %{
288 302
      family: :pull_request,
289 303
      api?: true,
lib/openagents/memories.ex added +345

@@ -0,0 +1,345 @@

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
lib/openagents/memories/memory.ex added +99

@@ -0,0 +1,99 @@

1
defmodule OpenAgents.Memories.Memory do
2
  @moduledoc """
3
  One thing the account asked to have remembered, or one thing the server
4
  learned on its behalf.
5
6
  A memory is account-scoped and authoritative. That is what separates it from
7
  the memory planes in `OpenAgents.Memory` (`docs/taxonomy.md`), which are
8
  disposable projections of messages that still exist underneath them: delete
9
  a memory and nothing can rebuild it, because the sentence a reader typed once
10
  is the only copy.
11
12
  Two buckets, kept distinct because they earn attention differently:
13
14
  * `user` — the reader said "remember that I prefer X". Explicit only, never
15
    inferred from what a turn happened to contain.
16
  * `learned` — server-side consolidation over thread events produced it. It
17
    carries `source_ref` so a wrong learning is traced back to the work that
18
    taught it.
19
20
  `superseded_by_id` is how a correction lands. The replacement is a new row
21
  and the old row points at it, so the store keeps the chain rather than
22
  overwriting the mistake. Nothing here updates `body`: a memory's text is
23
  fixed for the life of the row.
24
25
  `user_id` is set on the struct and never cast, so a request body cannot name
26
  whose memory it is writing.
27
  """
28
29
  use Ecto.Schema
30
31
  import Ecto.Changeset
32
33
  alias OpenAgents.Accounts.User
34
35
  @primary_key {:id, :binary_id, autogenerate: true}
36
  @foreign_key_type :binary_id
37
  @timestamps_opts [type: :utc_datetime_usec]
38
39
  @buckets ~w(user learned)
40
  @default_bucket "user"
41
  @body_characters 2_000
42
  @source_ref_characters 200
43
44
  schema "memories" do
45
    belongs_to :user, User
46
    field :bucket, :string, default: "user"
47
    field :body, :string, redact: true
48
    field :source_ref, :string
49
    field :embedding, {:array, :float}
50
    field :embedding_model, :string
51
    # The generated `tsvector` the lexical stand-in ranks over. PostgreSQL
52
    # writes it; nothing here reads it back, so it never rides a select.
53
    field :search_vector, :string, load_in_query: false
54
    belongs_to :superseded_by, __MODULE__, foreign_key: :superseded_by_id
55
    timestamps()
56
  end
57
58
  @type t :: %__MODULE__{}
59
60
  @doc "The buckets a memory may be written into."
61
  @spec buckets() :: [String.t()]
62
  def buckets, do: @buckets
63
64
  @doc "The bucket a write lands in when it names none."
65
  @spec default_bucket() :: String.t()
66
  def default_bucket, do: @default_bucket
67
68
  @doc "The longest body the store accepts, in characters."
69
  @spec body_characters() :: pos_integer()
70
  def body_characters, do: @body_characters
71
72
  @doc """
73
  Validates one new memory. The owner is not cast: pass it on the struct.
74
  """
75
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
76
  def changeset(memory, attrs) do
77
    memory
78
    |> cast(attrs, [:bucket, :body, :source_ref, :embedding, :embedding_model])
79
    |> update_change(:body, &trim/1)
80
    |> update_change(:source_ref, &trim/1)
81
    |> validate_required([:bucket, :body])
82
    |> validate_inclusion(:bucket, @buckets)
83
    |> validate_length(:body, min: 1, max: @body_characters, count: :graphemes)
84
    |> validate_length(:source_ref, min: 1, max: @source_ref_characters, count: :graphemes)
85
    |> foreign_key_constraint(:user_id)
86
    |> check_constraint(:body, name: :memories_shape)
87
  end
88
89
  @doc "Points a memory at the memory that replaced it."
90
  @spec supersede_changeset(t(), t()) :: Ecto.Changeset.t()
91
  def supersede_changeset(memory, replacement) do
92
    memory
93
    |> change(superseded_by_id: replacement.id)
94
    |> check_constraint(:superseded_by_id, name: :memories_shape)
95
  end
96
97
  defp trim(value) when is_binary(value), do: String.trim(value)
98
  defp trim(value), do: value
99
end
lib/openagents/memories/note.ex added +57

@@ -0,0 +1,57 @@

1
defmodule OpenAgents.Memories.Note do
2
  @moduledoc """
3
  The `[From memory: …]` block a recalled turn carries.
4
5
  The convention is the retrieval rails': the capability catalog and the
6
  knowledge base each attach what they found as a bracketed note the model
7
  reads as context rather than as a tool result, and memory reads the same way.
8
  One line per memory, each labelled with its bucket and its age, because a
9
  model shown "you use pnpm" without knowing whether the reader said so
10
  yesterday or eighteen months ago cannot weigh it.
11
12
  When the bounds excluded something, the block says so in its last line. A
13
  note that trailed off would leave the model believing it had been told
14
  everything the account remembers.
15
  """
16
17
  alias OpenAgents.Memories.{Memory, Recall}
18
  alias OpenAgentsWeb.RelativeTime
19
20
  @doc """
21
  Renders one recall as a note, or `nil` when there is nothing to say.
22
23
  An empty recall renders `nil` rather than an empty block, so a turn with no
24
  memories behind it is exactly the turn it was before recall existed.
25
  """
26
  @spec render(Recall.t()) :: String.t() | nil
27
  def render(%Recall{memories: []}), do: nil
28
29
  def render(%Recall{memories: memories, dropped: dropped}) do
30
    (Enum.map(memories, &line/1) ++ omission(dropped))
31
    |> Enum.join("\n")
32
  end
33
34
  defp line(%Memory{} = memory) do
35
    "[From memory: #{memory.bucket}, #{age(memory)}] #{memory.body}"
36
  end
37
38
  defp omission(0), do: []
39
40
  defp omission(dropped) do
41
    [
42
      "[From memory: #{dropped} more #{noun(dropped)} not attached; " <>
43
        "this turn's recall is bounded to #{OpenAgents.Memories.maximum_attached()} entries " <>
44
        "and #{OpenAgents.Memories.maximum_attached_characters()} characters.]"
45
    ]
46
  end
47
48
  defp noun(1), do: "memory was"
49
  defp noun(_many), do: "memories were"
50
51
  defp age(%Memory{inserted_at: at}) do
52
    case RelativeTime.ago(at) do
53
      nil -> "age unknown"
54
      ago -> ago
55
    end
56
  end
57
end
lib/openagents/memories/recall.ex added +23

@@ -0,0 +1,23 @@

1
defmodule OpenAgents.Memories.Recall do
2
  @moduledoc """
3
  What one turn's recall found, and what it left behind.
4
5
  `dropped` is the point of the struct. Recall is bounded — by how many
6
  memories may attach and by how many characters they may spend — and a bound
7
  that silently discards is a bound nobody can debug. So the count of what did
8
  not fit travels with what did, and the note states it in words rather than
9
  trailing off.
10
  """
11
12
  alias OpenAgents.Memories.Memory
13
  alias OpenAgents.Memories.Retrieval
14
15
  @enforce_keys [:memories, :dropped, :backend]
16
  defstruct [:memories, :dropped, :backend]
17
18
  @type t :: %__MODULE__{
19
          memories: [Memory.t()],
20
          dropped: non_neg_integer(),
21
          backend: Retrieval.backend()
22
        }
23
end
lib/openagents/memories/retrieval.ex added +107

@@ -0,0 +1,107 @@

1
defmodule OpenAgents.Memories.Retrieval do
2
  @moduledoc """
3
  The swappable boundary that decides which memories a turn is about.
4
5
  Two implementations sit behind one behaviour:
6
7
  * `OpenAgents.Memories.Retrieval.Semantic` — **the target.** It embeds the
8
    incoming input and ranks the account's live memories by cosine similarity
9
    over the embeddings stored on the rows.
10
  * `OpenAgents.Memories.Retrieval.Lexical` — **the stand-in.** PostgreSQL
11
    full-text scoring, marked as a stand-in in the same way the capability
12
    rail's lexical scorer is (`OpenAgents.Tools.Selector`). It is here so
13
    recall works on a deployment with no embedding credential, not because
14
    word overlap is the right way to decide what a sentence is about.
15
16
  `rank/3` chooses: the semantic backend when the embedding rail is configured
17
  and answers, the lexical stand-in otherwise, and the stand-in again when the
18
  provider errors mid-turn. A turn is never starved of memory because the
19
  embedding provider is cold, and it is never failed by one either — an
20
  unreachable backend recalls nothing rather than raising.
21
22
  Scores are comparable only within a backend. What crosses the boundary is the
23
  ordering and the backend's own `floor/0`, never a number a caller interprets.
24
  """
25
26
  alias OpenAgents.Memories.Memory
27
  alias OpenAgents.Memories.Retrieval.{Lexical, Semantic}
28
29
  require Logger
30
31
  @typedoc "One ranked memory and the backend's score for it."
32
  @type ranked :: {Memory.t(), float()}
33
34
  @typedoc "Which backend produced a ranking."
35
  @type backend :: :semantic | :lexical
36
37
  @doc """
38
  Scores `candidates` against `query`, as a map of memory id to score.
39
40
  `user_id` is passed rather than inferred from the candidates because
41
  MEMORY-010 requires the account boundary to be a predicate in every query a
42
  backend issues. A backend that reads PostgreSQL names the column; the list of
43
  candidate ids narrows the read, it does not scope it.
44
  """
45
  @callback score(user_id :: String.t(), query :: String.t(), candidates :: [Memory.t()]) ::
46
              {:ok, %{optional(String.t()) => float()}} | :error
47
48
  @doc "The score a `learned` memory must clear before it interrupts a turn."
49
  @callback floor() :: float()
50
51
  @doc "Whether this backend is configured on this deployment."
52
  @callback available?() :: boolean()
53
54
  @doc """
55
  Ranks `candidates` against `query`, highest first.
56
57
  Returns `{backend, ranked, floor}`. Every candidate appears, scored; the
58
  caller decides what the floor means for each bucket, because a `user` memory
59
  the reader asked for is attached whether or not it shares vocabulary with
60
  this turn, and a `learned` one is not.
61
  """
62
  @spec rank(String.t(), String.t(), [Memory.t()]) :: {backend(), [ranked()], float()}
63
  def rank(user_id, query, candidates)
64
      when is_binary(user_id) and is_binary(query) and is_list(candidates) do
65
    rank(user_id, query, candidates, backend())
66
  end
67
68
  @doc "Ranks with a named backend. Falls back to the stand-in when it cannot answer."
69
  @spec rank(String.t(), String.t(), [Memory.t()], module()) :: {backend(), [ranked()], float()}
70
  def rank(_user_id, _query, [], module), do: {name(module), [], module.floor()}
71
72
  def rank(user_id, query, candidates, module) do
73
    case module.score(user_id, query, candidates) do
74
      {:ok, scores} ->
75
        {name(module), ordered(candidates, scores), module.floor()}
76
77
      :error when module != Lexical ->
78
        Logger.info("memory_retrieval_fell_back backend=#{name(module)}")
79
        rank(user_id, query, candidates, Lexical)
80
81
      :error ->
82
        {name(module), ordered(candidates, %{}), module.floor()}
83
    end
84
  end
85
86
  @doc "The backend this deployment ranks with."
87
  @spec backend() :: module()
88
  def backend do
89
    if Semantic.available?(), do: Semantic, else: Lexical
90
  end
91
92
  @doc "The short name of a backend module, for a note or a log line."
93
  @spec name(module()) :: backend()
94
  def name(Semantic), do: :semantic
95
  def name(_lexical), do: :lexical
96
97
  # Highest score first, then newest first, so an account whose memories all
98
  # score zero still gets a stable, meaningful order rather than table order.
99
  defp ordered(candidates, scores) do
100
    candidates
101
    |> Enum.map(&{&1, Map.get(scores, &1.id, 0.0)})
102
    |> Enum.sort_by(fn {memory, score} -> {-score, -stamp(memory)} end)
103
  end
104
105
  defp stamp(%Memory{inserted_at: %DateTime{} = at}), do: DateTime.to_unix(at, :microsecond)
106
  defp stamp(_memory), do: 0
107
end
lib/openagents/memories/retrieval/lexical.ex added +106

@@ -0,0 +1,106 @@

1
defmodule OpenAgents.Memories.Retrieval.Lexical do
2
  @moduledoc """
3
  PostgreSQL full-text scoring over memory bodies. **This is the stand-in, not
4
  the target.**
5
6
  The workspace retrieval rule is that user-facing retrieval routes on meaning,
7
  not on words: embeddings and cosine similarity, which
8
  `OpenAgents.Memories.Retrieval.Semantic` implements. This module exists so a
9
  deployment with no embedding credential still recalls something, and it is
10
  marked here the way the capability rail marks its own lexical scorer
11
  (`OpenAgents.Tools.Selector`) rather than being presented as the answer.
12
13
  What it cannot do is the reason the marking matters. "Remember I use pnpm,
14
  not npm" shares no word with "install the deps", so word overlap scores that
15
  pair at zero. The `user` bucket is attached regardless of score for exactly
16
  that reason (`OpenAgents.Memories.recall/3`), which is a bound on the damage
17
  rather than a fix. Only the semantic backend actually connects the two.
18
19
  Scoring is `ts_rank_cd` over the generated `search_vector` column, read
20
  through the partial GIN index on live rows, under the `english` text-search
21
  configuration so that stop words drop out and words stem.
22
23
  The query is the turn's words joined with `or`, not with the `and` that
24
  `websearch_to_tsquery` defaults to. A turn is a sentence, not a search box:
25
  requiring every word of "the migration failed" to appear in a memory would
26
  match nothing an account ever wrote. Sharing one content word is the bar,
27
  which is the same bar the issue states for the `learned` bucket, and stop
28
  words are gone before it is applied so "the" cannot clear it.
29
  """
30
31
  @behaviour OpenAgents.Memories.Retrieval
32
33
  import Ecto.Query
34
35
  alias OpenAgents.Memories.Memory
36
  alias OpenAgents.Repo
37
38
  # Only what fits a text-search query. A whole conversation turn pasted into
39
  # `websearch_to_tsquery` costs more to parse than the ranking is worth.
40
  @maximum_query_characters 512
41
  @maximum_query_words 64
42
43
  # A word the parser would read as an operator rather than as a word. They are
44
  # stripped so a turn saying "or" cannot produce `or or or` and fail to parse.
45
  @operators ~w(or and not)
46
47
  @impl true
48
  def available?, do: true
49
50
  # Any match at all. `ts_rank_cd` is unnormalized and its magnitude means
51
  # nothing across queries, so the only honest floor is "the words appear".
52
  @impl true
53
  def floor, do: 0.0
54
55
  @impl true
56
  def score(user_id, query, candidates) do
57
    text = prepare(query)
58
    ids = Enum.map(candidates, & &1.id)
59
60
    if text == "" or ids == [] do
61
      {:ok, %{}}
62
    else
63
      {:ok, ranked(user_id, text, ids)}
64
    end
65
  rescue
66
    _error -> :error
67
  end
68
69
  # `user_id` is the scope predicate, and it is written here rather than
70
  # inherited from the candidate ids (MEMORY-010). The id list narrows the
71
  # read; it does not bound it, and a caller that assembled that list wrongly
72
  # would otherwise reach another account's rows through this query.
73
  defp ranked(user_id, text, ids) do
74
    from(memory in Memory,
75
      where: memory.user_id == ^user_id,
76
      where: is_nil(memory.superseded_by_id),
77
      where: memory.id in ^ids,
78
      where: fragment("? @@ websearch_to_tsquery('english', ?)", memory.search_vector, ^text),
79
      select: {
80
        memory.id,
81
        fragment(
82
          "ts_rank_cd(?, websearch_to_tsquery('english', ?), 32)",
83
          memory.search_vector,
84
          ^text
85
        )
86
      }
87
    )
88
    |> Repo.all()
89
    |> Map.new(fn {id, rank} -> {id, rank / 1} end)
90
  end
91
92
  # `websearch_to_tsquery` reads `-` as negation, quotes as phrases, and `or`
93
  # as disjunction. A turn carrying punctuation would ask for something the
94
  # reader did not, so everything but letters and digits goes first; then the
95
  # words are rejoined with `or`, which is the operator this wants and not the
96
  # one the parser assumes.
97
  defp prepare(query) do
98
    query
99
    |> String.slice(0, @maximum_query_characters)
100
    |> String.replace(~r/[^\p{L}\p{N}\s]/u, " ")
101
    |> String.split(~r/\s+/u, trim: true)
102
    |> Enum.reject(&(&1 in @operators))
103
    |> Enum.take(@maximum_query_words)
104
    |> Enum.join(" or ")
105
  end
106
end
lib/openagents/memories/retrieval/semantic.ex added +123

@@ -0,0 +1,123 @@

1
defmodule OpenAgents.Memories.Retrieval.Semantic do
2
  @moduledoc """
3
  Cosine similarity over memory embeddings. **This is the target backend.**
4
5
  Retrieval routes on meaning here rather than on shared words, which is what
6
  lets "remember I use pnpm, not npm" reach a later "install the deps" — a pair
7
  the lexical stand-in scores at zero because they have no word in common.
8
9
  An account holds few memories, and `OpenAgents.Memories` caps how many are
10
  live, so the vectors are compared in this process rather than through an
11
  index. That is the same trade the tool catalog makes in
12
  `OpenAgents.Tools.Embeddings`: a bounded set does not earn a per-turn
13
  pgvector query. The embedding itself is written on the row at create time by
14
  `OpenAgents.Memories.create/2`, under the same provider boundary the rest of
15
  this repository embeds through (`OpenAgents.Memory.EmbeddingProvider`).
16
17
  Every failure degrades rather than raising. No credential, a provider that
18
  errors, a query the provider will not embed, a row whose vector was written
19
  under a different model — each one leaves the caller falling back to the
20
  stand-in or scoring nothing, never failing the turn.
21
  """
22
23
  @behaviour OpenAgents.Memories.Retrieval
24
25
  alias OpenAgents.Memories.Memory
26
  alias OpenAgents.Tools.Embeddings
27
28
  @impl true
29
  def available? do
30
    config = config()
31
    Keyword.get(config, :embeddings_enabled, false) == true and not is_nil(config[:provider])
32
  end
33
34
  # A `learned` memory has to be about this turn before it interrupts it.
35
  # Cosine over a small embedding puts unrelated sentences well under this and
36
  # related ones well over it.
37
  @impl true
38
  def floor, do: Keyword.get(config(), :floor, 0.3)
39
40
  # This backend issues no query of its own: it compares vectors already loaded
41
  # by `OpenAgents.Memories.list/2`, whose read names `user_id`. `user_id` is
42
  # still taken and still enforced, so a candidate from another account cannot
43
  # be scored even if a caller assembled the list wrongly (MEMORY-010).
44
  @impl true
45
  def score(user_id, query, candidates) do
46
    with true <- available?(),
47
         {:ok, vector} <- embed(query) do
48
      {:ok, cosines(vector, Enum.filter(candidates, &(&1.user_id == user_id)))}
49
    else
50
      _unavailable -> :error
51
    end
52
  rescue
53
    _error -> :error
54
  end
55
56
  @doc """
57
  The embedding to store on a new memory, or `nil` when the rail is off.
58
59
  Called on the write path so recall never has to embed the store, only the
60
  turn. A provider failure returns `nil`: the memory is still written, and it
61
  is recalled through the stand-in until something re-embeds it.
62
  """
63
  @spec embedding_for(String.t()) :: {[float()], String.t()} | nil
64
  def embedding_for(body) when is_binary(body) do
65
    case embed(body) do
66
      {:ok, vector} -> {vector, model_id()}
67
      :error -> nil
68
    end
69
  end
70
71
  def embedding_for(_body), do: nil
72
73
  @doc "The embedding model rows are written under on this deployment."
74
  @spec model_id() :: String.t()
75
  def model_id, do: Keyword.get(config(), :model_id, "text-embedding-3-small")
76
77
  defp embed(text) do
78
    if available?() do
79
      case provider().embed(text, embed_config()) do
80
        {:ok, vector} when is_list(vector) and vector != [] -> {:ok, vector}
81
        _failure -> :error
82
      end
83
    else
84
      :error
85
    end
86
  rescue
87
    _error -> :error
88
  end
89
90
  # A row embedded under a different model is not comparable to this query, so
91
  # it scores nothing here and reaches the turn through the stand-in instead.
92
  defp cosines(vector, candidates) do
93
    model = model_id()
94
95
    candidates
96
    |> Enum.flat_map(fn
97
      %Memory{embedding: stored, embedding_model: ^model, id: id}
98
      when is_list(stored) and stored != [] ->
99
        [{id, Embeddings.cosine(vector, stored)}]
100
101
      _unembedded ->
102
        []
103
    end)
104
    |> Map.new()
105
  end
106
107
  # `|| []` rather than a `get_env/3` default: the key can be present and nil,
108
  # and a nil there would reach `Keyword.get/3` as a hard crash on a path whose
109
  # whole contract is to degrade.
110
  defp config, do: Application.get_env(:openagents, :memory_recall) || []
111
112
  defp provider, do: Keyword.get(config(), :provider)
113
114
  defp embed_config do
115
    config = config()
116
117
    %{
118
      model_id: model_id(),
119
      model_version: Keyword.get(config, :model_version, "2024-01"),
120
      dimensions: Keyword.get(config, :dimensions, 64)
121
    }
122
  end
123
end
lib/openagents_web/api_error.ex modified +5

@@ -66,6 +66,11 @@ defmodule OpenAgentsWeb.ApiError do

66 66
    # malformed request and not a forbidden one: the same call succeeds once
67 67
    # the caller revokes one, so it is the rate-limit status and its own code.
68 68
    "thread_quota_reached" => {429, "This account holds the maximum number of open threads"},
69
    # Memory admission. Like the thread ceiling, this is neither a malformed
70
    # request nor a forbidden one: the same call succeeds once the account
71
    # removes or supersedes a memory, so it is the rate-limit status and its
72
    # own code rather than a field message inside a generic 422.
73
    "memory_quota_reached" => {429, "This account holds the maximum number of memories"},
69 74
    # Model availability (PROVIDER-002). The model is in the catalog but its
70 75
    # provider credential is not configured on this deployment, which is the
71 76
    # server's condition and not the caller's mistake: the same call succeeds
lib/openagents_web/api_route_authority.ex modified +9 -3

@@ -118,9 +118,12 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

118 118
    %{
119 119
      # Anonymous by design: the extension index is a public API description.
120 120
      "get /api/v1" => {:anonymous, :meta, :legacy},
121
      # Anonymous while the stub answers; required_bearer when a real loop
122
      # stands behind it.
123
      "post /api/v1/responses" => {:anonymous, :response, :envelope},
121
      # An anonymous caller is answered as it always was. A caller that
122
      # presents a `chat:account` bearer is recognized so its own memories are
123
      # recalled into the turn, which is what makes this optional rather than
124
      # anonymous. The credential widens the context of the answer, never the
125
      # authority of the call.
126
      "post /api/v1/responses" => {:optional_bearer, :response, :envelope},
124 127
      # Anonymous by design: device authorization bootstraps credentials.
125 128
      "post /api/v1/device/authorizations" => {:anonymous, :device, :legacy},
126 129
      "post /api/v1/device/authorizations/token" => {:anonymous, :device, :legacy},

@@ -205,6 +208,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

205 208
      "get /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
206 209
      "post /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
207 210
      "post /api/v1/threads/:thread_id/grants" => {:required_bearer, :thread, :envelope},
211
      "post /api/v1/memories" => {:required_bearer, :memory, :envelope},
212
      "get /api/v1/memories" => {:required_bearer, :memory, :envelope},
213
      "delete /api/v1/memories/:id" => {:required_bearer, :memory, :envelope},
208 214
      "get /api/v1/capacity" => {:required_bearer, :capacity, :legacy},
209 215
      "post /api/v1/capacity/matches" => {:required_bearer, :capacity, :legacy},
210 216
      # The bootstrap read for a box client. It answers a conversation, but it
lib/openagents_web/controllers/memory_controller.ex added +132

@@ -0,0 +1,132 @@

1
defmodule OpenAgentsWeb.MemoryController do
2
  @moduledoc """
3
  The account's memories: write one, read them back, remove one.
4
5
  These three routes are the whole write path. Recall does not run here — it
6
  runs server-side inside `POST /api/v1/responses`, so the CLI, the web app,
7
  and a direct API caller all get memory attached to their turns without any of
8
  them implementing retrieval. What a client needs to implement is a `remember`
9
  tool that posts here when the reader explicitly asks to have something
10
  remembered, and nothing else.
11
12
  Explicit only. Nothing on this surface infers a memory from a conversation:
13
  a memory exists because somebody asked for it.
14
15
  Corrections supersede rather than edit. `POST` accepts `supersedes` — the id
16
  of a memory this one replaces — and there is no `PATCH`, because the store
17
  keeps the chain a wrong memory was corrected through instead of overwriting
18
  it. `DELETE` is the other operation, and it means what it says: the row is
19
  gone, not marked.
20
21
  The account scope is `chat:account`, the same lane threads and chat already
22
  use, because a memory belongs to an account rather than to a repository or a
23
  thread.
24
  """
25
26
  use OpenAgentsWeb, :controller
27
28
  alias OpenAgents.Memories
29
  alias OpenAgents.Memories.Memory
30
  alias OpenAgentsWeb.ApiError
31
32
  @doc """
33
  Writes one memory.
34
35
  Body: `body` (required), `bucket` (`user` or `learned`, `user` by default),
36
  `source_ref` (the thread or session the request came out of), and
37
  `supersedes` (the id of a live memory of this account that this one
38
  replaces).
39
  """
40
  def create(conn, params) do
41
    user = conn.assigns.current_user
42
43
    attrs = %{
44
      "body" => params["body"],
45
      "bucket" => params["bucket"] || Memory.default_bucket(),
46
      "source_ref" => params["source_ref"],
47
      "supersedes" => params["supersedes"]
48
    }
49
50
    case Memories.create(user, attrs) do
51
      {:ok, memory} ->
52
        conn
53
        |> put_status(:created)
54
        |> json(%{"memory" => view(memory)})
55
56
      {:error, :quota_reached} ->
57
        ApiError.refuse(conn, "memory_quota_reached",
58
          message:
59
            "This account already holds #{Memories.maximum_live_memories()} memories. " <>
60
              "Remove one, or supersede one, before writing another."
61
        )
62
63
      {:error, :supersedes_not_found} ->
64
        ApiError.validation_failed(conn, %{
65
          "supersedes" => ["names no live memory of this account"]
66
        })
67
68
      {:error, changeset} ->
69
        ApiError.changeset(conn, changeset)
70
    end
71
  end
72
73
  @doc """
74
  The account's memories, newest first.
75
76
  Query parameters: `bucket` to narrow to one bucket, `limit` to bound the
77
  page, and `include_superseded=true` to read the corrections behind the live
78
  rows as well.
79
  """
80
  def index(conn, params) do
81
    memories = Memories.list(conn.assigns.current_user, listing_options(params))
82
83
    json(conn, %{"memories" => Enum.map(memories, &view/1)})
84
  end
85
86
  @doc "Removes one memory outright."
87
  def delete(conn, %{"id" => id}) do
88
    case Memories.delete(conn.assigns.current_user, id) do
89
      {:ok, memory} -> json(conn, %{"memory" => view(memory)})
90
      {:error, :not_found} -> ApiError.not_found(conn)
91
    end
92
  end
93
94
  defp listing_options(params) do
95
    []
96
    |> bucket(params["bucket"])
97
    |> limit(params["limit"])
98
    |> superseded(params["include_superseded"])
99
  end
100
101
  defp bucket(opts, value) when value in ["user", "learned"], do: [{:bucket, value} | opts]
102
  defp bucket(opts, _absent), do: opts
103
104
  defp limit(opts, value) when is_binary(value) do
105
    case Integer.parse(value) do
106
      {parsed, ""} when parsed > 0 -> [{:limit, parsed} | opts]
107
      _unreadable -> opts
108
    end
109
  end
110
111
  defp limit(opts, value) when is_integer(value) and value > 0, do: [{:limit, value} | opts]
112
  defp limit(opts, _absent), do: opts
113
114
  defp superseded(opts, value) when value in [true, "true", "1"],
115
    do: [{:include_superseded, true} | opts]
116
117
  defp superseded(opts, _absent), do: opts
118
119
  defp view(%Memory{} = memory) do
120
    %{
121
      "id" => memory.id,
122
      "bucket" => memory.bucket,
123
      "body" => memory.body,
124
      "source_ref" => memory.source_ref,
125
      "superseded_by" => memory.superseded_by_id,
126
      "created_at" => stamp(memory.inserted_at)
127
    }
128
  end
129
130
  defp stamp(nil), do: nil
131
  defp stamp(%DateTime{} = at), do: DateTime.to_iso8601(at)
132
end
lib/openagents_web/controllers/responses_controller.ex modified +64 -5

@@ -19,9 +19,38 @@ defmodule OpenAgentsWeb.ResponsesController do

19 19
  stream has opened arrives as `response.failed`, which is the
20 20
  specification's shape for exactly that.
21 21
22
  The system prompt is deliberately minimal: the caller's `instructions`
23
  when given, one sentence otherwise. This surface adds no context of its
24
  own — what the coder wants the model to know arrives in the request.
22
  The system prompt is deliberately minimal: the caller's `instructions` when
23
  given, one sentence otherwise. This surface adds nothing the caller did not
24
  ask for, with exactly one exception, stated here because it used to say it
25
  added nothing at all.
26
27
  ## Recall
28
29
  A caller that presents a `chat:account` bearer is recognized by
30
  `OpenAgentsWeb.Plugs.AmbientApiTokenAuth`, and that account's memories
31
  (`OpenAgents.Memories`) are recalled against the incoming `input` and
32
  appended to the instructions as a bounded `[From memory: …]` note. This is
33
  where recall lives so that no client implements it: the CLI, the web app, and
34
  a direct API caller all get the same memory attached to the same turns.
35
36
  Three properties hold, and the tests pin all three:
37
38
  * **Anonymous is unchanged.** No credential, an unreadable one, or one scoped
39
    for something else means no recall and byte-identical behavior to before.
40
    The plug refuses nobody, so a caller reaching this route with an unrelated
41
    `Authorization` header is not newly broken.
42
  * **The turns are untouched.** The note rides `instructions`, so the input
43
    items the caller sent reach the provider exactly as sent.
44
  * **It is bounded, and says what it dropped.** Count and characters both cap,
45
    and the note's last line reports what did not fit rather than trailing off.
46
47
  What this deliberately does **not** do is adopt `OpenAgents.Context.Composer`.
48
  That module is the browser conversation's prompt builder, not a general
49
  assembler: it requires an admitted persona, role, and Blueprint projection,
50
  it composes only the `text` and `voice` surfaces, and its output tells the
51
  model it is "in Simply Sarah: one text conversation scoped to this signed
52
  browser". Running an API caller's turn through it would replace the caller's
53
  own instructions with a description of a surface this is not.
25 54
26 55
  This codebase has long spoken OpenResponses as a client
27 56
  (`OpenAgents.Providers.OpenAI` at `/v1/responses` upstream); this is where

@@ -30,7 +59,10 @@ defmodule OpenAgentsWeb.ResponsesController do

30 59
31 60
  use OpenAgentsWeb, :controller
32 61
62
  alias OpenAgents.Accounts.User
33 63
  alias OpenAgents.Inference.Models
64
  alias OpenAgents.Memories
65
  alias OpenAgents.Memories.Note
34 66
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
35 67
  alias OpenAgentsWeb.ApiError
36 68

@@ -41,7 +73,7 @@ defmodule OpenAgentsWeb.ResponsesController do

41 73
    with {:ok, input} <- input_of(params),
42 74
         {:ok, model} <- model_of(params),
43 75
         :ok <- serving(model) do
44
      request = build_request(model, input, params)
76
      request = build_request(model, input, params, conn.assigns[:current_user])
45 77
46 78
      if params["stream"] == true do
47 79
        stream(conn, model, request)

@@ -196,7 +228,7 @@ defmodule OpenAgentsWeb.ResponsesController do

196 228
    if Models.available?(model), do: :ok, else: {:error, :model_unavailable}
197 229
  end
198 230
199
  defp build_request(model, {messages, tool_outputs}, params) do
231
  defp build_request(model, {messages, tool_outputs}, params, account) do
200 232
    {system, turns} = Enum.split_with(messages, &(&1.role == "system"))
201 233
202 234
    instructions =

@@ -205,6 +237,8 @@ defmodule OpenAgentsWeb.ResponsesController do

205 237
        _absent -> joined_or_default(system)
206 238
      end
207 239
240
    instructions = with_memory(instructions, account, turns)
241
208 242
    max_output =
209 243
      case params["max_output_tokens"] do
210 244
        tokens when is_integer(tokens) and tokens > 0 -> min(tokens, model.max_output)

@@ -224,6 +258,31 @@ defmodule OpenAgentsWeb.ResponsesController do

224 258
  defp joined_or_default([]), do: @default_instructions
225 259
  defp joined_or_default(system), do: Enum.map_join(system, "\n\n", & &1.content)
226 260
261
  # Recall, and the whole of it. An anonymous request returns the instructions
262
  # it came in with, unchanged and untouched — this is the line that keeps the
263
  # dev lane behaving exactly as it did.
264
  #
265
  # The note goes below the caller's instructions rather than above them: it is
266
  # material the model reads, never an instruction that outranks what the
267
  # caller asked for.
268
  defp with_memory(instructions, %User{} = account, turns) do
269
    case Note.render(Memories.recall(account, recall_query(turns))) do
270
      nil -> instructions
271
      note -> instructions <> "\n\n" <> note
272
    end
273
  end
274
275
  defp with_memory(instructions, _anonymous, _turns), do: instructions
276
277
  # What this turn is about: the user turns of the request, newest last, which
278
  # is the text a memory has to be relevant to. Assistant turns are the
279
  # model's own words and would rank memory against what it already said.
280
  defp recall_query(turns) do
281
    turns
282
    |> Enum.filter(&(&1.role == "user"))
283
    |> Enum.map_join("\n", & &1.content)
284
  end
285
227 286
  # ── streaming ────────────────────────────────────────────────────────────
228 287
229 288
  # Each provider delta becomes one OpenResponses event, flushed as it
lib/openagents_web/plugs/ambient_api_token_auth.ex added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgentsWeb.Plugs.AmbientApiTokenAuth do
2
  @moduledoc """
3
  Recognizes an account when one presents a valid credential, and refuses
4
  nobody.
5
6
  `OpenAgentsWeb.Plugs.OptionalApiTokenAuth` widens what an anonymous caller
7
  may *read*, so a credential it cannot verify is an error worth a `401`. This
8
  plug does something different: it grants no authority at all. It only lets a
9
  route that already answers anonymously know whose account is on the other
10
  end, so the route can add what that account is owed — its memories, on
11
  `POST /api/v1/responses` — and answer exactly as before when it cannot tell.
12
13
  Because nothing here is granted, nothing here is refused. A malformed header,
14
  an expired token, a token scoped for something else: each leaves
15
  `:current_user` `nil` and the request proceeds as the anonymous request it
16
  already was. Refusing instead would break every caller that reaches an
17
  anonymous route with an unrelated `Authorization` header — which is a live
18
  shape on this endpoint, and which was working before recall existed.
19
20
  A route behind this plug must therefore treat `:current_user` as a
21
  convenience and never as authorization. If a route needs authority, it
22
  belongs on `ApiTokenAuth` instead.
23
  """
24
25
  import Plug.Conn
26
27
  alias OpenAgents.ApiTokens
28
29
  def init(options), do: Keyword.fetch!(options, :scope)
30
31
  def call(conn, required_scope) do
32
    case get_req_header(conn, "authorization") do
33
      ["Bearer " <> token] when token != "" -> recognize(conn, token, required_scope)
34
      _absent_or_unreadable -> anonymous(conn)
35
    end
36
  end
37
38
  defp recognize(conn, token, required_scope) do
39
    case ApiTokens.authenticate(token, required_scope) do
40
      {:ok, user, api_token} ->
41
        conn
42
        |> put_resp_header("cache-control", "no-store")
43
        |> assign(:current_user, user)
44
        |> assign(:api_token, api_token)
45
        |> assign(:api_scope, required_scope)
46
47
      {:error, :invalid_api_token} ->
48
        anonymous(conn)
49
    end
50
  end
51
52
  defp anonymous(conn) do
53
    conn
54
    |> assign(:current_user, nil)
55
    |> assign(:api_token, nil)
56
    |> assign(:api_scope, nil)
57
  end
58
end
lib/openagents_web/router.ex modified +31 -5

@@ -65,6 +65,15 @@ defmodule OpenAgentsWeb.Router do

65 65
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "chat:account"
66 66
  end
67 67
68
  # An anonymous lane that recognizes an account when one presents a credential
69
  # it can verify. It grants no authority and refuses nobody, so a route behind
70
  # it answers an anonymous caller exactly as it would without it.
71
  pipeline :ambient_account_api do
72
    plug :accepts, ["json"]
73
    plug OpenAgentsWeb.Plugs.RequestOrigin
74
    plug OpenAgentsWeb.Plugs.AmbientApiTokenAuth, scope: "chat:account"
75
  end
76
68 77
  pipeline :box_control_api do
69 78
    plug :accepts, ["json"]
70 79
    plug OpenAgentsWeb.Plugs.RequestOrigin

@@ -492,12 +501,17 @@ defmodule OpenAgentsWeb.Router do

492 501
    pipe_through :api
493 502
494 503
    post "/agents/register", AgentController, :register
504
  end
505
506
  # The OpenResponses surface. Anonymous callers are answered exactly as they
507
  # always were — the coder's dev lane reaches this route with no credential —
508
  # and a caller that does present a `chat:account` bearer is recognized so the
509
  # controller can recall that account's memories into the turn. The plug
510
  # grants nothing and refuses nobody; see
511
  # `OpenAgentsWeb.Plugs.AmbientApiTokenAuth`.
512
  scope "/api/v1", OpenAgentsWeb do
513
    pipe_through :ambient_account_api
495 514
496
    # The OpenResponses surface, currently a stub that acknowledges every
497
    # request: the coder's dev lane speaks it first, and a real loop stands
498
    # behind it later. Anonymous while it is a stub — a canned sentence
499
    # spends nothing and reads nothing — and the auth flips to a required
500
    # bearer with the loop that makes it worth protecting.
501 515
    post "/responses", ResponsesController, :create
502 516
  end
503 517

@@ -596,6 +610,18 @@ defmodule OpenAgentsWeb.Router do

596 610
    post "/capacity/matches", CapacityController, :matches
597 611
  end
598 612
613
  # Memories: what the account asked to have remembered. The write path only —
614
  # recall runs server-side inside `POST /api/v1/responses`, so no client
615
  # implements retrieval. The account scope, because a memory belongs to an
616
  # account rather than to a repository or a thread.
617
  scope "/api/v1", OpenAgentsWeb do
618
    pipe_through :chat_account_api
619
620
    post "/memories", MemoryController, :create
621
    get "/memories", MemoryController, :index
622
    delete "/memories/:id", MemoryController, :delete
623
  end
624
599 625
  # Threads: the unit of agent work, and the model authority bound to one. The
600 626
  # same account scope as the chat lane, because a thread is what that lane's
601 627
  # single conversation could not be — plural, disposable, and fenced on its
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -308,7 +308,8 @@

308 308
    20260825140545,
309 309
    20260825160000,
310 310
    20260825170000,
311
    20260825170100
311
    20260825170100,
312
    20260825195623
312 313
  ],
313 314
  "required_tables": [
314 315
    "users",
priv/repo/migrations/20260825195623_create_memories.exs added +82

@@ -0,0 +1,82 @@

1
defmodule OpenAgents.Repo.Migrations.CreateMemories do
2
  use Ecto.Migration
3
4
  # Cloud memories: what an account explicitly asked the system to remember,
5
  # and what server-side consolidation later learns on its behalf. The store
6
  # is account-scoped and authoritative — unlike the disposable recall planes,
7
  # nothing else can rebuild it — so the row carries its own bounds rather
8
  # than trusting the context that writes it.
9
  #
10
  # A correction supersedes rather than edits: the replacement is a new row and
11
  # the old row's `superseded_by_id` points at it, so the chain a wrong memory
12
  # was fixed through stays readable.
13
  def up do
14
    create table(:memories, primary_key: false) do
15
      add :id, :binary_id, primary_key: true
16
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
17
      add :bucket, :string, null: false
18
      add :body, :text, null: false
19
20
      # Where the memory came from: the thread or session that was open when
21
      # the reader asked for it. Nullable, because a memory written through the
22
      # API without a thread behind it is still a memory.
23
      add :source_ref, :string
24
25
      add :superseded_by_id, references(:memories, type: :binary_id, on_delete: :nilify_all)
26
27
      # The embedding of `body` under the active recall model, when the
28
      # embedding rail is configured. Nullable and advisory: recall falls back
29
      # to the lexical stand-in for every row that has none.
30
      add :embedding, {:array, :float}
31
      add :embedding_model, :string
32
33
      timestamps(type: :utc_datetime_usec)
34
    end
35
36
    # Recall reads one account's live memories, newest first, so the index it
37
    # uses excludes superseded rows rather than filtering them after the read.
38
    create index(:memories, [:user_id, :inserted_at],
39
             where: "superseded_by_id IS NULL",
40
             name: :memories_live_index
41
           )
42
43
    create index(:memories, [:superseded_by_id])
44
45
    create constraint(:memories, :memories_shape,
46
             check: """
47
             bucket IN ('user','learned')
48
             AND char_length(body) BETWEEN 1 AND 2000
49
             AND (source_ref IS NULL OR char_length(source_ref) BETWEEN 1 AND 200)
50
             AND (superseded_by_id IS NULL OR superseded_by_id <> id)
51
             """
52
           )
53
54
    # The lexical stand-in's index. It is generated and stored rather than
55
    # computed per query, and it is partial on the same predicate recall reads
56
    # under, so a superseded row costs nothing to keep.
57
    #
58
    # `english`, not `simple`, and the difference matters here in a way it does
59
    # not for `messages`. Conversation recall searches a phrase somebody typed;
60
    # this ranks a whole turn against a store, so under `simple` the turn's
61
    # stop words would match every memory the account holds and "shared
62
    # vocabulary" would stop meaning anything. Stemming earns its place for the
63
    # same reason: "the migration failed" should reach a memory about
64
    # migrations.
65
    execute("""
66
    ALTER TABLE memories
67
    ADD COLUMN search_vector tsvector
68
    GENERATED ALWAYS AS (to_tsvector('english', coalesce(body, ''))) STORED
69
    """)
70
71
    execute("""
72
    CREATE INDEX memories_live_recall_gin_index
73
    ON memories USING GIN (search_vector)
74
    WHERE superseded_by_id IS NULL
75
    """)
76
  end
77
78
  def down do
79
    execute("DROP INDEX IF EXISTS memories_live_recall_gin_index")
80
    drop table(:memories)
81
  end
82
end
test/openagents/memories/retrieval_test.exs added +164

@@ -0,0 +1,164 @@

1
defmodule OpenAgents.Memories.RetrievalTest do
2
  @moduledoc """
3
  The swappable retrieval boundary, and which backend a deployment actually
4
  gets.
5
6
  The distinction this file exists to keep honest is between the target and the
7
  stand-in. The embedding backend is the one the workspace retrieval rule asks
8
  for, and it is off unless a deployment configures it; the full-text backend
9
  is what runs otherwise, and it is a stand-in rather than an answer. A change
10
  that quietly made the stand-in the permanent backend, or that let the target
11
  fail a turn instead of degrading, should turn this red.
12
  """
13
  use OpenAgents.DataCase, async: false
14
15
  alias OpenAgents.Memories
16
  alias OpenAgents.Memories.{Recall, Retrieval}
17
  alias OpenAgents.Memories.Retrieval.{Lexical, Semantic}
18
  alias OpenAgents.Plugins.EmbeddingsErrorProvider
19
20
  defp account(key) do
21
    digest = :crypto.hash(:sha256, key)
22
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
23
    login = "retrieval-" <> (digest |> Base.encode16(case: :lower) |> binary_part(0, 12))
24
25
    {:ok, user} =
26
      OpenAgents.Accounts.upsert_github_user(%{
27
        github_id: github_id,
28
        github_login: login,
29
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
30
      })
31
32
    user
33
  end
34
35
  defp configure(overrides) do
36
    previous = Application.get_env(:openagents, :memory_recall)
37
38
    Application.put_env(
39
      :openagents,
40
      :memory_recall,
41
      Keyword.merge(previous || [], overrides)
42
    )
43
44
    on_exit(fn -> Application.put_env(:openagents, :memory_recall, previous) end)
45
  end
46
47
  describe "backend selection" do
48
    test "a deployment with no embedding credential runs the stand-in" do
49
      refute Semantic.available?()
50
      assert Retrieval.backend() == Lexical
51
    end
52
53
    test "the target backend takes over once it is configured" do
54
      configure(embeddings_enabled: true, provider: OpenAgents.Memories.SynonymEmbeddingsProvider)
55
56
      assert Semantic.available?()
57
      assert Retrieval.backend() == Semantic
58
    end
59
  end
60
61
  describe "the target backend" do
62
    setup do
63
      configure(
64
        embeddings_enabled: true,
65
        provider: OpenAgents.Memories.SynonymEmbeddingsProvider,
66
        dimensions: 3
67
      )
68
69
      :ok
70
    end
71
72
    # The whole reason the embedding backend is the target: these two sentences
73
    # share no word, and the stand-in scores them at zero.
74
    test "connects a learned memory to a turn that shares no word with it" do
75
      user = account("semantic-learned")
76
77
      {:ok, _related} =
78
        Memories.create(user, %{
79
          "body" => "This project uses pnpm for packages.",
80
          "bucket" => "learned"
81
        })
82
83
      {:ok, _unrelated} =
84
        Memories.create(user, %{
85
          "body" => "Ship the release from the production branch.",
86
          "bucket" => "learned"
87
        })
88
89
      %Recall{memories: recalled, backend: backend} =
90
        Memories.recall(user, "install the dependencies")
91
92
      assert backend == :semantic
93
      assert Enum.map(recalled, & &1.body) == ["This project uses pnpm for packages."]
94
    end
95
96
    test "the same pair is missed by the stand-in, which is why it is the stand-in" do
97
      user = account("semantic-versus-lexical")
98
99
      {:ok, memory} =
100
        Memories.create(user, %{
101
          "body" => "This project uses pnpm for packages.",
102
          "bucket" => "learned"
103
        })
104
105
      assert {:ok, scores} = Lexical.score(user.id, "install the dependencies", [memory])
106
      assert scores == %{}
107
    end
108
  end
109
110
  describe "degrading" do
111
    test "a provider that errors falls back to the stand-in rather than failing" do
112
      configure(embeddings_enabled: true, provider: EmbeddingsErrorProvider)
113
114
      user = account("semantic-error")
115
116
      {:ok, _memory} = Memories.create(user, %{"body" => "Deploy with the pnpm command."})
117
118
      %Recall{memories: recalled, backend: backend} = Memories.recall(user, "deploy the project")
119
120
      assert backend == :lexical
121
      assert Enum.map(recalled, & &1.body) == ["Deploy with the pnpm command."]
122
    end
123
124
    test "a memory written before the rail was on is still recalled through the stand-in" do
125
      user = account("semantic-unembedded")
126
127
      # Written with embeddings off, so the row carries no vector.
128
      {:ok, memory} = Memories.create(user, %{"body" => "Deploy with the pnpm command."})
129
      assert memory.embedding == nil
130
131
      configure(embeddings_enabled: true, provider: OpenAgents.Memories.SynonymEmbeddingsProvider)
132
133
      %Recall{memories: recalled} = Memories.recall(user, "deploy the project")
134
135
      assert Enum.map(recalled, & &1.body) == ["Deploy with the pnpm command."]
136
    end
137
138
    test "an empty candidate set is answered, not queried" do
139
      assert {:lexical, [], _floor} = Retrieval.rank(Ecto.UUID.generate(), "anything", [])
140
    end
141
  end
142
143
  describe "the write path" do
144
    test "stores the embedding and the model it was written under" do
145
      configure(embeddings_enabled: true, provider: OpenAgents.Memories.SynonymEmbeddingsProvider)
146
147
      user = account("semantic-write")
148
149
      {:ok, memory} = Memories.create(user, %{"body" => "This project uses pnpm."})
150
151
      assert is_list(memory.embedding)
152
      assert memory.embedding_model == Semantic.model_id()
153
    end
154
155
    test "a provider failure still writes the memory" do
156
      configure(embeddings_enabled: true, provider: EmbeddingsErrorProvider)
157
158
      user = account("semantic-write-failure")
159
160
      assert {:ok, memory} = Memories.create(user, %{"body" => "Written anyway."})
161
      assert memory.embedding == nil
162
    end
163
  end
164
end
test/openagents/memories_test.exs added +365

@@ -0,0 +1,365 @@

1
defmodule OpenAgents.MemoriesTest do
2
  @moduledoc """
3
  The cloud memory store: what it writes, what it hands back, and what it
4
  refuses.
5
6
  The two properties worth the most attention are the ones a later change is
7
  most likely to break quietly. A superseded memory must leave recall the
8
  moment it is corrected, or a reader who fixed a wrong preference keeps being
9
  answered from the wrong one. And the account boundary must be a predicate
10
  in the query rather than a filter someone applied afterwards (MEMORY-010),
11
  because the second kind holds right up until a caller assembles a candidate
12
  list wrongly.
13
  """
14
  # Not async: the ceiling test narrows `:memory_recall`, which is application
15
  # environment and therefore shared with every test running beside it.
16
  use OpenAgents.DataCase, async: false
17
18
  alias OpenAgents.Memories
19
  alias OpenAgents.Memories.{Memory, Recall}
20
21
  # Merge over the configured recall settings and put them back afterwards.
22
  # Deleting the key instead would leave every later reader without the
23
  # `config/config.exs` values, which is a different and much wider change than
24
  # the one a test meant to make.
25
  defp narrow(overrides) do
26
    previous = Application.get_env(:openagents, :memory_recall) || []
27
    Application.put_env(:openagents, :memory_recall, Keyword.merge(previous, overrides))
28
    on_exit(fn -> Application.put_env(:openagents, :memory_recall, previous) end)
29
  end
30
31
  defp account(key) do
32
    digest = :crypto.hash(:sha256, key)
33
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
34
    login = "memories-" <> (digest |> Base.encode16(case: :lower) |> binary_part(0, 12))
35
36
    {:ok, user} =
37
      OpenAgents.Accounts.upsert_github_user(%{
38
        github_id: github_id,
39
        github_login: login,
40
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
41
      })
42
43
    user
44
  end
45
46
  describe "create/2" do
47
    test "writes a user-bucket memory owned by the account" do
48
      user = account("create-user")
49
50
      assert {:ok, memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})
51
52
      assert memory.user_id == user.id
53
      assert memory.bucket == "user"
54
      assert memory.body == "I use pnpm, not npm."
55
      assert memory.superseded_by_id == nil
56
    end
57
58
    test "writes a learned memory with the work that taught it" do
59
      user = account("create-learned")
60
61
      assert {:ok, memory} =
62
               Memories.create(user, %{
63
                 "body" => "The suite needs a database before it will boot.",
64
                 "bucket" => "learned",
65
                 "source_ref" => "thread:0e2f"
66
               })
67
68
      assert memory.bucket == "learned"
69
      assert memory.source_ref == "thread:0e2f"
70
    end
71
72
    # The owner is set on the struct, never cast, so a request body naming
73
    # somebody else's account changes nothing.
74
    test "refuses to take the owner from the attributes" do
75
      user = account("create-owner")
76
      other = account("create-owner-other")
77
78
      assert {:ok, memory} =
79
               Memories.create(user, %{"body" => "Mine.", "user_id" => other.id})
80
81
      assert memory.user_id == user.id
82
    end
83
84
    test "refuses an empty body and an unknown bucket" do
85
      user = account("create-invalid")
86
87
      assert {:error, changeset} = Memories.create(user, %{"body" => "   "})
88
      assert %{body: _} = errors_on(changeset)
89
90
      assert {:error, changeset} =
91
               Memories.create(user, %{"body" => "Fine.", "bucket" => "system"})
92
93
      assert %{bucket: _} = errors_on(changeset)
94
    end
95
96
    test "refuses a body longer than the store's bound" do
97
      user = account("create-long")
98
      body = String.duplicate("x", Memory.body_characters() + 1)
99
100
      assert {:error, changeset} = Memories.create(user, %{"body" => body})
101
      assert %{body: _} = errors_on(changeset)
102
    end
103
104
    test "refuses a write past the account's ceiling" do
105
      user = account("create-quota")
106
      narrow(maximum_live_memories: 2)
107
108
      assert {:ok, _first} = Memories.create(user, %{"body" => "One."})
109
      assert {:ok, second} = Memories.create(user, %{"body" => "Two."})
110
      assert {:error, :quota_reached} = Memories.create(user, %{"body" => "Three."})
111
112
      # A correction is admitted at the ceiling: it replaces a live row with a
113
      # live row, so an account that filled its store can still fix it.
114
      assert {:ok, _corrected} =
115
               Memories.create(user, %{"body" => "Two, corrected.", "supersedes" => second.id})
116
    end
117
  end
118
119
  describe "supersession" do
120
    test "a correction points the old memory at the new one and leaves recall" do
121
      user = account("supersede")
122
123
      {:ok, wrong} = Memories.create(user, %{"body" => "I use npm."})
124
125
      {:ok, right} =
126
        Memories.create(user, %{"body" => "I use pnpm, not npm.", "supersedes" => wrong.id})
127
128
      assert Repo.get!(Memory, wrong.id).superseded_by_id == right.id
129
130
      live = Memories.list(user)
131
      assert Enum.map(live, & &1.id) == [right.id]
132
133
      both = Memories.list(user, include_superseded: true)
134
      assert length(both) == 2
135
    end
136
137
    test "a superseded memory is never recalled" do
138
      user = account("supersede-recall")
139
140
      {:ok, wrong} = Memories.create(user, %{"body" => "Deploy with the yarn command."})
141
142
      {:ok, _right} =
143
        Memories.create(user, %{
144
          "body" => "Deploy with the pnpm command.",
145
          "supersedes" => wrong.id
146
        })
147
148
      %Recall{memories: recalled} = Memories.recall(user, "deploy the project")
149
150
      assert Enum.map(recalled, & &1.body) == ["Deploy with the pnpm command."]
151
    end
152
153
    test "refuses to supersede a memory of another account" do
154
      user = account("supersede-scope")
155
      other = account("supersede-scope-other")
156
157
      {:ok, theirs} = Memories.create(other, %{"body" => "Theirs."})
158
159
      assert {:error, :supersedes_not_found} =
160
               Memories.create(user, %{"body" => "Mine.", "supersedes" => theirs.id})
161
    end
162
163
    test "refuses to supersede an already superseded memory" do
164
      user = account("supersede-twice")
165
166
      {:ok, first} = Memories.create(user, %{"body" => "First."})
167
      {:ok, _second} = Memories.create(user, %{"body" => "Second.", "supersedes" => first.id})
168
169
      assert {:error, :supersedes_not_found} =
170
               Memories.create(user, %{"body" => "Third.", "supersedes" => first.id})
171
    end
172
  end
173
174
  describe "list/2 and delete/2" do
175
    test "lists one account's memories and never another's" do
176
      user = account("list-scope")
177
      other = account("list-scope-other")
178
179
      {:ok, mine} = Memories.create(user, %{"body" => "Mine."})
180
      {:ok, _theirs} = Memories.create(other, %{"body" => "Theirs."})
181
182
      assert Enum.map(Memories.list(user), & &1.id) == [mine.id]
183
    end
184
185
    test "narrows to one bucket" do
186
      user = account("list-bucket")
187
188
      {:ok, _explicit} = Memories.create(user, %{"body" => "Explicit."})
189
      {:ok, learned} = Memories.create(user, %{"body" => "Learned.", "bucket" => "learned"})
190
191
      assert Enum.map(Memories.list(user, bucket: "learned"), & &1.id) == [learned.id]
192
    end
193
194
    test "removes a memory outright, and refuses another account's" do
195
      user = account("delete")
196
      other = account("delete-other")
197
198
      {:ok, mine} = Memories.create(user, %{"body" => "Mine."})
199
200
      assert {:error, :not_found} = Memories.delete(other, mine.id)
201
      assert {:ok, _deleted} = Memories.delete(user, mine.id)
202
      assert Memories.list(user) == []
203
      assert {:error, :not_found} = Memories.delete(user, mine.id)
204
    end
205
206
    test "an unreadable id is not found rather than an error" do
207
      user = account("delete-unreadable")
208
209
      assert {:error, :not_found} = Memories.delete(user, "not-a-uuid")
210
    end
211
  end
212
213
  describe "recall/3" do
214
    test "attaches a user memory that shares no word with the turn" do
215
      user = account("recall-user-bucket")
216
217
      {:ok, _memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})
218
219
      %Recall{memories: recalled} = Memories.recall(user, "install the deps")
220
221
      assert Enum.map(recalled, & &1.body) == ["I use pnpm, not npm."]
222
    end
223
224
    # The other half of the same rule: a learned memory has to be about the
225
    # turn before it interrupts it, so an unrelated one stays out.
226
    test "leaves an unrelated learned memory out" do
227
      user = account("recall-learned-floor")
228
229
      {:ok, _related} =
230
        Memories.create(user, %{
231
          "body" => "The migration must run before the suite boots.",
232
          "bucket" => "learned"
233
        })
234
235
      {:ok, _unrelated} =
236
        Memories.create(user, %{
237
          "body" => "Screenshots belong in the artifacts directory.",
238
          "bucket" => "learned"
239
        })
240
241
      %Recall{memories: recalled} = Memories.recall(user, "the migration failed")
242
243
      assert Enum.map(recalled, & &1.body) == [
244
               "The migration must run before the suite boots."
245
             ]
246
    end
247
248
    test "recalls nothing for an account with no memories" do
249
      user = account("recall-empty")
250
251
      assert %Recall{memories: [], dropped: 0} = Memories.recall(user, "anything at all")
252
    end
253
254
    test "recalls nothing for an empty turn rather than everything" do
255
      user = account("recall-empty-query")
256
257
      {:ok, _memory} = Memories.create(user, %{"body" => "Something."})
258
259
      assert %Recall{memories: []} = Memories.recall(user, "")
260
    end
261
262
    test "never reaches another account's memories" do
263
      user = account("recall-scope")
264
      other = account("recall-scope-other")
265
266
      {:ok, _theirs} = Memories.create(other, %{"body" => "Deploy with yarn."})
267
268
      assert %Recall{memories: []} = Memories.recall(user, "deploy with yarn")
269
    end
270
271
    test "bounds the count and reports what it dropped" do
272
      user = account("recall-count-bound")
273
274
      for index <- 1..6 do
275
        {:ok, _memory} = Memories.create(user, %{"body" => "Preference number #{index}."})
276
      end
277
278
      %Recall{memories: recalled, dropped: dropped} =
279
        Memories.recall(user, "what do you know", maximum_attached: 2)
280
281
      assert length(recalled) == 2
282
      assert dropped == 4
283
    end
284
285
    test "bounds the characters and reports what it dropped" do
286
      user = account("recall-size-bound")
287
288
      for index <- 1..4 do
289
        {:ok, _memory} =
290
          Memories.create(user, %{"body" => String.duplicate("#{index}", 40)})
291
      end
292
293
      %Recall{memories: recalled, dropped: dropped} =
294
        Memories.recall(user, "what do you know", maximum_attached_characters: 100)
295
296
      assert length(recalled) == 2
297
      assert dropped == 2
298
    end
299
  end
300
301
  # MEMORY-010. The account boundary is written into the queries, not applied
302
  # to their results. This reads each module's own source AST the way
303
  # `OpenAgents.Memory.ScopeBoundaryTest` does, so a query added beside the
304
  # scoped ones fails here whether or not anyone remembered it exists.
305
  describe "MEMORY-010" do
306
    @scoped_modules [
307
      "lib/openagents/memories.ex",
308
      "lib/openagents/memories/retrieval/lexical.ex"
309
    ]
310
311
    test "every query rooted at the memory plane names user_id" do
312
      for path <- @scoped_modules, query <- memory_queries(path) do
313
        assert names_user_id?(query),
314
               """
315
               A query in #{path} is rooted at `Memory` and does not name
316
               `user_id`. MEMORY-010 requires the account boundary to be a
317
               database predicate. Add the column, or amend the invariant.
318
319
               #{Macro.to_string(query)}
320
               """
321
      end
322
    end
323
324
    test "the enumeration actually finds queries" do
325
      found = Enum.flat_map(@scoped_modules, &memory_queries/1)
326
      assert length(found) >= 4
327
    end
328
329
    defp memory_queries(path) do
330
      path
331
      |> File.read!()
332
      |> Code.string_to_quoted!()
333
      |> Macro.prewalker()
334
      |> Enum.filter(&rooted_at_memory?/1)
335
    end
336
337
    # An Ecto query rooted at the schema: `from(memory in Memory, …)`.
338
    defp rooted_at_memory?(
339
           {:from, _meta, [{:in, _, [_binding, {:__aliases__, _, [:Memory]}]} | _]}
340
         ),
341
         do: true
342
343
    # A repo call taking the schema directly: `Repo.get_by(Memory, …)`. Without
344
    # this clause the enumeration would pass a `get_by` that dropped the
345
    # account, which is the same hole in a different shape.
346
    defp rooted_at_memory?(
347
           {{:., _, [{:__aliases__, _, [:Repo]}, _function]}, _meta,
348
            [{:__aliases__, _, [:Memory]} | _rest]}
349
         ),
350
         do: true
351
352
    defp rooted_at_memory?(_node), do: false
353
354
    # `memory.user_id` in a query, or `user_id:` in a repo call's clauses.
355
    defp names_user_id?(query) do
356
      query
357
      |> Macro.prewalker()
358
      |> Enum.any?(fn
359
        {{:., _, [_, :user_id]}, _, _} -> true
360
        {:user_id, _value} -> true
361
        _other -> false
362
      end)
363
    end
364
  end
365
end
test/openagents_web/controllers/memory_controller_test.exs added +241

@@ -0,0 +1,241 @@

1
defmodule OpenAgentsWeb.MemoryControllerTest do
2
  @moduledoc """
3
  The three routes that write, read, and remove an account's memories.
4
5
  This file is also the export proof `OpenAgents.DataRights.ExportInventory`
6
  names for the `:memory` family: the list route is how an account takes its
7
  memories with it, so the test that it returns the account's own rows — and
8
  only its own — is what that portability claim rests on.
9
  """
10
  use OpenAgentsWeb.ConnCase, async: false
11
12
  alias OpenAgents.Conversations
13
  alias OpenAgents.DataRights
14
  alias OpenAgents.Memories
15
16
  describe "POST /api/v1/memories" do
17
    test "writes a memory and returns it", %{conn: conn} do
18
      body =
19
        conn
20
        |> put_chat_api_token("memory-create")
21
        |> post(~p"/api/v1/memories", %{"body" => "I use pnpm, not npm."})
22
        |> json_response(201)
23
24
      assert %{"memory" => memory} = body
25
      assert memory["body"] == "I use pnpm, not npm."
26
      assert memory["bucket"] == "user"
27
      assert memory["superseded_by"] == nil
28
      assert is_binary(memory["id"])
29
      assert is_binary(memory["created_at"])
30
    end
31
32
    test "takes the bucket and the source the caller names", %{conn: conn} do
33
      memory =
34
        conn
35
        |> put_chat_api_token("memory-create-learned")
36
        |> post(~p"/api/v1/memories", %{
37
          "body" => "The suite needs a database before it boots.",
38
          "bucket" => "learned",
39
          "source_ref" => "thread:0e2f"
40
        })
41
        |> json_response(201)
42
        |> Map.fetch!("memory")
43
44
      assert memory["bucket"] == "learned"
45
      assert memory["source_ref"] == "thread:0e2f"
46
    end
47
48
    test "a correction supersedes rather than edits", %{conn: conn} do
49
      conn = put_chat_api_token(conn, "memory-supersede")
50
51
      wrong =
52
        conn
53
        |> post(~p"/api/v1/memories", %{"body" => "I use npm."})
54
        |> json_response(201)
55
        |> Map.fetch!("memory")
56
57
      right =
58
        conn
59
        |> post(~p"/api/v1/memories", %{
60
          "body" => "I use pnpm, not npm.",
61
          "supersedes" => wrong["id"]
62
        })
63
        |> json_response(201)
64
        |> Map.fetch!("memory")
65
66
      live = conn |> get(~p"/api/v1/memories") |> json_response(200) |> Map.fetch!("memories")
67
      assert Enum.map(live, & &1["id"]) == [right["id"]]
68
69
      all =
70
        conn
71
        |> get(~p"/api/v1/memories?include_superseded=true")
72
        |> json_response(200)
73
        |> Map.fetch!("memories")
74
75
      superseded = Enum.find(all, &(&1["id"] == wrong["id"]))
76
      assert superseded["superseded_by"] == right["id"]
77
    end
78
79
    test "refuses an empty body with the envelope", %{conn: conn} do
80
      body =
81
        conn
82
        |> put_chat_api_token("memory-invalid")
83
        |> post(~p"/api/v1/memories", %{"body" => ""})
84
        |> json_response(422)
85
86
      assert body["code"] == "validation_failed"
87
      assert Map.has_key?(body["errors"], "body")
88
    end
89
90
    test "refuses a bucket outside the vocabulary", %{conn: conn} do
91
      body =
92
        conn
93
        |> put_chat_api_token("memory-bucket")
94
        |> post(~p"/api/v1/memories", %{"body" => "Fine.", "bucket" => "system"})
95
        |> json_response(422)
96
97
      assert body["code"] == "validation_failed"
98
      assert Map.has_key?(body["errors"], "bucket")
99
    end
100
101
    test "refuses a supersedes that names no live memory of this account", %{conn: conn} do
102
      body =
103
        conn
104
        |> put_chat_api_token("memory-supersede-missing")
105
        |> post(~p"/api/v1/memories", %{
106
          "body" => "Corrected.",
107
          "supersedes" => "00000000-0000-4000-8000-000000000001"
108
        })
109
        |> json_response(422)
110
111
      assert body["code"] == "validation_failed"
112
      assert Map.has_key?(body["errors"], "supersedes")
113
    end
114
115
    test "refuses a write past the ceiling with its own code", %{conn: conn} do
116
      previous = Application.get_env(:openagents, :memory_recall) || []
117
118
      Application.put_env(
119
        :openagents,
120
        :memory_recall,
121
        Keyword.merge(previous, maximum_live_memories: 1)
122
      )
123
124
      on_exit(fn -> Application.put_env(:openagents, :memory_recall, previous) end)
125
126
      conn = put_chat_api_token(conn, "memory-quota")
127
128
      assert conn |> post(~p"/api/v1/memories", %{"body" => "One."}) |> json_response(201)
129
130
      body = conn |> post(~p"/api/v1/memories", %{"body" => "Two."}) |> json_response(429)
131
      assert body["code"] == "memory_quota_reached"
132
    end
133
  end
134
135
  describe "GET /api/v1/memories" do
136
    test "lists the account's memories and never another account's", %{conn: conn} do
137
      mine = put_chat_api_token(conn, "memory-list-mine")
138
      theirs = put_chat_api_token(conn, "memory-list-theirs")
139
140
      post(mine, ~p"/api/v1/memories", %{"body" => "Mine."})
141
      post(theirs, ~p"/api/v1/memories", %{"body" => "Theirs."})
142
143
      bodies =
144
        mine
145
        |> get(~p"/api/v1/memories")
146
        |> json_response(200)
147
        |> Map.fetch!("memories")
148
        |> Enum.map(& &1["body"])
149
150
      assert bodies == ["Mine."]
151
    end
152
153
    test "narrows to one bucket", %{conn: conn} do
154
      conn = put_chat_api_token(conn, "memory-list-bucket")
155
156
      post(conn, ~p"/api/v1/memories", %{"body" => "Explicit."})
157
      post(conn, ~p"/api/v1/memories", %{"body" => "Learned.", "bucket" => "learned"})
158
159
      bodies =
160
        conn
161
        |> get(~p"/api/v1/memories?bucket=learned")
162
        |> json_response(200)
163
        |> Map.fetch!("memories")
164
        |> Enum.map(& &1["body"])
165
166
      assert bodies == ["Learned."]
167
    end
168
  end
169
170
  describe "DELETE /api/v1/memories/:id" do
171
    test "removes the account's own memory", %{conn: conn} do
172
      conn = put_chat_api_token(conn, "memory-delete")
173
174
      memory =
175
        conn
176
        |> post(~p"/api/v1/memories", %{"body" => "Temporary."})
177
        |> json_response(201)
178
        |> Map.fetch!("memory")
179
180
      assert conn |> delete(~p"/api/v1/memories/#{memory["id"]}") |> json_response(200)
181
182
      assert conn |> get(~p"/api/v1/memories") |> json_response(200) |> Map.fetch!("memories") ==
183
               []
184
    end
185
186
    test "refuses another account's memory as absent", %{conn: conn} do
187
      mine = put_chat_api_token(conn, "memory-delete-mine")
188
      theirs = put_chat_api_token(conn, "memory-delete-theirs")
189
190
      memory =
191
        mine
192
        |> post(~p"/api/v1/memories", %{"body" => "Mine."})
193
        |> json_response(201)
194
        |> Map.fetch!("memory")
195
196
      body = theirs |> delete(~p"/api/v1/memories/#{memory["id"]}") |> json_response(404)
197
      assert body["code"] == "not_found"
198
    end
199
  end
200
201
  describe "authority" do
202
    test "every route refuses a caller with no credential", %{conn: conn} do
203
      assert conn |> post(~p"/api/v1/memories", %{"body" => "x"}) |> json_response(401)
204
      assert conn |> get(~p"/api/v1/memories") |> json_response(401)
205
206
      assert conn
207
             |> delete(~p"/api/v1/memories/00000000-0000-4000-8000-000000000001")
208
             |> json_response(401)
209
    end
210
211
    # The lane is `chat:account`. A credential minted for another scope is not
212
    # a credential for this surface, however valid it is elsewhere.
213
    test "refuses a credential scoped for something else", %{conn: conn} do
214
      body =
215
        conn
216
        |> put_box_api_token("memory-wrong-scope")
217
        |> get(~p"/api/v1/memories")
218
        |> json_response(401)
219
220
      assert body["code"] == "unauthenticated"
221
    end
222
  end
223
224
  # DATA-004. Memories key on the account row, and the account row is
225
  # deliberately retained through a product-data deletion, so the visitor
226
  # cascade does not reach them. They have to be removed explicitly, and this
227
  # is the test that says so.
228
  describe "DATA-004" do
229
    test "deleting product data removes the account's memories" do
230
      user = github_user("memory-data-rights")
231
232
      {:ok, _memory} = Memories.create(user, %{"body" => "Remember this."})
233
234
      {:ok, conversation} = Conversations.ensure_conversation(user)
235
      owner = Conversations.get_conversation_owner!(conversation)
236
237
      assert {:ok, :deleted} = DataRights.delete(user, owner, conversation)
238
      assert Memories.list(user) == []
239
    end
240
  end
241
end
test/openagents_web/controllers/responses_controller_test.exs modified +148

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.ResponsesControllerTest do
2 2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4
  alias OpenAgents.Memories
5
4 6
  alias OpenAgents.Providers.{
5 7
    FailingTestProvider,
6 8
    RecordingTestProvider,

@@ -151,6 +153,152 @@ defmodule OpenAgentsWeb.ResponsesControllerTest do

151 153
    end
152 154
  end
153 155
156
  # Server-side recall (#51). The point of putting recall here is that no
157
  # client implements it, so what these prove is the seam: a recognized account
158
  # gets its memories in the model context, an unrecognized caller gets exactly
159
  # what it got before, and the attachment is bounded in both directions.
160
  describe "recall for a recognized account" do
161
    setup do
162
      swap_lane(RecordingTestProvider)
163
      Application.put_env(:openagents, :test_recording_provider_observer, self())
164
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)
165
      :ok
166
    end
167
168
    test "attaches the account's memory as a bounded note", %{conn: conn} do
169
      conn = put_chat_api_token(conn, "responses-recall")
170
      user = github_user("api-token-responses-recall")
171
172
      {:ok, _memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})
173
174
      assert conn
175
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
176
             |> json_response(200)
177
178
      assert_receive {:recorded_request, _id, request}
179
      assert request.instructions =~ "[From memory: user, "
180
      assert request.instructions =~ "I use pnpm, not npm."
181
    end
182
183
    test "leaves the caller's own input untouched", %{conn: conn} do
184
      conn = put_chat_api_token(conn, "responses-recall-input")
185
      user = github_user("api-token-responses-recall-input")
186
187
      {:ok, _memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})
188
189
      assert conn
190
             |> post(~p"/api/v1/responses", %{
191
               instructions: "Answer in French.",
192
               input: "install the deps"
193
             })
194
             |> json_response(200)
195
196
      assert_receive {:recorded_request, _id, request}
197
      assert request.input == [%{role: "user", content: "install the deps"}]
198
      # The note rides below the caller's instructions: material, not an
199
      # instruction that outranks what the caller asked for.
200
      assert String.starts_with?(request.instructions, "Answer in French.")
201
      assert request.instructions =~ "[From memory:"
202
    end
203
204
    test "says what the bounds excluded rather than trailing off", %{conn: conn} do
205
      previous = Application.get_env(:openagents, :memory_recall) || []
206
207
      Application.put_env(
208
        :openagents,
209
        :memory_recall,
210
        Keyword.merge(previous, maximum_attached: 1)
211
      )
212
213
      on_exit(fn -> Application.put_env(:openagents, :memory_recall, previous) end)
214
215
      conn = put_chat_api_token(conn, "responses-recall-bounds")
216
      user = github_user("api-token-responses-recall-bounds")
217
218
      for index <- 1..4 do
219
        {:ok, _memory} = Memories.create(user, %{"body" => "Preference number #{index}."})
220
      end
221
222
      assert conn
223
             |> post(~p"/api/v1/responses", %{input: "what do you know"})
224
             |> json_response(200)
225
226
      assert_receive {:recorded_request, _id, request}
227
      assert request.instructions =~ "3 more memories were not attached"
228
      assert Enum.count(String.split(request.instructions, "[From memory: user,")) == 2
229
    end
230
231
    test "an account with no memories is not told about memory at all", %{conn: conn} do
232
      conn = put_chat_api_token(conn, "responses-recall-empty")
233
234
      assert conn
235
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
236
             |> json_response(200)
237
238
      assert_receive {:recorded_request, _id, request}
239
      refute request.instructions =~ "From memory"
240
    end
241
242
    test "never reaches another account's memories", %{conn: conn} do
243
      other = github_user("responses-recall-other-account")
244
      {:ok, _theirs} = Memories.create(other, %{"body" => "Deploy with yarn."})
245
246
      conn = put_chat_api_token(conn, "responses-recall-mine")
247
248
      assert conn
249
             |> post(~p"/api/v1/responses", %{input: "deploy with yarn"})
250
             |> json_response(200)
251
252
      assert_receive {:recorded_request, _id, request}
253
      refute request.instructions =~ "From memory"
254
    end
255
  end
256
257
  describe "an unrecognized caller is unchanged" do
258
    setup do
259
      swap_lane(RecordingTestProvider)
260
      Application.put_env(:openagents, :test_recording_provider_observer, self())
261
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)
262
      :ok
263
    end
264
265
    test "an anonymous request carries no memory note", %{conn: conn} do
266
      assert conn
267
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
268
             |> json_response(200)
269
270
      assert_receive {:recorded_request, _id, request}
271
      assert request.instructions == "You are OpenAgents Coder. Answer directly and concisely."
272
    end
273
274
    # The dev lane reaches this route with credentials this endpoint knows
275
    # nothing about. Recognizing an account must never turn those into a
276
    # refusal, so an unreadable bearer is answered, not rejected.
277
    test "an unreadable bearer is answered rather than refused", %{conn: conn} do
278
      assert conn
279
             |> put_req_header("authorization", "Bearer not-a-token-this-server-minted")
280
             |> post(~p"/api/v1/responses", %{input: "hello"})
281
             |> json_response(200)
282
    end
283
284
    test "a bearer scoped for something else is answered rather than refused", %{conn: conn} do
285
      assert conn
286
             |> put_box_api_token("responses-wrong-scope")
287
             |> post(~p"/api/v1/responses", %{input: "hello"})
288
             |> json_response(200)
289
290
      assert_receive {:recorded_request, _id, request}
291
      refute request.instructions =~ "From memory"
292
    end
293
294
    test "a malformed authorization header is answered rather than refused", %{conn: conn} do
295
      assert conn
296
             |> put_req_header("authorization", "Basic bm90OmJlYXJlcg==")
297
             |> post(~p"/api/v1/responses", %{input: "hello"})
298
             |> json_response(200)
299
    end
300
  end
301
154 302
  describe "tools through the surface" do
155 303
    setup do
156 304
      swap_lane(ToolCallingTestProvider)
test/support/openagents/memories/synonym_embeddings_provider.ex added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgents.Memories.SynonymEmbeddingsProvider do
2
  @moduledoc """
3
  A deterministic embedding provider that puts related words in one dimension.
4
5
  The point of the target retrieval backend is that it connects text sharing no
6
  word — "install the deps" and "I use pnpm, not npm" — so a test provider that
7
  embedded by token would prove nothing the lexical stand-in does not already
8
  do. This one maps a small set of related words onto shared dimensions, which
9
  is the property a real embedding has and the property recall depends on.
10
  """
11
12
  @behaviour OpenAgents.Memory.EmbeddingProvider
13
14
  # Each list is one dimension: words in it embed toward each other.
15
  @topics [
16
    ~w(install deps dependencies pnpm npm yarn packages),
17
    ~w(deploy release ship promote production),
18
    ~w(test suite spec assertion coverage)
19
  ]
20
21
  @impl true
22
  def embed(text, %{dimensions: dimensions}) do
23
    tokens = text |> String.downcase() |> String.split(~r/[^a-z0-9]+/u, trim: true)
24
25
    vector =
26
      for index <- 0..(dimensions - 1) do
27
        case Enum.at(@topics, index) do
28
          nil -> 0.0
29
          words -> tokens |> Enum.count(&(&1 in words)) |> :erlang.float()
30
        end
31
      end
32
33
    {:ok, vector}
34
  end
35
end

This page updates live while a promote is in flight · changelog