Packages
fnord
0.8.74
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/memory/selector.ex
defmodule AI.Memory.Selector do
@moduledoc """
Evaluation engine for memory-based automatic thoughts.
Performs best-path hierarchical matching against conversation accumulated tokens.
Returns formatted <think> blocks to prime the LLM with learned patterns.
"""
@type tree :: {AI.Memory.t(), [tree]}
# Configuration from settings or defaults
@beam_width 2
@max_thinks 6
# Hard floor to filter complete garbage
@minimum_score 0.01
# Minimum memories needed for statistical threshold
@min_for_stats 5
@doc """
Evaluates memories against conversation state and generates automatic thoughts.
Returns list of memory trees for formatting into nested <think> blocks.
"""
@spec evaluate(pid) :: [tree]
def evaluate(conversation_pid) do
# Get memories and conversation state
roots = Services.Memories.get_roots()
AI.Memory.debug("Evaluating: #{length(roots)} root memories loaded")
# Skip if no memories loaded
if Enum.empty?(roots) do
AI.Memory.debug("No memories loaded, skipping evaluation")
[]
else
# Get accumulated tokens from conversation metadata
accumulated_tokens = get_accumulated_tokens(conversation_pid)
AI.Memory.debug("Accumulated tokens: #{map_size(accumulated_tokens)} unique tokens")
if map_size(accumulated_tokens) == 0 do
AI.Memory.debug("No accumulated tokens, skipping evaluation")
[]
else
# Evaluate and generate thought trees
all_scored = score_all(roots, accumulated_tokens)
AI.Memory.debug("Total scored: #{length(all_scored)}")
# Show top 3 scores
all_scored
|> Enum.take(3)
|> Enum.each(fn {mem, score} ->
AI.Memory.debug(" #{mem.slug}: #{Float.round(score, 4)}")
end)
# Apply hybrid threshold selection
selected = select_firing_memories(all_scored)
AI.Memory.debug("Selected #{length(selected)} memories to fire")
selected
|> Enum.map(&build_tree(&1, accumulated_tokens))
|> limit_total_nodes(@max_thinks)
end
end
end
# ----------------------------------------------------------------------------
# Private Helpers
# ----------------------------------------------------------------------------
# Gets accumulated tokens from conversation metadata
defp get_accumulated_tokens(conversation_pid) do
# Get metadata from GenServer state (not from disk)
metadata = Services.Conversation.get_metadata(conversation_pid)
metadata
|> Map.get("memory_state", %{})
|> Map.get("accumulated_tokens", %{})
end
# Scores all memories and sorts by score descending (no filtering)
defp score_all(memories, accumulated_tokens) do
memories
|> Util.async_stream(fn memory ->
score = AI.Memory.compute_score(memory, accumulated_tokens)
{memory, score}
end)
|> Enum.map(fn {:ok, result} -> result end)
|> Enum.sort_by(fn {_memory, score} -> -score end)
end
# Selects which memories should fire using hybrid threshold approach
defp select_firing_memories(scored_memories) do
if Enum.empty?(scored_memories) do
[]
else
# Step 1: Filter absolute garbage
viable = Enum.filter(scored_memories, fn {_, score} -> score > @minimum_score end)
# Step 2: Apply dynamic threshold if enough data
filtered =
if length(viable) >= @min_for_stats do
threshold = find_elbow_threshold(viable)
Enum.filter(viable, fn {_, score} -> score > threshold end)
else
# Not enough memories for statistics, just use minimum
viable
end
# Step 3: Cap at beam_width
Enum.take(filtered, @beam_width)
end
end
# Finds threshold using elbow/gap method
defp find_elbow_threshold(scored_memories) do
scores = Enum.map(scored_memories, fn {_, score} -> score end)
# Find largest gap between consecutive scores
gaps =
scores
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [high, low] -> {high - low, low} end)
case Enum.max_by(gaps, fn {gap, _} -> gap end, fn -> nil end) do
{_gap, cutoff} -> cutoff
# No gap found, use minimum
nil -> @minimum_score
end
end
# Builds a tree from a scored root by recursively following best child
defp build_tree({memory, _score}, accumulated_tokens) do
# Get children and find best match
children = Services.Memories.get_children(memory.id)
if Enum.empty?(children) do
{memory, []}
else
# Score children and take best
best_child =
children
|> score_all(accumulated_tokens)
|> List.first()
case best_child do
nil ->
{memory, []}
child_with_score ->
child_tree = build_tree(child_with_score, accumulated_tokens)
{memory, [child_tree]}
end
end
end
# Limits total number of nodes across all trees to max_thinks
defp limit_total_nodes(trees, max_nodes) do
{limited_trees, _count} =
Enum.reduce(trees, {[], 0}, fn tree, {acc_trees, count} ->
nodes_in_tree = count_nodes(tree)
if count + nodes_in_tree <= max_nodes do
{[tree | acc_trees], count + nodes_in_tree}
else
{acc_trees, count}
end
end)
Enum.reverse(limited_trees)
end
# Counts total nodes in a tree
defp count_nodes({_memory, children}) do
1 + Enum.sum(Enum.map(children, &count_nodes/1))
end
@doc """
Formats memory trees as an assistant message with nested <think> blocks.
Returns nil if no trees to inject.
"""
@spec format_as_message([tree]) :: AI.Util.msg() | nil
def format_as_message([]), do: nil
def format_as_message(trees) do
total_nodes = Enum.sum(Enum.map(trees, &count_nodes/1))
# Show each memory that's firing
AI.Memory.debug("Firing #{total_nodes} automatic thoughts (#{length(trees)} chains)")
Enum.each(trees, fn tree -> debug_tree(tree, 0) end)
content =
trees
|> Enum.map(&format_tree(&1, 0))
|> Enum.join("\n")
AI.Util.assistant_msg(content)
end
# Debug output for fired memories
defp debug_tree({memory, children}, depth) do
indent = String.duplicate(" ", depth)
AI.Memory.debug(
"#{indent}└─ #{memory.slug} (#{memory.scope}): \"#{memory.response_template}\""
)
Enum.each(children, fn child ->
debug_tree(child, depth + 1)
end)
end
# Formats a tree as nested <think> tags with proper indentation
defp format_tree({memory, children}, depth) do
indent = String.duplicate(" ", depth)
scope_str = to_string(memory.scope)
# Build opening tag with attributes
attrs = ~s(memory="#{memory.slug}" scope="#{scope_str}")
# Add parent attribute for children (depth > 0)
attrs =
if depth > 0 && memory.parent_id do
parent = Services.Memories.get_by_id(memory.parent_id)
parent_slug = if parent, do: parent.slug, else: memory.parent_id
~s(#{attrs} parent="#{parent_slug}")
else
attrs
end
# Format children recursively
if Enum.empty?(children) do
"#{indent}<think #{attrs}>#{memory.response_template}</think>"
else
children_xml =
children
|> Enum.map(&format_tree(&1, depth + 1))
|> Enum.join("\n")
"""
#{indent}<think #{attrs}>
#{indent} #{memory.response_template}
#{children_xml}
#{indent}</think>
"""
|> String.trim_trailing()
end
end
end