Memory is a page

09bfe138b718 · AtlantisPleb · · parent f870e2c31643

Memory is a page

It was a panel inside the conversation: a sidebar row swapped the transcript
out for it, put it back on the way out, and that was the only way to reach it.
Three things were wrong with that. It was reachable only from chat, so the
sidebar row had to point at chat and hope. It had no address, so it could not
be linked, bookmarked, or opened in a second tab. And the way out of it lived
in chrome outside the thing you were leaving.

Memory is a place, so it has a URL. `OpenAgentsWeb.MemoryLive` at `/memory`
owns the panel, its four events, its helpers and its two components, and
subscribes to the owner's memory directly so another tab's correction lands
here. `ChatLive` loses about 16k of code and the whole `panel=memory` query
parameter along with it: the conversation is the conversation again.

The route is declared in `RouteAuthority` rather than left unclassified, and
its prefix loses a trailing slash -- `"/memory/"` matched the export beneath
it but never the page itself.

The tests moved with the code. Four memory tests left the chat suite for
`MemoryLiveTest`, which visits the page directly, and the two other suites
that reached memory through chat now visit `/memory` too. What they hold is
unchanged: correction supersedes rather than overwrites, and a forget control
states which of its three scopes it means before it acts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <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 lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/chat_live.ex
  • added lib/openagents_web/live/memory_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/data_controller_test.exs
  • modified test/openagents_web/icon_affordances_test.exs
  • modified test/openagents_web/live/chat_live_test.exs
  • added test/openagents_web/live/memory_live_test.exs

Diff

9 files changed, +660 -584

lib/openagents_web/components/layouts.ex modified +1 -4

@@ -558,11 +558,8 @@ defmodule OpenAgentsWeb.Layouts do

558 558
          icon="desktop"
559 559
          patchable={false}
560 560
        />
561
        <%!-- Memory is the conversation's, but reaching it should not require
562
        knowing that: the row goes to chat with the panel open, which is where
563
        it lives. --%>
564 561
        <Layouts.sidebar_link
565
          path={~p"/chat?panel=memory"}
562
          path={~p"/memory"}
566 563
          label="Memory"
567 564
          icon="brain"
568 565
          patchable={false}
lib/openagents_web/live/chat_live.ex modified +4 -461

@@ -50,11 +50,6 @@ defmodule OpenAgentsWeb.ChatLive do

50 50
      |> assign(:page_title, "Sarah")
51 51
      |> assign(:reset_enabled?, DataRights.reset_enabled?())
52 52
      |> assign(:conversation, conversation)
53
      |> assign(:memory_owner, owner)
54
      |> assign(:memory_open?, false)
55
      |> assign(:memory_records, [])
56
      |> assign(:memory_status, nil)
57
      |> assign(:pending_memory_action, nil)
58 53
      |> assign(:has_older?, has_older?)
59 54
      |> assign(:oldest_message_id, first_id(messages))
60 55
      |> assign(:active_turn, active_turn)

@@ -68,7 +63,6 @@ defmodule OpenAgentsWeb.ChatLive do

68 63
      |> assign(:job_rollups, Conversations.list_work_job_rollups_by_message(messages))
69 64
      |> assign(:composer_error, nil)
70 65
      |> assign(:form, composer_form())
71
      |> assign(:privacy_delete_form, to_form(%{"confirmation" => ""}, as: :privacy))
72 66
      |> assign(:live_voice_items, MapSet.new())
73 67
      |> assign(:paced_voice_items, MapSet.new())
74 68
      |> assign(:delegation, nil)

@@ -79,38 +73,6 @@ defmodule OpenAgentsWeb.ChatLive do

79 73
    {:ok, socket}
80 74
  end
81 75
82
  @impl true
83
  def handle_params(params, _uri, socket) do
84
    # The sidebar's Memory row is a link, not a chat-local event: it must work
85
    # from any page, so the panel's state is addressable rather than private.
86
    # Arriving by URL has to load what arriving by click loads -- a panel that
87
    # opens empty depending on how you reached it is worse than no panel.
88
    {:noreply, open_memory(socket, params["panel"] == "memory")}
89
  end
90
91
  defp open_memory(socket, true) do
92
    socket
93
    |> assign(:memory_open?, true)
94
    |> assign(:pending_memory_action, nil)
95
    |> assign(:memory_status, nil)
96
    |> reload_memory()
97
  end
98
99
  defp open_memory(socket, false) do
100
    # Closing restores the conversation, which means reloading it: the panel
101
    # replaces the transcript rather than covering it, so coming back has to
102
    # put the messages back whether you left by URL or by control.
103
    {messages, has_older?} = Conversations.list_messages(socket.assigns.conversation)
104
105
    socket
106
    |> assign(:memory_open?, false)
107
    |> assign(:pending_memory_action, nil)
108
    |> assign(:memory_status, nil)
109
    |> assign(:has_older?, has_older?)
110
    |> assign(:oldest_message_id, first_id(messages))
111
    |> stream(:messages, messages, reset: true)
112
  end
113
114 76
  @impl true
115 77
  def handle_event("send_message", %{"chat" => %{"message" => content}}, socket) do
116 78
    case voice_route(socket) do

@@ -167,58 +129,6 @@ defmodule OpenAgentsWeb.ChatLive do

167 129
    {:noreply, socket}
168 130
  end
169 131
170
  def handle_event("toggle_memory", _params, socket) do
171
    if socket.assigns.memory_open? do
172
      {:noreply, socket |> open_memory(false) |> push_event("composer:focus", %{})}
173
    else
174
      {:noreply, open_memory(socket, true)}
175
    end
176
  end
177
178
  def handle_event("correct_memory", %{"record_id" => record_id, "claim" => claim}, socket) do
179
    owner = socket.assigns.memory_owner
180
181
    result =
182
      with {:ok, record} <- ProfileMemory.get(owner, record_id),
183
           true <- record.status == "active",
184
           {:ok, _corrected} <-
185
             ProfileMemory.correct(owner, record.id, record.generation, %{
186
               category: record.category,
187
               claim: claim,
188
               creator: "user_explicit",
189
               owner_asserted: true,
190
               sources: [],
191
               provenance: %{
192
                 "operation" => "first_party_ui_correction",
193
                 "supersedes_record_id" => record.id
194
               }
195
             }) do
196
        :ok
197
      else
198
        false -> {:error, :memory_not_active}
199
        {:error, reason} -> {:error, reason}
200
      end
201
202
    {:noreply, memory_result(socket, result, "Memory corrected and previous wording retained.")}
203
  end
204
205
  def handle_event("request_memory_forget", params, socket) do
206
    case pending_action(socket.assigns.memory_owner, params) do
207
      {:ok, pending} ->
208
        {:noreply,
209
         socket
210
         |> assign(:pending_memory_action, pending)
211
         |> assign(:memory_status, nil)}
212
213
      {:error, _reason} ->
214
        {:noreply, assign(socket, :memory_status, {:error, "That memory is no longer active."})}
215
    end
216
  end
217
218
  def handle_event("cancel_memory_action", _params, socket) do
219
    {:noreply, assign(socket, :pending_memory_action, nil)}
220
  end
221
222 132
  # The live delegation panel is ephemeral: dismissing it clears the whole
223 133
  # projection. The durable event header in the transcript stays the record.
224 134
  def handle_event("dismiss_delegation", _params, socket) do

