Packages
nous
0.12.17
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/sqlite.ex
if Code.ensure_loaded?(Exqlite) do
defmodule Nous.Memory.Store.SQLite do
@moduledoc """
SQLite-backed memory store using Exqlite.
Uses FTS5 for full-text search and stores embeddings as JSON-encoded blobs.
Vector search is implemented via in-Elixir cosine similarity computation.
## Options
* `:path` - database file path (default: `":memory:"`)
"""
@behaviour Nous.Memory.Store
alias Nous.Memory.Entry
@create_memories """
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'semantic',
importance REAL NOT NULL DEFAULT 0.5,
evergreen INTEGER NOT NULL DEFAULT 0,
embedding BLOB,
agent_id TEXT,
session_id TEXT,
user_id TEXT,
namespace TEXT,
metadata_json TEXT DEFAULT '{}',
access_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_accessed_at TEXT NOT NULL
)
"""
@create_fts """
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, content, tokenize='porter unicode61'
)
"""
@create_idx_agent "CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id)"
@create_idx_user "CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id)"
@create_idx_session "CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id)"
@impl true
def init(opts) do
path = Keyword.get(opts, :path, ":memory:")
with {:ok, conn} <- Exqlite.Sqlite3.open(path),
:ok <- Exqlite.Sqlite3.execute(conn, @create_memories),
:ok <- Exqlite.Sqlite3.execute(conn, @create_fts),
:ok <- Exqlite.Sqlite3.execute(conn, @create_idx_agent),
:ok <- Exqlite.Sqlite3.execute(conn, @create_idx_user),
:ok <- Exqlite.Sqlite3.execute(conn, @create_idx_session) do
{:ok, conn}
end
end
@impl true
def store(conn, %Entry{} = entry) do
sql = """
INSERT INTO memories (id, content, type, importance, evergreen, embedding,
agent_id, session_id, user_id, namespace, metadata_json, access_count,
created_at, updated_at, last_accessed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
"""
fts_sql = "INSERT INTO memories_fts (id, content) VALUES (?1, ?2)"
params = [
entry.id,
entry.content,
to_string(entry.type),
entry.importance,
bool_to_int(entry.evergreen),
encode_embedding(entry.embedding),
entry.agent_id,
entry.session_id,
entry.user_id,
entry.namespace,
JSON.encode!(entry.metadata || %{}),
entry.access_count,
datetime_to_iso(entry.created_at),
datetime_to_iso(entry.updated_at),
datetime_to_iso(entry.last_accessed_at)
]
with {:ok, stmt} <- Exqlite.Sqlite3.prepare(conn, sql),
:ok <- bind_and_step(conn, stmt, params),
{:ok, fts_stmt} <- Exqlite.Sqlite3.prepare(conn, fts_sql),
:ok <- bind_and_step(conn, fts_stmt, [entry.id, entry.content]) do
{:ok, conn}
end
end
@impl true
def fetch(conn, id) do
sql = "SELECT * FROM memories WHERE id = ?1"
with {:ok, stmt} <- Exqlite.Sqlite3.prepare(conn, sql),
:ok <- Exqlite.Sqlite3.bind(conn, stmt, [id]) do
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, row} ->
columns = Exqlite.Sqlite3.columns(conn, stmt)
Exqlite.Sqlite3.release(conn, stmt)
{:ok, row_to_entry(columns, row)}
:done ->
Exqlite.Sqlite3.release(conn, stmt)
{:error, :not_found}
end
end
end
@impl true
def delete(conn, id) do
mem_sql = "DELETE FROM memories WHERE id = ?1"
fts_sql = "DELETE FROM memories_fts WHERE id = ?1"
with {:ok, stmt} <- Exqlite.Sqlite3.prepare(conn, mem_sql),
:ok <- bind_and_step(conn, stmt, [id]),
{:ok, fts_stmt} <- Exqlite.Sqlite3.prepare(conn, fts_sql),
:ok <- bind_and_step(conn, fts_stmt, [id]) do
{:ok, conn}
end
end
@impl true
def update(conn, id, updates) when is_map(updates) do
case fetch(conn, id) do
{:ok, entry} ->
now = DateTime.utc_now()
updates = Map.put(updates, :updated_at, now)
updated = struct(entry, updates)
set_clauses =
updates
|> Map.keys()
|> Enum.map(&field_to_column/1)
|> Enum.with_index(1)
|> Enum.map(fn {col, idx} -> "#{col} = ?#{idx}" end)
|> Enum.join(", ")
params =
updates
|> Map.keys()
|> Enum.map(fn key -> encode_field(key, Map.get(updated, key)) end)
sql = "UPDATE memories SET #{set_clauses} WHERE id = ?#{map_size(updates) + 1}"
params = params ++ [id]
with {:ok, stmt} <- Exqlite.Sqlite3.prepare(conn, sql),
:ok <- bind_and_step(conn, stmt, params) do
if Map.has_key?(updates, :content) do
update_fts(conn, id, updated.content)
end
{:ok, conn}
end
error ->
error
end
end
@impl true
def search_text(conn, query, opts) do
scope = Keyword.get(opts, :scope, %{})
limit = Keyword.get(opts, :limit, 10)
{scope_sql, scope_params} = build_scope_clause(scope, 1)
sql = """
SELECT m.*, bm25(memories_fts) AS rank
FROM memories_fts f
JOIN memories m ON m.id = f.id
WHERE memories_fts MATCH ?#{map_size(scope) + 1}
#{scope_sql |> String.replace("WHERE", "AND")}
ORDER BY rank
LIMIT ?#{map_size(scope) + 2}
"""
# Escape FTS5 special characters by wrapping each term in double quotes
escaped_query =
query
|> String.split(~r/\s+/, trim: true)
|> Enum.map(&"\"#{&1}\"")
|> Enum.join(" ")
params = scope_params ++ [escaped_query, limit]
case query_all(conn, sql, params) do
{:ok, rows, columns} ->
# bm25 returns negative values (closer to 0 = better match)
# Normalize to 0..1 range where 1 is best
entries_with_scores =
rows
|> Enum.map(fn row ->
rank_idx = Enum.find_index(columns, &(&1 == "rank"))
rank = Enum.at(row, rank_idx)
entry = row_to_entry(columns, row)
{entry, normalize_bm25(rank)}
end)
{:ok, entries_with_scores}
error ->
error
end
end
@impl true
def search_vector(conn, embedding, opts) when is_list(embedding) do
scope = Keyword.get(opts, :scope, %{})
limit = Keyword.get(opts, :limit, 10)
min_score = Keyword.get(opts, :min_score, 0.0)
{scope_sql, scope_params} = build_scope_clause(scope, 0)
sql = """
SELECT * FROM memories
WHERE embedding IS NOT NULL
#{scope_sql |> String.replace("WHERE", "AND")}
"""
case query_all(conn, sql, scope_params) do
{:ok, rows, columns} ->
results =
rows
|> Enum.map(fn row ->
entry = row_to_entry(columns, row)
score = cosine_similarity(embedding, entry.embedding || [])
{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}
error ->
error
end
end
@impl true
def list(conn, opts) do
scope = Keyword.get(opts, :scope, %{})
{scope_sql, scope_params} = build_scope_clause(scope, 0)
sql = "SELECT * FROM memories #{scope_sql}"
case query_all(conn, sql, scope_params) do
{:ok, rows, columns} ->
{:ok, Enum.map(rows, &row_to_entry(columns, &1))}
error ->
error
end
end
# -- Private helpers --
defp bind_and_step(conn, stmt, params) do
with :ok <- Exqlite.Sqlite3.bind(conn, stmt, params) do
case Exqlite.Sqlite3.step(conn, stmt) do
:done ->
Exqlite.Sqlite3.release(conn, stmt)
:ok
{:row, _} ->
Exqlite.Sqlite3.release(conn, stmt)
:ok
{:error, _} = error ->
Exqlite.Sqlite3.release(conn, stmt)
error
end
end
end
defp query_all(conn, sql, params) do
with {:ok, stmt} <- Exqlite.Sqlite3.prepare(conn, sql),
:ok <- Exqlite.Sqlite3.bind(conn, stmt, params) do
columns = Exqlite.Sqlite3.columns(conn, stmt)
rows = fetch_rows(conn, stmt, [])
Exqlite.Sqlite3.release(conn, stmt)
{:ok, rows, columns}
end
end
defp fetch_rows(conn, stmt, acc) do
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, row} -> fetch_rows(conn, stmt, [row | acc])
:done -> Enum.reverse(acc)
end
end
defp row_to_entry(columns, row) do
map =
columns
|> Enum.zip(row)
|> Map.new()
%Entry{
id: map["id"],
content: map["content"],
type: String.to_existing_atom(map["type"]),
importance: map["importance"] || 0.5,
evergreen: int_to_bool(map["evergreen"]),
embedding: decode_embedding(map["embedding"]),
agent_id: map["agent_id"],
session_id: map["session_id"],
user_id: map["user_id"],
namespace: map["namespace"],
metadata: decode_json(map["metadata_json"]),
access_count: map["access_count"] || 0,
created_at: parse_datetime(map["created_at"]),
updated_at: parse_datetime(map["updated_at"]),
last_accessed_at: parse_datetime(map["last_accessed_at"])
}
end
defp build_scope_clause(scope, param_offset) when map_size(scope) == 0,
do: {"", List.duplicate(nil, 0) |> then(fn _ -> [] end)}
defp build_scope_clause(scope, param_offset) do
{clauses, params} =
scope
|> Enum.with_index(param_offset + 1)
|> Enum.map(fn {{key, value}, idx} ->
col = field_to_column(key)
{"#{col} = ?#{idx}", value}
end)
|> Enum.unzip()
sql = "WHERE " <> Enum.join(clauses, " AND ")
{sql, params}
end
defp update_fts(conn, id, new_content) do
del_sql = "DELETE FROM memories_fts WHERE id = ?1"
ins_sql = "INSERT INTO memories_fts (id, content) VALUES (?1, ?2)"
with {:ok, del_stmt} <- Exqlite.Sqlite3.prepare(conn, del_sql),
:ok <- bind_and_step(conn, del_stmt, [id]),
{:ok, ins_stmt} <- Exqlite.Sqlite3.prepare(conn, ins_sql),
:ok <- bind_and_step(conn, ins_stmt, [id, new_content]) do
:ok
end
end
defp field_to_column(:metadata), do: "metadata_json"
defp field_to_column(field), do: to_string(field)
defp encode_field(:embedding, val), do: encode_embedding(val)
defp encode_field(:metadata, val), do: JSON.encode!(val || %{})
defp encode_field(:evergreen, val), do: bool_to_int(val)
defp encode_field(:type, val), do: to_string(val)
defp encode_field(key, val)
when key in [:created_at, :updated_at, :last_accessed_at],
do: datetime_to_iso(val)
defp encode_field(_key, val), do: val
defp encode_embedding(nil), do: nil
defp encode_embedding(list) when is_list(list), do: JSON.encode!(list)
defp decode_embedding(nil), do: nil
defp decode_embedding(blob) when is_binary(blob), do: JSON.decode!(blob)
defp decode_json(nil), do: %{}
defp decode_json(str) when is_binary(str), do: decode_json_atoms(str)
defp decode_json_atoms(str) when is_binary(str), do: str |> JSON.decode!() |> atomize_keys()
defp atomize_keys(map) when is_map(map),
do: Map.new(map, fn {k, v} -> {String.to_existing_atom(k), v} end)
defp bool_to_int(true), do: 1
defp bool_to_int(false), do: 0
defp bool_to_int(nil), do: 0
defp int_to_bool(1), do: true
defp int_to_bool(0), do: false
defp int_to_bool(nil), do: false
defp datetime_to_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp datetime_to_iso(nil), do: DateTime.to_iso8601(DateTime.utc_now())
defp parse_datetime(nil), do: nil
defp parse_datetime(str) when is_binary(str) do
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> dt
_ -> nil
end
end
defp normalize_bm25(rank) when is_number(rank) do
# bm25() returns negative values; closer to 0 = better
# Convert to 0..1 scale: score = 1 / (1 + abs(rank))
1.0 / (1.0 + abs(rank))
end
defp normalize_bm25(_), do: 0.0
defp cosine_similarity([], _), do: 0.0
defp cosine_similarity(_, []), do: 0.0
defp cosine_similarity(a, b) when length(a) != length(b), do: 0.0
defp cosine_similarity(a, b) do
dot = Enum.zip(a, b) |> Enum.reduce(0.0, fn {x, y}, acc -> acc + x * y end)
mag_a = :math.sqrt(Enum.reduce(a, 0.0, fn x, acc -> acc + x * x end))
mag_b = :math.sqrt(Enum.reduce(b, 0.0, fn x, acc -> acc + x * x end))
if mag_a == 0.0 or mag_b == 0.0, do: 0.0, else: dot / (mag_a * mag_b)
end
end
else
defmodule Nous.Memory.Store.SQLite do
@moduledoc """
SQLite-backed memory store (stub).
Add `{:exqlite, "~> 0.27"}` to your dependencies to enable this store.
"""
@behaviour Nous.Memory.Store
@dialyzer {:nowarn_function,
init: 1,
store: 2,
fetch: 2,
delete: 2,
update: 3,
search_text: 3,
search_vector: 3,
list: 2}
@error {:error, "Exqlite is not available. Add {:exqlite, \"~> 0.27\"} to your dependencies."}
@impl true
def init(_opts), do: @error
@impl true
def store(_state, _entry), do: @error
@impl true
def fetch(_state, _id), do: @error
@impl true
def delete(_state, _id), do: @error
@impl true
def update(_state, _id, _updates), do: @error
@impl true
def search_text(_state, _query, _opts), do: @error
@impl true
def search_vector(_state, _embedding, _opts), do: @error
@impl true
def list(_state, _opts), do: @error
end
end