context

js/ai/context.ts

fino:ai/context — immutable message history and history strategies.

This module contains the low-level history substrate used by agents and sessions. MessageHistory is a persistent, revisioned sequence of model messages: append, edit, fork, restore, and view operations return new history objects while retaining immutable entries and revision lineage. HistoryStrategy is the policy boundary between runtime code and history curation.

Design

The runtime does not compact, retrieve, or summarize by itself. It calls strategy.onAppend() whenever a user, assistant, or tool-result message is recorded, and strategy.onRead() before each model request. Strategies may update their own history reference, return a temporary model-facing view without changing the active history, or emit memory through their own dependencies.

MessageHistory never writes durable storage. Sessions own persistence by committing toSnapshot() or changesSince() output into a SessionStore. That keeps the immutable graph model separate from run/thread lifecycle concerns such as checkpoints, suspension, cancellation, and atomic commits.

Alongside the history substrate, the module carries the budget helpers that strategies and agent loops share: estimateTokens() for cheap size checks, the PRICING table and costOf() for converting reported usage into USD, and the maxTokens() / maxCost() stop predicates for bounding agent runs.

import { MessageHistory, appendOnlyHistoryStrategy } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'remember this' });
history = await history.edit({
  op: 'summary',
  sourceIds: history.refs().map((entry) => entry.id),
  entry: { message: { role: 'system', content: 'User asked us to remember a fact.' } },
  replace: true,
});

const strategy = appendOnlyHistoryStrategy(history);

Interfaces

