Files
oh-my-pi/packages/coding-agent/src/modes/utils/context-usage.ts
T
can1357 e8d7c0a493 feat: introduced pluggable composer layouts and status line gauge modes
- Introduced embedded mode and context gauge render options for the status line alongside boundary calculation helpers.
- Added pluggable composer styles with a style registry supporting box, claude, pi, and borderless layout rendering.
- Extracted speculation lead resolution into a dedicated shared module and added theme symbol definitions.
- Expanded test coverage for status line context gauge modes, composer shape previews, and editor style assertions.
2026-08-20 05:41:42 +02:00

555 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Tokenizer } from "@oh-my-pi/pi-agent-core";
import type { CompactionSettings } from "@oh-my-pi/pi-agent-core/compaction";
import { effectiveReserveTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction";
import type { Tool as AiTool, Model } from "@oh-my-pi/pi-ai";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { Skill } from "../../extensibility/skills";
import type { AgentSession } from "../../session/agent-session";
import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline";
import { resolveSpeculationLeadTokens } from "../../session/speculation-lead";
import type { Tool } from "../../tools";
import type { theme as Theme } from "../theme/theme";
const GRID_COLS = 20;
const GRID_ROWS = 10;
const GRID_CELLS = GRID_COLS * GRID_ROWS;
const GRID_GUTTER = " ";
const CELL_FILLED = "⛁";
const CELL_FILLED_MESSAGES = "⛃";
const CELL_FREE = "⛶";
const CELL_BUFFER = "⛝";
type CategoryId = "systemPrompt" | "systemContext" | "systemTools" | "skills" | "messages";
interface CategoryInfo {
id: CategoryId;
label: string;
tokens: number;
color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel";
glyph: string;
}
export interface ContextBreakdown {
model: Model | undefined;
contextWindow: number;
categories: CategoryInfo[];
usedTokens: number;
autoCompactBufferTokens: number;
freeTokens: number;
/** Estimated snapcompact wire savings; set when requested and a snapcompact.* setting is enabled. */
snapcompact?: SnapcompactSavingsEstimate;
}
/** Percent positions (0–100 of the context window) for the auto-compaction boundaries. */
export interface CompactionBoundaries {
/** Where auto-compaction fires. */
thresholdPercent: number;
/** Where the background speculative summarizer starts (threshold − lead). */
speculationPercent: number;
}
/**
* Boundary positions for the status line's annotated context gauge. `null`
* when compaction is disabled/off or the window is unknown — the gauge then
* renders without markers.
*/
export function computeCompactionBoundaries(
settings: AgentSession["settings"],
contextWindow: number,
): CompactionBoundaries | null {
if (!(contextWindow > 0)) return null;
const compactionSettings = settings.getGroup("compaction") as CompactionSettings;
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return null;
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
if (!(thresholdTokens > 0) || thresholdTokens > contextWindow) return null;
const leadTokens = resolveSpeculationLeadTokens(thresholdTokens);
return {
thresholdPercent: (thresholdTokens / contextWindow) * 100,
speculationPercent: (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100,
};
}
/** Stable inputs used to cache non-message token estimates. */
export interface NonMessageTokenSource {
readonly systemPrompt?: string[];
readonly agent?: {
readonly state?: {
readonly tools?: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>;
};
};
readonly skills?: readonly Skill[];
}
const EMPTY_STRING_PARTS: string[] = [];
const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
const EMPTY_SKILLS: readonly Skill[] = [];
/**
* Skills actually rendered into the system prompt, mirroring the filter in
* `buildSystemPrompt` (`system-prompt.ts`): the `read` tool must be present so
* the model can fetch skill content, and skills with frontmatter `hide: true`
* (or `disable-model-invocation`, normalized onto `hide`) are excluded.
* Accounting must count only these so the Skills category and the System-prompt
* subtraction stay aligned with the provider-facing prompt.
*/
function renderedSkills(
skills: readonly Skill[],
tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
): readonly Skill[] {
if (!tools.some(tool => tool.name === "read")) return EMPTY_SKILLS;
return skills.filter(skill => skill.hide !== true);
}
export function estimateSkillsTokens(skills: readonly Skill[], tokenizer: Tokenizer): number {
const fragments: string[] = [];
for (const skill of skills) {
// "- name: description\n" wire framing tokenizes ~identically to the
// concatenated form, so encode each piece separately and sum.
fragments.push(skill.name, skill.description);
}
return tokenizer.countTokens(fragments);
}
export function estimateToolSchemaTokens(
tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
tokenizer: Tokenizer,
): number {
const fragments: string[] = [];
for (const tool of tools) {
fragments.push(tool.name, tool.description);
try {
const wireTool: AiTool = {
name: tool.name,
description: tool.description,
parameters: tool.parameters as AiTool["parameters"],
};
fragments.push(JSON.stringify(toolWireSchema(wireTool) ?? {}));
} catch {
// Schema may contain functions or cycles; ignore.
}
}
return tokenizer.countTokens(fragments);
}
/**
* Compute just the NON-MESSAGE token total: system prompt (with its skills
* section subtracted, since skills are tokenized separately) + system context
* (the rest of the system-prompt array) + tools + skills.
*
* Exposed so callers like `StatusLineComponent` can cache the non-message
* total separately from the message total. Non-message inputs (skills,
* tools, system prompt) change rarely; the message list grows on every
* streaming turn. Splitting the two lets the caller refresh each on its own
* cadence — non-message recomputed only when the inputs identity changes,
* messages walked incrementally as new entries append.
*/
// Non-message inputs (system prompt, tools, skills) change rarely — at most
// once per turn via setSystemPrompt/setTools — but the per-turn compaction and
// threshold paths call these helpers several times: getContextBreakdown calls
// both, and #estimateStoredContextTokens adds a third. Memoize on the identity
// of the three input arrays so the expensive parts (system-prompt tokenization
// and the per-tool JSON.stringify(toolWireSchema) inside estimateToolSchemaTokens)
// run at most once per input change rather than per call. The identity keys are
// the same stable references the StatusLineComponent cache already trusts
// (setSystemPrompt/setTools replace the array reference rather than mutating it).
interface NonMessageTokenCache {
systemPromptRef: readonly string[];
toolsRef: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>;
skillsRef: readonly Skill[];
// The Agent swaps its Tokenizer instance when the model's encoding changes,
// so instance identity doubles as the encoding key.
tokenizerRef: Tokenizer;
tokens: number | undefined;
breakdown:
| {
skillsTokens: number;
toolsTokens: number;
systemContextTokens: number;
systemPromptTokens: number;
}
| undefined;
}
const NON_MESSAGE_TOKEN_CACHE = Symbol("non-message-token-cache");
interface CachedNonMessageTokenSource extends NonMessageTokenSource {
[NON_MESSAGE_TOKEN_CACHE]?: NonMessageTokenCache;
}
function nonMessageTokenCacheEntry(session: NonMessageTokenSource, tokenizer: Tokenizer): NonMessageTokenCache {
const cachedSession: CachedNonMessageTokenSource = session;
const systemPromptRef = session.systemPrompt ?? EMPTY_STRING_PARTS;
const toolsRef = session.agent?.state?.tools ?? EMPTY_TOOLS;
const skillsRef = session.skills ?? EMPTY_SKILLS;
let entry = cachedSession[NON_MESSAGE_TOKEN_CACHE];
if (
entry &&
entry.systemPromptRef === systemPromptRef &&
entry.toolsRef === toolsRef &&
entry.skillsRef === skillsRef &&
entry.tokenizerRef === tokenizer
) {
return entry;
}
entry = { systemPromptRef, toolsRef, skillsRef, tokenizerRef: tokenizer, tokens: undefined, breakdown: undefined };
cachedSession[NON_MESSAGE_TOKEN_CACHE] = entry;
return entry;
}
export function computeNonMessageTokens(session: NonMessageTokenSource, tokenizer: Tokenizer): number {
const entry = nonMessageTokenCacheEntry(session, tokenizer);
if (entry.tokens !== undefined) return entry.tokens;
const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
const tokens = tokenizer.countTokens(systemPromptParts) + estimateToolSchemaTokens(tools, tokenizer);
entry.tokens = tokens;
return tokens;
}
/**
* Shared helper for the four non-message token totals used by
* `computeContextBreakdown` (/context panel). Keep this category split stable:
* the status-line fast path intentionally uses the equivalent collapsed total
* in `computeNonMessageTokens`.
*/
export function computeNonMessageBreakdown(
session: NonMessageTokenSource,
tokenizer: Tokenizer,
): {
skillsTokens: number;
toolsTokens: number;
systemContextTokens: number;
systemPromptTokens: number;
} {
const entry = nonMessageTokenCacheEntry(session, tokenizer);
if (entry.breakdown) return entry.breakdown;
const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
const skillsTokens = estimateSkillsTokens(renderedSkills(session.skills ?? EMPTY_SKILLS, tools), tokenizer);
const toolsTokens = estimateToolSchemaTokens(tools, tokenizer);
const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
const systemContextTokens = tokenizer.countTokens(systemPromptParts.slice(1));
const systemPromptTokens = Math.max(0, tokenizer.countTokens(systemPromptParts[0] ?? "") - skillsTokens);
const breakdown = { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
entry.breakdown = breakdown;
return breakdown;
}
/**
* Compute a breakdown of estimated context usage by category for the active
* session and model.
*/
export function computeContextBreakdown(
session: AgentSession,
options?: { snapcompactSavings?: boolean },
): ContextBreakdown {
const model = session.model;
const tokenizer = session.agent.tokenizer;
const contextWindow = model?.contextWindow ?? 0;
const breakdown = typeof session.getContextBreakdown === "function" ? session.getContextBreakdown() : undefined;
let messagesTokens = 0;
let skillsTokens = 0;
let toolsTokens = 0;
let systemContextTokens = 0;
let systemPromptTokens = 0;
let usedTokens = 0;
if (breakdown) {
messagesTokens = breakdown.messagesTokens;
skillsTokens = breakdown.skillsTokens;
toolsTokens = breakdown.systemToolsTokens;
systemContextTokens = breakdown.systemContextTokens;
systemPromptTokens = breakdown.systemPromptTokens;
usedTokens = breakdown.usedTokens;
} else {
// Category split needs a messages-only number, so this walk stays local:
// an anchored total folds the system prompt and tool schemas into it.
messagesTokens = tokenizer.countMessages(session.messages ?? []);
const nonMessage = computeNonMessageBreakdown(session, tokenizer);
skillsTokens = nonMessage.skillsTokens;
toolsTokens = nonMessage.toolsTokens;
systemContextTokens = nonMessage.systemContextTokens;
systemPromptTokens = nonMessage.systemPromptTokens;
usedTokens = skillsTokens + toolsTokens + systemContextTokens + systemPromptTokens + messagesTokens;
}
const categories: CategoryInfo[] = [
{ id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
{ id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED },
{
id: "systemContext",
label: "System context",
tokens: systemContextTokens,
color: "customMessageLabel",
glyph: CELL_FILLED,
},
{ id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED },
{
id: "messages",
label: "Messages",
tokens: messagesTokens,
color: "userMessageText",
glyph: CELL_FILLED_MESSAGES,
},
];
let autoCompactBufferTokens = 0;
if (contextWindow > 0) {
const compactionSettings = session.settings.getGroup("compaction") as CompactionSettings;
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
const threshold = resolveThresholdTokens(contextWindow, compactionSettings);
autoCompactBufferTokens = Math.max(0, contextWindow - threshold);
} else {
autoCompactBufferTokens = 0;
}
// Even when fully disabled, fall back to a sensible reserve floor for display.
if (autoCompactBufferTokens === 0 && compactionSettings.enabled) {
autoCompactBufferTokens = effectiveReserveTokens(contextWindow, compactionSettings);
}
}
autoCompactBufferTokens = Math.min(autoCompactBufferTokens, Math.max(0, contextWindow - usedTokens));
const freeTokens = Math.max(0, contextWindow - usedTokens - autoCompactBufferTokens);
// Estimated wire savings from snapcompact inline imaging. Opt-in: only the
// /context surfaces need it; other callers skip the extra token counting.
let snapcompactSavings: SnapcompactSavingsEstimate | undefined;
if (options?.snapcompactSavings) {
const renderSystemPrompt = session.settings.get("snapcompact.systemPrompt");
const renderToolResults = session.settings.get("snapcompact.toolResults");
if (renderSystemPrompt !== "none" || renderToolResults) {
snapcompactSavings = estimateInlineSavings({
options: { renderSystemPrompt, renderToolResults, shape: session.settings.get("snapcompact.shape") },
model,
systemPrompt: session.systemPrompt ?? [],
messages: session.messages ?? [],
});
}
}
return {
model,
contextWindow,
categories,
usedTokens,
autoCompactBufferTokens,
freeTokens,
snapcompact: snapcompactSavings,
};
}
interface CellSpec {
glyph: string;
color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel" | "muted" | "dim";
}
function planCells(breakdown: ContextBreakdown): CellSpec[] {
const cells: CellSpec[] = [];
const window = breakdown.contextWindow;
if (window <= 0) {
for (let i = 0; i < GRID_CELLS; i++) {
cells.push({ glyph: CELL_FREE, color: "dim" });
}
return cells;
}
const tokensPerCell = window / GRID_CELLS;
const ratioCells = (tokens: number): number => {
if (tokens <= 0) return 0;
return Math.max(1, Math.round(tokens / tokensPerCell));
};
const categoryCounts = breakdown.categories.map(category => ({
category,
count: ratioCells(category.tokens),
}));
let bufferCount = ratioCells(breakdown.autoCompactBufferTokens);
let usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
// Prevent the visualization from over-running the grid.
const maxUsable = GRID_CELLS - bufferCount;
if (usedCount > maxUsable) {
// Scale categories proportionally down to fit.
let overflow = usedCount - maxUsable;
// Trim from the largest categories first to preserve visibility for small ones.
const order = [...categoryCounts].sort((a, b) => b.count - a.count);
for (const entry of order) {
while (overflow > 0 && entry.count > 1) {
entry.count -= 1;
overflow -= 1;
}
}
usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
if (usedCount + bufferCount > GRID_CELLS) {
bufferCount = Math.max(0, GRID_CELLS - usedCount);
}
}
for (const { category, count } of categoryCounts) {
for (let i = 0; i < count; i++) {
cells.push({ glyph: category.glyph, color: category.color });
}
}
const freeCount = Math.max(0, GRID_CELLS - cells.length - bufferCount);
for (let i = 0; i < freeCount; i++) {
cells.push({ glyph: CELL_FREE, color: "dim" });
}
for (let i = 0; i < bufferCount; i++) {
cells.push({ glyph: CELL_BUFFER, color: "warning" });
}
// Pad to exactly GRID_CELLS in case rounding undershot.
while (cells.length < GRID_CELLS) {
cells.push({ glyph: CELL_FREE, color: "dim" });
}
return cells.slice(0, GRID_CELLS);
}
function percentString(part: number, whole: number, fractionDigits = 1): string {
if (whole <= 0) return "0%";
const pct = (part / whole) * 100;
if (pct > 0 && pct < 0.05) return "<0.1%";
return `${pct.toFixed(fractionDigits)}%`;
}
function buildLegendLines(breakdown: ContextBreakdown, theme: typeof Theme): string[] {
const lines: string[] = [];
const { model, contextWindow, categories, usedTokens, autoCompactBufferTokens, freeTokens } = breakdown;
const modelName = model?.name ?? model?.id ?? "no model";
const modelId = model?.id ?? "unknown";
const windowLabel = formatNumber(contextWindow).toLowerCase();
lines.push(theme.bold(`${modelName}`) + theme.fg("dim", ` (${windowLabel} context)`));
lines.push(theme.fg("muted", `${modelId}[${windowLabel}]`));
lines.push(
`${theme.bold(formatNumber(usedTokens))}${theme.fg("dim", `/${windowLabel} tokens`)}` +
theme.fg("muted", ` (${percentString(usedTokens, contextWindow)})`),
);
lines.push("");
lines.push(theme.fg("muted", "Estimated usage by category"));
for (const category of categories) {
const dot = theme.fg(category.color, category.glyph);
const label = category.label;
const tokens = formatNumber(category.tokens);
const pct = percentString(category.tokens, contextWindow);
lines.push(`${dot} ${label}: ${theme.bold(tokens)} ${theme.fg("dim", `tokens (${pct})`)}`);
}
const freeDot = theme.fg("dim", CELL_FREE);
lines.push(
`${freeDot} Free space: ${theme.bold(formatNumber(freeTokens))} ${theme.fg("dim", `(${percentString(freeTokens, contextWindow)})`)}`,
);
if (autoCompactBufferTokens > 0) {
const bufferDot = theme.fg("warning", CELL_BUFFER);
lines.push(
`${bufferDot} Autocompact buffer: ${theme.bold(formatNumber(autoCompactBufferTokens))} ${theme.fg(
"dim",
`tokens (${percentString(autoCompactBufferTokens, contextWindow)})`,
)}`,
);
}
const snap = breakdown.snapcompact;
if (snap) {
lines.push("");
if (!snap.visionCapable) {
lines.push(theme.fg("muted", "Snapcompact: inactive (model has no image input)"));
} else {
lines.push(theme.fg("muted", "Snapcompact (estimated wire savings)"));
if (snap.systemPrompt) {
const sp = snap.systemPrompt;
if (sp.applied) {
lines.push(
` System prompt (${sp.scope === "agents-md" ? "AGENTS.md" : "all"}): saves ${theme.bold(`~${formatNumber(sp.savedTokens)}`)} ` +
theme.fg(
"dim",
`(${formatNumber(sp.textTokens)} text → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${formatNumber(sp.imageTokens)})`,
),
);
} else {
const reason =
sp.reason === "budget"
? "image budget exhausted"
: sp.reason === "empty"
? "nothing to image"
: "frames would not save tokens";
lines.push(
` System prompt (${sp.scope === "agents-md" ? "AGENTS.md" : "all"}): ${theme.fg("dim", `stays text (${reason})`)}`,
);
}
}
if (snap.toolResults) {
const tr = snap.toolResults;
if (tr.swapped > 0) {
lines.push(
` Tool results: saves ${theme.bold(`~${formatNumber(tr.savedTokens)}`)} ` +
theme.fg(
"dim",
`(${tr.swapped}/${tr.total} imaged, ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)})`,
),
);
} else {
lines.push(` Tool results: ${theme.fg("dim", `none imaged (${tr.total} in history)`)}`);
}
}
if (snap.savedTokens > 0) {
lines.push(
` Next request: ${theme.bold(`~${formatNumber(Math.max(0, usedTokens - snap.savedTokens))}`)} ${theme.fg("dim", "tokens on the wire")}`,
);
}
}
}
return lines;
}
/**
* Render a colorful context-usage panel as ANSI text. Output is a series of
* lines pairing the grid (left) with the legend (right).
*/
export function renderContextUsage(breakdown: ContextBreakdown, theme: typeof Theme): string {
if (breakdown.contextWindow <= 0) {
return theme.fg("muted", "Context usage is unavailable: no model is selected for this session.");
}
const cells = planCells(breakdown);
const legend = buildLegendLines(breakdown, theme);
const totalLines = Math.max(GRID_ROWS, legend.length);
const lines: string[] = [];
for (let row = 0; row < totalLines; row++) {
let gridSegment = "";
if (row < GRID_ROWS) {
const rowCells: string[] = [];
for (let col = 0; col < GRID_COLS; col++) {
const cell = cells[row * GRID_COLS + col];
rowCells.push(theme.fg(cell.color, cell.glyph));
}
gridSegment = rowCells.join(" ");
} else {
// Pad with blanks the same visible width as a grid row so legend lines
// past the grid stay aligned with their column.
const blank = " ".repeat(GRID_COLS * 2 - 1);
gridSegment = blank;
}
const legendSegment = legend[row] ?? "";
const line = legendSegment.length > 0 ? `${gridSegment}${GRID_GUTTER}${legendSegment}` : gridSegment;
lines.push(line);
}
return lines.join("\n");
}