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(modelas"provider/modelId"), first, and on a switchmessagewith pi'sAgentMessageinmessage: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'sdetailswhen the host keeps some,tool_result/5)custom_message(customType"<ns>.notice"/"<ns>.error"/"<ns>.aside",<ns>beingRho.Config.namespace/0,content,display) for loop noticescompaction(pi'sCompactionEntry:summary,firstKeptEntryId,tokensBefore,tokensAfter,method: "soft"): the branch beforefirstKeptEntryId, 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, ...).
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
@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, ...).
Functions
@spec aborted_tool_text() :: String.t()
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}.
@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.
@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.
A loop notice: kind is :notice, :error or :aside; its
customType is "<namespace>.<kind>" (Rho.Config.namespace/0).
@spec dir() :: String.t()
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.
@spec list(String.t()) :: [ %{id: String.t(), path: String.t(), mtime: integer(), title: String.t() | nil} ]
Sessions in dir, most recently written first.
@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.
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.
@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.
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.
@spec totals([entry()]) :: %{ tokens: non_neg_integer(), output: non_neg_integer(), cost: float() }
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].