interface MessageMeta {

Optional metadata attached to a history entry.

All fields are advisory: MessageHistory stores them verbatim and never interprets them. Strategies and tooling typically use labels to tag entries for later selection and links to relate an entry to external records such as run ids or memory documents.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({
  message: { role: 'user', content: 'the deploy window is Friday' },
  meta: { turn: 3, labels: ['decision'], links: [{ rel: 'run', to: 'run-42' }] },
});
const decisions = history.refs().filter((e) => e.meta?.labels?.includes('decision'));

Properties

turn?: number

Conversation turn number the entry belongs to.

createdAt?: number

Creation time in milliseconds since the Unix epoch.

labels?: string[]

Free-form tags used to select or filter entries later.

interface HistoryReadContext {

Read context passed to a HistoryStrategy before a model request.

Strategies can use the active model, token budget, and abort signal to decide whether to summarize, select a subset, or return the persisted view unchanged.

import { appendOnlyHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const model = anthropic({ model: 'claude-sonnet-4-6' });
const strategy = appendOnlyHistoryStrategy();
const { messages } = await strategy.onRead({ model, budgetTokens: 100000 });

Properties

model: Model

Model the upcoming request targets; strategies may also use it for summarization calls.

budgetTokens: number

Token budget the returned model-facing view should aim to fit within.

signal?: AbortSignal

Abort signal that cancels any model calls the strategy issues while curating.

interface HistoryAppendContext extends HistoryReadContext {

Append context passed when the runtime records a new inbound or generated message.

The run metadata is advisory. Strategies should update their own history reference by assigning the immutable collection returned by MessageHistory.

import { appendOnlyHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const model = anthropic({ model: 'claude-sonnet-4-6' });
const strategy = appendOnlyHistoryStrategy();
await strategy.onAppend(
  { role: 'user', content: 'hello' },
  { model, budgetTokens: 100000, runId: 'run-42', stepIndex: 0 },
);

Properties

runId?: string

Identifier of the agent run recording the message.

stepIndex?: number

Zero-based agent-loop step within the run.

interface HistoryStrategy {

Owns the conversation history policy for an agent.

The runtime only calls onAppend and onRead. It does not interpret why a view was compacted, selected, summarized, or left unchanged. Because MessageHistory is immutable, implementations advance state by reassigning their history property with the collection returned from each operation; the runtime treats that property as the strategy's current source of truth.

import { MessageHistory } from 'fino:ai/context';
import type { HistoryStrategy } from 'fino:ai/context';

const lastTen: HistoryStrategy = {
  history: new MessageHistory(),
  async onAppend(message) {
    this.history = await this.history.append(message);
  },
  async onRead() {
    const recent = this.history.refs().slice(-10).map((entry) => entry.id);
    return { history: this.history, messages: this.history.render(recent) };
  },
};

Properties

history: MessageHistory

Current immutable history; reassigned after every history operation.

Methods

onAppend(message: ModelMessage, ctx: HistoryAppendContext): Promise<void>

Record an inbound user, assistant, or tool-result message.

onRead(ctx: HistoryReadContext): Promise<{ history: MessageHistory; messages: ModelMessage[]; }>

Produce the model-facing view for the next request, optionally compacting first.

interface MessageHistoryEntry {

Immutable message entry stored by MessageHistory.

Summary entries can point at source entry ids so restore() can expand a compacted view back to the original messages. Entries are never mutated in place; refs() and toSnapshot() return defensive clones.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'hi' });
const [entry] = history.refs();
console.log(entry.id, entry.kind); // '1-abc1234' 'turn'

Properties

id: string

Stable unique id referenced by revisions, patches, and summaries.

message: ModelMessage

The stored model message.

kind: 'turn' | 'summary'

turn for recorded conversation messages, summary for entries that condense others.

meta?: MessageMeta

Advisory metadata supplied when the entry was created.

sources?: string[]

Entry ids a summary condenses; consumed by restore().

interface MessageHistoryRevision {

Immutable ordered sequence of active entry ids.

Revisions form a parent chain so forks and rewritten views keep lineage while retaining the original entries.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'hi' });
const { revisions, head } = history.toSnapshot();
const current = revisions.find((rev) => rev.id === head);
console.log(current?.operation); // { type: 'append', entryIds: ['…'] }

Properties

id: string

Unique revision id; the history's revisionId when this revision is active.

entryIds: string[]

Active entry ids in render order.

parent?: string

Id of the revision this one was derived from; absent on the root.

createdAt: number

Creation time in milliseconds since the Unix epoch.

operation?: MessageHistoryOperation

The operation that produced this revision; absent on the root.

interface MessageHistorySnapshot {

Serializable history payload used by in-process tests and store reloads.

A snapshot carries the full immutable graph — every entry and revision ever created, not just the active view — so a reload preserves forks, summary sources, and lineage exactly.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'persist me' });

const snapshot = history.toSnapshot();
const loaded = MessageHistory.fromSnapshot(JSON.parse(JSON.stringify(snapshot)));

Properties

entries: MessageHistoryEntry[]

Every entry in the graph, including entries only reachable from older revisions.

revisions: MessageHistoryRevision[]

The full revision graph, parents included.

head: string

Id of the active revision.

interface MessageHistoryDelta extends MessageHistorySnapshot {

Incremental immutable graph payload produced by changesSince().

Contains only the entries and revisions added after the base revision, so a store can commit deltas instead of rewriting the whole snapshot.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'committed' });
const checkpoint = history.revisionId;

history = await history.append({ role: 'assistant', content: 'new since checkpoint' });
const delta = history.changesSince(checkpoint);
console.log(delta.base === checkpoint, delta.entries.length); // true 1

Properties

base?: string

Revision id the delta was computed against; absent when the delta is a full export.

interface Pricing {

Per-million-token pricing used by costOf().

Cache rates are optional; when omitted, cache traffic contributes nothing to the computed cost even if the usage reports it.

import { costOf } from 'fino:ai/context';
import type { Pricing } from 'fino:ai/context';

const pricing: Record<string, Pricing> = {
  'my-local-model': { inputPer1M: 0.2, outputPer1M: 0.8 },
};
const usd = costOf({ inputTokens: 1000000, outputTokens: 250000 }, 'my-local-model', pricing);

Properties

inputPer1M: number

USD per million input tokens.

outputPer1M: number

USD per million output tokens.

cacheReadPer1M?: number

USD per million cache-read input tokens, when the provider bills them.

cacheWritePer1M?: number

USD per million cache-write input tokens, when the provider bills them.

interface SummarizingHistoryStrategyOptions {

Options for summarizingHistoryStrategy().

import { summarizingHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const strategy = summarizingHistoryStrategy({
  model: anthropic({ model: 'claude-haiku-4-5' }),
  triggerTokens: 50000,
  keepRecent: 20,
});

Properties

model: Model

Model used for the summarization call; may differ from the conversation model.

triggerTokens: number

Estimated token count at which onRead compacts older entries.

keepRecent: number

Number of most-recent entries always kept verbatim.

memoryStore?: { ingest(docs: { text: string; metadata?: Record<string, unknown>; }[]): Promise<void>; }

Optional sink that receives each summary text, e.g. a fino:ai/memory store.

history?: MessageHistory

Initial history; defaults to a new empty MessageHistory.

interface SelectiveSummaryHistoryStrategyOptions {

Options for selectiveSummaryHistoryStrategy().

import { selectiveSummaryHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const strategy = selectiveSummaryHistoryStrategy({
  model: anthropic({ model: 'claude-haiku-4-5' }),
  summaryPrompt: 'Condense these tool results into key facts.',
  selector: (history) => history.refs()
    .filter((entry) => entry.meta?.labels?.includes('tool-noise'))
    .map((entry) => entry.id),
});

Properties

model: Model

Model used for the summarization call.

selector: (history: MessageHistory, ctx: HistoryReadContext) => string[] | Promise<string[]>

Chooses entry ids to compact on each read; return [] to leave the history unchanged.

summaryPrompt: string

System prompt for the summarization call.

memoryStore?: { ingest(docs: { text: string; metadata?: Record<string, unknown>; }[]): Promise<void>; }

Optional sink that receives each summary text, e.g. a fino:ai/memory store.

history?: MessageHistory

Initial history; defaults to a new empty MessageHistory.

Functions

function signalHistoryStrategy(inner: HistoryStrategy): { strategy: HistoryStrategy; history: ReadonlySignal<MessageHistory>; }

Wrap a history strategy with a retained signal of its current history.

The wrapper delegates all behavior to inner and publishes after onAppend, after onRead, and after direct assignments to strategy.history. Use it to observe history evolution — UI live views, persistence triggers, metrics — without teaching the strategy itself about subscribers.

import { appendOnlyHistoryStrategy, signalHistoryStrategy } from 'fino:ai/context';
import { agent } from 'fino:ai/agent';
import { anthropic } from 'fino:ai/model';

const { strategy, history } = signalHistoryStrategy(appendOnlyHistoryStrategy());
history.subscribe((h) => console.log('history entries:', h.size));

const bot = agent({ model: anthropic({ model: 'claude-sonnet-4-6' }), history: strategy });
await bot.generate('hello');

function estimateTokens(messages: ModelMessage[]): number

Estimate tokens for a set of messages.

This helper is intentionally approximate and uses serialized character count divided by four. Strategies should use model-native tokenizers when exact accounting is required.

import { estimateTokens } from 'fino:ai/context';

const tokens = estimateTokens([{ role: 'user', content: 'four chars per token, roughly' }]);

function costOf(usage: Usage, modelName: string, pricing?: Record<string, Pricing>): number

Calculate approximate USD cost for reported usage.

Unknown models return 0 unless a custom pricing table contains modelName. Cache read/write tokens are billed only when both the pricing entry defines a cache rate and the usage reports the corresponding token count.

import { costOf } from 'fino:ai/context';

const usd = costOf(
  { inputTokens: 1200000, outputTokens: 80000, cacheReadInputTokens: 400000 },
  'claude-sonnet-4-6',
);

function maxTokens(n: number): (state: { usage: Usage; }, info: unknown) => boolean

Create a stop predicate that triggers once total tokens reach n.

The predicate sums accumulated input and output tokens from the run state. Wire it into an agent's stopWhen to bound loop size.

import { agent } from 'fino:ai/agent';
import { maxTokens } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const bot = agent({
  model: anthropic({ model: 'claude-sonnet-4-6' }),
  stopWhen: maxTokens(50000),
});

function maxCost(usd: number): (state: { cost?: number; }, info: unknown) => boolean

Create a stop predicate that triggers once estimated cost reaches usd.

Cost comes from the run state's accumulated cost, which is populated when the model id is present in the pricing table. A missing cost is treated as 0, so runs against unpriced models never stop on this condition.

import { agent } from 'fino:ai/agent';
import { maxCost } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const bot = agent({
  model: anthropic({ model: 'claude-sonnet-4-6' }),
  stopWhen: maxCost(2.50),
});

function appendOnlyHistoryStrategy(history?: MessageHistory): HistoryStrategy

Create a strategy that appends every message and reads the full active view.

This is the default strategy for simple agents and a useful base for custom strategies that want to layer policy on top of append-only persistence. Pass an existing MessageHistory to resume a conversation from a snapshot.

import { agent } from 'fino:ai/agent';
import { appendOnlyHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const strategy = appendOnlyHistoryStrategy();
const bot = agent({ model: anthropic({ model: 'claude-sonnet-4-6' }), history: strategy });
await bot.generate('hello');
console.log(strategy.history.render()); // full transcript so far

function summarizingHistoryStrategy(opts: SummarizingHistoryStrategyOptions): HistoryStrategy

Create a strategy that lazily summarizes older entries during onRead.

Appends are policy-free. When the active history reaches triggerTokens, the strategy summarizes older safe entries and keeps the most recent keepRecent entries verbatim. The summary replaces the compacted span as a single summary entry with sources links, so history.restore() can still recover the original messages.

Compaction only cuts at safe boundaries: the summarized span ends after the last assistant message that carries no tool use, so tool_use/tool_result pairs are never split. If no safe boundary exists, the read returns the history unchanged. When memoryStore is provided, each summary text is also ingested with kind: 'conversation_summary' metadata.

import { agent } from 'fino:ai/agent';
import { summarizingHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const model = anthropic({ model: 'claude-sonnet-4-6' });
const bot = agent({
  model,
  history: summarizingHistoryStrategy({ model, triggerTokens: 60000, keepRecent: 10 }),
});

function selectiveSummaryHistoryStrategy( opts: SelectiveSummaryHistoryStrategyOptions ): HistoryStrategy

Create a strategy that summarizes a selector-chosen subset during onRead.

The selector receives the current history and returns entry ids. Selected entries are summarized in one model call and replaced by a single summary entry at the position of the first selected entry, while unselected entries keep their positions. An empty selection leaves the history unchanged.

Unlike summarizingHistoryStrategy(), which compacts a chronological prefix on a token trigger, this strategy compacts an arbitrary subset every read — use it to fold away noisy tool output or stale side threads while keeping the live conversation verbatim. Summary sources links preserve the originals for history.restore(); memoryStore, when provided, receives each summary text with kind: 'selected_history_summary' metadata.

import { agent } from 'fino:ai/agent';
import { selectiveSummaryHistoryStrategy } from 'fino:ai/context';
import { anthropic } from 'fino:ai/model';

const model = anthropic({ model: 'claude-sonnet-4-6' });
const bot = agent({
  model,
  history: selectiveSummaryHistoryStrategy({
    model,
    summaryPrompt: 'Condense the older conversation into key facts and decisions.',
    selector: (history) => history.refs().slice(0, -5).map((entry) => entry.id),
  }),
});

Types

type MessageHistoryOperation = { type: 'append'; entryIds: string[]; } | { type: 'edit'; patches: MessageHistoryPatch[]; } | { type: 'fork'; } | { type: 'view'; entryIds: string[]; }

Metadata describing why a history revision exists.

Each variant mirrors the MessageHistory operation that produced the revision: append records the added entry ids, edit records the applied patch list, fork marks a branch point, and view records a temporary entry-id selection created by withView().

type MessageHistoryPatch = { op: 'remove'; id: string; } | { op: 'replace'; id: string; entries: MessageHistoryEntryInput[]; } | { op: 'insertBefore' | 'insertAfter'; id: string; entries: MessageHistoryEntryInput[]; } | { op: 'move'; ids: string[]; before?: string; after?: string; } | { op: 'split'; id: string; entries: MessageHistoryEntryInput[]; } | { op: 'summary'; sourceIds: string[]; entry: MessageHistoryEntryInput; replace?: boolean; }

Rewrite operation applied to a MessageHistory.

Patches can remove entries, replace an entry with one or more entries, insert around an entry, move entries, split one message into extracted entries, or add a summary linked to source ids.

edit() applies patches in order against the evolving sequence; a patch that targets an id absent from the active sequence is silently skipped. A move with no matching before/after anchor moves the entries to the end. A summary patch with replace: true swaps the source entries for the summary at the position of the first source; without replace the summary is appended and the sources stay active.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'keep' });
history = await history.append({ role: 'user', content: 'drop' });
const [, drop] = history.refs();
history = await history.edit({ op: 'remove', id: drop.id });

type MessageHistoryEntryInput = ModelMessage | { id?: string; message: ModelMessage; kind?: 'turn' | 'summary'; meta?: MessageMeta; sources?: string[]; }

Input accepted by MessageHistory.append() and rewrite operations.

A bare ModelMessage becomes a turn entry with a generated id. The object form controls the id, kind, metadata, and summary sources; omitted fields fall back to the same defaults. Messages are deep-cloned on intake, so later mutation of the input does not affect stored entries.

Classes

class MessageHistory {

Immutable persistent sequence of model messages.

Every operation returns a new collection pointing at a new revision. Original entries remain available for restore, fork, and lineage inspection. Because instances never change, callers hold the latest history by reassigning a variable (or a HistoryStrategy.history property) after each operation — older references keep working and still see their own revision.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'question' });
history = await history.append({ role: 'assistant', content: 'answer' });

const branch = await (await history.fork()).append({ role: 'user', content: 'what if?' });
console.log(history.size); // 2 — the original is untouched
console.log(branch.size);  // 3

Constructors

constructor(data?: { entries?: Iterable<MessageHistoryEntry>; revisions?: Iterable<MessageHistoryRevision>; head?: string; })

Create a history, optionally rehydrating entries, revisions, and head.

All inputs are defensively cloned. When head is omitted, or names a revision that was not supplied, an empty revision with that id is created — so new MessageHistory() starts blank.

Getters

get revisionId(): string

Current active sequence revision id.

get size(): number

Number of entries in the active sequence.

Methods

async append(entry: MessageHistoryEntryInput): Promise<MessageHistory>

Return a new history with entry appended to the active sequence.

async edit(patches: MessageHistoryPatch | MessageHistoryPatch[]): Promise<MessageHistory>

Apply one or more sequence rewrites and return the resulting history.

Patches apply in order against the evolving sequence; patches that target ids absent from the active sequence are skipped. The resulting revision records the full patch list as its operation.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'a' });
history = await history.append({ role: 'assistant', content: 'b' });
const [a] = history.refs();
history = await history.edit([
  { op: 'insertAfter', id: a.id, entries: [{ role: 'user', content: 'a2' }] },
  { op: 'remove', id: a.id },
]);
async fork(): Promise<MessageHistory>

Return a new revision with the same active sequence for branch isolation.

Appends and edits on the fork never affect the original history, but both branches share the same underlying entries and lineage.

withView(view: string | string[]): MessageHistory

Return a history focused on a revision id or temporary entry-id view.

With a revision id, the returned history points its head at that revision — useful for time travel over persisted lineage. With an entry-id array, a temporary view revision is minted in the returned history only; the original history is unaffected.

Throws if a revision id is given that does not exist in the graph.

import { MessageHistory } from 'fino:ai/context';

let history = new MessageHistory();
history = await history.append({ role: 'user', content: 'first' });
const checkpoint = history.revisionId;
history = await history.append({ role: 'assistant', content: 'second' });

const past = history.withView(checkpoint);
console.log(past.render().length); // 1
render(view?: string | string[]): ModelMessage[]

Render the active sequence, revision id, or entry-id view as model messages.

restore(entryIds?: string[]): ModelMessage[]

Expand summaries in the active sequence or selected ids to original messages.

Summary entries are replaced by their sources, recursively, so nested compactions unwind all the way back to the original turns. Ids that no longer resolve to an entry are skipped.

refs(view?: string | string[]): MessageHistoryEntry[]

Return immutable entry references for the active sequence or selected view.

Accepts a revision id or an entry-id array like render(). Entries are defensive clones; unknown ids are dropped from the result.

estimateTokens(): number

Estimate tokens for the active rendered view using the local heuristic.

toSnapshot(): MessageHistorySnapshot

Export entries, revisions, and active head for durable persistence.

changesSince(baseRevisionId?: string): MessageHistoryDelta

Export graph entries and revisions added after baseRevisionId.

The delta includes the head revision and each ancestor until, but not including, the base revision. Entry payloads are limited to ids introduced by those revisions compared with the base lineage. Omitting baseRevisionId exports the full lineage as a delta with no base.

Throws if baseRevisionId is not an ancestor of the current head — a delta across divergent branches would be ambiguous.

toJSON(): MessageHistorySnapshot

Serialize entries, revisions, and active head for in-memory transfer.

Static Methods

static fromSnapshot(snapshot: MessageHistorySnapshot): MessageHistory

Reconstruct a history from toSnapshot() output, restoring full lineage.

static fromJSON(j: unknown): MessageHistory

Reconstruct a history from parsed toJSON() output.

Constants

const PRICING: Record<string, Pricing>

Built-in pricing table for bundled provider model ids.

Keyed by model name as reported in usage accounting. Pass a custom table to costOf() to extend or override these rates — the built-in table is a fallback, not a registry.

Properties

'claude-fable-5'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M
'claude-opus-4-8'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M
'claude-opus-4-7'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M
'claude-opus-4-6'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M
'claude-sonnet-4-6'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M
'claude-haiku-4-5'
inputPer1M
outputPer1M
cacheReadPer1M
cacheWritePer1M