# `Nous.Transcript`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/transcript.ex#L1)

Lightweight conversation history compaction.

Provides utility functions for managing conversation message lists
without requiring an LLM call. For LLM-powered summarization,
see `Nous.Plugins.Summarization`.

## Usage

    messages = [msg1, msg2, msg3, ..., msg20]

    # Keep last 10 messages, summarize the rest
    compacted = Nous.Transcript.compact(messages, 10)

    # Auto-compact: every 20 messages, keep last 10
    compacted = Nous.Transcript.maybe_compact(messages, every: 20, keep_last: 10)

    # Auto-compact: at 80% of token budget
    compacted = Nous.Transcript.maybe_compact(messages,
      token_budget: 128_000,
      keep_last: 10
    )

    # Both triggers (whichever fires first)
    compacted = Nous.Transcript.maybe_compact(messages,
      every: 30,
      token_budget: 128_000,
      threshold: 0.8,
      keep_last: 10
    )

    # Run compaction in the background (returns a Task)
    task = Nous.Transcript.compact_async(messages, 10)
    compacted = Task.await(task)

    # Fire-and-forget with callback
    Nous.Transcript.compact_async(messages, 10, fn compacted ->
      send(self(), {:compacted, compacted})
    end)

    # Prune oversized tool results in place (no LLM, no reordering)
    pruned = Nous.Transcript.prune_tool_results(messages, 8192)

    # Estimate token count (coarse ~4-bytes-per-token heuristic)
    tokens = Nous.Transcript.estimate_tokens("Hello world, how are you?")
    #=> 6

## Token estimates are coarse

`estimate_tokens/1` and `estimate_messages_tokens/1` divide UTF-8 byte
length by 4. That ratio is roughly right for English prose and
systematically wrong elsewhere: it under-counts code and JSON (dense in
punctuation, which tokenizes finely) and badly over-counts CJK text (3
bytes per character, often ~1 token per character). Both `maybe_compact/2`
triggers are built on it, so `:token_budget` is approximate in both
directions — size the budget with headroom, or measure with a real
tokenizer and drive compaction from `compact/2` directly.

# `balance_tool_call_boundary`

```elixir
@spec balance_tool_call_boundary([Nous.Message.t()], [Nous.Message.t()]) ::
  {[Nous.Message.t()], [Nous.Message.t()]}
```

Moves messages across an `{old, recent}` boundary so it never splits a
`tool_call`/`tool_result` pair.

Anthropic, OpenAI and Gemini all reject a request whose tool results have
no preceding assistant `tool_call` (and vice versa), so any code that
splits a conversation — compaction, summarization, windowing — must pass
its boundary through here.

Leading `:tool` messages in `recent` are orphans: their assistant prelude
is the last message of `old`. They are moved into `old`, which both closes
the pair and keeps every message in its original relative order.

The reverse case needs no work: if `old` ends with an assistant
`tool_call`, its results are the very next messages, so they are exactly
the leading `:tool` messages this function pulls back.

## Examples

    iex> old = [Nous.Message.assistant("calling", tool_calls: [%{id: "c1"}])]
    iex> recent = [Nous.Message.tool("c1", "done"), Nous.Message.user("next")]
    iex> {old, recent} = Nous.Transcript.balance_tool_call_boundary(old, recent)
    iex> {length(old), length(recent)}
    {2, 1}

# `compact`

```elixir
@spec compact([Nous.Message.t()], pos_integer()) :: [Nous.Message.t()]
```

Compacts a message list by keeping the last `keep_last` messages.

If messages exceed the threshold, older messages are replaced with a
summary system message. System messages at the start are always preserved.

Returns the original list if it's already within the limit.

## Examples

    iex> messages = for i <- 1..20, do: Nous.Message.user("Message #{i}")
    iex> compacted = Nous.Transcript.compact(messages, 10)
    iex> length(compacted)
    11

# `compact_async`

```elixir
@spec compact_async([Nous.Message.t()], pos_integer()) :: Task.t()
```

Compacts messages asynchronously under `Nous.TaskSupervisor`.

Returns a `Task` that resolves to the compacted message list.
Useful when compaction runs inside a GenServer and you don't
want to block the current process.

## Examples

    task = Nous.Transcript.compact_async(messages, 10)
    # ... do other work ...
    compacted = Task.await(task)

## With a callback (fire-and-forget)

    Nous.Transcript.compact_async(messages, 10, fn compacted ->
      send(self(), {:compacted, compacted})
    end)

# `compact_async`

```elixir
@spec compact_async([Nous.Message.t()], pos_integer(), ([Nous.Message.t()] -&gt; any())) ::
  {:ok, pid()}
```

Compacts messages in the background with a callback.

Starts a fire-and-forget task under `Nous.TaskSupervisor`.
The callback receives the compacted message list when done.
Returns `{:ok, pid}`.

