Current section
Files
Jump to
Current section
Files
lib/omni/session/tree.ex
defmodule Omni.Session.Tree do
@moduledoc """
A branching message tree used by `Omni.Session` to store conversation history.
Each node holds a message and an optional parent pointer, forming a tree where
linear conversations are the common case and branches represent alternate replies
or edits.
## Active path and cursors
An **active path** acts as a cursor through the tree — `push/3` always appends
to the head of this path, and `navigate/2` moves it to a different branch.
Both `push/3` and `navigate/2` record **cursors** that track which child was
most recently selected at a given parent. `push/3` sets a single cursor at the
new node's immediate parent. `navigate/2` sets cursors for every parent-child
pair along the path from root to the target, so the branch that was just
navigated to is fully pinned. `extend/1` follows these cursors to walk from
the current head to a leaf, reconstructing a full path after navigating to a
mid-tree branch point.
## Example
A fresh tree is empty. Push messages to build up an active path; node
ids are auto-assigned starting at `1`:
tree =
%Tree{}
|> Tree.push(%Message{role: :user, content: "..."})
|> Tree.push(%Message{role: :assistant, content: "..."})
|> Tree.push(%Message{role: :user, content: "..."})
|> Tree.push(%Message{role: :assistant, content: "..."})
Tree.messages(tree) #=> [user, assistant, user, assistant]
Tree.size(tree) #=> 4
Navigate back to an earlier node, then push — this creates a branch:
{:ok, tree} = Tree.navigate(tree, 3)
tree = Tree.push(tree, %Message{role: :assistant, content: "..."})
Tree.size(tree) #=> 5
Tree.children(tree, 3) #=> [4, 5] # two branches from node 3
Navigate between siblings to make either branch the live conversation:
{:ok, tree} = Tree.navigate(tree, 4) # original branch is live
{:ok, tree} = Tree.navigate(tree, 5) # switch to the new branch
When `navigate/2` lands on an interior node, `extend/1` walks down to
a leaf, following cursors left by previous pushes and navigations:
{:ok, tree} = Tree.navigate(tree, 2)
tree = Tree.extend(tree) # path extended to a leaf
## Enumerable
Implements `Enumerable`, yielding tree nodes (maps with `:id`, `:parent_id`,
`:message`, and `:usage` keys) along the active path in root-to-leaf order.
`Enum.count/1` returns the active path length.
"""
alias Omni.{Message, Usage}
@typedoc "A tree of conversation messages with an active path cursor."
@type t :: %__MODULE__{
nodes: %{node_id() => tree_node()},
path: [node_id()],
cursors: %{node_id() => node_id()}
}
@typedoc "Integer node identifier, assigned sequentially."
@type node_id :: non_neg_integer()
@typedoc "A tree node wrapping a message with tree metadata."
@type tree_node :: %{
id: node_id(),
parent_id: node_id() | nil,
message: Message.t(),
usage: Usage.t() | nil
}
defstruct nodes: %{}, path: [], cursors: %{}
@doc """
Creates a new tree from a keyword list or map.
Accepts `:nodes` (list of tree nodes or a pre-built `node_id => node` map),
`:path`, and `:cursors`. Primarily used for hydration from a persisted store.
"""
@spec new(Enumerable.t()) :: t()
def new(attrs) do
attrs
|> Map.new()
|> Map.update(:nodes, %{}, fn
nodes when is_map(nodes) -> nodes
nodes when is_list(nodes) -> Map.new(nodes, &{&1.id, &1})
end)
|> then(&struct!(__MODULE__, &1))
end
# Query
@doc "Returns a flat list of all messages along the active path, in order."
@spec messages(t()) :: [Message.t()]
def messages(%__MODULE__{} = tree) do
Enum.map(tree, & &1.message)
end
@doc "Returns the total number of nodes in the tree."
@spec size(t()) :: non_neg_integer()
def size(%__MODULE__{nodes: nodes}), do: map_size(nodes)
@doc "Returns the cumulative usage across all nodes in the tree."
@spec usage(t()) :: Usage.t()
def usage(%__MODULE__{nodes: nodes}) do
Enum.reduce(nodes, %Usage{}, fn {_id, node}, acc ->
case node.usage do
nil -> acc
usage -> Usage.add(acc, usage)
end
end)
end
@doc "Returns the ID of the last node in the active path, or `nil` if empty."
@spec head(t()) :: node_id() | nil
def head(%__MODULE__{path: []}), do: nil
def head(%__MODULE__{path: path}), do: List.last(path)
@doc "Returns the full tree node for a given ID, or `nil` if not found."
@spec get_node(t(), node_id()) :: tree_node() | nil
def get_node(%__MODULE__{nodes: nodes}, id), do: Map.get(nodes, id)
@doc "Returns the message for a given ID, or `nil` if not found."
@spec get_message(t(), node_id()) :: Message.t() | nil
def get_message(%__MODULE__{nodes: nodes}, id), do: get_in(nodes, [id, :message])
# Mutate
@doc """
Appends a message to the head of the active path. Pipe-safe.
Also sets the cursor for the parent node to point at the new node, so that
`extend/1` will follow this branch by default.
"""
@spec push(t(), Message.t(), Usage.t() | nil) :: t()
def push(%__MODULE__{} = tree, %Message{} = message, usage \\ nil) do
{_id, tree} = push_node(tree, message, usage)
tree
end
@doc """
Like `push/3`, but returns `{node_id, tree}` for when you need the new node's ID.
Sets the cursor for the parent node, same as `push/3`.
"""
@spec push_node(t(), Message.t(), Usage.t() | nil) :: {node_id(), t()}
def push_node(
%__MODULE__{nodes: nodes, path: path, cursors: cursors} = tree,
%Message{} = message,
usage \\ nil
) do
id = size(tree) + 1
parent_id = head(tree)
node = %{
id: id,
parent_id: parent_id,
message: message,
usage: usage
}
cursors = if parent_id, do: Map.put(cursors, parent_id, id), else: cursors
{id, %{tree | nodes: Map.put(nodes, id, node), path: path ++ [id], cursors: cursors}}
end
@doc """
Sets the active path by walking parent pointers from `node_id` back to root.
Sets cursors for every parent-child pair along the navigated path, so
`extend/1` from any ancestor reproduces this branch. This makes navigation
fully pin the selected branch, overriding any cursors left by earlier pushes
or navigations.
Passing `nil` clears the active path without touching the node set or
cursors. A subsequent `push/3` will create a new root (parent `nil`),
allowing the tree to hold multiple disjoint roots.
Returns `{:error, :not_found}` if the node ID doesn't exist in the tree.
"""
@spec navigate(t(), node_id() | nil) :: {:ok, t()} | {:error, :not_found}
def navigate(%__MODULE__{} = tree, nil), do: {:ok, %{tree | path: []}}
def navigate(%__MODULE__{nodes: nodes} = tree, node_id) do
case walk_to_root(nodes, node_id) do
{:ok, path} ->
cursors =
path
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(tree.cursors, fn [parent, child], acc ->
Map.put(acc, parent, child)
end)
{:ok, %{tree | path: path, cursors: cursors}}
{:error, :not_found} ->
{:error, :not_found}
end
end
@doc """
Extends the active path from head to a leaf node.
At each level, follows the cursor if one exists for the current head,
otherwise falls back to the last (most recent) child. Stops when
reaching a node with no children.
"""
@spec extend(t()) :: t()
def extend(%__MODULE__{path: []} = tree), do: tree
def extend(%__MODULE__{nodes: nodes, path: path, cursors: cursors} = tree) do
%{tree | path: extend_path(nodes, cursors, path)}
end
# Introspect
@doc "Returns the IDs of all nodes whose parent is the given node."
@spec children(t(), node_id()) :: [node_id()]
def children(%__MODULE__{nodes: nodes}, node_id), do: children_of(nodes, node_id)
@doc "Returns other children of the same parent, excluding the given node."
@spec siblings(t(), node_id()) :: [node_id()]
def siblings(%__MODULE__{nodes: nodes} = tree, node_id) do
case Map.get(nodes, node_id) do
nil ->
[]
%{parent_id: nil} ->
roots(tree) -- [node_id]
%{parent_id: parent_id} ->
children(tree, parent_id) -- [node_id]
end
end
@doc """
Walks parent pointers from `node_id` to root, returns the path in root-first order.
Useful for UIs that need to show the full path to a specific branch point.
"""
@spec path_to(t(), node_id()) :: {:ok, [node_id()]} | {:error, :not_found}
def path_to(%__MODULE__{nodes: nodes}, node_id), do: walk_to_root(nodes, node_id)
@doc "Returns IDs of all nodes with `parent_id: nil`."
@spec roots(t()) :: [node_id()]
def roots(%__MODULE__{nodes: nodes}) do
nodes
|> Enum.filter(fn {_id, node} -> node.parent_id == nil end)
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
end
# Internal
defp children_of(nodes, node_id) do
nodes
|> Enum.filter(fn {_id, node} -> node.parent_id == node_id end)
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
end
defp extend_path(nodes, cursors, path) do
head = List.last(path)
case children_of(nodes, head) do
[] ->
path
children ->
next = Map.get(cursors, head, List.last(children))
extend_path(nodes, cursors, path ++ [next])
end
end
defp walk_to_root(nodes, id, acc \\ [])
defp walk_to_root(nodes, id, acc) do
case Map.get(nodes, id) do
nil -> {:error, :not_found}
%{parent_id: nil} -> {:ok, [id | acc]}
%{parent_id: parent_id} -> walk_to_root(nodes, parent_id, [id | acc])
end
end
defimpl Enumerable do
def reduce(tree, cmd, fun) do
tree.path
|> Enum.map(&tree.nodes[&1])
|> Enumerable.List.reduce(cmd, fun)
end
def count(tree), do: {:ok, length(tree.path)}
def member?(_tree, _element), do: {:error, __MODULE__}
def slice(_tree), do: {:error, __MODULE__}
end
end