defmodule Jido.Memory.Runtime do
@moduledoc """
Facade API for writing and retrieving memory records.
Works with either:
- agent/context inputs that already carry memory plugin state, or
- explicit `namespace` and optional `provider` options for non-plugin callers.
"""
alias Jido.Memory.{
CapabilitySet,
ConsolidationResult,
Explanation,
Helpers,
IngestResult,
ProviderInfo,
ProviderRef,
ProviderRegistry,
Query,
Record,
RetrieveResult,
Scope
}
@plugin_state_key :__memory__
@type target :: map() | struct()
@doc "Returns the plugin state key used for memory metadata."
@spec plugin_state_key() :: atom()
def plugin_state_key, do: @plugin_state_key
@doc "Writes a memory record."
@spec remember(target(), map() | keyword(), keyword()) :: {:ok, Record.t()} | {:error, term()}
def remember(target, attrs, opts \\ [])
def remember(target, attrs, opts) when is_list(attrs),
do: remember(target, Map.new(attrs), opts)
def remember(target, attrs, opts) when is_map(attrs) and is_list(opts) do
with_provider(target, attrs, opts, fn provider_mod, provider_opts ->
provider_mod.remember(target, attrs, provider_opts)
end)
end
def remember(_target, _attrs, _opts), do: {:error, :invalid_attrs}
@doc "Reads a single memory record by id."
@spec get(target(), String.t(), keyword()) :: {:ok, Record.t()} | {:error, term()}
def get(target, id, opts \\ [])
def get(target, id, opts) when is_binary(id) and is_list(opts) do
with_provider(target, %{}, opts, fn provider_mod, provider_opts ->
provider_mod.get(target, id, provider_opts)
end)
end
def get(_target, _id, _opts), do: {:error, :invalid_id}
@doc "Deletes a memory record by id and returns whether it existed."
@spec forget(target(), String.t(), keyword()) :: {:ok, boolean()} | {:error, term()}
def forget(target, id, opts \\ [])
def forget(target, id, opts) when is_binary(id) and is_list(opts) do
with_provider(target, %{}, opts, fn provider_mod, provider_opts ->
provider_mod.forget(target, id, provider_opts)
end)
end
def forget(_target, _id, _opts), do: {:error, :invalid_id}
@doc "Canonical retrieval entrypoint that delegates through provider."
@spec retrieve(target(), Query.t() | map() | keyword(), keyword()) ::
{:ok, RetrieveResult.t()} | {:error, term()}
def retrieve(target, query_attrs, opts \\ [])
def retrieve(target, query_attrs, opts) when is_list(query_attrs) and is_list(opts),
do: retrieve(target, Map.new(query_attrs), opts)
def retrieve(target, query_attrs, opts) when is_map(query_attrs) and is_list(opts) do
with_provider(target, query_attrs, opts, fn provider_mod, provider_opts ->
with {:ok, result} <- provider_mod.retrieve(target, query_attrs, provider_opts),
{:ok, query} <- normalize_query(query_attrs) do
normalize_retrieve_result(result, query, provider_mod, target, query_attrs, provider_opts)
end
end)
end
def retrieve(_target, _query, _opts), do: {:error, :invalid_query}
@doc "Returns the canonical capability set for the active provider."
@spec capabilities(target(), keyword()) :: {:ok, CapabilitySet.t()} | {:error, term()}
def capabilities(target, opts \\ []) when is_list(opts) do
with_provider(target, %{}, opts, fn provider_mod, provider_opts ->
load_capability_set(provider_mod, provider_opts)
end)
end
@doc "Returns canonical provider metadata."
@spec info(target(), keyword()) :: {:ok, ProviderInfo.t()} | {:error, term()}
def info(target, opts \\ []) when is_list(opts), do: info(target, opts, :all)
@doc "Returns provider metadata, optionally filtered to selected fields."
@spec info(target(), keyword(), :all | [atom()]) :: {:ok, ProviderInfo.t() | map()} | {:error, term()}
def info(target, opts, fields) when is_list(opts) do
with_provider(target, %{}, opts, fn provider_mod, provider_opts ->
with {:ok, info} <- provider_mod.info(provider_opts, :all),
{:ok, normalized} <- normalize_provider_info(info, provider_mod) do
select_provider_info_fields(normalized, fields)
end
end)
end
@doc "Runs canonical ingestion when supported by the provider."
@spec ingest(target(), map() | keyword(), keyword()) :: {:ok, IngestResult.t()} | {:error, term()}
def ingest(target, request, opts \\ [])
def ingest(target, request, opts) when is_list(request) and is_list(opts),
do: ingest(target, Map.new(request), opts)
def ingest(target, request, opts) when is_map(request) and is_list(opts) do
with_provider_capability(target, request, opts, :ingest, :ingest, 3, fn provider_mod, provider_opts ->
with {:ok, result} <- provider_mod.ingest(target, request, provider_opts) do
normalize_ingest_result(result, provider_mod, target, request, provider_opts)
end
end)
end
def ingest(_target, _request, _opts), do: {:error, :invalid_ingest_request}
@doc "Returns a canonical retrieval explanation when supported by the provider."
@spec explain_retrieval(target(), Query.t() | map() | keyword(), keyword()) ::
{:ok, Explanation.t()} | {:error, term()}
def explain_retrieval(target, query_attrs, opts \\ [])
def explain_retrieval(target, query_attrs, opts) when is_list(query_attrs) and is_list(opts),
do: explain_retrieval(target, Map.new(query_attrs), opts)
def explain_retrieval(target, query_attrs, opts) when is_map(query_attrs) and is_list(opts) do
with_provider_capability(
target,
query_attrs,
opts,
:explain_retrieval,
:explain_retrieval,
3,
fn provider_mod, provider_opts ->
with {:ok, explanation} <- provider_mod.explain_retrieval(target, query_attrs, provider_opts) do
normalize_explanation(explanation, provider_mod, target, query_attrs, provider_opts)
end
end
)
end
def explain_retrieval(_target, _query, _opts), do: {:error, :invalid_query}
@doc "Runs canonical lifecycle consolidation when supported by the provider."
@spec consolidate(target(), keyword()) :: {:ok, ConsolidationResult.t()} | {:error, term()}
def consolidate(target, opts \\ []) when is_list(opts) do
with_provider_capability(target, %{}, opts, :consolidate, :consolidate, 2, fn provider_mod, provider_opts ->
with {:ok, result} <- provider_mod.consolidate(target, provider_opts) do
normalize_consolidation_result(result, provider_mod, target, provider_opts)
end
end)
end
@doc "Prunes expired records in the active store."
@spec prune_expired(target(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
def prune_expired(target, opts \\ []) when is_list(opts) do
with_provider(target, %{}, opts, fn provider_mod, provider_opts ->
provider_mod.prune(target, provider_opts)
end)
end
@doc "Infers provider and provider options for plugin and action code paths."
@spec resolve_provider(target(), map(), keyword()) ::
{:ok, {module(), keyword()}} | {:error, term()}
def resolve_provider(target, attrs, opts) when is_map(attrs) and is_list(opts) do
plugin_state = Helpers.plugin_state(target, @plugin_state_key)
provider_input = Helpers.pick_value(opts, attrs, :provider)
with {:ok, provider_ref} <- ProviderRef.normalize(provider_input),
{:ok, provider_opts} <- merge_provider_opts(provider_ref, plugin_state, attrs, opts),
{:ok, _module} <- ProviderRef.validate(%{provider_ref | opts: provider_opts}) do
{:ok, {provider_ref.module, provider_opts}}
end
end
@spec merge_provider_opts(ProviderRef.t(), map(), map(), keyword()) :: {:ok, keyword()} | {:error, term()}
defp merge_provider_opts(%ProviderRef{module: provider_mod, opts: base_opts}, plugin_state, attrs, opts)
when is_list(base_opts) and is_map(plugin_state) and is_map(attrs) and is_list(opts) do
plugin_state_opts = provider_defaults_from_plugin_state(plugin_state, provider_mod)
attr_defaults = provider_defaults_from_map(attrs, provider_mod)
attr_opts = Helpers.map_get(attrs, :provider_opts, [])
runtime_defaults = provider_defaults_from_opts(opts, provider_mod)
runtime_opts = Keyword.get(opts, :provider_opts, [])
with :ok <- validate_provider_opts_input(plugin_state_opts),
:ok <- validate_provider_opts_input(attr_defaults),
:ok <- validate_provider_opts_input(attr_opts),
:ok <- validate_provider_opts_input(runtime_defaults),
:ok <- validate_provider_opts_input(runtime_opts) do
merged_opts = Keyword.merge(base_opts, plugin_state_opts)
merged_opts = Keyword.merge(merged_opts, attr_defaults)
merged_opts = Keyword.merge(merged_opts, attr_opts)
merged_opts = Keyword.merge(merged_opts, runtime_defaults)
merged_opts = Keyword.merge(merged_opts, runtime_opts)
{:ok, merged_opts}
end
end
defp normalize_retrieve_result(%RetrieveResult{} = result, query, provider_mod, target, attrs, provider_opts) do
{:ok, merge_retrieve_defaults(result, query, provider_mod, target, attrs, provider_opts)}
end
defp normalize_retrieve_result(records, query, provider_mod, target, attrs, provider_opts) when is_list(records) do
{:ok,
RetrieveResult.from_records(records,
query: query,
scope: infer_scope(target, attrs, provider_mod, provider_opts),
provider: infer_provider_info(provider_mod, provider_opts)
)}
end
defp normalize_retrieve_result(%{} = attrs, query, provider_mod, target, query_attrs, provider_opts) do
with {:ok, result} <- RetrieveResult.new(attrs) do
{:ok, merge_retrieve_defaults(result, query, provider_mod, target, query_attrs, provider_opts)}
end
end
defp normalize_retrieve_result(other, _query, _provider_mod, _target, _attrs, _opts),
do: {:error, {:invalid_retrieve_result, other}}
defp merge_retrieve_defaults(%RetrieveResult{} = result, query, provider_mod, target, attrs, provider_opts) do
default_scope = infer_scope(target, attrs, provider_mod, provider_opts)
default_provider = infer_provider_info(provider_mod, provider_opts)
%RetrieveResult{
result
| query: result.query || query,
scope: result.scope || default_scope,
provider: result.provider || default_provider,
total_count:
if(result.total_count == 0 and result.hits != [], do: length(result.hits), else: result.total_count)
}
end
defp normalize_capability_set(%CapabilitySet{} = capabilities, provider_mod) do
capabilities
|> Map.from_struct()
|> Map.put_new(:provider, provider_mod)
|> Map.put_new(:key, ProviderRegistry.key_for(provider_mod))
|> CapabilitySet.new()
end
defp normalize_capability_set(%{} = attrs, provider_mod) do
attrs
|> Map.put_new(:provider, provider_mod)
|> Map.put_new(:key, ProviderRegistry.key_for(provider_mod))
|> CapabilitySet.new()
end
defp normalize_capability_set(values, provider_mod) when is_list(values) do
CapabilitySet.new(%{
provider: provider_mod,
key: ProviderRegistry.key_for(provider_mod),
capabilities: values
})
end
defp normalize_capability_set(other, _provider_mod), do: {:error, {:invalid_capability_set, other}}
defp normalize_provider_info(%ProviderInfo{} = info, provider_mod) do
info
|> Map.from_struct()
|> Map.put_new(:provider, provider_mod)
|> Map.put_new(:key, ProviderRegistry.key_for(provider_mod))
|> ProviderInfo.new()
end
defp normalize_provider_info(%{} = attrs, provider_mod) do
attrs
|> Map.put_new(:provider, provider_mod)
|> Map.put_new(:key, ProviderRegistry.key_for(provider_mod))
|> ProviderInfo.new()
end
defp normalize_provider_info(other, _provider_mod), do: {:error, {:invalid_provider_info, other}}
defp select_provider_info_fields(%ProviderInfo{} = info, :all), do: {:ok, info}
defp select_provider_info_fields(%ProviderInfo{} = info, values) when is_list(values) do
{:ok, Map.take(Map.from_struct(info), values)}
end
defp normalize_ingest_result(%IngestResult{} = result, provider_mod, _target, request, provider_opts) do
{:ok, merge_ingest_defaults(result, provider_mod, request, provider_opts)}
end
defp normalize_ingest_result(%{} = attrs, provider_mod, _target, request, provider_opts) do
with {:ok, result} <- IngestResult.new(attrs) do
{:ok, merge_ingest_defaults(result, provider_mod, request, provider_opts)}
end
end
defp normalize_ingest_result(other, _provider_mod, _target, _request, _opts),
do: {:error, {:invalid_ingest_result, other}}
defp merge_ingest_defaults(%IngestResult{} = result, provider_mod, request, provider_opts) do
%IngestResult{
result
| scope: result.scope || infer_scope_from_request(request, provider_mod, provider_opts),
provider: result.provider || infer_provider_info(provider_mod, provider_opts)
}
end
defp normalize_explanation(%Explanation{} = explanation, provider_mod, target, query, provider_opts) do
{:ok, merge_explanation_defaults(explanation, provider_mod, target, query, provider_opts)}
end
defp normalize_explanation(%{} = attrs, provider_mod, target, query, provider_opts) do
with {:ok, explanation} <- Explanation.new(attrs) do
{:ok, merge_explanation_defaults(explanation, provider_mod, target, query, provider_opts)}
end
end
defp normalize_explanation(other, _provider_mod, _target, _query, _opts),
do: {:error, {:invalid_explanation, other}}
defp merge_explanation_defaults(%Explanation{} = explanation, provider_mod, target, query, provider_opts) do
%Explanation{
explanation
| query: explanation.query || normalize_query!(query),
scope: explanation.scope || infer_scope(target, query, provider_mod, provider_opts),
provider: explanation.provider || infer_provider_info(provider_mod, provider_opts)
}
end
defp normalize_consolidation_result(%ConsolidationResult{} = result, provider_mod, target, provider_opts) do
{:ok, merge_consolidation_defaults(result, provider_mod, target, provider_opts)}
end
defp normalize_consolidation_result(%{} = attrs, provider_mod, target, provider_opts) do
with {:ok, result} <- ConsolidationResult.new(attrs) do
{:ok, merge_consolidation_defaults(result, provider_mod, target, provider_opts)}
end
end
defp normalize_consolidation_result(other, _provider_mod, _target, _opts),
do: {:error, {:invalid_consolidation_result, other}}
defp merge_consolidation_defaults(%ConsolidationResult{} = result, provider_mod, target, provider_opts) do
%ConsolidationResult{
result
| scope: result.scope || infer_scope(target, %{}, provider_mod, provider_opts),
provider: result.provider || infer_provider_info(provider_mod, provider_opts)
}
end
defp load_capability_set(provider_mod, provider_opts) do
case provider_mod.capabilities(provider_opts) do
{:ok, capability_set} -> normalize_capability_set(capability_set, provider_mod)
other -> other
end
end
defp ensure_capability(provider_mod, provider_opts, callback, arity, capability) do
with {:ok, capability_set} <- load_capability_set(provider_mod, provider_opts) do
cond do
not CapabilitySet.supports?(capability_set, capability) ->
{:error, {:unsupported_capability, capability, provider_mod}}
function_exported?(provider_mod, callback, arity) ->
:ok
true ->
{:error, {:invalid_provider_capability, capability, provider_mod}}
end
end
end
defp infer_provider_info(provider_mod, provider_opts) do
case provider_mod.info(provider_opts, :all) do
{:ok, info} ->
case normalize_provider_info(info, provider_mod) do
{:ok, %ProviderInfo{} = normalized} -> normalized
{:error, _reason} -> default_provider_info(provider_mod)
end
{:error, _reason} ->
default_provider_info(provider_mod)
end
end
defp default_provider_info(provider_mod) do
ProviderInfo.new!(%{
name: Scope.provider_name(provider_mod) || "provider",
key: ProviderRegistry.key_for(provider_mod),
provider: provider_mod,
capabilities: []
})
end
defp infer_scope(target, attrs, provider_mod, provider_opts) do
namespace =
Helpers.pick_value(
provider_opts,
attrs,
:namespace,
Helpers.map_get(Helpers.plugin_state(target, @plugin_state_key), :namespace)
)
provider_opts = maybe_put(provider_opts, :namespace, namespace)
Scope.from_provider(provider_mod, provider_opts)
end
defp infer_scope_from_request(request, provider_mod, provider_opts) when is_map(request) do
scope = Helpers.map_get(request, :scope)
case scope do
%Scope{} = scope ->
scope
%{} = scope_map ->
Scope.new!(scope_map)
_ ->
records = Helpers.map_get(request, :records, [])
namespace =
records
|> List.wrap()
|> Enum.find_value(fn
%Record{namespace: namespace} -> namespace
%{} = attrs -> Helpers.map_get(attrs, :namespace)
_ -> nil
end)
Scope.from_provider(provider_mod, maybe_put(provider_opts, :namespace, namespace))
end
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put_new(opts, key, value)
defp with_provider(target, attrs, opts, fun) when is_map(attrs) and is_list(opts) and is_function(fun, 2) do
with {:ok, {provider_mod, provider_opts}} <- resolve_provider(target, attrs, opts),
:ok <- provider_mod.validate_config(provider_opts) do
fun.(provider_mod, provider_opts)
end
end
defp with_provider_capability(target, attrs, opts, capability, callback, arity, fun)
when is_map(attrs) and is_list(opts) and is_function(fun, 2) do
with_provider(target, attrs, opts, fn provider_mod, provider_opts ->
with :ok <- ensure_capability(provider_mod, provider_opts, callback, arity, capability) do
fun.(provider_mod, provider_opts)
end
end)
end
defp normalize_query(%Query{} = query), do: {:ok, query}
defp normalize_query(query_attrs) when is_map(query_attrs), do: Query.new(query_attrs)
defp normalize_query!(query_attrs) do
case normalize_query(query_attrs) do
{:ok, query} -> query
{:error, _reason} -> Query.new!(%{})
end
end
defp validate_provider_opts_input(opts) when is_list(opts), do: :ok
defp validate_provider_opts_input(_), do: {:error, :invalid_provider_opts}
defp provider_defaults_from_plugin_state(plugin_state, provider_mod) when is_map(plugin_state) do
[]
|> maybe_put_provider_opt(:namespace, Helpers.normalize_optional_string(Helpers.map_get(plugin_state, :namespace)))
|> maybe_put_provider_store_defaults(provider_mod, plugin_state)
end
defp provider_defaults_from_map(attrs, provider_mod) when is_map(attrs) do
[]
|> maybe_put_provider_opt(:namespace, Helpers.normalize_optional_string(Helpers.map_get(attrs, :namespace)))
|> maybe_put_provider_store_default(provider_mod, :store, Helpers.map_get(attrs, :store))
|> maybe_put_provider_store_default(
provider_mod,
:store_opts,
normalize_provider_store_opts(Helpers.map_get(attrs, :store_opts))
)
end
defp provider_defaults_from_opts(opts, provider_mod) when is_list(opts) do
[]
|> maybe_put_provider_opt(:namespace, Helpers.normalize_optional_string(Keyword.get(opts, :namespace)))
|> maybe_put_provider_store_default(provider_mod, :store, Keyword.get(opts, :store))
|> maybe_put_provider_store_default(
provider_mod,
:store_opts,
normalize_provider_store_opts(Keyword.get(opts, :store_opts))
)
end
defp maybe_put_provider_opt(opts, _key, nil), do: opts
defp maybe_put_provider_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp maybe_put_provider_store_defaults(opts, provider_mod, plugin_state) do
opts
|> maybe_put_provider_store_default(provider_mod, :store, Helpers.map_get(plugin_state, :store))
end
defp maybe_put_provider_store_default(opts, provider_mod, key, value) do
if store_backed_provider?(provider_mod) do
maybe_put_provider_opt(opts, key, value)
else
opts
end
end
defp store_backed_provider?(Jido.Memory.Provider.Basic), do: true
defp store_backed_provider?(Jido.Memory.Provider.Redis), do: true
defp store_backed_provider?(_provider_mod), do: false
defp normalize_provider_store_opts(nil), do: nil
defp normalize_provider_store_opts(opts) when is_list(opts), do: opts
defp normalize_provider_store_opts(opts), do: opts
end