Skip to main content

lib/jido_memory/basic_plugin.ex

require Jido.Memory.Actions.Forget
require Jido.Memory.Actions.Remember
require Jido.Memory.Actions.Retrieve

defmodule Jido.Memory.BasicPlugin do
  @moduledoc """
  Jido plugin for the built-in basic memory path.

  `BasicPlugin` is the clean Jido integration story in core `jido_memory`:

  - it manages namespace derivation for agents
  - it manages store configuration for the built-in `:basic` provider
  - it exposes memory actions and optional signal auto-capture

  More advanced providers should integrate through `Jido.Memory.Runtime` or
  ship their own package-specific plugin story instead of expanding this plugin
  into a generic backend adapter.
  """

  alias Jido.Memory.Actions.{Forget, Remember, Retrieve}
  alias Jido.Memory.Helpers
  alias Jido.Memory.Runtime
  alias Jido.Memory.Store
  alias Jido.Signal

  @default_store {Jido.Memory.Store.ETS, [table: :jido_memory]}

  @default_capture_patterns [
    "ai.react.query",
    "ai.llm.response",
    "ai.tool.result"
  ]

  @state_schema Zoi.object(%{
                  namespace: Zoi.string() |> Zoi.optional(),
                  store: Zoi.any() |> Zoi.default(@default_store),
                  auto_capture: Zoi.boolean() |> Zoi.default(true),
                  capture_signal_patterns:
                    Zoi.list(Zoi.string())
                    |> Zoi.default(@default_capture_patterns),
                  capture_rules: Zoi.map() |> Zoi.default(%{})
                })

  @config_schema Zoi.object(%{
                   store: Zoi.any() |> Zoi.default(@default_store),
                   store_opts: Zoi.list(Zoi.any()) |> Zoi.default([]),
                   namespace: Zoi.string() |> Zoi.optional(),
                   namespace_mode: Zoi.atom() |> Zoi.default(:per_agent),
                   shared_namespace: Zoi.string() |> Zoi.optional(),
                   auto_capture: Zoi.boolean() |> Zoi.default(true),
                   capture_signal_patterns:
                     Zoi.list(Zoi.string())
                     |> Zoi.default(@default_capture_patterns),
                   capture_rules: Zoi.map() |> Zoi.default(%{})
                 })

  use Jido.Plugin,
    name: "memory",
    state_key: :__memory__,
    actions: [Remember, Retrieve, Forget],
    schema: @state_schema,
    config_schema: @config_schema,
    singleton: true,
    description: "Basic memory plugin with namespace management and optional auto-capture.",
    capabilities: [:memory]

  @impl Jido.Plugin
  def mount(agent, config) do
    config_map = Helpers.normalize_map(config)

    with {:ok, namespace} <- resolve_namespace(agent, config_map),
         {:ok, {store_mod, store_opts}} <- resolve_store(config_map),
         :ok <- Store.validate_options(store_mod, store_opts),
         :ok <- store_mod.ensure_ready(store_opts) do
      {:ok, build_plugin_state(config_map, namespace, {store_mod, store_opts})}
    end
  end

  @impl Jido.Plugin
  def signal_routes(_config) do
    [
      {"remember", Remember},
      {"retrieve", Retrieve},
      {"forget", Forget}
    ]
  end

  @impl Jido.Plugin
  def handle_signal(%Signal{} = signal, context) do
    agent = Helpers.map_get(context, :agent, %{})
    plugin_state = Helpers.plugin_state(agent, Runtime.plugin_state_key())

    _ = maybe_capture_signal(signal, agent, plugin_state)

    {:ok, :continue}
  rescue
    _ -> {:ok, :continue}
  end

  @impl Jido.Plugin
  def on_checkpoint(_plugin_state, _context), do: :keep

  @impl Jido.Plugin
  def on_restore(pointer, _context) when is_map(pointer) do
    state_map = Helpers.normalize_map(pointer)
    namespace = Helpers.normalize_optional_string(Helpers.map_get(state_map, :namespace))

    with {:ok, {store_mod, store_opts}} <- resolve_store(state_map),
         :ok <- Store.validate_options(store_mod, store_opts) do
      {:ok, build_plugin_state(state_map, namespace, {store_mod, store_opts})}
    end
  end

  def on_restore(_pointer, _context), do: {:ok, nil}

  defp build_plugin_state(source, namespace, store) do
    %{
      namespace: Helpers.normalize_optional_string(namespace),
      store: store,
      auto_capture: Helpers.map_get(source, :auto_capture, true),
      capture_signal_patterns: Helpers.map_get(source, :capture_signal_patterns, @default_capture_patterns),
      capture_rules: Helpers.map_get(source, :capture_rules, %{})
    }
  end

  @spec resolve_store(map()) :: {:ok, {module(), keyword()}} | {:error, term()}
  defp resolve_store(config) do
    store_value = Helpers.map_get(config, :store, @default_store)
    override_opts = Helpers.map_get(config, :store_opts, [])

    with {:ok, {store_mod, base_opts}} <- Store.normalize_store(store_value),
         :ok <- validate_store_opts_shape(base_opts),
         :ok <- validate_store_opts_shape(override_opts) do
      {:ok, {store_mod, Keyword.merge(base_opts, override_opts)}}
    else
      {:error, _} = error -> error
    end
  end

  defp validate_store_opts_shape(opts) do
    if Keyword.keyword?(opts), do: :ok, else: {:error, :invalid_store_opts}
  end

  @spec resolve_namespace(map(), map()) :: {:ok, String.t()} | {:error, term()}
  defp resolve_namespace(agent, config) do
    explicit = Helpers.normalize_optional_string(Helpers.map_get(config, :namespace))

    if is_binary(explicit) do
      {:ok, explicit}
    else
      resolve_namespace_by_mode(agent, config, Helpers.map_get(config, :namespace_mode, :per_agent))
    end
  end

  @spec build_capture_attrs(Signal.t(), map()) :: map() | :skip
  defp build_capture_attrs(%Signal{} = signal, plugin_state) do
    signal
    |> capture_attrs_for_signal()
    |> maybe_apply_capture_rule(signal.type, Helpers.map_get(plugin_state, :capture_rules, %{}))
  end

  defp maybe_capture_signal(signal, agent, plugin_state) do
    if capture_signal?(signal.type, plugin_state) do
      case build_capture_attrs(signal, plugin_state) do
        :skip -> :ok
        attrs when is_map(attrs) -> Runtime.remember(agent, attrs, [])
      end
    else
      :ok
    end
  end

  defp capture_signal?(type, plugin_state) when is_binary(type) do
    Helpers.map_get(plugin_state, :auto_capture, true) and
      signal_matches_any?(type, Helpers.map_get(plugin_state, :capture_signal_patterns, @default_capture_patterns))
  end

  defp capture_attrs_for_signal(%Signal{type: "ai.react.query"} = signal) do
    data = normalize_data(signal.data)

    capture_attrs(signal,
      class: :episodic,
      kind: :user_query,
      text: pick(data, :query) || pick(data, :text),
      content: data,
      tags: ["ai", "query", signal.type]
    )
  end

  defp capture_attrs_for_signal(%Signal{type: "ai.llm.response"} = signal) do
    data = normalize_data(signal.data)

    capture_attrs(signal,
      class: :episodic,
      kind: :assistant_response,
      text: extract_llm_text(data),
      content: data,
      tags: ["ai", "llm", signal.type]
    )
  end

  defp capture_attrs_for_signal(%Signal{type: "ai.tool.result"} = signal) do
    data = normalize_data(signal.data)
    tool_name = pick(data, :tool_name) || pick(data, :name) || "tool"

    capture_attrs(signal,
      class: :episodic,
      kind: :tool_result,
      text: "#{tool_name} result",
      content: data,
      tags: ["ai", "tool", tool_name, signal.type]
    )
  end

  defp capture_attrs_for_signal(%Signal{} = signal) do
    capture_attrs(signal,
      class: :working,
      kind: :signal_event,
      text: signal.type,
      content: normalize_data(signal.data),
      tags: ["signal", signal.type]
    )
  end

  defp capture_attrs(signal, opts) do
    %{
      class: Keyword.fetch!(opts, :class),
      kind: Keyword.fetch!(opts, :kind),
      text: Keyword.fetch!(opts, :text),
      content: Keyword.fetch!(opts, :content),
      tags: Keyword.fetch!(opts, :tags),
      source: signal.source,
      observed_at: signal_timestamp(signal),
      metadata: base_metadata(signal)
    }
  end

  defp resolve_namespace_by_mode(_agent, config, :shared) do
    shared = Helpers.normalize_optional_string(Helpers.map_get(config, :shared_namespace)) || "default"
    {:ok, "shared:" <> shared}
  end

  defp resolve_namespace_by_mode(agent, _config, _mode) do
    case Helpers.target_id(agent) do
      id when is_binary(id) and id != "" -> {:ok, "agent:" <> id}
      _ -> {:error, :namespace_required}
    end
  end

  @spec maybe_apply_capture_rule(map(), String.t(), map()) :: map() | :skip
  defp maybe_apply_capture_rule(base, signal_type, rules) when is_map(rules) do
    rule = Map.get(rules, signal_type) || Map.get(rules, safe_existing_atom(signal_type))

    case rule do
      %{skip: true} ->
        :skip

      %{} ->
        merged =
          base
          |> maybe_override(rule, :class)
          |> maybe_override(rule, :kind)
          |> maybe_override(rule, :text)
          |> maybe_override(rule, :source)
          |> merge_tags(rule)

        Map.update(merged, :metadata, %{}, fn metadata ->
          Map.merge(metadata, Helpers.map_get(rule, :metadata, %{}))
        end)

      _ ->
        base
    end
  end

  defp maybe_apply_capture_rule(base, _signal_type, _rules), do: base

  @spec maybe_override(map(), map(), atom()) :: map()
  defp maybe_override(base, rule, key) do
    value = Helpers.map_get(rule, key)
    if is_nil(value), do: base, else: Map.put(base, key, value)
  end

  @spec merge_tags(map(), map()) :: map()
  defp merge_tags(base, rule) do
    case Helpers.map_get(rule, :tags) do
      tags when is_list(tags) ->
        tags = Enum.map(tags, &to_string/1)
        Map.put(base, :tags, Enum.uniq((base.tags || []) ++ tags))

      _ ->
        base
    end
  end

  @spec extract_llm_text(map()) :: String.t() | nil
  defp extract_llm_text(data) do
    pick(data, :text) ||
      pick(data, :answer) ||
      pick(data, :content) ||
      case pick(data, :result) do
        nil -> nil
        result when is_binary(result) -> result
        result -> inspect(result)
      end
  end

  @spec signal_timestamp(Signal.t()) :: integer()
  defp signal_timestamp(%Signal{time: nil}), do: System.system_time(:millisecond)

  defp signal_timestamp(%Signal{time: time}) when is_binary(time) do
    case DateTime.from_iso8601(time) do
      {:ok, dt, _offset} -> DateTime.to_unix(dt, :millisecond)
      _ -> System.system_time(:millisecond)
    end
  end

  defp signal_timestamp(_), do: System.system_time(:millisecond)

  @spec base_metadata(Signal.t()) :: map()
  defp base_metadata(%Signal{} = signal) do
    %{
      signal_id: signal.id,
      signal_type: signal.type,
      subject: signal.subject
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Map.new()
  end

  @spec normalize_data(term()) :: map()
  defp normalize_data(%{} = data), do: data
  defp normalize_data(nil), do: %{}
  defp normalize_data(data), do: %{value: data}

  @spec signal_matches_any?(String.t(), [String.t()]) :: boolean()
  defp signal_matches_any?(_type, []), do: false

  defp signal_matches_any?(type, patterns) when is_binary(type) and is_list(patterns) do
    Enum.any?(patterns, &signal_type_matches?(type, &1))
  end

  @spec signal_type_matches?(String.t(), String.t()) :: boolean()
  defp signal_type_matches?(type, pattern) when type == pattern, do: true

  defp signal_type_matches?(type, pattern) do
    cond do
      String.ends_with?(pattern, ".*") ->
        prefix = String.trim_trailing(pattern, ".*")
        String.starts_with?(type, prefix <> ".")

      String.contains?(pattern, "*") ->
        regex =
          pattern
          |> Regex.escape()
          |> String.replace("\\*", "[^.]*")

        Regex.match?(~r/^#{regex}$/, type)

      true ->
        false
    end
  end

  @spec pick(map(), atom()) :: term()
  defp pick(map, key), do: Helpers.map_get(map, key)

  @spec safe_existing_atom(String.t()) :: atom() | nil
  defp safe_existing_atom(value) when is_binary(value) do
    String.to_existing_atom(value)
  rescue
    ArgumentError -> nil
  end
end