Current section
Files
Jump to
Current section
Files
lib/adk/memory/in_memory.ex
defmodule ADK.Memory.InMemory do
@moduledoc """
In-memory memory service backed by an ETS table.
Stores session event content indexed by `{app_name, user_id}` for
word-based search. Each entry includes precomputed lowercase word maps
for fast matching.
"""
use GenServer
@behaviour ADK.Memory.Service
alias ADK.Memory.Entry
alias ADK.Types.Content
# -- Client API --
@doc "Starts the InMemory memory service."
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@impl ADK.Memory.Service
def add_session(server, %ADK.Session{} = session) do
GenServer.call(server, {:add_session, session})
end
@impl ADK.Memory.Service
def search(server, opts) do
GenServer.call(server, {:search, opts})
end
# -- GenServer Callbacks --
@impl GenServer
def init(opts) do
table_prefix = Keyword.get(opts, :table_prefix, :adk_memory)
table =
:ets.new(:"#{table_prefix}_entries", [
:set,
:protected,
read_concurrency: true
])
{:ok, %{table: table}}
end
@impl GenServer
def handle_call({:add_session, session}, _from, state) do
entries = extract_entries(session)
key = {session.app_name, session.user_id}
current =
case :ets.lookup(state.table, key) do
[{^key, sessions_map}] -> sessions_map
[] -> %{}
end
updated = Map.put(current, session.id, entries)
:ets.insert(state.table, {key, updated})
{:reply, :ok, state}
end
@impl GenServer
def handle_call({:search, opts}, _from, state) do
query = Keyword.get(opts, :query, "")
app_name = Keyword.fetch!(opts, :app_name)
user_id = Keyword.fetch!(opts, :user_id)
results = do_search(state.table, app_name, user_id, query)
{:reply, {:ok, results}, state}
end
# -- Private Helpers --
defp extract_entries(session) do
session.events
|> Enum.filter(&has_text_content?/1)
|> Enum.map(fn event ->
words = extract_words(event.content)
%{
content: event.content,
author: event.author,
timestamp: event.timestamp,
words: words
}
end)
end
defp has_text_content?(%{content: nil}), do: false
defp has_text_content?(%{content: %Content{parts: parts}}) do
Enum.any?(parts, fn part -> is_binary(part.text) and part.text != "" end)
end
defp extract_words(%Content{parts: parts}) do
parts
|> Enum.flat_map(fn part ->
if is_binary(part.text) do
part.text
|> String.downcase()
|> String.split(~r/[^a-z0-9]+/, trim: true)
else
[]
end
end)
|> Map.new(fn word -> {word, true} end)
end
defp do_search(_table, _app_name, _user_id, ""), do: []
defp do_search(table, app_name, user_id, query) do
query_words =
query
|> String.downcase()
|> String.split(~r/[^a-z0-9]+/, trim: true)
|> Map.new(fn word -> {word, true} end)
if map_size(query_words) == 0 do
[]
else
find_matching_entries(table, app_name, user_id, query_words)
end
end
defp find_matching_entries(table, app_name, user_id, query_words) do
key = {app_name, user_id}
case :ets.lookup(table, key) do
[{^key, sessions_map}] ->
sessions_map
|> Map.values()
|> List.flatten()
|> Enum.filter(fn entry -> words_intersect?(entry.words, query_words) end)
|> Enum.map(fn entry ->
%Entry{
content: entry.content,
author: entry.author,
timestamp: entry.timestamp
}
end)
[] ->
[]
end
end
defp words_intersect?(entry_words, query_words) do
Enum.any?(Map.keys(query_words), fn word ->
Map.has_key?(entry_words, word)
end)
end
end