Packages
fnord
0.9.19
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/services/memory_indexer.ex
defmodule Services.MemoryIndexer do
@moduledoc """
Background service that promotes session-scoped memories to long-term
(project/global) storage. Independently scans conversations for
unprocessed session memories, processes one conversation at a time via
the Memory.Indexer agent, and applies the resulting actions.
The service is self-driven: on startup it begins scanning for work. After
processing a conversation, it scans again. When no unprocessed memories
remain, it goes idle. External callers can nudge it via `scan/0` if they
know new work is available (e.g. after saving a conversation).
Public API:
- start_link/1
- scan/0 -- nudge the service to look for work
- process_sync/1 -- test-only synchronous processing
- status/0
"""
use GenServer
@lt_memory_tool %{"long_term_memory_tool" => AI.Tools.LongTermMemory}
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Nudge the service to scan for unprocessed conversations"
def scan do
GenServer.cast(__MODULE__, :scan)
end
@doc "Process a conversation synchronously; returns :ok | {:error, term()}"
def process_sync(convo) do
GenServer.call(__MODULE__, {:process_sync, convo}, :infinity)
end
@doc "Get status: whether a task is currently running"
def status do
GenServer.call(__MODULE__, :status)
end
# --------------------------------------------------------------------------
# GenServer callbacks
# --------------------------------------------------------------------------
def init(opts) do
{:ok, sup} = Task.Supervisor.start_link()
auto_scan = Keyword.get(opts, :auto_scan, true)
if auto_scan do
{:ok, %{task: nil, sup: sup}, {:continue, :scan}}
else
{:ok, %{task: nil, sup: sup}}
end
end
# Scan for the next conversation with unprocessed memories and spawn a
# background task to process it. If already busy or nothing found, no-op.
def handle_continue(:scan, %{task: nil, sup: sup} = state) do
case find_next_conversation() do
nil ->
{:noreply, state}
convo ->
task = spawn_processing_task(sup, convo)
{:noreply, %{state | task: task}}
end
end
def handle_continue(:scan, state), do: {:noreply, state}
def handle_cast(:scan, %{task: nil} = state) do
{:noreply, state, {:continue, :scan}}
end
def handle_cast(:scan, state), do: {:noreply, state}
# Compile-time environment gate. process_sync blocks the GenServer for the
# entire LLM round-trip, which is fine for deterministic test execution but
# would deadlock in production. Rather than trusting callers to know this,
# we simply don't compile the working implementation outside of test. Yes,
# this is a compile-time conditional in application code. We are not proud,
# but we are correct.
if Mix.env() == :test do
def handle_call({:process_sync, convo}, _from, state) do
HttpPool.set(:ai_memory)
res = do_process_conversation(convo)
{:reply, res, state}
end
else
def handle_call({:process_sync, _convo}, _from, _state) do
raise "process_sync is only available in the test environment"
end
end
def handle_call(:status, _from, state) do
{:reply, %{busy: state.task != nil}, state}
end
# Task completed: clear state and scan for more work.
def handle_info({ref, _result}, %{task: %Task{ref: ref}} = state) do
Process.demonitor(ref, [:flush])
{:noreply, %{state | task: nil}, {:continue, :scan}}
end
# Task crashed: clear state and scan for more work.
def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
{:noreply, %{state | task: nil}, {:continue, :scan}}
end
def handle_info(_msg, state), do: {:noreply, state}
# On shutdown, kill any in-flight task so the BEAM can exit promptly.
def terminate(_reason, %{task: %Task{pid: pid}}) when is_pid(pid) do
Process.exit(pid, :kill)
:ok
end
def terminate(_reason, _state), do: :ok
# --------------------------------------------------------------------------
# Scanning
# --------------------------------------------------------------------------
# Walk conversations oldest-first, return the first that has unprocessed
# session memories. Skips the currently active conversation.
defp find_next_conversation do
with {:ok, project} <- Store.get_project() do
current_id = current_conversation_id()
project
|> Store.Project.Conversation.list()
|> Enum.reject(fn convo -> convo.id == current_id end)
|> Enum.find(&has_unprocessed_memories?/1)
else
_ -> nil
end
end
defp current_conversation_id do
case Services.Globals.get_env(:fnord, :current_conversation, nil) do
nil -> nil
pid -> Services.Conversation.get_id(pid)
end
end
defp has_unprocessed_memories?(convo) do
case Store.Project.Conversation.read(convo) do
{:ok, data} -> find_unprocessed_memories(data) != []
_ -> false
end
end
# --------------------------------------------------------------------------
# Task spawning
# --------------------------------------------------------------------------
# Spawn the processing task without linking to the GenServer. We use
# Task.Supervisor.async_nolink so the GenServer is not dragged down if
# the task crashes, and more importantly, so the BEAM can shut down
# cleanly without waiting for in-flight LLM calls to complete.
defp spawn_processing_task(sup, convo) do
root = Services.Globals.current_root()
Task.Supervisor.async_nolink(sup, fn ->
if root, do: Process.put(:globals_root_pid, root)
HttpPool.set(:ai_memory)
do_process_conversation(convo)
end)
end
# --------------------------------------------------------------------------
# Conversation processing
# --------------------------------------------------------------------------
@spec do_process_conversation(any()) :: :ok | {:error, any()}
defp do_process_conversation(convo) do
process_conversation(convo)
rescue
e ->
UI.debug("memory_indexer", "Worker crashed: #{Exception.message(e)}")
{:error, e}
end
defp process_conversation(conversation) do
with {:ok, data} <- Store.Project.Conversation.read(conversation),
session_mems when session_mems != [] <- find_unprocessed_memories(data),
{:ok, payload} <- build_indexer_payload(data, session_mems),
{:ok, response} <- invoke_indexer_agent(payload),
{:ok, decoded} <- parse_indexer_response(response),
:ok <- validate_indexer_response(decoded) do
apply_actions_and_mark(conversation, decoded)
else
[] -> :ok
_ -> :ok
end
rescue
e ->
UI.debug("memory_indexer", "Processing failed: #{Exception.message(e)}")
:ok
end
# --------------------------------------------------------------------------
# Conversation processing helpers
# --------------------------------------------------------------------------
# Filter session memories that haven't been processed yet.
defp find_unprocessed_memories(data) do
data
|> Map.get(:memory, [])
|> Enum.filter(fn
%Memory{scope: :session} = m -> is_nil(m.index_status) or m.index_status == :new
_ -> false
end)
end
# For each session memory, retrieve up to 5 matching global and 5 matching
# project memories as candidates for merge/dedup/correction decisions.
defp build_indexer_payload(data, session_mems) do
memories_with_candidates = Enum.map(session_mems, &enrich_with_candidates/1)
payload = %{
conversation_summary: summarize_conversation(data.messages),
memories: memories_with_candidates
}
{:ok, SafeJson.encode!(payload)}
end
defp enrich_with_candidates(mem) do
%{
title: mem.title,
content: mem.content,
topics: mem.topics,
global_candidates: recall_candidates(mem.content, "global"),
project_candidates: recall_candidates(mem.content, "project")
}
end
defp recall_candidates(query, scope) do
case AI.Tools.LongTermMemory.call(%{
"action" => "recall",
"query" => query,
"search_type" => "project_global",
"limit" => 5,
"scope" => scope
}) do
{:ok, res} -> res
{:error, _} -> []
end
end
defp invoke_indexer_agent(json_payload) do
AI.Agent.Memory.Indexer
|> AI.Agent.new(named?: false)
|> AI.Agent.get_response(%{payload: json_payload})
end
defp parse_indexer_response(response) do
case SafeJson.decode(response) do
{:ok, decoded} -> {:ok, decoded}
_ -> {:error, :invalid_json}
end
end
defp validate_indexer_response(%{"actions" => actions, "processed" => processed} = decoded) do
status_updates = Map.get(decoded, "status_updates", %{})
cond do
not is_list(actions) ->
{:error, "actions must be a list"}
not is_list(processed) ->
{:error, "processed must be a list"}
not is_map(status_updates) ->
{:error, "status_updates must be a map"}
not Enum.all?(processed, &is_binary/1) ->
{:error, "processed must be list of strings"}
not Enum.all?(actions, &valid_action?/1) ->
{:error, "invalid action object in actions"}
true ->
:ok
end
end
defp validate_indexer_response(_), do: {:error, "missing actions or processed keys"}
defp valid_action?(%{"action" => a, "target" => %{"scope" => _s, "title" => _t}})
when a in ["add", "replace", "delete"],
do: true
defp valid_action?(_), do: false
# --------------------------------------------------------------------------
# Apply actions and mark session memories as processed
# --------------------------------------------------------------------------
defp apply_actions_and_mark(conversation, decoded) do
actions = Map.get(decoded, "actions", [])
processed = Map.get(decoded, "processed", [])
status_updates = Map.get(decoded, "status_updates", %{})
FileLock.with_lock(conversation.store_path, fn ->
{:ok, fresh} = Store.Project.Conversation.read(conversation)
Enum.each(actions, &apply_action/1)
fresh
|> Map.put(:memory, mark_processed(fresh.memory, processed, status_updates))
|> then(&Store.Project.Conversation.write(conversation, &1))
end)
:ok
end
# First pass: mark all processed session memories as :analyzed.
# Second pass: override with explicit status_updates from the agent.
defp mark_processed(memories, processed, status_updates) do
memories
|> Enum.map(fn
%Memory{scope: :session, title: title} = m ->
if title in processed, do: %{m | index_status: :analyzed}, else: m
other ->
other
end)
|> Enum.map(fn
%Memory{scope: :session, title: title} = m ->
maybe_apply_status_update(m, Map.get(status_updates, title))
other ->
other
end)
end
@valid_statuses ["analyzed", "rejected", "incorporated", "merged"]
defp maybe_apply_status_update(mem, status) when status in @valid_statuses do
%{mem | index_status: String.to_existing_atom(status)}
end
defp maybe_apply_status_update(mem, _), do: mem
# --------------------------------------------------------------------------
# Action dispatch
# --------------------------------------------------------------------------
defp apply_action(%{"action" => "add", "target" => target, "content" => content}) do
call_lt_memory("remember", target, content)
end
defp apply_action(%{"action" => "replace", "target" => target, "content" => content}) do
call_lt_memory("update", target, content)
end
defp apply_action(%{"action" => "delete", "target" => target}) do
call_lt_memory("forget", target, nil)
end
defp apply_action(_), do: :ok
defp call_lt_memory(action, %{"scope" => scope, "title" => title}, content) do
args =
%{"action" => action, "scope" => scope, "title" => title}
|> maybe_put_content(content)
case AI.Tools.perform_tool_call("long_term_memory_tool", args, @lt_memory_tool) do
{:ok, _} -> :ok
{:error, reason} -> UI.debug("memory_indexer", "#{action} failed: #{inspect(reason)}")
end
:ok
end
defp maybe_put_content(args, nil), do: args
defp maybe_put_content(args, content), do: Map.put(args, "content", content)
# --------------------------------------------------------------------------
# Conversation summarization
# --------------------------------------------------------------------------
def summarize_conversation(messages) when is_list(messages) do
user = first_user_message(messages)
assistant = last_assistant_message(messages)
case {user, assistant} do
{"", ""} -> ""
{u, ""} -> "User: " <> u
{"", a} -> "Assistant: " <> a
{u, a} -> "User: " <> u <> " \nAssistant: " <> a
end
end
def summarize_conversation(_), do: ""
defp first_user_message(messages) do
messages
|> Enum.find(fn
%{role: "user"} -> true
_ -> false
end)
|> extract_content()
end
defp last_assistant_message(messages) do
messages
|> Enum.reverse()
|> Enum.find(fn
%{role: "assistant", content: c} when is_binary(c) ->
not String.starts_with?(c, "<think>")
_ ->
false
end)
|> extract_content()
end
defp extract_content(%{content: c}), do: String.slice(c, 0, 400)
defp extract_content(_), do: ""
end