@@ -236,39 +146,6 @@ defmodule OpenAgentsWeb.ChatLive do

236 146
    {:noreply, assign(socket, :delegation_collapsed, !socket.assigns.delegation_collapsed)}
237 147
  end
238 148
239
  def handle_event(
240
        "confirm_memory_forget",
241
        _params,
242
        %{assigns: %{pending_memory_action: nil}} = socket
243
      ) do
244
    {:noreply, socket}
245
  end
246
247
  def handle_event("confirm_memory_forget", _params, socket) do
248
    pending = socket.assigns.pending_memory_action
249
    result = ProfileMemory.forget_active(socket.assigns.memory_owner, pending.selector)
250
251
    message =
252
      case result do
253
        {:ok, %{disposition: "already_absent"}} ->
254
          "Those memories were already absent."
255
256
        {:ok, %{records: records}} ->
257
          "Forgot #{length(records)} memory record(s) in this account."
258
259
        {:error, _reason} ->
260
          "Sarah could not forget that selection. Refresh and try again."
261
      end
262
263
    status = if match?({:ok, _result}, result), do: :ok, else: :error
264
265
    {:noreply,
266
     socket
267
     |> assign(:pending_memory_action, nil)
268
     |> assign(:memory_status, {status, message})
269
     |> reload_memory()}
270
  end
271
272 149
  @impl true
273 150
  def handle_info({:message_updated, message}, socket) do