## Examples

    {:ok, _pid} = Nous.Transcript.compact_async(messages, 10, fn compacted ->
      GenServer.cast(self, {:update_messages, compacted})
    end)

# `estimate_messages_tokens`

```elixir
@spec estimate_messages_tokens([Nous.Message.t()]) :: non_neg_integer()
```

Estimates total tokens across a list of messages.

Sums message text in bytes and divides once, so the result matches
`estimate_tokens/1` on the concatenated text rather than accumulating a
rounding error per message. Same caveats as `estimate_tokens/1`: it is a
byte ratio, not a tokenizer.

## Examples

    iex> messages = [Nous.Message.user("Hello"), Nous.Message.assistant("Hi there")]
    iex> Nous.Transcript.estimate_messages_tokens(messages)
    3

# `estimate_tokens`

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

Estimates the token count of a string as UTF-8 bytes divided by four.

A coarse byte-ratio estimate, not a tokenizer. Four bytes per token is a
passable average for English prose; it under-counts code, JSON and other
punctuation-dense text (which tokenizes far finer than 4 bytes per token)
and heavily over-counts CJK, where a 3-byte character is often a single
token. Anything that must be exact — a hard context-window check, billing
— needs a real tokenizer.

This is deliberately the same arithmetic as the agent runner's pre-request
reservation estimate (`estimate_request_tokens` in `Nous.AgentRunner.RequestDispatch`),
so the framework has one token heuristic rather than two that disagree.

## Examples

    iex> Nous.Transcript.estimate_tokens("Hello world")
    2

    iex> Nous.Transcript.estimate_tokens("antidisestablishmentarianism")
    7

    iex> Nous.Transcript.estimate_tokens("")
    0

# `maybe_compact`

```elixir
@spec maybe_compact(
  [Nous.Message.t()],
  keyword()
) :: [Nous.Message.t()]
```

Automatically compacts messages when a trigger condition is met.

Returns the original messages unchanged if no trigger fires.
Supports message count, token budget, or both (OR logic).

## Options

  * `:every` — compact when message count exceeds this number
  * `:token_budget` — total token budget for the conversation
  * `:threshold` — fraction of token budget that triggers compaction (default `0.8`)
  * `:keep_last` — how many recent messages to keep (required)

## Examples

    # Compact every 20 messages
    messages = Nous.Transcript.maybe_compact(messages, every: 20, keep_last: 10)

    # Compact at 80% of 128k token budget
    messages = Nous.Transcript.maybe_compact(messages,
      token_budget: 128_000,
      keep_last: 10
    )

    # Both triggers — whichever fires first
    messages = Nous.Transcript.maybe_compact(messages,
      every: 30,
      token_budget: 128_000,
      threshold: 0.75,
      keep_last: 10
    )

# `maybe_compact_async`

```elixir
@spec maybe_compact_async([Nous.Message.t()], keyword(), (term() -&gt; any())) ::
  {:ok, pid()}
```

Like `maybe_compact/2` but runs asynchronously with a callback.

The callback receives `{:compacted, messages}` if compaction happened,
or `{:unchanged, messages}` if no trigger fired.

## Examples

    Nous.Transcript.maybe_compact_async(messages,
      [every: 20, keep_last: 10],
      fn
        {:compacted, msgs} -> GenServer.cast(self, {:update, msgs})
        {:unchanged, _msgs} -> :ok
      end
    )

# `prune_tool_results`

```elixir
@spec prune_tool_results([Nous.Message.t()], pos_integer()) :: [Nous.Message.t()]
```

Truncates oversized tool results in place, without an LLM call.

A single tool result — a 1 MB file read, a wide `SELECT`, a verbose build
log — can dominate a context window and is re-sent on every subsequent
turn. This rewrites the content of any `:tool` message whose text exceeds
`max_result_chars`, keeping the head and tail (where the useful signal
almost always is) and replacing the middle with a marker naming how many
bytes were dropped.

Structural guarantees, relied on by callers that split conversations:

  * the returned list has the same length, in the same order — only
    content is rewritten, so a `tool_call`/`tool_result` pair can never be
    broken by pruning;
  * non-`:tool` messages are returned identically;
  * a tool result whose content is not plain text (a content-part list)
    is returned identically rather than flattened into a string.

## Examples

    iex> big = Nous.Message.tool("c1", String.duplicate("x", 20_000))
    iex> [pruned] = Nous.Transcript.prune_tool_results([big])
    iex> String.contains?(pruned.content, "bytes elided")
    true

    iex> small = Nous.Message.tool("c1", "ok")
    iex> Nous.Transcript.prune_tool_results([small]) == [small]
    true

# `should_compact?`

```elixir
@spec should_compact?([Nous.Message.t()], pos_integer()) :: boolean()
```

Checks if a message list should be compacted based on a threshold.

## Examples

    iex> messages = for i <- 1..25, do: Nous.Message.user("msg #{i}")
    iex> Nous.Transcript.should_compact?(messages, 20)
    true

---

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