# `Rho.Session`
[🔗](https://github.com/GenericJam/rho/blob/v0.1.0/lib/rho/session.ex#L1)

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.

# `attachment`

```elixir
@type attachment() :: %{
  :kind =&gt; :image | :text | :pdf | :file,
  :name =&gt; String.t(),
  :path =&gt; String.t(),
  :mime =&gt; String.t(),
  :about =&gt; String.t(),
  optional(:image) =&gt; {String.t(), binary()} | nil,
  optional(:text) =&gt; String.t() | nil,
  optional(:pages) =&gt; 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`

```elixir
@type entry() :: %{required(String.t()) =&gt; term()}
```

# `t`

```elixir
@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()
}
```

# `aborted_tool_text`

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

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

# `add_usage`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `branch`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `from_pi_model`

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

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

# `latest`

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

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

# `list`

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

Sessions in `dir`, most recently written first.

# `messages`

```elixir
@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`

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

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

# `model_from`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `thinking`

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

Thinking text of an assistant message.

# `to_pi_model`

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

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

# `tool_calls`

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

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

# `tool_result`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `user`

```elixir
@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]`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
