Rho.Session (rho v0.1.0)

Copy Markdown View Source

Sessions in omp/pi's JSONL format (pi-coding-agent/src/session/ session-entries.ts, CURRENT_SESSION_VERSION = 3), so a session can move between omp on a computer and a rho host (Operator on a phone, say).

A file is a session header line, then entries. Every entry carries type, id (8 hex chars), parentId (the previous entry; entries form a tree and the last entry is the leaf) and an ISO timestamp. rho writes:

  • model_change (model as "provider/modelId"), first, and on a switch
  • message with pi's AgentMessage in message: user, assistant (text / thinking / image / toolCall content, api, provider, model, usage, stopReason, errorMessage; image parts in an assistant message are omp's pi extension, upstream pi has none), toolResult (with pi's details when the host keeps some, tool_result/5)
  • custom_message (customType "<ns>.notice" / "<ns>.error" / "<ns>.aside", <ns> being Rho.Config.namespace/0, content, display) for loop notices
  • compaction (pi's CompactionEntry: summary, firstKeptEntryId, tokensBefore, tokensAfter, method: "soft"): the branch before firstKeptEntryId, summarized by the model (Rho.Compaction)

A user message may carry attachments (user/2): each is a text part wrapped in <attachment …> (name, type, path, a line about it and, for a text file, its text), then pi's image part for a picture. The message's attachments field (rho's; pi ignores it) lists them for the chat (attachments/1), and typed/1 is what the user typed. A picture goes to the model only if it takes pictures, a PDF (read from its path) only if it takes PDFs (context/2's :inputs); otherwise the model gets the path and the line about it. After a compaction, the summary message lists the attachments it summarized away, so their paths aren't lost.

Reading keeps every entry, including types rho doesn't know (omp's custom, title_change, thinking_level_change, …). They are carried along (rho only appends, never rewrites) and ignored when building the model context. context/2 walks the leaf's branch as pi's buildSessionContext does: the latest compaction's summary first, then the entries from its firstKeptEntryId on, message and custom_message entries turned into req_llm messages.

The loop builds every request with context/1 over its entry list, and append/2 returns the entry as it reads back from disk, so a resumed session sends exactly what the live one did.

Summary

Types

A file going with a user message (built by the host from a picker, the camera, a download, ...): kind :image (with image, the picture for the model), :text (with text, already cut to the output budget), :pdf (with pages when known) or :file; path is its copy in the workspace, about one line for the model (size, when and where a photo was taken, ...).

t()

Functions

The text pi uses for a tool call that never ran.

Adds one assistant message's pi usage map to a totals/1 accumulator: for a host that keeps a running total as messages arrive.

Appends one entry (adding id, parentId, timestamp). Returns the updated session and the entry as it reads back from disk.

An assistant message. reply has :text, :thinking, :tool_calls ([%{"id", "name", "arguments"}]), :usage (req_llm's usage map or nil), :stop_reason (pi's: "stop" | "length" | "toolUse" | "error" | "aborted") and optionally :error and :images ({mime_type, bytes} each, pictures the model made), which go in as image parts (omp's pi; upstream pi's assistant messages have none) after the text, before the tool calls. They stay in the session for the chat; messages/2 doesn't send them back to the model (a reply that was only pictures goes as "(made a picture)").

A user message's attachments: [%{"kind", "name", "path", "mimeType", "about"}].

The leaf's branch: from the last entry with an id, follow parentId to the root. Entries without an id (pi's title slot) are not on any branch.

Splits a branch at its latest compaction: {compaction, kept, later}. kept runs from the compaction's firstKeptEntryId up to it (empty when that entry is not before it), later is everything after it. Without a compaction: {nil, [], entries}.

A compaction, as pi's local summarizer writes its CompactionEntry (method: "soft", fromExtension: false): from now on context/1 sends summary in place of the branch before first_kept_id.

The req_llm messages for a branch (no system prompt), as pi's buildSessionContext builds them. With a compaction on the branch, the latest one's summary comes first (rendered as pi renders it, plus the attachments it summarized away), then the entries from its firstKeptEntryId up to it, then everything after it (see compacted/1); otherwise the whole branch. See messages/2 for opts.

A loop notice: kind is :notice, :error or :aside; its customType is "<namespace>.<kind>" (Rho.Config.namespace/0).

Where sessions live by default: <data dir>/sessions.

"openai-codex/gpt-5" (pi) → "openai_codex:gpt-5" (req_llm).

Path of the most recently written session in dir, or nil.

Sessions in dir, most recently written first.

The req_llm messages for entries as given (no compaction applied). message and custom_message entries contribute; every other type is ignored. Assistant turns with no text and no tool calls (empty errors / aborts) and pi's superseded retryRecovery turns are dropped, and a tool call left without a result gets pi's synthetic "aborted" result, so the context is always valid for the provider.