274 151
    {:noreply,

@@ -343,12 +220,6 @@ defmodule OpenAgentsWeb.ChatLive do

343 220
    end
344 221
  end
345 222
346
  def handle_info({:profile_memory_updated, _result}, socket) do
347
    if socket.assigns.memory_open?,
348
      do: {:noreply, reload_memory(socket)},
349
      else: {:noreply, socket}
350
  end
351
352 223
  def handle_info(
353 224
        {:voice_session_updated, %{conversation_id: conversation_id} = voice_session},
354 225
        %{assigns: %{conversation: %{id: conversation_id}}} = socket

@@ -626,74 +497,6 @@ defmodule OpenAgentsWeb.ChatLive do

626 497
627 498
  defp tool_activity(nil, voice_session), do: Voice.list_tool_step_activity(voice_session)
628 499
629
  defp reload_memory(socket) do
630
    case ProfileMemory.export(socket.assigns.memory_owner) do
631
      {:ok, export} -> assign(socket, :memory_records, export["records"])
632
      {:error, _reason} -> assign(socket, :memory_status, {:error, "Memory is unavailable."})
633
    end
634
  end
635
636
  defp memory_result(socket, :ok, message) do
637
    socket
638
    |> assign(:memory_status, {:ok, message})
639
    |> reload_memory()
640
  end
641
642
  defp memory_result(socket, {:error, reason}, _message) do
643
    assign(socket, :memory_status, {:error, memory_error(reason)})
644
  end
645
646
  defp pending_action(owner, %{"kind" => "record", "id" => record_id}) do
647
    with {:ok, record} <- ProfileMemory.get(owner, record_id),
648
         true <- record.status == "active" do
649
      {:ok,
650
       %{
651
         label: ~s(Forget "#{record.claim}"?),
652
         selector: %{
653
           "mode" => "record",
654
           "record_id" => record.id,
655
           "expected_generation" => record.generation
656
         }
657
       }}
658
    else
659
      _invalid -> {:error, :not_found}
660
    end
661
  end
662
663
  defp pending_action(_owner, %{"kind" => "category", "category" => category})
664
       when category in ~w(name role project preference constraint other) do
665
    {:ok,
666
     %{
667
       label: "Forget every active #{category} memory in this account?",
668
       selector: %{"mode" => "category", "category" => category}
669
     }}
670
  end
671
672
  defp pending_action(_owner, %{"kind" => "all"}) do
673
    {:ok,
674
     %{
675
       label: "Forget every active profile memory in this account?",
676
       selector: %{"mode" => "all"}
677
     }}
678
  end
679
680
  defp pending_action(_owner, _params), do: {:error, :invalid_action}
681
682
  defp memory_error(:duplicate_memory), do: "That exact memory is already active."
683
684
  defp memory_error(:memory_conflict_requires_correction),
685
    do: "That category has a conflicting active memory."
686
687
  defp memory_error(:invalid_claim), do: "Enter a non-empty correction under 500 bytes."
688
689
  defp memory_error({:memory_policy_rejected, _reason}),
690
    do: "That correction was refused by the memory privacy policy."
691
692
  defp memory_error(_reason), do: "Sarah could not update that memory. Refresh and try again."
693
694
  defp memory_date(nil), do: "DATE UNAVAILABLE"
695
  defp memory_date(timestamp), do: String.slice(timestamp, 0, 10)
696
697 500
  # A live voice session keeps the call open and reads typed messages as
698 501
  # first-class conversation input. A stale active record whose runtime
699 502
  # process is gone is closed honestly so typed chat proceeds normally.

@@ -805,12 +608,6 @@ defmodule OpenAgentsWeb.ChatLive do

805 608
  defp rollup_status_note(%{status: status}),
806 609
    do: status |> String.upcase() |> String.replace("_", " ")
807 610
808
  defp memory_status_variant({:error, _message}), do: :danger
809
  defp memory_status_variant(_status), do: :success
810
811
  defp memory_badge_variant("active"), do: :success
812
  defp memory_badge_variant(_status), do: :default
813
814 611
  defp message_status_variant("streaming"), do: :info
815 612
  defp message_status_variant(_status), do: :warning
816 613

@@ -844,7 +641,6 @@ defmodule OpenAgentsWeb.ChatLive do

844 641
      <:sidebar_extra>
845 642
        <.chat_sidebar_rows
846 643
          current_user={@current_user}
847
          memory_open?={@memory_open?}
848 644
          reset_enabled?={@reset_enabled?}
849 645
          recent_jobs={@recent_jobs}
850 646
        />

@@ -857,7 +653,6 @@ defmodule OpenAgentsWeb.ChatLive do

857 653
          </header>
858 654
859 655
          <section
860
            :if={!@memory_open?}
861 656
            id="transcript"
862 657
            class="transcript"
863 658
            aria-label="Conversation transcript"

@@ -928,16 +723,7 @@ defmodule OpenAgentsWeb.ChatLive do

928 723
            <.delegation_inline :if={@delegation} delegation={@delegation} />
929 724
          </section>
930 725
931
          <.memory_manager
932
            :if={@memory_open?}
933
            memory_records={@memory_records}
934
            memory_status={@memory_status}
935
            pending_memory_action={@pending_memory_action}
936
            privacy_delete_form={@privacy_delete_form}
937
            recording_config={@recording_config}
938
          />
939
940
          <footer :if={!@memory_open?} class="composer-region">
726
          <footer class="composer-region">
941 727
            <section
942 728
              :if={@voice_enabled?}
943 729
              id="voice-controller"

@@ -1317,13 +1103,12 @@ defmodule OpenAgentsWeb.ChatLive do

1317 1103
  end
1318 1104
1319 1105
  attr :current_user, :map, required: true
1320
  attr :memory_open?, :boolean, required: true
1321 1106
  attr :reset_enabled?, :boolean, required: true
1322 1107
  attr :recent_jobs, :list, required: true
1323 1108
1324
  # The conversation's chrome. The sidebar navigates Sarah's surfaces —
1325
  # computers, memory, leaderboard, admin, export — never conversations; one
1326
  # conversation remains the product (DESIGN.md, Layout). Rows are the
1109
  # The conversation's own rows, contributed to the application sidebar. The
1110
  # shared destinations live there directly; what is left here is the work
1111
  # projection and the conversation's data action. Rows are the
1327 1112
  # stretched-anchor pattern: the hit control owns the whole row and the
1328 1113
  # accessible name, the visible content beneath is pointer-transparent, and
1329 1114
  # any future trailing control floats back above it at its own z-index.

@@ -2096,246 +1881,4 @@ defmodule OpenAgentsWeb.ChatLive do

2096 1881
    </.button>
2097 1882
    """
2098 1883
  end
2099
2100
  attr :memory_records, :list, required: true
2101
  attr :memory_status, :any, default: nil
2102
  attr :pending_memory_action, :any, default: nil
2103
  attr :privacy_delete_form, :any, required: true
2104
  attr :recording_config, :map, required: true
2105
2106
  defp memory_manager(assigns) do
2107
    ~H"""
2108
    <section id="memory-manager" class="memory-manager" aria-labelledby="memory-heading">
2109
      <header class="memory-header">
2110
        <div>
2111
          <h1 id="memory-heading">Memory in your account</h1>
2112
          <p>
2113
            These records follow your authenticated Sarah account across browsers. Logging
2114
            out removes this browser's access; server records follow the documented
2115
            retention lifecycle.
2116
          </p>
2117
        </div>
2118
        <div class="memory-header__actions">
2119
          <%!-- The way out of a panel belongs in the panel. This used to be a
2120
          sidebar row, which meant leaving depended on chrome outside the thing
2121
          you were leaving. --%>
2122
          <.text_button
2123
            id="toggle-memory"
2124
            phx-click="toggle_memory"
2125
            aria-label="Return to conversation"
2126
          >
2127
            <.icon name="arrow-left" /> Return to conversation
2128
          </.text_button>
2129
          <.text_button id="export-all-data" href="/data/export" download>
2130
            <.icon name="download" /> Export ALL DATA
2131
          </.text_button>
2132
          <.text_button id="export-memory" href="/memory/export" download>
2133
            <.icon name="download" /> Export Memory ONLY
2134
          </.text_button>
2135
          <.text_button
2136
            id="forget-all-memory"
2137
            tone={:danger}
2138
            phx-click="request_memory_forget"
2139
            phx-value-kind="all"
2140
            disabled={not Enum.any?(@memory_records, &(&1["status"] == "active"))}
2141
          >
2142
            <.icon name="trash" /> FORGET ALL ACTIVE
2143
          </.text_button>
2144
        </div>
2145
      </header>
2146
2147
      <.alert
2148
        :if={@memory_status}
2149
        id="memory-status"
2150
        appearance={:row}
2151
        variant={memory_status_variant(@memory_status)}
2152
      >
2153
        {elem(@memory_status, 1)}
2154
      </.alert>
2155
2156
      <.card
2157
        :if={@pending_memory_action}
2158
        id="memory-confirmation"
2159
        variant={:danger}
2160
        aria-labelledby="memory-confirmation-heading"
2161
      >
2162
        <header>
2163
          <h2 id="memory-confirmation-heading">Confirm destructive action</h2>
2164
          <p>{@pending_memory_action.label}</p>
2165
          <p>Future snapshots will no longer include the affected active records.</p>
2166
        </header>
2167
        <footer class="memory-confirmation__actions">
2168
          <.button
2169
            id="confirm-memory-forget"
2170
            size={:sm}
2171
            variant={:destructive}
2172
            phx-click="confirm_memory_forget"
2173
          >
2174
            <.icon name="trash" /> CONFIRM FORGET
2175
          </.button>
2176
          <.button
2177
            id="cancel-memory-action"
2178
            size={:sm}
2179
            variant={:secondary}
2180
            phx-click="cancel_memory_action"
2181
          >
2182
            KEEP Memory
2183
          </.button>
2184
        </footer>
2185
      </.card>
2186
2187
      <.empty :if={@memory_records == []} id="memory-empty" title="No profile memories yet">
2188
        Sarah automatically remembers lasting facts you share in conversation, like
2189
        your name, role, projects, and preferences. Memory belongs to your Sarah account.
2190
      </.empty>
2191
2192
      <div :if={@memory_records != []} id="memory-records" class="memory-records">
2193
        <.memory_record :for={record <- @memory_records} record={record} />
2194
      </div>
2195
2196
      <.card
2197
        id="privacy-controls"
2198
        variant={:danger}
2199
        class="memory-confirmation"
2200
        aria-labelledby="privacy-heading"
2201
      >
2202
        <header>
2203
          <h2 id="privacy-heading">Voice and deletion</h2>
2204
          <p>
2205
            Final and interrupted transcripts remain in this account's conversation.
2206
            Detailed operational voice evidence is purged after 90 days. Export before
2207
            deleting if you want a copy.
2208
          </p>
2209
          <%!-- The recording sentence appears only while recording is on, so this
2210
                surface and the voice control row can never disagree about it. --%>
2211
          <p :if={@recording_config.enabled?} id="privacy-recording">
2212
            Call audio is recorded, stored encrypted, and readable by a Sarah operator.
2213
            It is deleted {@recording_config.retention_days} days after a call ends, and
2214
            deleting your data removes it immediately.
2215
          </p>
2216
        </header>
2217
        <footer>
2218
          <.form
2219
            for={@privacy_delete_form}
2220
            id="delete-data-form"
2221
            action="/data"
2222
            method="delete"
2223
            class="privacy-delete-form"
2224
          >
2225
            <.field>
2226
              <.label for={@privacy_delete_form[:confirmation].id}>
2227
                Type DELETE MY SARAH DATA to delete this account's Sarah conversation,
2228
                transcripts, memory, receipts, and voice records. Minimal GitHub identity and
2229
                access-status data remains so bans and access controls cannot be bypassed. A
2230
                retained GitHub tools grant remains until you use Disconnect GitHub tools in
2231
                the account menu. API tokens remain until you revoke them from API token
2232
                settings.
2233
              </.label>
2234
              <div class="control-row">
2235
                <.input
2236
                  id={@privacy_delete_form[:confirmation].id}
2237
                  name={@privacy_delete_form[:confirmation].name}
2238
                  value={@privacy_delete_form[:confirmation].value}
2239
                  type="text"
2240
                  class="control-row__input"
2241
                  autocomplete="off"
2242
                  required
2243
                />
2244
                <.button id="delete-all-data" type="submit" size={:sm} variant={:destructive}>
2245
                  <.icon name="trash" /> DELETE ALL DATA
2246
                </.button>
2247
              </div>
2248
            </.field>
2249
          </.form>
2250
        </footer>
2251
      </.card>
2252
    </section>
2253
    """
2254
  end
2255
2256
  attr :record, :map, required: true
2257
2258
  defp memory_record(assigns) do
2259
    ~H"""
2260
    <.card
2261
      id={"memory-record-#{@record["id"]}"}
2262
      state={@record["status"]}
2263
      frame={:corners}
2264
      data-status={@record["status"]}
2265
    >
2266
      <div class="memory-record__meta">
2267
        <.badge>{String.upcase(@record["category"])}</.badge>
2268
        <.badge variant={memory_badge_variant(@record["status"])}>
2269
          {String.upcase(@record["status"])}
2270
        </.badge>
2271
        <.badge>
2272
          <time datetime={@record["inserted_at"]}>{memory_date(@record["inserted_at"])}</time>
2273
        </.badge>
2274
      </div>
2275
2276
      <p class="memory-record__claim">{@record["claim"] || "WITHHELD BY PRIVACY POLICY"}</p>
2277
2278
      <dl class="memory-record__sources">
2279
        <div>
2280
          <dt>Scope</dt>
2281
          <dd>This account</dd>
2282
        </div>
2283
        <div>
2284
          <dt>Generation</dt>
2285
          <dd>{@record["generation"]}</dd>
2286
        </div>
2287
        <div>
2288
          <dt>Sources</dt>
2289
          <dd>
2290
            <span :if={@record["sources"] == []}>Explicit account-owner assertion</span>
2291
            <span :for={source <- @record["sources"]}>
2292
              {source["kind"]} / {memory_date(source["observed_at"])}
2293
            </span>
2294
          </dd>
2295
        </div>
2296
      </dl>
2297
2298
      <div :if={@record["status"] == "active"} class="memory-record__controls">
2299
        <form phx-submit="correct_memory" class="memory-correction">
2300
          <input type="hidden" name="record_id" value={@record["id"]} />
2301
          <.field>
2302
            <.label for={"memory-claim-#{@record["id"]}"}>Correct this memory</.label>
2303
            <div class="control-row">
2304
              <.input
2305
                id={"memory-claim-#{@record["id"]}"}
2306
                name="claim"
2307
                type="text"
2308
                value={@record["claim"]}
2309
                class="control-row__input"
2310
                maxlength="500"
2311
                required
2312
              />
2313
              <.button type="submit" size={:sm} variant={:secondary}>SAVE CORRECTION</.button>
2314
            </div>
2315
          </.field>
2316
        </form>
2317
        <div class="memory-record__destructive">
2318
          <.text_button
2319
            id={"forget-record-#{@record["id"]}"}
2320
            tone={:danger}
2321
            phx-click="request_memory_forget"
2322
            phx-value-kind="record"
2323
            phx-value-id={@record["id"]}
2324
          >
2325
            <.icon name="trash" /> FORGET RECORD
2326
          </.text_button>
2327
          <.text_button
2328
            id={"forget-category-#{@record["id"]}"}
2329
            tone={:danger}
2330
            phx-click="request_memory_forget"
2331
            phx-value-kind="category"
2332
            phx-value-category={@record["category"]}
2333
          >
2334
            <.icon name="trash" /> FORGET {String.upcase(@record["category"])} CATEGORY
2335
          </.text_button>
2336
        </div>
2337
      </div>
2338
    </.card>
2339
    """
2340
  end
2341 1884
end
lib/openagents_web/live/memory_live.ex added +464

@@ -0,0 +1,464 @@

1
defmodule OpenAgentsWeb.MemoryLive do
2
  @moduledoc """
3
  Memory in your account, as a page.
4
5
  It used to be a panel inside the conversation, opened by a sidebar row that
6
  swapped the transcript out. That made it reachable only from chat, gave it no
7
  address of its own, and meant the way back out lived in chrome outside the
8
  thing you were leaving. It is a place, so it has a URL.
9
10
  Everything here is scoped to the signed-in account's own memory owner.
11
  Correction supersedes rather than overwrites: the previous wording is kept,
12
  because a memory system that silently rewrites its own history cannot be
13
  audited by the person it is about.
14
15
  Destructive actions confirm inline and state their exact breadth -- one
16
  record, one category, or the whole account -- since "forget" covering three
17
  different scopes behind one word is how someone deletes more than they meant.
18
  """
19
20
  use OpenAgentsWeb, :live_view
21
22
  alias OpenAgents.Conversations
23
  alias OpenAgents.DataRights
24
  alias OpenAgents.ProfileMemory
25
  alias OpenAgents.Voice.Recordings
26
27
  @impl true
28
  def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
29
    {:ok, conversation} = Conversations.ensure_conversation(current_user)
30
    owner = Conversations.get_conversation_owner!(conversation)
31
32
    if connected?(socket), do: :ok = ProfileMemory.subscribe(owner)
33
34
    {:ok,
35
     socket
36
     |> assign(:page_title, "Memory")
37
     |> assign(:memory_owner, owner)
38
     |> assign(:memory_records, [])
39
     |> assign(:memory_status, nil)
40
     |> assign(:pending_memory_action, nil)
41
     |> assign(:reset_enabled?, DataRights.reset_enabled?())
42
     |> assign(:recording_config, Recordings.config())
43
     |> assign(:privacy_delete_form, to_form(%{"confirmation" => ""}, as: :privacy))
44
     |> reload_memory()}
45
  end
46
47
  @impl true
48
  def render(assigns) do
49
    ~H"""
50
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Memory" wide>
51
      <.memory_manager
52
        memory_records={@memory_records}
53
        memory_status={@memory_status}
54
        pending_memory_action={@pending_memory_action}
55
        privacy_delete_form={@privacy_delete_form}
56
        recording_config={@recording_config}
57
      />
58
    </Layouts.app>
59
    """
60
  end
61
62
  @impl true
63
  def handle_event("correct_memory", %{"record_id" => record_id, "claim" => claim}, socket) do
64
    owner = socket.assigns.memory_owner
65
66
    result =
67
      with {:ok, record} <- ProfileMemory.get(owner, record_id),
68
           true <- record.status == "active",
69
           {:ok, _corrected} <-
70
             ProfileMemory.correct(owner, record.id, record.generation, %{
71
               category: record.category,
72
               claim: claim,
73
               creator: "user_explicit",
74
               owner_asserted: true,
75
               sources: [],
76
               provenance: %{
77
                 "operation" => "first_party_ui_correction",
78
                 "supersedes_record_id" => record.id
79
               }
80
             }) do
81
        :ok
82
      else
83
        false -> {:error, :memory_not_active}
84
        {:error, reason} -> {:error, reason}
85
      end
86
87
    {:noreply, memory_result(socket, result, "Memory corrected and previous wording retained.")}
88
  end
89
90
  def handle_event("request_memory_forget", params, socket) do
91
    case pending_action(socket.assigns.memory_owner, params) do
92
      {:ok, pending} ->
93
        {:noreply,
94
         socket
95
         |> assign(:pending_memory_action, pending)
96
         |> assign(:memory_status, nil)}
97
98
      {:error, _reason} ->
99
        {:noreply, assign(socket, :memory_status, {:error, "That memory is no longer active."})}
100
    end
101
  end
102
103
  def handle_event("cancel_memory_action", _params, socket) do
104
    {:noreply, assign(socket, :pending_memory_action, nil)}
105
  end
106
107
  def handle_event(
108
        "confirm_memory_forget",
109
        _params,
110
        %{assigns: %{pending_memory_action: nil}} = socket
111
      ) do
112
    {:noreply, socket}
113
  end
114
115
  def handle_event("confirm_memory_forget", _params, socket) do
116
    pending = socket.assigns.pending_memory_action
117
    result = ProfileMemory.forget_active(socket.assigns.memory_owner, pending.selector)
118
119
    message =
120
      case result do
121
        {:ok, %{disposition: "already_absent"}} ->
122
          "Those memories were already absent."
123
124
        {:ok, %{records: records}} ->
125
          "Forgot #{length(records)} memory record(s) in this account."
126
127
        {:error, _reason} ->
128
          "Sarah could not forget that selection. Refresh and try again."
129
      end
130
131
    status = if match?({:ok, _result}, result), do: :ok, else: :error
132
133
    {:noreply,
134
     socket
135
     |> assign(:pending_memory_action, nil)
136
     |> assign(:memory_status, {status, message})
137
     |> reload_memory()}
138
  end
139
140
  # Another tab correcting or forgetting a record must be reflected here: the
141
  # records are one account's, not one socket's.
142
  @impl true
143
  def handle_info({:profile_memory_updated, _result}, socket) do
144
    {:noreply, reload_memory(socket)}
145
  end
146
147
  def handle_info(_message, socket), do: {:noreply, socket}
148
149
  defp reload_memory(socket) do
150
    case ProfileMemory.export(socket.assigns.memory_owner) do
151
      {:ok, export} -> assign(socket, :memory_records, export["records"])
152
      {:error, _reason} -> assign(socket, :memory_status, {:error, "Memory is unavailable."})
153
    end
154
  end
155
156
  defp memory_result(socket, :ok, message) do
157
    socket
158
    |> assign(:memory_status, {:ok, message})
159
    |> reload_memory()
160
  end
161
162
  defp memory_result(socket, {:error, reason}, _message) do
163
    assign(socket, :memory_status, {:error, memory_error(reason)})
164
  end
165
166
  defp pending_action(owner, %{"kind" => "record", "id" => record_id}) do
167
    with {:ok, record} <- ProfileMemory.get(owner, record_id),
168
         true <- record.status == "active" do
169
      {:ok,
170
       %{
171
         label: ~s(Forget "#{record.claim}"?),
172
         selector: %{
173
           "mode" => "record",
174
           "record_id" => record.id,
175
           "expected_generation" => record.generation
176
         }
177
       }}
178
    else
179
      _invalid -> {:error, :not_found}
180
    end
181
  end
182
183
  defp pending_action(_owner, %{"kind" => "category", "category" => category})
184
       when category in ~w(name role project preference constraint other) do
185
    {:ok,
186
     %{
187
       label: "Forget every active #{category} memory in this account?",
188
       selector: %{"mode" => "category", "category" => category}
189
     }}
190
  end
191
192
  defp pending_action(_owner, %{"kind" => "all"}) do
193
    {:ok,
194
     %{
195
       label: "Forget every active profile memory in this account?",
196
       selector: %{"mode" => "all"}
197
     }}
198
  end
199
200
  defp pending_action(_owner, _params), do: {:error, :invalid_action}
201
202
  defp memory_error(:duplicate_memory), do: "That exact memory is already active."
203
204
  defp memory_error(:memory_conflict_requires_correction),
205
    do: "That category has a conflicting active memory."
206
207
  defp memory_error(:invalid_claim), do: "Enter a non-empty correction under 500 bytes."
208
209
  defp memory_error({:memory_policy_rejected, _reason}),
210
    do: "That correction was refused by the memory privacy policy."
211
212
  defp memory_error(_reason), do: "Sarah could not update that memory. Refresh and try again."
213
214
  defp memory_date(nil), do: "DATE UNAVAILABLE"
215
  defp memory_date(timestamp), do: String.slice(timestamp, 0, 10)
216
217
  defp memory_status_variant({:error, _message}), do: :danger
218
  defp memory_status_variant(_status), do: :success
219
220
  defp memory_badge_variant("active"), do: :success
221
  defp memory_badge_variant(_status), do: :default
222
223
  attr :memory_records, :list, required: true
224
  attr :memory_status, :any, default: nil
225
  attr :pending_memory_action, :any, default: nil
226
  attr :privacy_delete_form, :any, required: true
227
  attr :recording_config, :map, required: true
228
229
  defp memory_manager(assigns) do
230
    ~H"""
231
    <section id="memory-manager" class="memory-manager" aria-labelledby="memory-heading">
232
      <header class="memory-header">
233
        <div>
234
          <h1 id="memory-heading">Memory in your account</h1>
235
          <p>
236
            These records follow your authenticated Sarah account across browsers. Logging
237
            out removes this browser's access; server records follow the documented
238
            retention lifecycle.
239
          </p>
240
        </div>
241
        <div class="memory-header__actions">
242
          <%!-- The way out of a panel belongs in the panel. This used to be a
243
          sidebar row, which meant leaving depended on chrome outside the thing
244
          you were leaving. --%>
245
          <.text_button
246
            id="toggle-memory"
247
            phx-click="toggle_memory"
248
            aria-label="Return to conversation"
249
          >
250
            <.icon name="arrow-left" /> Return to conversation
251
          </.text_button>
252
          <.text_button id="export-all-data" href="/data/export" download>
253
            <.icon name="download" /> Export ALL DATA
254
          </.text_button>
255
          <.text_button id="export-memory" href="/memory/export" download>
256
            <.icon name="download" /> Export Memory ONLY
257
          </.text_button>
258
          <.text_button
259
            id="forget-all-memory"
260
            tone={:danger}
261
            phx-click="request_memory_forget"
262
            phx-value-kind="all"
263
            disabled={not Enum.any?(@memory_records, &(&1["status"] == "active"))}
264
          >
265
            <.icon name="trash" /> FORGET ALL ACTIVE
266
          </.text_button>
267
        </div>
268
      </header>
269
270
      <.alert
271
        :if={@memory_status}
272
        id="memory-status"
273
        appearance={:row}
274
        variant={memory_status_variant(@memory_status)}
275
      >
276
        {elem(@memory_status, 1)}
277
      </.alert>
278
279
      <.card
280
        :if={@pending_memory_action}
281
        id="memory-confirmation"
282
        variant={:danger}
283
        aria-labelledby="memory-confirmation-heading"
284
      >
285
        <header>
286
          <h2 id="memory-confirmation-heading">Confirm destructive action</h2>
287
          <p>{@pending_memory_action.label}</p>
288
          <p>Future snapshots will no longer include the affected active records.</p>
289
        </header>
290
        <footer class="memory-confirmation__actions">
291
          <.button
292
            id="confirm-memory-forget"
293
            size={:sm}
294
            variant={:destructive}
295
            phx-click="confirm_memory_forget"
296
          >
297
            <.icon name="trash" /> CONFIRM FORGET
298
          </.button>
299
          <.button
300
            id="cancel-memory-action"
301
            size={:sm}
302
            variant={:secondary}
303
            phx-click="cancel_memory_action"
304
          >
305
            KEEP Memory
306
          </.button>
307
        </footer>
308
      </.card>
309
310
      <.empty :if={@memory_records == []} id="memory-empty" title="No profile memories yet">
311
        Sarah automatically remembers lasting facts you share in conversation, like
312
        your name, role, projects, and preferences. Memory belongs to your Sarah account.
313
      </.empty>
314
315
      <div :if={@memory_records != []} id="memory-records" class="memory-records">
316
        <.memory_record :for={record <- @memory_records} record={record} />
317
      </div>
318
319
      <.card
320
        id="privacy-controls"
321
        variant={:danger}
322
        class="memory-confirmation"
323
        aria-labelledby="privacy-heading"
324
      >
325
        <header>
326
          <h2 id="privacy-heading">Voice and deletion</h2>
327
          <p>
328
            Final and interrupted transcripts remain in this account's conversation.
329
            Detailed operational voice evidence is purged after 90 days. Export before
330
            deleting if you want a copy.
331
          </p>
332
          <%!-- The recording sentence appears only while recording is on, so this
333
                surface and the voice control row can never disagree about it. --%>
334
          <p :if={@recording_config.enabled?} id="privacy-recording">
335
            Call audio is recorded, stored encrypted, and readable by a Sarah operator.
336
            It is deleted {@recording_config.retention_days} days after a call ends, and
337
            deleting your data removes it immediately.
338
          </p>
339
        </header>
340
        <footer>
341
          <.form
342
            for={@privacy_delete_form}
343
            id="delete-data-form"
344
            action="/data"
345
            method="delete"
346
            class="privacy-delete-form"
347
          >
348
            <.field>
349
              <.label for={@privacy_delete_form[:confirmation].id}>
350
                Type DELETE MY SARAH DATA to delete this account's Sarah conversation,
351
                transcripts, memory, receipts, and voice records. Minimal GitHub identity and
352
                access-status data remains so bans and access controls cannot be bypassed. A
353
                retained GitHub tools grant remains until you use Disconnect GitHub tools in
354
                the account menu. API tokens remain until you revoke them from API token
355
                settings.
356
              </.label>
357
              <div class="control-row">
358
                <.input
359
                  id={@privacy_delete_form[:confirmation].id}
360
                  name={@privacy_delete_form[:confirmation].name}
361
                  value={@privacy_delete_form[:confirmation].value}
362
                  type="text"
363
                  class="control-row__input"
364
                  autocomplete="off"
365
                  required
366
                />
367
                <.button id="delete-all-data" type="submit" size={:sm} variant={:destructive}>
368
                  <.icon name="trash" /> DELETE ALL DATA
369
                </.button>
370
              </div>
371
            </.field>
372
          </.form>
373
        </footer>
374
      </.card>
375
    </section>
376
    """
377
  end
378
379
  attr :record, :map, required: true
380
381
  defp memory_record(assigns) do
382
    ~H"""
383
    <.card
384
      id={"memory-record-#{@record["id"]}"}
385
      state={@record["status"]}
386
      frame={:corners}
387
      data-status={@record["status"]}
388
    >
389
      <div class="memory-record__meta">
390
        <.badge>{String.upcase(@record["category"])}</.badge>
391
        <.badge variant={memory_badge_variant(@record["status"])}>
392
          {String.upcase(@record["status"])}
393
        </.badge>
394
        <.badge>
395
          <time datetime={@record["inserted_at"]}>{memory_date(@record["inserted_at"])}</time>
396
        </.badge>
397
      </div>
398
399
      <p class="memory-record__claim">{@record["claim"] || "WITHHELD BY PRIVACY POLICY"}</p>
400
401
      <dl class="memory-record__sources">
402
        <div>
403
          <dt>Scope</dt>
404
          <dd>This account</dd>
405
        </div>
406
        <div>
407
          <dt>Generation</dt>
408
          <dd>{@record["generation"]}</dd>
409
        </div>
410
        <div>
411
          <dt>Sources</dt>
412
          <dd>
413
            <span :if={@record["sources"] == []}>Explicit account-owner assertion</span>
414
            <span :for={source <- @record["sources"]}>
415
              {source["kind"]} / {memory_date(source["observed_at"])}
416
            </span>
417
          </dd>
418
        </div>
419
      </dl>
420
421
      <div :if={@record["status"] == "active"} class="memory-record__controls">
422
        <form phx-submit="correct_memory" class="memory-correction">
423
          <input type="hidden" name="record_id" value={@record["id"]} />
424
          <.field>
425
            <.label for={"memory-claim-#{@record["id"]}"}>Correct this memory</.label>
426
            <div class="control-row">
427
              <.input
428
                id={"memory-claim-#{@record["id"]}"}
429
                name="claim"
430
                type="text"
431
                value={@record["claim"]}
432
                class="control-row__input"
433
                maxlength="500"
434
                required
435
              />
436
              <.button type="submit" size={:sm} variant={:secondary}>SAVE CORRECTION</.button>
437
            </div>
438
          </.field>
439
        </form>
440
        <div class="memory-record__destructive">
441
          <.text_button
442
            id={"forget-record-#{@record["id"]}"}
443
            tone={:danger}
444
            phx-click="request_memory_forget"
445
            phx-value-kind="record"
446
            phx-value-id={@record["id"]}
447
          >
448
            <.icon name="trash" /> FORGET RECORD
449
          </.text_button>
450
          <.text_button
451
            id={"forget-category-#{@record["id"]}"}
452
            tone={:danger}
453
            phx-click="request_memory_forget"
454
            phx-value-kind="category"
455
            phx-value-category={@record["category"]}
456
          >
457
            <.icon name="trash" /> FORGET {String.upcase(@record["category"])} CATEGORY
458
          </.text_button>
459
        </div>
460
      </div>
461
    </.card>
462
    """
463
  end
464
end
lib/openagents_web/route_authority.ex modified +4 -2

@@ -37,7 +37,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

37 37
    "/voice/",
38 38
    "/data",
39 39
    "/machines",
40
    "/memory/",
40
    # No trailing slash: the memory page is "/memory" itself, and its export
41
    # lives under it.
42
    "/memory",
41 43
    "/settings/api-tokens",
42 44
    "/github/connection",
43 45
    "/api/tokens",

@@ -193,7 +195,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

193 195
  defp browser_scope(_path), do: "product:self"
194 196
195 197
  defp browser_mutation?(path, :get),
196
    do: path in ["/chat", "/computers", "/settings/api-tokens"]
198
    do: path in ["/chat", "/memory", "/computers", "/settings/api-tokens"]
197 199
198 200
  defp browser_mutation?(_path, _verb), do: true
199 201
lib/openagents_web/router.ex modified +1

@@ -115,6 +115,7 @@ defmodule OpenAgentsWeb.Router do

115 115
    live_session :authenticated,
116 116
      on_mount: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}] do
117 117
      live "/chat", ChatLive, :index
118
      live "/memory", MemoryLive, :index
118 119
      live "/computers", ComputersLive, :index
119 120
      live "/settings/api-tokens", ApiTokensLive, :index
120 121
      live "/:owner/:repo/issues/new", IssueNewLive, :new
test/openagents_web/controllers/data_controller_test.exs modified +3 -2

@@ -186,8 +186,9 @@ defmodule OpenAgentsWeb.DataControllerTest do

186 186
    conn: conn
187 187
  } do
188 188
    conn = log_in_github_user(conn, "privacy-controls-browser")
189
    assert {:ok, view, _html} = live(conn, ~p"/chat")
190
    render_click(view, "toggle_memory")
189
    # Memory is its own page now, reached from the sidebar rather than by
190
    # swapping the transcript out from under the reader.
191
    assert {:ok, view, _html} = live(conn, ~p"/memory")
191 192
    html = render(view)
192 193
193 194
    assert html =~ "Detailed operational voice evidence is purged after 90 days"
test/openagents_web/icon_affordances_test.exs modified +2 -2

@@ -51,9 +51,9 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

51 51
    test "the memory surface pairs destructive actions with a glyph and keeps the words",
52 52
         %{conn: conn} do
53 53
      conn = log_in_github_user(conn, "icon-memory-user")
54
      {:ok, view, _html} = live(conn, ~p"/chat")
54
      {:ok, view, _html} = live(conn, ~p"/memory")
55 55
56
      html = render_patch(view, ~p"/chat?panel=memory")
56
      html = render(view)
57 57
58 58
      assert html =~ "Export ALL DATA"
59 59
      assert html =~ "DELETE ALL DATA"
test/openagents_web/live/chat_live_test.exs modified +1 -113

@@ -59,7 +59,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

59 59
    # Computers, Memory and Leaderboard are destinations for everyone, so they
60 60
    # sit in the application sidebar rather than appearing only on chat.
61 61
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
62
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat?panel=memory"]))
62
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
63 63
    # Leaderboard is a secondary destination, so it sits in the footer with the
64 64
    # docs and the component library rather than in the working nav.
65 65
    assert has_element?(view, ~s(#sidebar .sidebar-footer a[href="/leaderboard"]))

@@ -808,118 +808,6 @@ defmodule OpenAgentsWeb.ChatLiveTest do

808 808
    refute inspect(receipt) =~ secret
809 809
  end
810 810
811
  test "memory surface is accessible, exact, source-dated, and account-scoped", %{conn: conn} do
812
    token = "memory-surface-browser-credential-000000000000000"
813
    %{record: record, source: source} = create_profile_memory(token, "I prefer concise answers")
814
    conn = log_in_github_user(conn, token)
815
    assert {:ok, view, _html} = live(conn, ~p"/chat")
816
817
    html = render_patch(view, ~p"/chat?panel=memory")
818
819
    assert html =~ ~s(id="memory-manager")
820
    assert html =~ ~s(aria-labelledby="memory-heading")
821
    assert html =~ "Memory in your account"
822
    assert html =~ "follow your authenticated Sarah account across browsers"
823
    assert html =~ "I prefer concise answers"
824
    assert html =~ "OWNER_STATEMENT" or html =~ "owner_statement"
825
    assert html =~ Date.to_iso8601(DateTime.to_date(source.inserted_at))
826
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")
827
    assert has_element?(view, "#memory-claim-#{record.id}")
828
    assert has_element?(view, "#forget-category-#{record.id}")
829
    assert has_element?(view, "#export-memory[href='/memory/export']")
830
    assert has_element?(view, "#delete-data-form label[for='privacy_confirmation']")
831
    assert has_element?(view, "#delete-data-form input#privacy_confirmation[type='text']")
832
    assert has_element?(view, "#delete-data-form button#delete-all-data[type='submit']")
833
    refute has_element?(view, "#message-form")
834
835
    render_patch(view, ~p"/chat")
836
    assert has_element?(view, "#message-form")
837
    assert render(view) =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
838
  end
839
840
  test "correction preserves supersession and reconciles another open tab", %{conn: conn} do
841
    token = "memory-correction-browser-credential-0000000000000"
842
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
843
    user = github_user(token)
844
    conn = log_in_github_user(conn, token)
845
    assert {:ok, first, _html} = live(conn, ~p"/chat")
846
    assert {:ok, second, _html} = live(conn, ~p"/chat")
847
    render_patch(first, ~p"/chat?panel=memory")
848
    render_patch(second, ~p"/chat?panel=memory")
849
850
    first
851
    |> form("#memory-record-#{record.id} form", %{"claim" => "I prefer concise, direct answers"})
852
    |> render_submit()
853
854
    assert eventually(fn ->
855
             html = render(second)
856
857
             html =~ "I prefer concise, direct answers" and
858
               has_element?(second, "#memory-record-#{record.id}[data-status=superseded]")
859
           end)
860
861
    conversation = Conversations.get_conversation_for_user(user)
862
    owner = Conversations.get_conversation_owner!(conversation)
863
    assert {:ok, [replacement]} = ProfileMemory.list_current(owner)
864
    assert replacement.claim == "I prefer concise, direct answers"
865
    assert replacement.supersedes_record_id == record.id
866
  end
867
868
  test "forget requires inline confirmation and disappears from future snapshots", %{conn: conn} do
869
    token = "memory-forget-ui-browser-credential-000000000000"
870
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
871
    user = github_user(token)
872
    conn = log_in_github_user(conn, token)
873
    assert {:ok, view, _html} = live(conn, ~p"/chat")
874
    render_patch(view, ~p"/chat?panel=memory")
875
876
    view |> element("#forget-record-#{record.id}") |> render_click()
877
    assert has_element?(view, "#memory-confirmation")
878
    assert render(view) =~ "Confirm destructive action"
879
880
    view |> element("#cancel-memory-action") |> render_click()
881
    refute has_element?(view, "#memory-confirmation")
882
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")
883
884
    view |> element("#forget-record-#{record.id}") |> render_click()
885
    view |> element("#confirm-memory-forget") |> render_click()
886
887
    assert has_element?(view, "#memory-record-#{record.id}[data-status=forgotten]")
888
    assert render(view) =~ "Forgot 1 memory record(s) in this account."
889
890
    conversation = Conversations.get_conversation_for_user(user)
891
    owner = Conversations.get_conversation_owner!(conversation)
892
    assert {:ok, snapshot} = ProfileMemory.capture_snapshot(owner)
893
    assert {:ok, []} = ProfileMemory.list_active(owner, snapshot)
894
  end
895
896
  test "category and whole-account controls confirm their exact destructive breadth", %{
897
    conn: conn
898
  } do
899
    token = "memory-bulk-forget-browser-credential-00000000000"
900
    %{record: preference} = create_profile_memory(token, "I prefer concise answers")
901
    %{record: project} = create_profile_memory(token, "My project is One", "project")
902
    user = github_user(token)
903
    conn = log_in_github_user(conn, token)
904
    assert {:ok, view, _html} = live(conn, ~p"/chat")
905
    render_patch(view, ~p"/chat?panel=memory")
906
907
    view |> element("#forget-category-#{project.id}") |> render_click()
908
    assert render(view) =~ "Forget every active project memory in this account?"
909
    view |> element("#confirm-memory-forget") |> render_click()
910
    assert has_element?(view, "#memory-record-#{project.id}[data-status=forgotten]")
911
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=active]")
912
913
    view |> element("#forget-all-memory") |> render_click()
914
    assert render(view) =~ "Forget every active profile memory in this account?"
915
    view |> element("#confirm-memory-forget") |> render_click()
916
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=forgotten]")
917
918
    conversation = Conversations.get_conversation_for_user(user)
919
    owner = Conversations.get_conversation_owner!(conversation)
920
    assert {:ok, []} = ProfileMemory.list_current(owner)
921
  end
922
923 811
  test "bounded export uses safe account-scoped public projections", %{conn: conn} do
924 812
    token = "memory-export-browser-credential-000000000000000"
925 813
    %{record: record} = create_profile_memory(token, "My project is One", "project")
test/openagents_web/live/memory_live_test.exs added +180

@@ -0,0 +1,180 @@

1
defmodule OpenAgentsWeb.MemoryLiveTest do
2
  @moduledoc """
3
  `/memory` is a page, not a panel inside the conversation.
4
5
  It was the latter: a sidebar row swapped the transcript out for it, which
6
  made it reachable only from chat and gave it no address. These tests visit
7
  it directly, which is what a reader now does.
8
9
  What they hold is the account boundary and the exactness of destructive
10
  controls: memory is a claim a system makes about a person, so correction
11
  must supersede rather than overwrite, and "forget" must state which of its
12
  three scopes it means before it acts.
13
  """
14
15
  use OpenAgentsWeb.ConnCase, async: false
16
  import Phoenix.LiveViewTest
17
18
  alias OpenAgents.Conversations
19
  alias OpenAgents.Conversations.Message
20
  alias OpenAgents.ProfileMemory
21
22
  test "memory surface is accessible, exact, source-dated, and account-scoped", %{conn: conn} do
23
    token = "memory-surface-browser-credential-000000000000000"
24
    %{record: record, source: source} = create_profile_memory(token, "I prefer concise answers")
25
    conn = log_in_github_user(conn, token)
26
    assert {:ok, view, _html} = live(conn, ~p"/memory")
27
28
    html = render(view)
29
30
    assert html =~ ~s(id="memory-manager")
31
    assert html =~ ~s(aria-labelledby="memory-heading")
32
    assert html =~ "Memory in your account"
33
    assert html =~ "follow your authenticated Sarah account across browsers"
34
    assert html =~ "I prefer concise answers"
35
    assert html =~ "OWNER_STATEMENT" or html =~ "owner_statement"
36
    assert html =~ Date.to_iso8601(DateTime.to_date(source.inserted_at))
37
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")
38
    assert has_element?(view, "#memory-claim-#{record.id}")
39
    assert has_element?(view, "#forget-category-#{record.id}")
40
    assert has_element?(view, "#export-memory[href='/memory/export']")
41
    assert has_element?(view, "#delete-data-form label[for='privacy_confirmation']")
42
    assert has_element?(view, "#delete-data-form input#privacy_confirmation[type='text']")
43
    assert has_element?(view, "#delete-data-form button#delete-all-data[type='submit']")
44
    refute has_element?(view, "#message-form")
45
46
    # The conversation is a different page now, reached from the sidebar.
47
    refute has_element?(view, "#message-form")
48
  end
49
50
  test "correction preserves supersession and reconciles another open tab", %{conn: conn} do
51
    token = "memory-correction-browser-credential-0000000000000"
52
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
53
    user = github_user(token)
54
    conn = log_in_github_user(conn, token)
55
    assert {:ok, first, _html} = live(conn, ~p"/memory")
56
    assert {:ok, second, _html} = live(conn, ~p"/memory")
57
58
    first
59
    |> form("#memory-record-#{record.id} form", %{"claim" => "I prefer concise, direct answers"})
60
    |> render_submit()
61
62
    assert eventually(fn ->
63
             html = render(second)
64
65
             html =~ "I prefer concise, direct answers" and
66
               has_element?(second, "#memory-record-#{record.id}[data-status=superseded]")
67
           end)
68
69
    conversation = Conversations.get_conversation_for_user(user)
70
    owner = Conversations.get_conversation_owner!(conversation)
71
    assert {:ok, [replacement]} = ProfileMemory.list_current(owner)
72
    assert replacement.claim == "I prefer concise, direct answers"
73
    assert replacement.supersedes_record_id == record.id
74
  end
75
76
  test "forget requires inline confirmation and disappears from future snapshots", %{conn: conn} do
77
    token = "memory-forget-ui-browser-credential-000000000000"
78
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
79
    user = github_user(token)
80
    conn = log_in_github_user(conn, token)
81
    assert {:ok, view, _html} = live(conn, ~p"/memory")
82
83
    view |> element("#forget-record-#{record.id}") |> render_click()
84
    assert has_element?(view, "#memory-confirmation")
85
    assert render(view) =~ "Confirm destructive action"
86
87
    view |> element("#cancel-memory-action") |> render_click()
88
    refute has_element?(view, "#memory-confirmation")
89
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")
90
91
    view |> element("#forget-record-#{record.id}") |> render_click()
92
    view |> element("#confirm-memory-forget") |> render_click()
93
94
    assert has_element?(view, "#memory-record-#{record.id}[data-status=forgotten]")
95
    assert render(view) =~ "Forgot 1 memory record(s) in this account."
96
97
    conversation = Conversations.get_conversation_for_user(user)
98
    owner = Conversations.get_conversation_owner!(conversation)
99
    assert {:ok, snapshot} = ProfileMemory.capture_snapshot(owner)
100
    assert {:ok, []} = ProfileMemory.list_active(owner, snapshot)
101
  end
102
103
  test "category and whole-account controls confirm their exact destructive breadth", %{
104
    conn: conn
105
  } do
106
    token = "memory-bulk-forget-browser-credential-00000000000"
107
    %{record: preference} = create_profile_memory(token, "I prefer concise answers")
108
    %{record: project} = create_profile_memory(token, "My project is One", "project")
109
    user = github_user(token)
110
    conn = log_in_github_user(conn, token)
111
    assert {:ok, view, _html} = live(conn, ~p"/memory")
112
113
    view |> element("#forget-category-#{project.id}") |> render_click()
114
    assert render(view) =~ "Forget every active project memory in this account?"
115
    view |> element("#confirm-memory-forget") |> render_click()
116
    assert has_element?(view, "#memory-record-#{project.id}[data-status=forgotten]")
117
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=active]")
118
119
    view |> element("#forget-all-memory") |> render_click()
120
    assert render(view) =~ "Forget every active profile memory in this account?"
121
    view |> element("#confirm-memory-forget") |> render_click()
122
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=forgotten]")
123
124
    conversation = Conversations.get_conversation_for_user(user)
125
    owner = Conversations.get_conversation_owner!(conversation)
126
    assert {:ok, []} = ProfileMemory.list_current(owner)
127
  end
128
129
  defp create_profile_memory(token, claim, category \\ "preference") do
130
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
131
    owner = Conversations.get_conversation_owner!(conversation)
132
133
    source =
134
      OpenAgents.Repo.insert!(%Message{
135
        conversation_id: conversation.id,
136
        role: "user",
137
        content: "Remember that #{claim}",
138
        status: "complete"
139
      })
140
141
    assert {:ok, %{record: record}} =
142
             ProfileMemory.remember_explicit(owner, %{
143
               category: category,
144
               claim: claim,
145
               creator: "user_explicit",
146
               provenance: %{
147
                 "operation" => "test_explicit_memory",
148
                 "tool_payload" => "INTERNAL_TEST_PAYLOAD"
149
               },
150
               sources: [%{source_ref: "message:#{source.id}", kind: "owner_statement"}]
151
             })
152
153
    %{record: record, source: source, owner: owner}
154
  end
155
156
  # A correction lands in another tab through PubSub, so the second view is
157
  # polled rather than assumed to have caught up.
158
  defp eventually(assertion, timeout \\ 1_000) do
159
    deadline = System.monotonic_time(:millisecond) + timeout
160
    do_eventually(assertion, deadline)
161
  end
162
163
  defp do_eventually(assertion, deadline) do
164
    if assertion.() do
165
      true
166
    else
167
      if System.monotonic_time(:millisecond) >= deadline do
168
        false
169
      else
170
        receive do
171
          _message -> :ok
172
        after
173
          10 -> :ok
174
        end
175
176
        do_eventually(assertion, deadline)
177
      end
178
    end
179
  end
180
end

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