lib/openagents/memories/note.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.Memories.Note do
  @moduledoc """
  The `[From memory: …]` block a recalled turn carries.

  The convention is the retrieval rails': the capability catalog and the
  knowledge base each attach what they found as a bracketed note the model
  reads as context rather than as a tool result, and memory reads the same way.
  One line per memory, each labelled with its bucket and its age, because a
  model shown "you use pnpm" without knowing whether the reader said so
  yesterday or eighteen months ago cannot weigh it.

  When the bounds excluded something, the block says so in its last line. A
  note that trailed off would leave the model believing it had been told
  everything the account remembers.

  ## The system bucket reads differently on purpose

  A `user` or `learned` line is labelled with its bucket and its age. A
  `system` line is labelled `(system, as of <date>, admitted)`, because a claim
  the network makes has to be weighed against a claim the reader made, and the
  three things that decide that weight are which bucket it came from, what date
  it was observed true, and whether a steward admitted it. The date is the
  row's `as_of` rather than its age, since `as_of` dates the claim while the
  insert time only orders the chain, and a stale truth should read as dated.

  The status is the one the admission records derived
  (`OpenAgents.Memories.Admissions`), carried on `derived_status`. It is never
  the `admission` column, which is the author's own claim about their own row.

  Specification section 7.1 draws the example with the body inside the
  brackets. This repository's rails put the body after them, and one note
  carrying two bracket shapes would be harder to read than either, so the label
  is the specification's verbatim and the placement is the rails'.
  """

  alias OpenAgents.Memories.{Memory, Recall}
  alias OpenAgentsWeb.RelativeTime

  @doc """
  Renders one recall as a note, or `nil` when there is nothing to say.

  An empty recall renders `nil` rather than an empty block, so a turn with no
  memories behind it is exactly the turn it was before recall existed.
  """
  @spec render(Recall.t()) :: String.t() | nil
  def render(%Recall{memories: []}), do: nil

  def render(%Recall{memories: memories, dropped: dropped}) do
    (Enum.map(memories, &line/1) ++ omission(dropped))
    |> Enum.join("\n")
  end

  defp line(%Memory{bucket: "system"} = memory) do
    "[From memory: (system, as of #{as_of(memory)}, #{status(memory)})] #{memory.body}"
  end

  defp line(%Memory{} = memory) do
    "[From memory: #{memory.bucket}, #{age(memory)}] #{memory.body}"
  end

  defp as_of(%Memory{as_of: %Date{} = date}), do: Date.to_iso8601(date)
  defp as_of(_undated), do: "date unknown"

  # Derived, never claimed. A row that reached a note without a derived status
  # is a bug in the eligibility filter rather than a candidate to describe as
  # one, so it says what it knows and nothing more.
  defp status(%Memory{derived_status: derived}) when is_binary(derived), do: derived
  defp status(_underived), do: "status unknown"

  defp omission(0), do: []

  defp omission(dropped) do
    [
      "[From memory: #{dropped} more #{noun(dropped)} not attached; " <>
        "this turn's recall is bounded to #{OpenAgents.Memories.maximum_attached()} entries " <>
        "and #{OpenAgents.Memories.maximum_attached_characters()} characters.]"
    ]
  end

  defp noun(1), do: "memory was"
  defp noun(_many), do: "memories were"

  defp age(%Memory{inserted_at: at}) do
    case RelativeTime.ago(at) do
      nil -> "age unknown"
      ago -> ago
    end
  end
end