Packages
nous
0.17.0
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/memory/store/ets.ex
defmodule Nous.Memory.Store.ETS do
@moduledoc """
ETS-backed memory store implementation.
Uses an unnamed ETS table so multiple instances can coexist.
Text search uses `String.jaro_distance/2` for fuzzy matching.
Does not implement `search_vector/3` (no vector support in ETS).
"""
@behaviour Nous.Memory.Store
alias Nous.Memory.Entry
@impl true
def init(_opts) do
table = :ets.new(:memory_store, [:set, :public])
{:ok, table}
end
@impl true
def store(table, %Entry{} = entry) do
:ets.insert(table, {entry.id, entry})
{:ok, table}
end
@impl true
def fetch(table, id) do
case :ets.lookup(table, id) do
[{^id, entry}] -> {:ok, entry}
[] -> {:error, :not_found}
end
end
@impl true
def delete(table, id) do
:ets.delete(table, id)
{:ok, table}
end
@impl true
def update(table, id, updates) do
case fetch(table, id) do
{:ok, entry} ->
now = DateTime.utc_now()
updated = struct(entry, Map.put(updates, :updated_at, now))
:ets.insert(table, {id, updated})
{:ok, table}
error ->
error
end
end
@impl true
def search_text(table, query, opts) do
scope = Keyword.get(opts, :scope, %{})
limit = Keyword.get(opts, :limit, 10)
min_score = Keyword.get(opts, :min_score, 0.0)
results =
table
|> scoped_entries(scope)
|> Enum.map(fn entry ->
score = String.jaro_distance(String.downcase(query), String.downcase(entry.content))
{entry, score}
end)
|> Enum.filter(fn {_entry, score} -> score > min_score end)
|> Enum.sort_by(fn {_entry, score} -> score end, :desc)
|> Enum.take(limit)
{:ok, results}
end
@impl true
def list(table, opts) do
scope = Keyword.get(opts, :scope, %{})
entries =
table
|> scoped_entries(scope)
{:ok, entries}
end
# Push the scope filter INTO ETS via a partial-map matchspec, so we only copy
# (and later jaro-score) the matching rows instead of tab2list-copying the
# whole table and filtering in Elixir. At 10k entries a scoped search copied
# ~49 MB (the full table) under tab2list; this drops it to just the matches.
#
# An empty scope still copies everything — unavoidable for a full scan, and
# the documented scaling ceiling for the ETS store. A scope carrying a key
# that isn't an Entry field can't be pushed down (a partial-map pattern
# requires the key to exist on the struct), so we fall back to copy+filter to
# stay behavior-identical.
# A non-map scope (e.g. :global) means "no scope" — return everything.
defp scoped_entries(table, scope) when not is_map(scope), do: all_entries(table)
defp scoped_entries(table, scope) when map_size(scope) == 0, do: all_entries(table)
defp scoped_entries(table, scope) do
if scope_pushable?(scope) do
pattern = {:_, Map.put(scope, :__struct__, Entry)}
table
|> :ets.select([{pattern, [], [:"$_"]}])
|> Enum.map(fn {_id, entry} -> entry end)
else
table |> all_entries() |> filter_by_scope(scope)
end
end
defp scope_pushable?(scope) do
entry_fields = %Entry{} |> Map.from_struct() |> Map.keys()
Enum.all?(Map.keys(scope), &(&1 in entry_fields))
end
defp all_entries(table) do
:ets.tab2list(table) |> Enum.map(fn {_id, entry} -> entry end)
end
# Fallback path only — reached when scope is non-empty AND has a non-Entry
# key (scoped_entries/2 short-circuits the empty-scope case).
defp filter_by_scope(entries, scope) do
Enum.filter(entries, fn entry ->
Enum.all?(scope, fn {key, value} ->
Map.get(entry, key) == value
end)
end)
end
end