Packages
fnord
0.9.21
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/tools/memory.ex
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