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.
links?: Array<{
rel: string;
to: string;
}>
Typed references from this entry to related records.
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.