From b477bfe84bf841018f6b3cb82aad3c770f0f671c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 13 Jun 2026 14:37:23 +0530 Subject: [PATCH] fix(usage): tighten usage footer template handling --- docs/concepts/usage-tracking.md | 17 +++++ src/auto-reply/reply/agent-runner.ts | 6 +- src/auto-reply/usage-bar/contract.ts | 19 ------ src/auto-reply/usage-bar/template.test.ts | 14 ++-- src/auto-reply/usage-bar/template.ts | 75 +++++++-------------- src/auto-reply/usage-bar/translator.test.ts | 39 ++++------- src/auto-reply/usage-bar/translator.ts | 32 --------- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.messages.ts | 10 +-- 10 files changed, 64 insertions(+), 151 deletions(-) diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 72bb60e12c5c..48e4de422724 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -30,6 +30,23 @@ title: "Usage tracking" - CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: "Usage" section under Context (only if available). +## Custom `/usage full` footer + +Set `messages.usageTemplate` to customize the per-response `/usage full` +footer. The value can be an inline template object or a JSON file path: + +```json +{ + "messages": { + "usageTemplate": "~/.openclaw/usage-footer.json" + } +} +``` + +Templates read the `openclaw.usageLine.v1` contract and can use `scales`, +`aliases`, and `output.surfaces` to render channel-specific footers. Missing, +unreadable, invalid, or empty templates fall back to the built-in usage line. + ## Providers + credentials - **Anthropic (Claude)**: OAuth tokens in auth profiles. diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 62022be98ef2..4e6ed8893b04 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -70,6 +70,9 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { buildUsageContract } from "../usage-bar/contract.js"; +import { loadUsageBarTemplate } from "../usage-bar/template.js"; +import { renderUsageBar } from "../usage-bar/translator.js"; import { buildKnownAgentRunFailureReplyPayload, runAgentTurnWithFallback, @@ -90,9 +93,6 @@ import { import { resetReplyRunSession } from "./agent-runner-session-reset.js"; import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js"; import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js"; -import { buildUsageContract } from "../usage-bar/contract.js"; -import { loadUsageBarTemplate } from "../usage-bar/template.js"; -import { renderUsageBar } from "../usage-bar/translator.js"; import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js"; import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js"; import { diff --git a/src/auto-reply/usage-bar/contract.ts b/src/auto-reply/usage-bar/contract.ts index c06624d5d1e7..a391fa8092a8 100644 --- a/src/auto-reply/usage-bar/contract.ts +++ b/src/auto-reply/usage-bar/contract.ts @@ -1,7 +1,3 @@ -// Build the `openclaw.usageLine.v1` contract that the translator consumes from -// the per-turn `reply_payload_sending` usage snapshot. This is the in-core port -// of the usage-footer plugin's `buildContract`, so the same template renders -// identically whether driven by the plugin or by the native /usage full path. import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js"; import type { UsageContract } from "./translator.js"; @@ -16,15 +12,10 @@ export function buildUsageContract( const cacheWrite = usage.cacheWrite; const total = usage.total; - // cache_hit_pct: cacheRead only (writes are misses being cached). Matches - // core status-message.ts. const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0); const cacheHitPct = promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined; - // Last-call usage (final model call only) so templates can render the last - // exchange instead of the turn aggregate. Its cache_hit_pct is computed over - // the last call's prompt total, same formula as the turn-level one. const last = state.lastUsage; const lastPromptTotal = last ? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0) @@ -35,11 +26,6 @@ export function buildUsageContract( : undefined; const maxTokens = state.contextTokenBudget; - // Context occupancy is a point-in-time STATE, never an aggregate. Prefer the - // turn's real end-of-turn context size (final call's prompt tokens); fall back - // to the aggregate prompt total only for harnesses that don't report it - // (single-call turns, where the two coincide). The aggregate over a multi-call - // tool loop overstates occupancy — often past the window — pinning the gauge. const usedTokens = typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0 ? state.contextUsedTokens @@ -58,7 +44,6 @@ export function buildUsageContract( return { schema: "openclaw.usageLine.v1", surface: surface ?? null, - // agentId is exposed flat so templates can key per-agent (e.g. emoji map). agentId: state.agentId ?? null, chat_type: state.chatType ?? null, model: { @@ -79,15 +64,12 @@ export function buildUsageContract( compactions: typeof state.compactionCount === "number" ? state.compactionCount : null, }, usage: { - // Turn aggregate: summed across every model call in the turn's tool loop. input_tokens: input, output_tokens: output, cache_read_tokens: cacheRead, cache_write_tokens: cacheWrite, total_tokens: total, cache_hit_pct: cacheHitPct, - // Final model call only. Templates choose `{usage.input_tokens}` (turn) - // vs `{usage.last.input_tokens}` (last exchange). Absent → segment drops. last: last ? { input_tokens: last.input, @@ -117,6 +99,5 @@ export function buildUsageContract( avatar: state.identity?.avatar ?? null, }, session: { id: state.sessionId ?? null }, - ...(state.limits ? { limits: state.limits } : {}), }; } diff --git a/src/auto-reply/usage-bar/template.test.ts b/src/auto-reply/usage-bar/template.test.ts index 65a3b06c895c..abd0c7367935 100644 --- a/src/auto-reply/usage-bar/template.test.ts +++ b/src/auto-reply/usage-bar/template.test.ts @@ -4,8 +4,6 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js"; -// Two structurally-valid templates (isUsableTemplate accepts either an `output` -// object or a `segments` array) so we can tell which one resolved. const tplA = { segments: [{ text: "A" }] }; const tplB = { output: { lines: [] } }; @@ -46,25 +44,23 @@ describe("loadUsageBarTemplate", () => { expect(loadUsageBarTemplate(path)).toBeUndefined(); }); - it("does not cache a missing file, so a later-created template is picked up", () => { + it("caches a missing path as no template", () => { dir = mkdtempSync(join(tmpdir(), "usage-template-")); const missing = join(dir, "missing.json"); expect(loadUsageBarTemplate(missing)).toBeUndefined(); writeFileSync(missing, JSON.stringify(tplB)); + expect(loadUsageBarTemplate(missing)).toBeUndefined(); + clearUsageBarTemplateCacheForTest(); expect(loadUsageBarTemplate(missing)).toMatchObject(tplB); }); - it("serves the cached template on the hot path without re-reading the file", () => { + it("serves the cached template without re-reading the file", () => { const path = tmpFile("t.json", JSON.stringify(tplA)); - expect(loadUsageBarTemplate(path)).toMatchObject(tplA); // first load caches + expect(loadUsageBarTemplate(path)).toMatchObject(tplA); - // Change the file on disk. The reply path must NOT re-read synchronously, so - // the very next call still returns the cached value (the watcher refresh is - // async and has not fired within this synchronous sequence). writeFileSync(path, JSON.stringify(tplB)); expect(loadUsageBarTemplate(path)).toMatchObject(tplA); - // After an explicit cache reset the fresh content loads. clearUsageBarTemplateCacheForTest(); expect(loadUsageBarTemplate(path)).toMatchObject(tplB); }); diff --git a/src/auto-reply/usage-bar/template.ts b/src/auto-reply/usage-bar/template.ts index 2f6d71a4018f..7abf803aa1e0 100644 --- a/src/auto-reply/usage-bar/template.ts +++ b/src/auto-reply/usage-bar/template.ts @@ -1,9 +1,3 @@ -// Resolve the usage-bar template from config (`messages.usageTemplate`): either -// an inline template object, or a path to a JSON file. For a path, the template -// is read ONCE into memory and then kept fresh by a filesystem watcher, so the -// per-reply render path never touches disk — no synchronous stat/read in the -// latency-sensitive reply-delivery path. When no usable template resolves, the -// caller falls back to the built-in (boring) usage line. import { type FSWatcher, readFileSync, watch } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; @@ -12,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js"; export type UsageTemplateConfig = string | Record | undefined; type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher }; -// Keyed by resolved path. A present entry means the file was read at least once; -// the reply path then serves `template` synchronously with zero filesystem -// access, and a watcher refreshes it off the hot path on change. const fileCache = new Map(); function expandPath(p: string): string { @@ -27,7 +18,6 @@ function expandPath(p: string): string { return isAbsolute(p) ? p : resolve(p); } -// A usable template must carry a layout the engine understands. function isUsableTemplate(value: unknown): value is UsageBarTemplate { if (typeof value !== "object" || value === null) { return false; @@ -37,24 +27,40 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate { return hasOutput || Array.isArray(obj.segments); } -// Read + parse a template file into a usable template, or undefined for -// unreadable/invalid contents. Only called off the reply path: at first load and -// from the watcher callback. function readTemplateFile(path: string): UsageBarTemplate | undefined { let raw: string; try { raw = readFileSync(path, "utf8"); } catch { - return undefined; // removed/unreadable -> boring fallback + return undefined; } try { const parsed: unknown = JSON.parse(raw); return isUsableTemplate(parsed) ? parsed : undefined; } catch { - return undefined; // invalid JSON -> boring fallback + return undefined; } } +function cacheTemplateFile(path: string): UsageBarTemplate | undefined { + const entry: CacheEntry = { template: readTemplateFile(path) }; + if (entry.template) { + try { + const watcher = watch(path, { persistent: false }, () => { + entry.template = readTemplateFile(path); + }); + watcher.on("error", () => { + watcher.close(); + }); + entry.watcher = watcher; + } catch { + // Cache remains valid without live refresh. + } + } + fileCache.set(path, entry); + return entry.template; +} + export function loadUsageBarTemplate( configured: UsageTemplateConfig, ): UsageBarTemplate | undefined { @@ -67,44 +73,9 @@ export function loadUsageBarTemplate( const path = expandPath(configured); const cached = fileCache.get(path); if (cached) { - return cached.template; // hot path: in-memory, no filesystem access + return cached.template; } - // First resolution for this path. Probe once; if the file is missing/unreadable - // we do NOT cache, so a later-created template is still picked up on a - // subsequent call (the only path that stats per reply is the misconfigured - // "configured but absent" one, never the normal one). - let raw: string; - try { - raw = readFileSync(path, "utf8"); - } catch { - return undefined; - } - let template: UsageBarTemplate | undefined; - try { - const parsed: unknown = JSON.parse(raw); - template = isUsableTemplate(parsed) ? parsed : undefined; - } catch { - template = undefined; - } - // The file exists and was read once; from here the reply path is filesystem - // free. Keep the in-memory copy fresh via a watcher (off the hot path). A watch - // failure (unsupported FS, race) just leaves the one-time load with no live - // refresh — still strictly better than a stat on every reply. - const entry: CacheEntry = { template }; - try { - const watcher = watch(path, { persistent: false }, () => { - entry.template = readTemplateFile(path); - }); - watcher.on("error", () => { - // Best-effort: keep the last-known template rather than throwing on a - // watch error (e.g. the file being removed). - }); - entry.watcher = watcher; - } catch { - // Unwatchable path: cache the one-time load anyway (no refresh until restart). - } - fileCache.set(path, entry); - return template; + return cacheTemplateFile(path); } export function clearUsageBarTemplateCacheForTest(): void { diff --git a/src/auto-reply/usage-bar/translator.test.ts b/src/auto-reply/usage-bar/translator.test.ts index 624c194fe879..42d0b72fcf4e 100644 --- a/src/auto-reply/usage-bar/translator.test.ts +++ b/src/auto-reply/usage-bar/translator.test.ts @@ -90,25 +90,23 @@ describe("usage-bar segment forms", () => { it("each with item_scales picks a scale per window by position", () => { const seg = [ { - text: "📊", - each: "limits.windows", + text: "W", + each: "windows", item: "{pct_left|meter:1:*}{resets_in_s|dur}", item_scales: ["weather", "plants"], }, ]; const out = render(seg, { - limits: { - windows: [ - { pct_left: 92, resets_in_s: 17100 }, - { pct_left: 70, resets_in_s: 570240 }, - ], - }, + windows: [ + { pct_left: 92, resets_in_s: 17100 }, + { pct_left: 70, resets_in_s: 570240 }, + ], }); - expect(out).toBe("📊 ☀️4h45m 🍀6.6d"); + expect(out).toBe("W ☀️4h45m 🍀6.6d"); }); it("each drops the whole segment when the array is empty", () => { - expect(render([{ text: "📊", each: "limits.windows", item: "{x}" }], { limits: {} })).toBe(""); + expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe(""); }); }); @@ -122,15 +120,9 @@ describe("usage-bar end-to-end with buildUsageContract", () => { fastMode: false, fallbackUsed: false, contextTokenBudget: 272000, + contextUsedTokens: 204000, usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 }, - limits: { - available: true, - source: "core", - windows: [ - { label: "5h", used_pct: 8, pct_left: 92, resets_in_s: 17100 }, - { label: "week", used_pct: 30, pct_left: 70, resets_in_s: 570240 }, - ], - }, + turnUsd: 0.03771985, }, "discord", ); @@ -141,15 +133,8 @@ describe("usage-bar end-to-end with buildUsageContract", () => { { when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" }, { map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } }, { text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" }, - { - text: " | 📊", - each: "limits.windows", - item: "{pct_left|meter:1:*}{resets_in_s|dur}", - item_scales: ["weather", "plants"], - }, + { text: " | ${cost.turn_usd|fixed:4}" }, ]; - expect(renderUsageBar(tpl(pieces), contract)).toBe( - "opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | 📊 ☀️4h45m 🍀6.6d", - ); + expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377"); }); }); diff --git a/src/auto-reply/usage-bar/translator.ts b/src/auto-reply/usage-bar/translator.ts index d9ed756bd7b6..2802e2187dc0 100644 --- a/src/auto-reply/usage-bar/translator.ts +++ b/src/auto-reply/usage-bar/translator.ts @@ -1,20 +1,3 @@ -// Declarative usage-bar translator — the in-core port of the reference -// `usage_bar.py` engine. It is a TRANSLATOR, not a dictionary: it contains no -// glyphs, no layout, and no default footer — only mechanisms. All *content* -// (which glyphs make a meter, the segment order, the framing) is DATA in the -// template (`scales` / `aliases` / `output`). See usage-bar/template.ts. -// -// Verbs, used as {path|verb:args|fallback}: -// num 3000 -> "3.0k" (compact count) -// fixed:N 0.03771985 -> "0.0377" (fixed-decimal; N digits, default 2) -// dur 14820 -> "4h07m" (seconds -> reset) -// pct 96 -> "96%" -// inv 100-value complement (88 -> 12); pipe before another verb -// alias:TABLE look value up in aliases[TABLE]; echo raw value if unlisted -// meter:WIDTH:SCALE 0-100 value -> WIDTH cells from scales[SCALE] (graded -// boundary cell); meter:1 = a single glyph -// Segment forms: text / when / map+cases / each(+item, item_scales). - export type UsageBarTemplate = Record; export type UsageContract = Record; type Vocab = Record; @@ -22,23 +5,16 @@ type Vocab = Record; const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); -// A "scale" is an ordered glyph vocabulary. Strings must be split by CODE POINT -// (not UTF-16 unit) so astral glyphs like 🌑 stay intact — JS string indexing -// would slice a surrogate pair in half. function toGlyphs(scale: unknown): string[] { if (Array.isArray(scale)) { return scale.filter((g): g is string => typeof g === "string"); } if (typeof scale === "string") { - // Array.from iterates by code point (like spread) so astral glyphs survive, - // without tripping no-misused-spread. Scales are single-code-point glyphs; - // multi-code-point emoji (e.g. ☀️) are supplied as array scales instead. return Array.from(scale); } return []; } -// --- number formatters (algorithms, not content) ---------------------------- function num(value: unknown): string { if (value === null || value === undefined || value === "") { return ""; @@ -111,7 +87,6 @@ function norm(value: unknown): number { return Math.max(0, Math.min(100, n)) / 100; } -// --- meter mechanism (glyphs supplied by the template, not here) -------------- function meter(value: unknown, width: number, scale: unknown): string { const glyphs = toGlyphs(scale); if (glyphs.length < 2 || width < 1) { @@ -171,7 +146,6 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): } } -// --- template walker ---------------------------------------------------------- function getPath(ctx: unknown, path: string): unknown { let cur: unknown = ctx; for (const part of path.split(".")) { @@ -273,7 +247,6 @@ function resolveLayout( const sep = typeof output.sep === "string" ? output.sep : ""; return { sep, pieces: Array.isArray(pieces) ? (pieces as Segment[]) : [] }; } - // legacy: top-level surfaces..{sep,segments} over top-level sep/segments const ov = typeof surface === "string" && isObject(template.surfaces) && @@ -290,11 +263,6 @@ function resolveLayout( return { sep, pieces: segments as Segment[] }; } -/** - * Render a usage footer from a template + contract. Returns "" when the template - * produces nothing (caller falls back to the boring built-in footer). Never - * throws for malformed templates — best-effort, fail-open. - */ export function renderUsageBar(template: UsageBarTemplate, contract: UsageContract): string { try { const { sep, pieces } = resolveLayout(template, contract.surface); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a74e0514fcad..b42150fcd6c6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1873,6 +1873,8 @@ export const FIELD_HELP: Record = { 'Controls visible source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for visible output and keeps normal final text private. "automatic" posts normal replies as before.', "messages.responsePrefix": "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", + "messages.usageTemplate": + "Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.", "messages.groupChat": "Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.", "messages.groupChat.mentionPatterns": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 0d78c1dbdd5d..a90978fa16cb 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -967,6 +967,7 @@ export const FIELD_LABELS: Record = { "messages.messagePrefix": "Inbound Message Prefix", "messages.visibleReplies": "Visible Replies", "messages.responsePrefix": "Outbound Response Prefix", + "messages.usageTemplate": "Usage Footer Template", "messages.groupChat": "Group Chat Rules", "messages.groupChat.mentionPatterns": "Group Mention Patterns", "messages.groupChat.historyLimit": "Group History Limit", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 48da6238bcce..3724f31cda91 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -139,15 +139,7 @@ export type MessagesConfig = { * Default: none */ responsePrefix?: string; - /** - * Custom `/usage full` footer template (the declarative `openclaw.usageBar.v1` - * format: `scales` / `aliases` / `output.surfaces`). When set and usable, the - * per-reply footer is rendered from it instead of the built-in line; an absent, - * unreadable, or invalid template falls back to the built-in (fail-open). - * - * - string: path to a JSON template file (supports a leading `~`). - * - object: an inline template. - */ + /** Custom `/usage full` footer template, inline or JSON file path. */ usageTemplate?: string | Record; groupChat?: GroupChatConfig; queue?: QueueConfig;