defmodule Memory.Presentation do @moduledoc """ Helpers for presenting `%Memory{}` metadata to humans. This module is intentionally small and pure (no I/O) so it can be reused by: - `AI.Tools.Memory` (tool output) - `Cmd.Memory` (CLI output) - `AI.Agent.Coordinator` (auto-injected recall section) Timestamps are stored on `%Memory{}` as ISO8601 strings (`DateTime.to_iso8601/1`). """ @type iso8601 :: binary() @doc """ Parse an ISO8601 timestamp string into a `DateTime`. Returns `{:ok, dt}` or `:error`. """ @spec parse_ts(nil | iso8601()) :: {:ok, DateTime.t()} | :error def parse_ts(nil), do: :error def parse_ts(ts) when is_binary(ts) do case DateTime.from_iso8601(ts) do {:ok, dt, _offset} -> {:ok, dt} _ -> :error end end defp age_days(%DateTime{} = dt, %DateTime{} = now) do secs = DateTime.diff(now, dt, :second) secs = if secs < 0, do: 0, else: secs div(secs, 86_400) end @doc """ Returns an "Age" line for a memory. Examples: - `"Age: unknown (missing timestamps)"` - `"Age: 312 days (updated 12 days ago)"` Notes: - Uses `updated_at` if present; otherwise uses `inserted_at`. - Uses `now` for deterministic testing. - If the first argument is `:error` or any non-struct value (e.g. a parse error), this will return `"Age: unknown (missing timestamps)"`. """ @spec age_line(:error | Memory.t() | term(), DateTime.t()) :: binary() def age_line(mem, %DateTime{} = _now) when not is_struct(mem) do "Age: unknown (missing timestamps)" end def age_line(%Memory{} = mem, %DateTime{} = now) do inserted_days = ts_days(mem.inserted_at, now) updated_days = ts_days(mem.updated_at, now) cond do inserted_days == :unknown and updated_days == :unknown -> "Age: unknown (missing timestamps)" inserted_days != :unknown and updated_days == :unknown -> "Age: #{inserted_days} days" inserted_days == :unknown and updated_days != :unknown -> "Age: #{updated_days} days (updated #{updated_days} days ago)" true -> "Age: #{inserted_days} days (updated #{updated_days} days ago)" end end @doc """ Returns an optional warning line based on how long ago the memory was updated. - If `updated_at` is missing/unparseable, returns `nil`. - If `updated_days >= strong_days`, returns a strong warning. - Else if `updated_days >= mild_days`, returns a mild warning. - Else returns `nil`. This is intended as a gentle prompt to check for cobwebs. If the first argument is `:error` or any non-struct value, this function will return `nil` (no warning). """ @spec warning_line(:error | Memory.t() | term(), DateTime.t(), keyword()) :: binary() | nil def warning_line(mem, now, opts \\ []) def warning_line(mem, %DateTime{} = _now, _opts) when not is_struct(mem) do nil end def warning_line(%Memory{} = mem, %DateTime{} = now, opts) do mild_days = Keyword.get(opts, :mild_days, 180) strong_days = Keyword.get(opts, :strong_days, 365) case ts_days(mem.updated_at, now) do :unknown -> nil days when is_integer(days) and days >= strong_days -> "Warning: last updated #{days} days ago. Check for cobwebs and verify/update/forget if stale." days when is_integer(days) and days >= mild_days -> "Note: last updated #{days} days ago. Consider checking for cobwebs." _ -> nil end end defp ts_days(ts, now) do case parse_ts(ts) do {:ok, dt} -> age_days(dt, now) :error -> :unknown end end end