defmodule AgentSea.Memory.Vector do
@moduledoc """
Vector-backed conversation memory: messages are embedded and indexed, so
`search/2` recalls the most *relevant* past messages (not just the most
recent). Wraps an `AgentSea.Embeddings` handle (any embedder + vector store).
`load/1` still returns a conversation's messages in order; `search/2` does
semantic retrieval across stored memory. A singleton (like
`AgentSea.Memory.Buffer`); start it with an `:embeddings` handle.
"""
@behaviour AgentSea.Memory
use GenServer
alias AgentSea.Embeddings
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl AgentSea.Memory
def save(conversation_id, messages),
do: GenServer.call(__MODULE__, {:save, conversation_id, messages})
@impl AgentSea.Memory
def load(conversation_id), do: GenServer.call(__MODULE__, {:load, conversation_id})
@impl AgentSea.Memory
def clear(conversation_id), do: GenServer.call(__MODULE__, {:clear, conversation_id})
@impl AgentSea.Memory
def search(query, limit), do: GenServer.call(__MODULE__, {:search, query, limit})
# --- Server ---
@impl GenServer
def init(opts) do
{:ok, %{embeddings: Keyword.fetch!(opts, :embeddings), store: %{}}}
end
@impl GenServer
def handle_call({:save, id, messages}, _from, state) do
state = delete_conversation(state, id)
entries =
Enum.with_index(messages, fn message, index ->
%{
id: "#{id}:#{index}",
text: to_string(message.content),
metadata: %{"conv_id" => id, "role" => to_string(message.role)}
}
end)
Embeddings.index(state.embeddings, entries)
entry = %{messages: messages, ids: Enum.map(entries, & &1.id)}
{:reply, :ok, %{state | store: Map.put(state.store, id, entry)}}
end
def handle_call({:load, id}, _from, state) do
messages = with %{messages: messages} <- Map.get(state.store, id), do: messages
{:reply, messages || [], state}
end
def handle_call({:clear, id}, _from, state) do
{:reply, :ok, delete_conversation(state, id)}
end
def handle_call({:search, query, limit}, _from, state) do
messages =
case Embeddings.search(state.embeddings, query, limit) do
hits when is_list(hits) -> Enum.map(hits, &hit_to_message/1)
{:error, _reason} -> []
end
{:reply, messages, state}
end
# --- Helpers ---
defp delete_conversation(state, id) do
case Map.get(state.store, id) do
%{ids: ids} ->
handle = state.embeddings
handle.store_mod.delete(handle.store, ids)
%{state | store: Map.delete(state.store, id)}
nil ->
state
end
end
defp hit_to_message(hit) do
%{role: role(get_in(hit, [:metadata, "role"])), content: hit.text}
end
defp role("assistant"), do: :assistant
defp role("system"), do: :system
defp role("tool"), do: :tool
defp role(_), do: :user
end