lib/planck/agent/message.ex

defmodule Planck.Agent.Message do
  @moduledoc """
  An agent-side message with metadata.

  Wraps `Planck.AI.Message` content parts with an id, timestamp, and metadata map.
  Messages with a `{:custom, atom()}` role are UI-only and filtered out before the
  context is sent to the LLM.
  """

  @type role :: :user | :assistant | :tool_result | {:custom, atom()}

  @type t :: %__MODULE__{
          id: non_neg_integer() | String.t(),
          role: role(),
          content: [Planck.AI.Message.content_part()],
          timestamp: DateTime.t(),
          metadata: map()
        }

  @enforce_keys [:id, :role, :content, :timestamp]
  defstruct [:id, :role, :content, :timestamp, metadata: %{}]

  @doc """
  Estimate the token count for a list of messages using a character-based
  approximation (4 characters ≈ 1 token). Fast enough for real-time display
  and compaction threshold checks; not a substitute for model tokenization.
  """
  @spec estimate_tokens([t()]) :: non_neg_integer()
  def estimate_tokens(messages) do
    Enum.reduce(messages, 0, fn msg, acc ->
      Enum.reduce(msg.content, acc, fn
        {:text, text}, a ->
          a + div(String.length(text), 4)

        {:thinking, text}, a ->
          a + div(String.length(text), 4)

        {:tool_result, _id, value}, a ->
          a + div(String.length(value), 4)

        {:tool_call, _id, name, args}, a ->
          a + div(String.length(name) + String.length(inspect(args)), 4)

        _other, a ->
          a
      end)
    end)
  end

  @doc """
  Build a new message with a generated id and current UTC timestamp.
  """
  @spec new(role(), [Planck.AI.Message.content_part()], map()) :: t()
  def new(role, content, metadata \\ %{}) do
    %__MODULE__{
      id: generate_id(),
      role: role,
      content: content,
      timestamp: DateTime.utc_now(),
      metadata: metadata
    }
  end

  @doc """
  Convert a list of agent messages to `Planck.AI.Message` structs.

  `{:custom, :summary}` messages are converted to `:user` so the LLM sees
  compacted context. All other `{:custom, _}` messages are dropped.
  """
  @spec to_ai_messages([t()]) :: [Planck.AI.Message.t()]
  def to_ai_messages(messages) do
    Enum.flat_map(messages, fn
      %__MODULE__{role: {:custom, :summary}, content: content} ->
        [%Planck.AI.Message{role: :user, content: content}]

      %__MODULE__{role: {:custom, :agent_response}, content: content, metadata: metadata} ->
        [%Planck.AI.Message{role: :user, content: agent_response(content, metadata)}]

      %__MODULE__{role: {:custom, _}} ->
        []

      %__MODULE__{role: role, content: content} ->
        [%Planck.AI.Message{role: role, content: content}]
    end)
  end

  @spec generate_id() :: String.t()
  defp generate_id do
    :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
  end

  @spec agent_response([Planck.AI.Message.content_part()], map()) :: [Planck.AI.Message.t()]
  defp agent_response(content, metadata)

  defp agent_response(content, %{sender_name: name}) when not is_nil(name) do
    Enum.map(content, fn
      {:text, text} -> {:text, "Response from #{name}: #{text}"}
      part -> part
    end)
  end

  defp agent_response(content, _metadata) do
    content
  end
end