defmodule Services.Notes do use GenServer # ----------------------------------------------------------------------------- # Client API # ----------------------------------------------------------------------------- @spec start_link(opts :: keyword()) :: GenServer.on_start() def start_link(opts \\ []) do with {:ok, pid} <- GenServer.start_link(__MODULE__, opts) do Services.Instance.register(__MODULE__, pid) {:ok, pid} end end @doc """ Load existing research notes from persistent storage. The project must be set from the --project command line option or CWD. """ @spec load_notes() :: :ok | {:error, any} def load_notes() do Services.Instance.cast(__MODULE__, :load_notes) end @doc """ Uses an AI model to answer a question about the existing research notes. The question should be a concise request for information about the project, such as "What is the purpose of this project?" or "What languages and technologies are used in this project?". The AI model will analyze the existing notes and return a concise answer. """ @spec ask(binary) :: binary def ask(question) do Services.Instance.call(__MODULE__, {:ask, question}, :infinity) end @doc """ Uses an AI model to analyze the user's message and extract insights about their coding preferences, learning style, personality, and other relevant traits. The insights are stored in the server's state and can be consolidated later. """ @spec ingest_user_msg(binary) :: :ok def ingest_user_msg(msg_text) do Services.Instance.cast(__MODULE__, {:ingest_user_msg, msg_text}) end @doc """ Uses an AI model to analyze the result of a tool call and extract facts about the project. The facts are stored in the server's state and can be consolidated later. """ @spec ingest_research(binary, binary, any) :: :ok def ingest_research(func, args_json, result) do Services.Instance.cast(__MODULE__, {:ingest_research, func, args_json, result}) end @doc """ Consolidates all newly extracted facts and user insights into the existing research notes. This uses an AI model to reorganize and consolidate the notes according to specified guidelines. The consolidated notes are saved to persistent storage. Currently, consolidation generally happens at the beginning of a new research session (from `AI.Agent.Coordinator`). """ @spec consolidate() :: :ok def consolidate() do Services.Instance.cast(__MODULE__, :consolidate) end @doc """ Waits for the server to complete all operations before returning. This is useful to ensure that all notes have been saved and consolidated before exiting the application or moving on to the next step in the workflow. """ def join() do UI.info("[notes-server]", "waiting for operations to complete") Services.Instance.call(__MODULE__, :join, :infinity) end @doc """ Saves the new notes that have been collected over the course of the current session to persistent storage. This saves any newly extracted facts and user insights that have not yet been consolidated into the main research notes. Returns immediately, but the actual saving process may take some time. Use `join/0` to wait for the server to complete all operations if needed. The new notes are added to a special section at the end of the existing research notes, labeled `# NEW NOTES (unconsolidated)`. This section is meant to be consolidated later by the `consolidate/0` function. """ def save() do UI.debug("[notes-server]", "saving new research") Services.Instance.cast(__MODULE__, :save) end # ----------------------------------------------------------------------------- # Server API # ----------------------------------------------------------------------------- @impl true def init(_opts) do ensure_ets() {:ok, AI.Notes.new()} end @impl true def handle_cast(:load_notes, state) do inc_pending() try do {:noreply, AI.Notes.init(state)} catch :exit, reason -> UI.debug("[notes-server]", "failed to load notes: #{inspect(reason)}") {:noreply, state} after dec_pending() end end @impl true def handle_cast({:ingest_user_msg, msg_text}, state) do inc_pending() try do {:noreply, AI.Notes.ingest_user_msg(state, msg_text)} catch :exit, reason -> UI.debug("[notes-server]", "failed to ingest user message: #{inspect(reason)}") {:noreply, state} after dec_pending() end end @impl true def handle_cast({:ingest_research, func, args_json, result}, state) do inc_pending() try do {:noreply, AI.Notes.ingest_research(state, func, args_json, result)} catch :exit, reason -> UI.debug("[notes-server]", "failed to ingest research: #{inspect(reason)}") {:noreply, state} after dec_pending() end end @impl true def handle_cast(:consolidate, state) do inc_pending() try do if AI.Notes.has_new_facts?() do try do state |> AI.Notes.consolidate() |> case do {:ok, result} -> UI.info("[notes-server]", "consolidated existing research notes") {:noreply, result} {:callback_error, error} -> UI.debug("[notes-server]", "callback error during consolidation: #{inspect(error)}") {:noreply, state} {:error, :lock_failed} -> UI.debug("[notes-server]", "failed to acquire lock for consolidation: lock_failed") {:noreply, state} {:error, reason} -> UI.debug("[notes-server]", "failed to consolidate notes: #{reason}") {:noreply, state} end catch :exit, {:timeout, {Task, :await, _}} -> UI.debug( "[notes-server]", "consolidation timed out - notes file may be too large. Skipping consolidation for this session." ) {:noreply, state} :exit, reason -> UI.debug("[notes-server]", "consolidation crashed: #{inspect(reason)}") {:noreply, state} end else UI.debug("[notes-server]", "no new notes; skipping consolidation") {:noreply, state} end after dec_pending() end end @impl true def handle_cast(:save, state) do inc_pending() try do case AI.Notes.commit(state) do {:ok, new_state} -> {:noreply, new_state} {:error, reason} -> UI.debug("[notes-server]", "failed to save notes: #{inspect(reason)}") {:noreply, state} end catch :exit, reason -> UI.debug("[notes-server]", "failed to save notes (crash): #{inspect(reason)}") {:noreply, state} after dec_pending() end end @impl true def handle_call({:ask, question}, _from, state) do try do {:reply, AI.Notes.ask(state, question), state} catch :exit, {:timeout, {Task, :await, _}} -> UI.debug("[notes-server]", "ask query timed out") {:reply, "No relevant information found.", state} :exit, reason -> UI.debug("[notes-server]", "ask query failed: #{inspect(reason)}") {:reply, "No relevant information found.", state} end end # Just a placeholder for a no-op `call` to allow the client to know that the # server has completed all operations prior to the call to `join/0`. @impl true def handle_call(:join, _from, state) do {:reply, :ok, state} end @impl true def terminate(_reason, _state) do # Clear any HttpPool override to avoid leaking process dictionary state HttpPool.clear() :ok end # ---------------------------------------------------------------------------- # Pending Operations Instrumentation (ETS) # Internal helpers for tracking pending async operations non-blockingly. # # The table itself is VM-global infrastructure, but entries are keyed by the # caller's Services.Globals root so that each instance tree counts its own # pending operations (same keying pattern as Globals' data table). Clients # and the server share a tree, so they resolve the same key. A dead root # leaves at most one stale integer behind - negligible, and acceptable for # a counter that exists only to answer "is my tree's notes server busy?". # ---------------------------------------------------------------------------- @doc """ True if there are any pending asynchronous operations being processed by the notes server. Backed by a non-blocking ETS counter. """ def pending?() do pending_count() > 0 end @doc """ Returns the current number of pending operations. """ def pending_count() do ensure_ets() case :ets.lookup(:notes_status, pending_key()) do [{_key, count}] -> count [] -> 0 end end # Private helpers for ETS-backed pending counter defp pending_key() do {Services.Globals.current_root() || self(), :pending_ops} end defp ensure_ets() do Services.Globals.ensure_shared_table( :notes_status, [:named_table, :public, read_concurrency: true] ) end defp inc_pending() do ensure_ets() :ets.update_counter(:notes_status, pending_key(), {2, 1}, {pending_key(), 0}) end defp dec_pending() do ensure_ets() key = pending_key() new = :ets.update_counter(:notes_status, key, {2, -1}, {key, 0}) if new < 0 do :ets.insert(:notes_status, {key, 0}) end :ok end end