lib/openagents_web/live/memory_live.ex

436d37facc96 · 33 KB

defmodule OpenAgentsWeb.MemoryLive do
  @moduledoc """
  Memory in your account, as a page.

  It used to be a panel inside the conversation, opened by a sidebar row that
  swapped the transcript out. That made it reachable only from chat, gave it no
  address of its own, and meant the way back out lived in chrome outside the
  thing you were leaving. It is a place, so it has a URL.

  Everything here is scoped to the signed-in account's own memory owner.
  Correction supersedes rather than overwrites: the previous wording is kept,
  because a memory system that silently rewrites its own history cannot be
  audited by the person it is about.

  Destructive actions confirm inline and state their exact breadth -- one
  record, one category, or the whole account -- since "forget" covering three
  different scopes behind one word is how someone deletes more than they meant.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.Analytics
  alias OpenAgents.Conversations
  alias OpenAgents.DataRights
  alias OpenAgents.ExperienceMemory
  alias OpenAgents.GraphMemory
  alias OpenAgents.Memory.Consent
  alias OpenAgents.ProfileMemory
  alias OpenAgents.Voice.Recordings

  @impl true
  def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
    {:ok, conversation} = Conversations.ensure_conversation(current_user)
    owner = Conversations.get_conversation_owner!(conversation)

    if connected?(socket) do
      :ok = ProfileMemory.subscribe(owner)

      Analytics.capture("memory_viewed", Analytics.distinct_id(current_user))
    end

    {:ok,
     socket
     |> assign(:page_title, "Memory")
     |> assign(:memory_owner, owner)
     |> assign(:memory_records, [])
     |> assign(:memory_status, nil)
     |> assign(:pending_memory_action, nil)
     |> assign(:reset_enabled?, DataRights.reset_enabled?())
     |> assign(:leaderboard_opted_out?, current_user.public_leaderboard_opted_out)
     |> assign(:recording_config, Recordings.config())
     |> assign(:privacy_delete_form, to_form(%{"confirmation" => ""}, as: :privacy))
     |> assign(:work_scope, "conversation:#{conversation.id}")
     |> assign(:operator?, Accounts.admin?(current_user))
     |> load_dashboard()}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Memory"
      wide
    >
      <.memory_manager
        memory_records={@memory_records}
        memory_status={@memory_status}
        pending_memory_action={@pending_memory_action}
        privacy_delete_form={@privacy_delete_form}
        recording_config={@recording_config}
        leaderboard_opted_out?={@leaderboard_opted_out?}
        work_scope={@work_scope}
        graph_entities={@graph_entities}
        engram_timeline={@engram_timeline}
        derived_heuristics={@derived_heuristics}
        supersession_trail={@supersession_trail}
        operator?={@operator?}
      />
    </Layouts.app>
    """
  end

  @impl true
  def handle_event("correct_memory", %{"record_id" => record_id, "claim" => claim}, socket) do
    owner = socket.assigns.memory_owner

    result =
      with {:ok, record} <- ProfileMemory.get(owner, record_id),
           true <- record.status == "active",
           {:ok, _corrected} <-
             ProfileMemory.correct(owner, record.id, record.generation, %{
               category: record.category,
               claim: claim,
               creator: "user_explicit",
               owner_asserted: true,
               sources: [],
               provenance: %{
                 "operation" => "first_party_ui_correction",
                 "supersedes_record_id" => record.id
               }
             }) do
        :ok
      else
        false -> {:error, :memory_not_active}
        {:error, reason} -> {:error, reason}
      end

    {:noreply, memory_result(socket, result, "Memory corrected and previous wording retained.")}
  end

  def handle_event("request_memory_forget", params, socket) do
    case pending_action(socket.assigns.memory_owner, params) do
      {:ok, pending} ->
        {:noreply,
         socket
         |> assign(:pending_memory_action, pending)
         |> assign(:memory_status, nil)}

      {:error, _reason} ->
        {:noreply, assign(socket, :memory_status, {:error, "That memory is no longer active."})}
    end
  end

  # The public board is the one projection that leaves the account boundary, so
  # the account it is about decides whether it appears there. The control states
  # the current publication state rather than a bare toggle, since "leaderboard"
  # on its own does not say which way it is set.
  def handle_event("set_leaderboard_visibility", %{"opted_out" => opted_out}, socket)
      when opted_out in ["true", "false"] do
    opted_out? = opted_out == "true"

    case Accounts.set_public_leaderboard_opt_out(socket.assigns.current_user, opted_out?) do
      {:ok, user} ->
        message =
          if opted_out?,
            do: "Your account no longer appears on the public leaderboard.",
            else: "Your account appears on the public leaderboard again."

        {:noreply,
         socket
         |> assign(:current_user, user)
         |> assign(:leaderboard_opted_out?, user.public_leaderboard_opted_out)
         |> assign(:memory_status, {:ok, message})}

      {:error, _changeset} ->
        {:noreply,
         assign(
           socket,
           :memory_status,
           {:error, "Sarah could not change your leaderboard preference. Try again."}
         )}
    end
  end

  def handle_event("cancel_memory_action", _params, socket) do
    {:noreply, assign(socket, :pending_memory_action, nil)}
  end

  def handle_event(
        "confirm_memory_forget",
        _params,
        %{assigns: %{pending_memory_action: nil}} = socket
      ) do
    {:noreply, socket}
  end

  def handle_event("confirm_memory_forget", _params, socket) do
    pending = socket.assigns.pending_memory_action
    result = ProfileMemory.forget_active(socket.assigns.memory_owner, pending.selector)

    message =
      case result do
        {:ok, %{disposition: "already_absent"}} ->
          "Those memories were already absent."

        {:ok, %{records: records}} ->
          "Forgot #{length(records)} memory record(s) in this account."

        {:error, _reason} ->
          "Sarah could not forget that selection. Refresh and try again."
      end

    status = if match?({:ok, _result}, result), do: :ok, else: :error

    {:noreply,
     socket
     |> assign(:pending_memory_action, nil)
     |> assign(:memory_status, {status, message})
     |> load_dashboard()}
  end

  def handle_event(
        "retract_record",
        %{"record_id" => record_id, "expected_generation" => generation},
        socket
      ) do
    if Accounts.admin?(socket.assigns.current_user) do
      case Integer.parse(generation) do
        {expected_generation, ""} -> do_retract_record(record_id, expected_generation, socket)
        _ -> {:noreply, assign(socket, :memory_status, {:error, "Invalid retraction request."})}
      end
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  def handle_event("retract_engram", %{"record_ref" => ref}, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      do_retract_engram(ref, socket)
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  defp do_retract_record(record_id, expected_generation, socket) do
    owner = socket.assigns.memory_owner

    result =
      ProfileMemory.forget_active(owner, %{
        "mode" => "record",
        "record_id" => record_id,
        "expected_generation" => expected_generation
      })

    message =
      case result do
        {:ok, %{disposition: "forgotten", records: records}} ->
          "Retracted #{length(records)} memory record(s)."

        {:ok, %{disposition: "already_absent"}} ->
          "Those records were already absent."

        {:error, reason} ->
          "Could not retract record: #{format_reason(reason)}"
      end

    status = if match?({:ok, _}, result), do: :ok, else: :error

    {:noreply,
     socket
     |> assign(:memory_status, {status, message})
     |> load_dashboard()}
  end

  defp do_retract_engram(ref, socket) do
    owner = socket.assigns.memory_owner

    context_consent = %{
      "kind" => "first_party_ui",
      "operation" => "forget",
      "claim" => ref
    }

    result =
      with {:ok, _consent} <- Consent.forget(nil, "record", ref, context_consent),
           record_id <- String.replace_prefix(ref, "experience:", ""),
           {:ok, parsed_id} <- Ecto.UUID.cast(record_id),
           {:ok, receipt} <- ExperienceMemory.delete(owner, parsed_id, "operator_retraction") do
        {:ok, receipt}
      else
        :error -> {:error, :invalid_engram_ref}
        {:error, reason} -> {:error, reason}
      end

    message =
      case result do
        {:ok, receipt} ->
          "Retracted #{ref}. Receipt #{receipt.receipt_digest}."

        {:error, :invalid_engram_ref} ->
          "That engram reference is not valid."

        {:error, reason} ->
          "Could not retract engram: #{format_reason(reason)}"
      end

    status = if match?({:ok, _}, result), do: :ok, else: :error

    {:noreply,
     socket
     |> assign(:memory_status, {status, message})
     |> load_dashboard()}
  end

  defp format_reason(reason) do
    cond do
      is_binary(reason) -> reason
      is_atom(reason) -> String.replace_leading(Atom.to_string(reason), "Elixir.", "")
      true -> inspect(reason)
    end
  end

  # Another tab correcting or forgetting a record must be reflected here: the
  # records are one account's, not one socket's.
  @impl true
  def handle_info({:profile_memory_updated, _result}, socket) do
    {:noreply, load_dashboard(socket)}
  end

  def handle_info(_message, socket), do: {:noreply, socket}

  defp load_dashboard(socket) do
    owner = socket.assigns.memory_owner
    work_scope = socket.assigns.work_scope
    profile_result = ProfileMemory.export(owner)

    profile_export =
      case profile_result do
        {:ok, export} -> export
        {:error, _reason} -> %{"records" => []}
      end

    memory_status =
      case {socket.assigns.memory_status, profile_result} do
        {nil, {:error, _reason}} -> {:error, "Memory is unavailable."}
        {status, _} -> status
      end

    graph_entities =
      case GraphMemory.export(owner, work_scope) do
        {:ok, export} -> export
        {:error, _reason} -> nil
      end

    {engram_timeline, derived_heuristics} =
      case ExperienceMemory.export_scope(owner, work_scope) do
        {:ok, export} -> {export["records"] || [], export["patterns"] || []}
        {:error, _reason} -> {[], []}
      end

    records = profile_export["records"] || []
    records_by_id = Map.new(records, &{&1["id"], &1})

    superseded_ids =
      for r <- records,
          r["supersedes_record_id"],
          into: MapSet.new(),
          do: r["supersedes_record_id"]

    supersession_trail =
      records
      |> Enum.reject(&MapSet.member?(superseded_ids, &1["id"]))
      |> Enum.map(&build_supersession_chain(&1, records_by_id))
      |> Enum.filter(&(&1.chain != []))
      |> Enum.sort_by(& &1.current["inserted_at"], :desc)

    socket
    |> assign(:memory_records, records)
    |> assign(:memory_status, memory_status)
    |> assign(:graph_entities, graph_entities)
    |> assign(:engram_timeline, engram_timeline)
    |> assign(:derived_heuristics, derived_heuristics)
    |> assign(:supersession_trail, supersession_trail)
  end

  defp memory_result(socket, :ok, message) do
    socket
    |> assign(:memory_status, {:ok, message})
    |> load_dashboard()
  end

  defp memory_result(socket, {:error, reason}, _message) do
    assign(socket, :memory_status, {:error, memory_error(reason)})
  end

  defp pending_action(owner, %{"kind" => "record", "id" => record_id}) do
    with {:ok, record} <- ProfileMemory.get(owner, record_id),
         true <- record.status == "active" do
      {:ok,
       %{
         label: ~s(Forget "#{record.claim}"?),
         selector: %{
           "mode" => "record",
           "record_id" => record.id,
           "expected_generation" => record.generation
         }
       }}
    else
      _invalid -> {:error, :not_found}
    end
  end

  defp pending_action(_owner, %{"kind" => "category", "category" => category})
       when category in ~w(name role project preference constraint other) do
    {:ok,
     %{
       label: "Forget every active #{category} memory in this account?",
       selector: %{"mode" => "category", "category" => category}
     }}
  end

  defp pending_action(_owner, %{"kind" => "all"}) do
    {:ok,
     %{
       label: "Forget every active profile memory in this account?",
       selector: %{"mode" => "all"}
     }}
  end

  defp pending_action(_owner, _params), do: {:error, :invalid_action}

  defp memory_error(:duplicate_memory), do: "That exact memory is already active."

  defp memory_error(:memory_conflict_requires_correction),
    do: "That category has a conflicting active memory."

  defp memory_error(:invalid_claim), do: "Enter a non-empty correction under 500 bytes."

  defp memory_error({:memory_policy_rejected, _reason}),
    do: "That correction was refused by the memory privacy policy."

  defp memory_error(_reason), do: "Sarah could not update that memory. Refresh and try again."

  defp memory_date(nil), do: "DATE UNAVAILABLE"
  defp memory_date(timestamp), do: String.slice(timestamp, 0, 10)

  defp memory_status_variant({:error, _message}), do: :danger
  defp memory_status_variant(_status), do: :success

  defp memory_badge_variant("active"), do: :success
  defp memory_badge_variant(_status), do: :default

  attr :memory_records, :list, required: true
  attr :memory_status, :any, default: nil
  attr :pending_memory_action, :any, default: nil
  attr :privacy_delete_form, :any, required: true
  attr :recording_config, :map, required: true
  attr :leaderboard_opted_out?, :boolean, required: true
  attr :work_scope, :string, required: true
  attr :graph_entities, :any, default: nil
  attr :engram_timeline, :list, default: []
  attr :derived_heuristics, :list, default: []
  attr :supersession_trail, :list, default: []
  attr :operator?, :boolean, default: false

  defp memory_manager(assigns) do
    ~H"""
    <section id="memory-manager" class="memory-manager" aria-labelledby="memory-heading">
      <header class="memory-header">
        <div>
          <h1 id="memory-heading">Memory in your account</h1>
          <p>
            These records follow your authenticated Sarah account across browsers. Logging
            out removes this browser's access; server records follow the documented
            retention lifecycle.
          </p>
        </div>
        <div class="memory-header__actions">
          <%!-- The way out of a panel belongs in the panel. This used to be a
          sidebar row, which meant leaving depended on chrome outside the thing
          you were leaving. Memory is a place with its own URL, so the way back
          navigates to the conversation rather than toggling a panel. --%>
          <.text_button
            id="toggle-memory"
            navigate={~p"/sarah"}
            aria-label="Return to conversation"
          >
            <.icon name="arrow-left" /> Return to conversation
          </.text_button>
          <.text_button id="export-all-data" href="/data/export" download>
            <.icon name="download" /> Export ALL DATA
          </.text_button>
          <.text_button id="export-memory" href="/memory/export" download>
            <.icon name="download" /> Export Memory ONLY
          </.text_button>
          <%!-- The two exports above are scoped to Sarah's one conversation.
          Forum posts, threads, push receipts, deployments, Box work,
          computers, and agent links key on the account instead, so they leave
          through their own document (EXIT-001). --%>
          <.text_button id="export-account-data" href="/data/export/account" download>
            <.icon name="download" /> Export Forge and Forum Data
          </.text_button>
          <%!-- Encrypting that document to a key you generated is the one
          place the operator's reach over an export can actually be removed
          (#178). It protects the file, not the database it was read from,
          and losing the key costs you nothing: ask for the export again. --%>
          <form
            method="get"
            action="/data/export/account"
            id="export-account-data-encrypted"
            class="flex flex-wrap items-center gap-2"
          >
            <.input
              type="text"
              name="recipient"
              id="export-recipient"
              value=""
              placeholder="age1… (age-keygen -y)"
              autocomplete="off"
              spellcheck="false"
              aria-label="age recipient public key"
            />
            <.button type="submit" id="export-account-data-encrypted-submit" data-size="sm">
              <.icon name="download" /> Export encrypted to my key
            </.button>
          </form>
          <.text_button
            id="forget-all-memory"
            tone={:danger}
            phx-click="request_memory_forget"
            phx-value-kind="all"
            disabled={not Enum.any?(@memory_records, &(&1["status"] == "active"))}
          >
            <.icon name="trash" /> FORGET ALL ACTIVE
          </.text_button>
        </div>
      </header>

      <.alert
        :if={@memory_status}
        id="memory-status"
        appearance={:row}
        variant={memory_status_variant(@memory_status)}
      >
        {elem(@memory_status, 1)}
      </.alert>

      <.card
        :if={@pending_memory_action}
        id="memory-confirmation"
        variant={:danger}
        aria-labelledby="memory-confirmation-heading"
      >
        <header>
          <h2 id="memory-confirmation-heading">Confirm destructive action</h2>
          <p>{@pending_memory_action.label}</p>
          <p>Future snapshots will no longer include the affected active records.</p>
        </header>
        <footer class="memory-confirmation__actions">
          <.button
            id="confirm-memory-forget"
            size={:sm}
            variant={:destructive}
            phx-click="confirm_memory_forget"
          >
            <.icon name="trash" /> CONFIRM FORGET
          </.button>
          <.button
            id="cancel-memory-action"
            size={:sm}
            variant={:secondary}
            phx-click="cancel_memory_action"
          >
            KEEP Memory
          </.button>
        </footer>
      </.card>

      <.empty :if={@memory_records == []} id="memory-empty" title="No profile memories yet">
        Sarah automatically remembers lasting facts you share in conversation, like
        your name, role, projects, and preferences. Memory belongs to your Sarah account.
      </.empty>

      <div :if={@memory_records != []} id="memory-records" class="memory-records">
        <.memory_record
          :for={record <- @memory_records}
          record={record}
          operator?={@operator?}
        />
      </div>

      <.memory_dashboard
        work_scope={@work_scope}
        graph_entities={@graph_entities}
        engram_timeline={@engram_timeline}
        derived_heuristics={@derived_heuristics}
        supersession_trail={@supersession_trail}
        operator?={@operator?}
      />

      <.card id="leaderboard-preference" aria-labelledby="leaderboard-preference-heading">
        <header>
          <h2 id="leaderboard-preference-heading">Public leaderboard</h2>
          <p>
            The public leaderboard publishes a rank, your GitHub login, name, avatar, and one
            token total. It publishes nothing else about your account.
          </p>
          <p id="leaderboard-preference-state">
            <%= if @leaderboard_opted_out? do %>
              Your account is withheld from the board.
            <% else %>
              Your account appears on the board when its token total is above zero.
            <% end %>
          </p>
        </header>
        <footer>
          <.button
            id="toggle-leaderboard-preference"
            size={:sm}
            variant={:secondary}
            phx-click="set_leaderboard_visibility"
            phx-value-opted_out={to_string(not @leaderboard_opted_out?)}
          >
            <%= if @leaderboard_opted_out? do %>
              SHOW ME ON THE LEADERBOARD
            <% else %>
              HIDE ME FROM THE LEADERBOARD
            <% end %>
          </.button>
        </footer>
      </.card>

      <.card
        id="privacy-controls"
        variant={:danger}
        class="memory-confirmation"
        aria-labelledby="privacy-heading"
      >
        <header>
          <h2 id="privacy-heading">Voice and deletion</h2>
          <p>
            Final and interrupted transcripts remain in this account's conversation.
            Detailed operational voice evidence is purged after 90 days. Export before
            deleting if you want a copy.
          </p>
          <%!-- The recording sentence appears only while recording is on, so this
                surface and the voice control row can never disagree about it. --%>
          <p :if={@recording_config.enabled?} id="privacy-recording">
            Call audio is recorded, stored encrypted, and readable by a Sarah operator.
            It is deleted {@recording_config.retention_days} days after a call ends, and
            deleting your data removes it immediately.
          </p>
        </header>
        <footer>
          <.form
            for={@privacy_delete_form}
            id="delete-data-form"
            action="/data"
            method="delete"
            class="privacy-delete-form"
          >
            <.field>
              <.label for={@privacy_delete_form[:confirmation].id}>
                Type DELETE MY SARAH DATA to delete this account's Sarah conversation,
                transcripts, memory, receipts, and voice records. Minimal GitHub identity and
                access-status data remains so bans and access controls cannot be bypassed. A
                retained GitHub tools grant remains until you use Disconnect GitHub tools in
                the account menu. API tokens remain until you revoke them from API token
                settings.
              </.label>
              <div class="control-row">
                <.input
                  id={@privacy_delete_form[:confirmation].id}
                  name={@privacy_delete_form[:confirmation].name}
                  value={@privacy_delete_form[:confirmation].value}
                  type="text"
                  class="control-row__input"
                  autocomplete="off"
                  required
                />
                <.button id="delete-all-data" type="submit" size={:sm} variant={:destructive}>
                  <.icon name="trash" /> DELETE ALL DATA
                </.button>
              </div>
            </.field>
          </.form>
        </footer>
      </.card>
    </section>
    """
  end

  attr :record, :map, required: true
  attr :operator?, :boolean, default: false

  defp memory_record(assigns) do
    ~H"""
    <.card
      id={"memory-record-#{@record["id"]}"}
      state={@record["status"]}
      frame={:corners}
      data-status={@record["status"]}
    >
      <div class="memory-record__meta">
        <.badge>{String.upcase(@record["category"])}</.badge>
        <.badge variant={memory_badge_variant(@record["status"])}>
          {String.upcase(@record["status"])}
        </.badge>
        <.badge>
          <time datetime={@record["inserted_at"]}>{memory_date(@record["inserted_at"])}</time>
        </.badge>
      </div>

      <p class="memory-record__claim">{@record["claim"] || "WITHHELD BY PRIVACY POLICY"}</p>

      <dl class="memory-record__sources">
        <div>
          <dt>Scope</dt>
          <dd>This account</dd>
        </div>
        <div>
          <dt>Generation</dt>
          <dd>{@record["generation"]}</dd>
        </div>
        <div>
          <dt>Sources</dt>
          <dd>
            <span :if={@record["sources"] == []}>Explicit account-owner assertion</span>
            <span :for={source <- @record["sources"]}>
              {source["kind"]} / {memory_date(source["observed_at"])}
            </span>
          </dd>
        </div>
      </dl>

      <div :if={@record["status"] == "active"} class="memory-record__controls">
        <form phx-submit="correct_memory" class="memory-correction">
          <input type="hidden" name="record_id" value={@record["id"]} />
          <.field>
            <.label for={"memory-claim-#{@record["id"]}"}>Correct this memory</.label>
            <div class="control-row">
              <.input
                id={"memory-claim-#{@record["id"]}"}
                name="claim"
                type="text"
                value={@record["claim"]}
                class="control-row__input"
                maxlength="500"
                required
              />
              <.button type="submit" size={:sm} variant={:secondary}>SAVE CORRECTION</.button>
            </div>
          </.field>
        </form>
        <div class="memory-record__destructive">
          <.text_button
            id={"forget-record-#{@record["id"]}"}
            tone={:danger}
            phx-click="request_memory_forget"
            phx-value-kind="record"
            phx-value-id={@record["id"]}
          >
            <.icon name="trash" /> FORGET RECORD
          </.text_button>
          <.text_button
            id={"forget-category-#{@record["id"]}"}
            tone={:danger}
            phx-click="request_memory_forget"
            phx-value-kind="category"
            phx-value-category={@record["category"]}
          >
            <.icon name="trash" /> FORGET {String.upcase(@record["category"])} CATEGORY
          </.text_button>
          <.button
            :if={@operator?}
            id={"retract-record-#{@record["id"]}"}
            variant={:link}
            tone={:danger}
            size={:sm}
            phx-click="retract_record"
            phx-value-record_id={@record["id"]}
            phx-value-expected_generation={to_string(@record["generation"])}
          >
            <.icon name="trash" /> RETRACT
          </.button>
        </div>
      </div>
    </.card>
    """
  end

  attr :work_scope, :string, required: true
  attr :graph_entities, :any, default: nil
  attr :engram_timeline, :list, default: []
  attr :derived_heuristics, :list, default: []
  attr :supersession_trail, :list, default: []
  attr :operator?, :boolean, default: false

  defp memory_dashboard(assigns) do
    ~H"""
    <section id="memory-dashboard" class="memory-dashboard" aria-labelledby="memory-dashboard-heading">
      <h2 id="memory-dashboard-heading">Memory evolution and consent</h2>

      <.card id="active-memory-graph" aria-labelledby="active-memory-graph-heading">
        <header>
          <h3 id="active-memory-graph-heading">Active memory graph</h3>
        </header>
        <.empty :if={is_nil(@graph_entities)} title="Graph projection unavailable">
          Graph memory is disabled or has not been built for this conversation yet.
        </.empty>
        <div :if={not is_nil(@graph_entities)} class="memory-graph">
          <p>
            <.badge variant={:info}>{@graph_entities["manifest"]["ref"]}</.badge>
          </p>
          <.list :if={active_graph_nodes(@graph_entities) != []}>
            <:item
              :for={node <- active_graph_nodes(@graph_entities)}
              title={node["artifact_id"]}
            >
              <.badge variant={:success}>{node["entity_kind"] || "node"}</.badge>
              <span class="memory-graph__label">{graph_node_label(node)}</span>
            </:item>
          </.list>
          <.empty :if={active_graph_nodes(@graph_entities) == []} title="No active graph entities">
            The current graph has no active nodes.
          </.empty>
        </div>
      </.card>

      <.card id="engram-timeline" aria-labelledby="engram-timeline-heading">
        <header>
          <h3 id="engram-timeline-heading">Recent engrams</h3>
        </header>
        <.empty :if={@engram_timeline == []} title="No recent engrams">
          Experience memory is empty for this conversation.
        </.empty>
        <.list :if={@engram_timeline != []}>
          <:item
            :for={engram <- @engram_timeline}
            title={engram["objective"]}
          >
            <div class="memory-engram__meta">
              <.badge variant={engram_state_variant(engram["state"])}>
                {String.upcase(engram["state"])}
              </.badge>
              <.badge variant={:default}>{engram["record_ref"]}</.badge>
            </div>
            <p :if={engram["outcome"]} class="memory-engram__outcome">
              {engram["outcome"]}
            </p>
            <.button
              :if={@operator?}
              id={"retract-engram-#{engram_id(engram["record_ref"])}"}
              variant={:link}
              tone={:danger}
              size={:sm}
              phx-click="retract_engram"
              phx-value-record_ref={engram["record_ref"]}
            >
              <.icon name="trash" /> RETRACT
            </.button>
          </:item>
        </.list>
      </.card>

      <.card id="derived-heuristics" aria-labelledby="derived-heuristics-heading">
        <header>
          <h3 id="derived-heuristics-heading">Derived heuristics</h3>
        </header>
        <.empty :if={@derived_heuristics == []} title="No derived heuristics">
          No advisory patterns have been synthesized yet.
        </.empty>
        <.list :if={@derived_heuristics != []}>
          <:item
            :for={pattern <- @derived_heuristics}
            title={pattern["phenomenon"]}
          >
            <p>{pattern["applicability"]}</p>
            <div class="memory-heuristic__support">
              <.badge variant={:info}>Effect: {pattern["expected_effect"]}</.badge>
              <.badge variant={:success}>Successes: {pattern["support"]["successes"]}</.badge>
              <.badge variant={:danger}>Failures: {pattern["support"]["failures"]}</.badge>
            </div>
          </:item>
        </.list>
      </.card>

      <.card id="supersession-audit" aria-labelledby="supersession-audit-heading">
        <header>
          <h3 id="supersession-audit-heading">Supersession audit trail</h3>
        </header>
        <.empty :if={@supersession_trail == []} title="No supersessions">
          No corrections or supersessions are recorded.
        </.empty>
        <.list :if={@supersession_trail != []}>
          <:item
            :for={chain <- @supersession_trail}
            title={chain.current["claim"] || "WITHHELD"}
          >
            <div class="memory-supersession__meta">
              <.badge>{String.upcase(chain.current["category"])}</.badge>
              <.badge variant={memory_badge_variant(chain.current["status"])}>
                {String.upcase(chain.current["status"])}
              </.badge>
            </div>
            <ol :if={chain.chain != []} class="memory-supersession__chain">
              <li
                :for={prior <- chain.chain}
                id={"superseded-#{chain.current["id"]}-#{if prior.record, do: prior.record["id"], else: "missing"}"}
              >
                <%= if prior.record do %>
                  <p>{prior.record["claim"] || "WITHHELD"}</p>
                  <p class="memory-supersession__detail">
                    Superseded
                    <time :if={prior.superseded_at} datetime={prior.superseded_at}>
                      {memory_date(prior.superseded_at)}
                    </time>
                    by {prior.superseded_by_id}
                    <span :if={prior.consent_kind}>with consent {prior.consent_kind}</span>
                  </p>
                <% else %>
                  <p>Prior value unavailable</p>
                  <p class="memory-supersession__detail">
                    Superseded record is no longer in the exported scope.
                  </p>
                <% end %>
              </li>
            </ol>
          </:item>
        </.list>
      </.card>
    </section>
    """
  end

  defp active_graph_nodes(graph) do
    (graph["artifacts"] || [])
    |> Enum.filter(fn artifact ->
      artifact["kind"] == "node" and
        (artifact["properties"]["eligible_for_traversal"] == true or
           artifact["entity_kind"] == "pattern")
    end)
  end

  defp graph_node_label(node) do
    properties = node["properties"] || %{}

    cond do
      properties["phenomenon"] -> properties["phenomenon"]
      properties["outcome"] -> properties["outcome"]
      properties["label"] -> properties["label"]
      true -> node["identity_key"] || ""
    end
  end

  defp build_supersession_chain(current, records_by_id) do
    build_supersession_chain(current, records_by_id, [])
  end

  defp build_supersession_chain(current, records_by_id, chain) do
    case current["supersedes_record_id"] do
      nil ->
        %{current: current, chain: chain}

      previous_id ->
        previous = Map.get(records_by_id, previous_id)

        prior = %{
          record: previous,
          superseded_at: current["inserted_at"],
          superseded_by_id: current["id"],
          consent_kind: if(previous, do: consent_kind(current), else: nil)
        }

        if previous do
          build_supersession_chain(previous, records_by_id, chain ++ [prior])
        else
          %{current: current, chain: chain ++ [prior]}
        end
    end
  end

  defp consent_kind(record) do
    provenance = record["provenance"] || %{}
    basis = provenance["basis"]
    creator = provenance["creator"] || record["creator"] || "unknown"

    if basis do
      "#{basis} (#{creator})"
    else
      creator
    end
  end

  defp engram_state_variant("succeeded"), do: :success
  defp engram_state_variant("failed"), do: :danger
  defp engram_state_variant("corrected"), do: :warning
  defp engram_state_variant("running"), do: :info
  defp engram_state_variant(_state), do: :default

  defp engram_id(ref) when is_binary(ref), do: String.replace_prefix(ref, "experience:", "")
  defp engram_id(_ref), do: ""
end