Skip to main content

lib/pi/self.ex

defmodule Pi.Self do
  @moduledoc """
  Self-introspection facade for pi-elixir eval.

  `Pi.Self` is the compact dashboard/API boundary for asking the running bridge
  about itself: bridge inventory, eval state, sessions, plugins, QuackDB mirror,
  and recall context. Eval preloads `alias Pi.Self, as: Self` for concise use.
  """

  use QuackDB.Ecto

  alias Pi.Bridge.Info
  alias Pi.Plugin.Manager
  alias Pi.Quack
  alias Pi.Skill.Loader, as: SkillLoader

  @default_context_limit 5

  @doc "Returns a compact all-in-one bridge status map."
  def status(opts \\ []) do
    %{
      bridge: info_summary(),
      eval: eval(opts),
      quack: quack(),
      sessions: sessions(),
      plugins: plugins(),
      skills: skills(),
      apis: apis()
    }
  end

  @doc "Returns the full bridge inventory snapshot."
  def info, do: Info.snapshot()

  @doc "Returns compact bridge environment metadata."
  def env do
    %{
      cwd: File.cwd!(),
      mix_project: Mix.Project.config()[:app],
      mix_env: Mix.env(),
      elixir: System.version(),
      otp: System.otp_release(),
      node: Node.self(),
      features: %{
        llm: Pi.Features.llm?(),
        sessions: Pi.Features.sessions?(),
        plugins: Pi.Features.plugins?(),
        mirror: Pi.Features.env_enabled?("PI_ELIXIR_MIRROR"),
        skills: Pi.Features.skills?()
      }
    }
  end

  @doc "Returns loaded runtime/extension API inventory."
  def apis do
    snapshot = Info.snapshot()
    %{runtime: snapshot.apis.runtime, extensions: snapshot.apis.extensions}
  end

  @doc "Returns eval alias/import prelude code."
  def aliases, do: Info.aliases_code()

  @doc "Returns current eval binding metadata."
  def bindings, do: Pi.Eval.bindings()

  @doc "Returns eval status metadata."
  def eval(_opts \\ []) do
    %{
      bindings: bindings(),
      binding_count: length(bindings())
    }
  end

  @doc "Returns active BEAM session snapshots."
  def sessions do
    %{
      active: Pi.Session.snapshots(),
      count: length(Pi.Session.snapshots())
    }
  catch
    :exit, reason -> %{error: Exception.format_exit(reason)}
  end

  @doc "Returns compact QuackDB mirror status."
  def quack do
    Quack.status()
  rescue
    exception in [RuntimeError, QuackDB.Error, DBConnection.ConnectionError] ->
      %{error: Exception.message(exception)}
  end

  @doc "Alias for `quack/0`, emphasizing storage status."
  def storage, do: quack()

  @doc "Returns loaded plugin metadata and commands."
  def plugins do
    if Pi.Features.plugins?() do
      %{
        plugins: Manager.plugins(),
        commands: Manager.commands()
      }
    else
      %{disabled: true}
    end
  rescue
    exception in [RuntimeError, ArgumentError] -> %{error: Exception.message(exception)}
  end

  @doc "Returns discovered executable skills."
  def skills do
    if Pi.Features.skills?(), do: SkillLoader.serializable(), else: []
  rescue
    exception in [RuntimeError, ArgumentError, File.Error, Code.LoadError, CompileError] ->
      %{error: Exception.message(exception)}
  end

  @doc "Returns a compact recall block from mirrored session history."
  def context(query, opts \\ []) when is_binary(query) do
    query
    |> recall(opts)
    |> format_context_block()
  end

  @doc "Returns structured recall rows from mirrored session history."
  def recall(query, opts \\ []) when is_binary(query) do
    import Ecto.Query
    require Quack

    limit = Keyword.get(opts, :limit, @default_context_limit)

    Quack.rebuild_fts!()

    from(e in Pi.Quack.Event,
      where: Quack.matches(e.id, ^query),
      order_by: [desc: Quack.score(e.id, ^query)],
      limit: ^limit,
      select: %{
        score: Quack.score(e.id, ^query),
        id: e.id,
        event_type: e.event_type,
        cwd: e.cwd,
        session_file: e.session_file,
        tool: e.tool_name,
        content: Quack.json_text(e.payload_json, "$.content"),
        payload: e.payload_json
      }
    )
    |> Quack.all()
  rescue
    exception in [RuntimeError, ArgumentError, QuackDB.Error, DBConnection.ConnectionError] ->
      [%{"error" => Exception.message(exception)}]
  end

  defp info_summary do
    snapshot = Info.snapshot()

    %{
      project: snapshot.project,
      version: snapshot.version,
      transport: snapshot.transport,
      plugins: Enum.map(snapshot.plugins, & &1.name),
      commands: Enum.map(snapshot.commands, & &1.name)
    }
  end

  defp format_context_block([%{"error" => error}]),
    do: "<recalled-sessions error=#{inspect(error)} />"

  defp format_context_block([]), do: ""

  defp format_context_block(rows) do
    body =
      rows
      |> Enum.with_index(1)
      |> Enum.map_join("\n\n", fn {row, index} -> format_context_row(index, row) end)

    "<recalled-sessions>\n" <> body <> "\n</recalled-sessions>"
  end

  defp format_context_row(index, row) do
    content = clean_text(row["content"] || row["payload"] || "")
    source = Path.basename(to_string(row["session_file"] || "session"))
    tool = row["tool"] || row["event_type"] || "event"

    [
      Integer.to_string(index),
      ". ",
      source,
      " ",
      to_string(tool),
      " score=",
      format_score(row["score"]),
      "\n",
      truncate(content, 1_200)
    ]
    |> IO.iodata_to_binary()
  end

  defp clean_text(text) do
    text
    |> to_string()
    |> String.replace(~r/\s+/u, " ")
    |> String.trim()
  end

  defp truncate(text, max) when byte_size(text) <= max, do: text
  defp truncate(text, max), do: String.slice(text, 0, max) <> "..."

  defp format_score(score) when is_float(score), do: :erlang.float_to_binary(score, decimals: 3)
  defp format_score(score), do: to_string(score || "?")
end