defmodule AI.Tools.Memory do alias Memory.Presentation @behaviour AI.Tools @impl AI.Tools def async?, do: true @impl AI.Tools def is_available?(), do: Memory.is_available?() @impl AI.Tools def ui_note_on_request(%{"action" => "list"}) do {"Listing memories", "Listing all memories grouped by scope (session, project, global)."} end def ui_note_on_request(%{"action" => "recall", "scope" => scope, "what" => what}) do {"Recalling memories", "<#{scope}> " <> what} end def ui_note_on_request(%{"action" => "remember", "scope" => scope, "title" => title}) do {"Note to self", "<#{scope}> " <> title} end def ui_note_on_request(%{"action" => "update", "scope" => scope, "title" => title}) do {"Note to self", "<#{scope}> " <> title} end def ui_note_on_request(%{"action" => "forget", "scope" => scope, "title" => title}) do {"Forgetting", "<#{scope}> " <> title} end def ui_note_on_request(_), do: nil @impl AI.Tools def ui_note_on_result(_request, _result), do: nil @impl AI.Tools def tool_call_failure_message(_args, _reason), do: :default @impl AI.Tools def read_args(args) do args = args |> normalize_arg_topics("topics") |> normalize_arg_topics("new_topics") {:ok, args} end # Normalize string-valued topic fields to lists before schema validation. # LLMs sometimes send comma- or pipe-separated strings instead of arrays. defp normalize_arg_topics(args, key) do case Map.get(args, key) do val when is_binary(val) -> Map.put(args, key, normalize_topics(val)) _ -> args end end @impl AI.Tools def spec do %{ type: "function", function: %{ name: "memory_tool", description: """ Tool for session-scoped memory operations and recall across scopes. - Writes (remember/update/forget) performed via this tool are session-scoped: they are persisted to the current conversation's memory store. These session memories are intended to be short-term and may be later *promoted* to project/global memory by the background indexer. - Use this tool to record ephemeral or session-relevant facts (goals, short-term decisions, troubleshooting notes). Do NOT attempt to persist stable, cross-project facts here; the background indexer will surface candidates and promote them to long-term storage when appropriate. - Recall (action=recall) will search across memory scopes (session, project, global) to provide Candidate memories and provenance to the caller. Hard rules: - memory_tool is the short-term tool for session memories. Do not call 'long_term_memory_tool' from Coordinator flows; long-term writes are handled by the indexer/ingest process. - Do NOT store conversation IDs, ephemeral commit hashes, or secrets in long-term memory. Focus on stable facts, preferences, and project conventions. """, parameters: %{ type: "object", additionalProperties: false, required: ["action"], properties: %{ "action" => %{ type: "string", enum: ["list", "recall", "remember", "update", "forget"], description: """ Which memory operation to perform. - list: List all memories - args: none - recall: Search for similar memories - args: - required: what - optional: limit - remember: Create a new memory - args: - required: scope, title, content - optional: topics - update: Append content to an existing memory - args: - required: scope, title, new_content - optional: new_topics - forget: Delete a memory - args: - required: title - optional: scope """ }, "scope" => %{ type: "string", enum: ["session"], description: "Memory scope for remember/update/forget operations. This tool accepts only 'session' scope; use the long_term_memory_tool for project or global memories." }, "what" => %{ type: "string", description: "Text to search for similar memories (action=recall)." }, "limit" => %{ type: "integer", description: "Maximum number of memories to return (action=recall).", default: 5 }, "title" => %{ type: "string", description: "Title of the memory (remember/update/forget). Short free-form plain-English title (max 200 chars). Titles are trimmed and must contain at least one letter or digit. Titles are unique within the specified scope. Internal filename slugs are derived from titles and the system will disambiguate filenames automatically if needed." }, "content" => %{ type: "string", description: "Content of the memory (remember)." }, "topics" => %{ type: "array", items: %{type: "string"}, description: "Optional list of topics related to the memory (remember)." }, "new_content" => %{ type: "string", description: "Content to append to an existing memory (update)." }, "new_topics" => %{ type: "array", items: %{type: "string"}, description: "Optional list of new topics to add to the memory (update)." } } } } } end @impl AI.Tools def call(%{"action" => "list"} = _args), do: do_list() def call(%{"action" => "recall"} = args), do: do_recall(args) def call(%{"action" => "remember"} = args), do: do_remember(args) def call(%{"action" => "update"} = args), do: do_update(args) def call(%{"action" => "forget"} = args), do: do_forget(args) def call(_), do: {:error, "Invalid or missing 'action' for memory_tool"} # --------------------------------------------------------------------------- # Operations # --------------------------------------------------------------------------- @default_search_limit 5 defp do_list() do case Memory.list() do {:ok, memories} -> {:ok, Enum.map(memories, &format_memory/1)} {:error, reason} -> {:error, inspect(reason)} end end defp do_recall(%{"what" => query} = args) do limit = Map.get(args, "limit", @default_search_limit) query |> String.trim() |> Memory.search(limit) |> case do {:ok, results} -> payload = results |> Enum.map(fn {:error, reason} -> # Handle individual memory read errors gracefully %{error: inspect(reason)} {mem, score} -> # Drop embeddings from the result for cleaner JSON output %{ title: mem.title, scope: Atom.to_string(mem.scope), topics: Enum.join(mem.topics, " | "), score: score, content: mem.content } end) {:ok, payload} {:error, :not_implemented} -> # Treat not-implemented search as an empty result for now. {:ok, []} {:error, reason} -> {:error, inspect(reason)} end end defp do_recall(_) do {:error, "Missing required field 'what' for action 'recall'"} end defp do_remember(%{"scope" => scope, "title" => title, "content" => content} = args) do # Only session writes are permitted via memory_tool. Reject other scopes. if scope != "session" do {:error, "memory_tool is session-only. Use long_term_memory_tool for project/global writes."} else with {:ok, scope_atom} <- parse_scope(scope), topics = normalize_topics(Map.get(args, "topics")), {:ok, memory} <- new_memory(scope_atom, title, content, topics), {:ok, saved} <- wrap_save(Memory.save(memory), memory) do {:ok, format_memory(saved)} end end end # If caller omitted 'scope', default to session scope for convenience. defp do_remember(%{"title" => _title, "content" => _content} = args) do do_remember(Map.put(args, "scope", "session")) end defp do_remember(_) do {:error, "Missing required fields 'scope', 'title', and 'content' for action 'remember'"} end defp do_update(%{"scope" => scope, "title" => title, "new_content" => new_content} = args) do # Only session updates are permitted via memory_tool. Reject other scopes. if scope != "session" do {:error, "memory_tool is session-only. Use long_term_memory_tool for project/global writes."} else case parse_scope(scope) do {:ok, scope_atom} -> case Memory.read(scope_atom, title) do {:ok, memory} -> new_topics = normalize_topics(Map.get(args, "new_topics")) updated = memory |> Memory.append(new_content) |> maybe_add_topics(new_topics) case wrap_save(Memory.save(updated), updated) do {:ok, saved} -> {:ok, format_memory(saved)} {:error, _} = error -> error end {:error, :not_found} -> {:error, "No memory found with title #{inspect(title)} in #{Atom.to_string(scope_atom)} scope. Use action=list to see available memories."} {:error, reason} -> {:error, inspect(reason)} end {:error, _} = error -> error end end end # If caller omitted 'scope', default to session scope for convenience. defp do_update(%{"title" => _title, "new_content" => _} = args) do do_update(Map.put(args, "scope", "session")) end defp do_update(_) do {:error, "Missing required fields 'scope', 'title', and 'new_content' for action 'update'"} end defp do_forget(%{"title" => title} = args) do scope_opt = Map.get(args, "scope") # For security and simplification, forbid forgetting in non-session scopes via memory_tool. case scope_opt do nil -> # Default behavior: only consider session scope when forgetting via memory_tool. case find_memory_by_title([:session], title) do {:ok, memory} -> Memory.forget(memory) :not_found -> :ok {:error, reason} -> {:error, inspect(reason)} end s -> if s != "session" do {:error, "memory_tool is session-only. Use long_term_memory_tool for project/global forget operations."} else case find_memory_by_title([:session], title) do {:ok, memory} -> Memory.forget(memory) :not_found -> :ok {:error, reason} -> {:error, inspect(reason)} end end end end # If caller omitted 'scope', default to session scope for convenience. # Note: this convenience clause would overlap with the primary clause above # that pattern-matches the same shape. To avoid compilation warnings we rely # on the primary clause handling nil scope explicitly; therefore we do not # include a duplicate delegating clause here. defp do_forget(_) do {:error, "Missing required field 'title' for action 'forget'"} end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp parse_scope("session"), do: {:ok, :session} defp parse_scope(other) do {:error, "Invalid scope #{inspect(other)}; memory_tool only accepts 'session' scope"} end defp normalize_topics(nil), do: [] defp normalize_topics(topics) when is_list(topics) do topics |> Enum.reject(&is_nil/1) |> Enum.map(&to_string/1) |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp normalize_topics(topic) when is_binary(topic) do topic |> String.split(~r/[\|,]/) |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp normalize_topics(_), do: [] defp maybe_add_topics(memory, topics) do normalized = normalize_topics(topics) if normalized == [] do memory else existing = case Map.get(memory, :topics) do list when is_list(list) -> list _ -> [] end %{memory | topics: Enum.uniq(existing ++ normalized)} end end defp wrap_save({:ok, saved}, _memory), do: {:ok, saved} defp wrap_save({:error, _} = error, _memory), do: error defp new_memory(scope_atom, title, content, topics) do case Memory.new(scope_atom, title, content, topics) do {:ok, memory} -> {:ok, memory} {:error, :invalid_title} -> reasons = case Memory.validate_title(title) do {:error, reasons} -> reasons :ok -> ["title failed validation (no further details available)"] end bullet_reasons = Enum.map_join(reasons, "\n", fn reason -> "- " <> reason end) {:error, """ Invalid memory title #{inspect(title)}. Reasons: #{bullet_reasons} Examples of valid titles: - "Meeting Notes" - "Project Architecture" - "User Preferences" """} {:error, :duplicate_title} -> {:error, "Memory title #{inspect(title)} already exists in #{Atom.to_string(scope_atom)} scope. Use action=update to modify the existing memory."} end end defp find_memory_by_title(scopes, title) do scopes |> Enum.reduce_while(:not_found, fn scope, _acc -> case Memory.read(scope, title) do {:ok, memory} -> {:halt, {:ok, memory}} {:error, _} -> {:cont, :not_found} end end) end defp format_memory(%Memory{} = memory) do now = DateTime.utc_now() age = Presentation.age_line(memory, now) warning = Presentation.warning_line(memory, now) warning_line = if warning do "#{warning}\n" else "" end """ Title: #{memory.title} Scope: #{Atom.to_string(memory.scope)} Topics: #{Enum.join(memory.topics, " | ")} #{age} #{warning_line}Content: #{memory.content} """ end defp format_memory({%Memory{} = memory, score}) do now = DateTime.utc_now() age = Presentation.age_line(memory, now) warning = Presentation.warning_line(memory, now) warning_line = if warning do "#{warning}\n" else "" end """ Title: #{memory.title} Score: #{Float.round(score, 4)} Scope: #{Atom.to_string(memory.scope)} Topics: #{Enum.join(memory.topics, " | ")} #{age} #{warning_line}Content: #{memory.content} """ end defp format_memory({scope, title}) when is_atom(scope) and is_binary(title) do """ Title: #{title} Scope: #{Atom.to_string(scope)} """ end defp format_memory(other) do inspect(other) end end