Records a model switch; model is a req_llm spec ("anthropic:…").

The req_llm model spec in effect on a branch: the last default-role model_change, else the last assistant's provider/model, else nil.

A new session in dir for model (a req_llm spec, "anthropic:…"). Nothing is written until the first append/2, which writes the header (titled title when set, else after the first entry when it is a user message) and a model_change entry.

The kind of a loop notice entry (custom/2), whatever namespace wrote it ("rho.error" and "operator.error" are both :error); nil for any other entry.

Reads a session file: returns the session and the entries on the leaf's branch, oldest first. Unknown entry types are kept; unparsable lines (a torn last write) are skipped and logged.

The text of a message's content (string or parts; text parts only).

Thinking text of an assistant message.

"openai_codex:gpt-5" (req_llm) → "openai-codex/gpt-5" (pi).

Tool calls of an assistant message: [%{"id", "name", "arguments"}].

A tool's result. Options: :images ({mime_type, bytes} each, a screenshot or photos) go in as pi's image content parts, after the text, and on to the model; :details (JSON-encodable; nil for none) is stored as the message's details (pi's ToolResultMessage.details): data for the host's screens, never sent to the model.

Summed usage over a branch's assistant messages (pi usage shape).

What the user typed: a user message's text without its attachments (each adds one text part, its envelope, after the typed text).

pi's usage (tokens; cost in dollars) from req_llm's usage map; nil gives zeros.

A user message: text (may be empty when opts[:attachments] isn't) then each attachment's parts. opts[:steering] marks a message sent while the agent was running; opts[:ask] (an id) a question another agent asked (Rho.Loop.ask/4), from node opts[:from].

Types

attachment()

@type attachment() :: %{
  :kind => :image | :text | :pdf | :file,
  :name => String.t(),
  :path => String.t(),
  :mime => String.t(),
  :about => String.t(),
  optional(:image) => {String.t(), binary()} | nil,
  optional(:text) => String.t() | nil,
  optional(:pages) => pos_integer() | nil
}

A file going with a user message (built by the host from a picker, the camera, a download, ...): kind :image (with image, the picture for the model), :text (with text, already cut to the output budget), :pdf (with pages when known) or :file; path is its copy in the workspace, about one line for the model (size, when and where a photo was taken, ...).

entry()

@type entry() :: %{required(String.t()) => term()}

t()

@type t() :: %Rho.Session{
  cwd: String.t(),
  id: String.t(),
  leaf_id: String.t() | nil,
  model: String.t(),
  path: String.t(),
  title: String.t() | nil,
  written?: boolean()
}

Functions

aborted_tool_text()

@spec aborted_tool_text() :: String.t()

The text pi uses for a tool call that never ran.

add_usage(acc, u)

@spec add_usage(map(), map()) :: map()

Adds one assistant message's pi usage map to a totals/1 accumulator: for a host that keeps a running total as messages arrive.

append(s, entry)

@spec append(t(), entry()) :: {t(), entry()}

Appends one entry (adding id, parentId, timestamp). Returns the updated session and the entry as it reads back from disk.

assistant(reply, model)

@spec assistant(map(), String.t()) :: entry()

An assistant message. reply has :text, :thinking, :tool_calls ([%{"id", "name", "arguments"}]), :usage (req_llm's usage map or nil), :stop_reason (pi's: "stop" | "length" | "toolUse" | "error" | "aborted") and optionally :error and :images ({mime_type, bytes} each, pictures the model made), which go in as image parts (omp's pi; upstream pi's assistant messages have none) after the text, before the tool calls. They stay in the session for the chat; messages/2 doesn't send them back to the model (a reply that was only pictures goes as "(made a picture)").

attachments(arg1)

@spec attachments(map()) :: [map()]

A user message's attachments: [%{"kind", "name", "path", "mimeType", "about"}].

branch(entries)

@spec branch([entry()]) :: [entry()]

The leaf's branch: from the last entry with an id, follow parentId to the root. Entries without an id (pi's title slot) are not on any branch.

compacted(entries)

@spec compacted([entry()]) :: {entry() | nil, [entry()], [entry()]}

Splits a branch at its latest compaction: {compaction, kept, later}. kept runs from the compaction's firstKeptEntryId up to it (empty when that entry is not before it), later is everything after it. Without a compaction: {nil, [], entries}.

compaction(summary, first_kept_id, tokens_before, tokens_after)

@spec compaction(String.t(), String.t(), non_neg_integer(), non_neg_integer()) ::
  entry()

A compaction, as pi's local summarizer writes its CompactionEntry (method: "soft", fromExtension: false): from now on context/1 sends summary in place of the branch before first_kept_id.

context(entries, opts \\ [])

@spec context([entry()], keyword()) :: [ReqLLM.Message.t()]

The req_llm messages for a branch (no system prompt), as pi's buildSessionContext builds them. With a compaction on the branch, the latest one's summary comes first (rendered as pi renders it, plus the attachments it summarized away), then the entries from its firstKeptEntryId up to it, then everything after it (see compacted/1); otherwise the whole branch. See messages/2 for opts.

custom(kind, text)

@spec custom(atom(), String.t()) :: entry()

A loop notice: kind is :notice, :error or :aside; its customType is "<namespace>.<kind>" (Rho.Config.namespace/0).

dir()

@spec dir() :: String.t()

Where sessions live by default: <data dir>/sessions.

from_pi_model(pi_model)

@spec from_pi_model(String.t()) :: String.t()

"openai-codex/gpt-5" (pi) → "openai_codex:gpt-5" (req_llm).

latest(dir)

@spec latest(String.t()) :: String.t() | nil

Path of the most recently written session in dir, or nil.

list(dir)

@spec list(String.t()) :: [
  %{id: String.t(), path: String.t(), mtime: integer(), title: String.t() | nil}
]

Sessions in dir, most recently written first.

messages(entries, opts \\ [])

@spec messages([entry()], keyword()) :: [ReqLLM.Message.t()]

The req_llm messages for entries as given (no compaction applied). message and custom_message entries contribute; every other type is ignored. Assistant turns with no text and no tool calls (empty errors / aborts) and pi's superseded retryRecovery turns are dropped, and a tool call left without a result gets pi's synthetic "aborted" result, so the context is always valid for the provider.

opts[:inputs] is what the model takes (Rho.Models.inputs/1, default [:text, :image]): without :image, pictures become a note; with :pdf, attached PDFs go as documents, read from their paths, the newest first while the request stays inside the provider's limits.

model_change(model)

@spec model_change(String.t()) :: entry()

Records a model switch; model is a req_llm spec ("anthropic:…").

model_from(entries)

@spec model_from([entry()]) :: String.t() | nil

The req_llm model spec in effect on a branch: the last default-role model_change, else the last assistant's provider/model, else nil.

new(dir, model, cwd)

@spec new(String.t(), String.t(), String.t()) :: t()

A new session in dir for model (a req_llm spec, "anthropic:…"). Nothing is written until the first append/2, which writes the header (titled title when set, else after the first entry when it is a user message) and a model_change entry.

notice_kind(arg1)

@spec notice_kind(entry()) :: :notice | :error | :aside | nil

The kind of a loop notice entry (custom/2), whatever namespace wrote it ("rho.error" and "operator.error" are both :error); nil for any other entry.

open(path, default_model)

@spec open(String.t(), String.t()) :: {:ok, t(), [entry()]} | {:error, term()}

Reads a session file: returns the session and the entries on the leaf's branch, oldest first. Unknown entry types are kept; unparsable lines (a torn last write) are skipped and logged.

text(content)

@spec text(term()) :: String.t()

The text of a message's content (string or parts; text parts only).

thinking(arg1)

@spec thinking(map()) :: String.t()

Thinking text of an assistant message.

to_pi_model(spec)

@spec to_pi_model(String.t()) :: String.t()

"openai_codex:gpt-5" (req_llm) → "openai-codex/gpt-5" (pi).

tool_calls(arg1)

@spec tool_calls(map()) :: [map()]

Tool calls of an assistant message: [%{"id", "name", "arguments"}].

tool_result(call_id, name, text, is_error, opts \\ [])

@spec tool_result(String.t(), String.t(), String.t(), boolean(), keyword()) :: entry()

A tool's result. Options: :images ({mime_type, bytes} each, a screenshot or photos) go in as pi's image content parts, after the text, and on to the model; :details (JSON-encodable; nil for none) is stored as the message's details (pi's ToolResultMessage.details): data for the host's screens, never sent to the model.

totals(entries)

@spec totals([entry()]) :: %{
  tokens: non_neg_integer(),
  output: non_neg_integer(),
  cost: float()
}

Summed usage over a branch's assistant messages (pi usage shape).

typed(message)

@spec typed(map()) :: String.t()

What the user typed: a user message's text without its attachments (each adds one text part, its envelope, after the typed text).

usage(u)

@spec usage(map() | nil) :: map()

pi's usage (tokens; cost in dollars) from req_llm's usage map; nil gives zeros.

user(text, opts \\ [])

@spec user(String.t(), keyword()) :: entry()

A user message: text (may be empty when opts[:attachments] isn't) then each attachment's parts. opts[:steering] marks a message sent while the agent was running; opts[:ask] (an id) a question another agent asked (Rho.Loop.ask/4), from node opts[:from].