Skip to content

Commit 64d0fc8

Browse files
Peetiegonzalezclaude
authored andcommitted
feat(usage): native templated /usage full renderer (retires the footer plugin)
Render the per-reply /usage full footer in core from a declarative template (the openclaw.usageBar.v1 format) when messages.usageTemplate is set; fall back to the built-in line otherwise. Ports the reference usage_bar.py engine to TS so no external process is involved (the external surface is just template data). - usage-bar/translator.ts: engine (verbs num/dur/pct/inv/alias/meter, segment forms text/when/map/each, output.surfaces, item_scales). Codepoint-correct glyph indexing; fail-open (empty render -> boring fallback). - usage-bar/contract.ts: buildUsageContract (snapshot -> openclaw.usageLine.v1). - usage-bar/template.ts: resolve from a path (mtime-cached) or inline object. - agent-runner: capture the per-turn usage snapshot and render the template at the /usage full branch in place of the built-in line when configured. - messages.usageTemplate config (string path | inline object) + strict schema. - translator.test.ts: verb parity, segment forms, astral glyphs, e2e render. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent f3df863 commit 64d0fc8

7 files changed

Lines changed: 625 additions & 3 deletions

File tree

src/auto-reply/reply/agent-runner.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "../../infra/diagnostic-trace-context.js";
4343
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
4444
import { enqueueSystemEvent } from "../../infra/system-events.js";
45+
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
4546
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
4647
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
4748
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -89,6 +90,9 @@ import {
8990
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
9091
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
9192
import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js";
93+
import { buildUsageContract } from "../usage-bar/contract.js";
94+
import { loadUsageBarTemplate } from "../usage-bar/template.js";
95+
import { renderUsageBar } from "../usage-bar/translator.js";
9296
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
9397
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
9498
import {
@@ -1739,13 +1743,14 @@ export async function runReplyAgent(params: {
17391743
const providerUsed =
17401744
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
17411745

1746+
let replyUsageState: PluginHookReplyUsageState | undefined;
17421747
{
17431748
const winnerProvider = runResult.meta?.executionTrace?.winnerProvider ?? providerUsed;
17441749
const winnerModel = runResult.meta?.executionTrace?.winnerModel ?? modelUsed;
17451750
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
17461751
const compactions = runResult.meta?.agentMeta?.compactionCount;
17471752
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
1748-
recordReplyUsageState(runId, {
1753+
replyUsageState = {
17491754
provider: providerUsed,
17501755
model: modelUsed,
17511756
resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined,
@@ -1805,7 +1810,8 @@ export async function runReplyAgent(params: {
18051810
total: lastCallUsage.total,
18061811
}
18071812
: undefined,
1808-
});
1813+
};
1814+
recordReplyUsageState(runId, replyUsageState);
18091815
}
18101816
const verboseEnabled = resolvedVerboseLevel !== "off";
18111817
const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
@@ -2175,7 +2181,16 @@ export async function runReplyAgent(params: {
21752181
showCost,
21762182
costConfig,
21772183
});
2178-
if (formatted && responseUsageMode === "full" && sessionKey) {
2184+
const usageTemplate =
2185+
responseUsageMode === "full" && replyUsageState
2186+
? loadUsageBarTemplate(cfg.messages?.usageTemplate)
2187+
: undefined;
2188+
const renderedUsageLine = usageTemplate
2189+
? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel))
2190+
: undefined;
2191+
if (renderedUsageLine) {
2192+
formatted = renderedUsageLine;
2193+
} else if (formatted && responseUsageMode === "full" && sessionKey) {
21792194
formatted = `${formatted} · session \`${sessionKey}\``;
21802195
}
21812196
if (formatted) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Build the `openclaw.usageLine.v1` contract that the translator consumes from
2+
// the per-turn `reply_payload_sending` usage snapshot. This is the in-core port
3+
// of the usage-footer plugin's `buildContract`, so the same template renders
4+
// identically whether driven by the plugin or by the native /usage full path.
5+
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
6+
import type { UsageContract } from "./translator.js";
7+
8+
export function buildUsageContract(
9+
state: PluginHookReplyUsageState,
10+
surface?: string,
11+
): UsageContract {
12+
const usage = state.usage ?? {};
13+
const input = usage.input;
14+
const output = usage.output;
15+
const cacheRead = usage.cacheRead;
16+
const cacheWrite = usage.cacheWrite;
17+
const total = usage.total;
18+
19+
// cache_hit_pct: cacheRead only (writes are misses being cached). Matches
20+
// core status-message.ts.
21+
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
22+
const cacheHitPct =
23+
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
24+
25+
const maxTokens = state.contextTokenBudget;
26+
const usedTokens = promptTotal > 0 ? promptTotal : undefined;
27+
const pctUsed =
28+
maxTokens && usedTokens !== undefined ? Math.round((usedTokens / maxTokens) * 100) : undefined;
29+
30+
const overrideSource = state.overrideSource ?? null;
31+
const isOverride =
32+
typeof state.overrideSource === "string" &&
33+
state.overrideSource !== "" &&
34+
state.overrideSource !== "auto";
35+
36+
return {
37+
schema: "openclaw.usageLine.v1",
38+
surface: surface ?? null,
39+
// agentId is exposed flat so templates can key per-agent (e.g. emoji map).
40+
agentId: state.agentId ?? null,
41+
chat_type: state.chatType ?? null,
42+
model: {
43+
id: state.model ?? null,
44+
display_name: state.model ?? null,
45+
provider: state.provider ?? null,
46+
reasoning: state.reasoningEffort ?? null,
47+
actual: state.resolvedRef ?? null,
48+
resolved_ref: state.resolvedRef ?? null,
49+
requested: state.requested ?? null,
50+
is_fallback: state.fallbackUsed === true,
51+
is_override: isOverride,
52+
override_source: overrideSource,
53+
auth_mode: state.authMode ?? null,
54+
},
55+
state: {
56+
fast_mode: typeof state.fastMode === "boolean" ? state.fastMode : null,
57+
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
58+
},
59+
usage: {
60+
input_tokens: input,
61+
output_tokens: output,
62+
cache_read_tokens: cacheRead,
63+
cache_write_tokens: cacheWrite,
64+
total_tokens: total,
65+
cache_hit_pct: cacheHitPct,
66+
},
67+
context: {
68+
used_tokens: usedTokens,
69+
max_tokens: maxTokens,
70+
pct_used: pctUsed,
71+
},
72+
cost: {
73+
turn_usd: typeof state.turnUsd === "number" ? state.turnUsd : null,
74+
available: typeof state.turnUsd === "number",
75+
},
76+
timing: {
77+
duration_ms: typeof state.durationMs === "number" ? state.durationMs : null,
78+
},
79+
identity: {
80+
name: state.identity?.name ?? null,
81+
emoji: state.identity?.emoji ?? null,
82+
avatar: state.identity?.avatar ?? null,
83+
},
84+
session: { id: state.sessionId ?? null },
85+
...(state.limits ? { limits: state.limits } : {}),
86+
};
87+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Resolve the usage-bar template from config (`messages.usageTemplate`): either
2+
// an inline template object, or a path to a JSON file. File reads are cached by
3+
// mtime so the per-reply render path does not hit disk every time, while still
4+
// picking up edits to the template. When no usable template resolves, the
5+
// caller falls back to the built-in (boring) usage line.
6+
import { readFileSync, statSync } from "node:fs";
7+
import { homedir } from "node:os";
8+
import { isAbsolute, resolve } from "node:path";
9+
import type { UsageBarTemplate } from "./translator.js";
10+
11+
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
12+
13+
const fileCache = new Map<string, { mtimeMs: number; template: UsageBarTemplate | undefined }>();
14+
15+
function expandPath(p: string): string {
16+
if (p === "~") {
17+
return homedir();
18+
}
19+
if (p.startsWith("~/")) {
20+
return resolve(homedir(), p.slice(2));
21+
}
22+
return isAbsolute(p) ? p : resolve(p);
23+
}
24+
25+
// A usable template must carry a layout the engine understands.
26+
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
27+
if (typeof value !== "object" || value === null) {
28+
return false;
29+
}
30+
const obj = value as Record<string, unknown>;
31+
const hasOutput = typeof obj.output === "object" && obj.output !== null;
32+
return hasOutput || Array.isArray(obj.segments);
33+
}
34+
35+
export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarTemplate | undefined {
36+
if (!configured) {
37+
return undefined;
38+
}
39+
if (typeof configured === "object") {
40+
return isUsableTemplate(configured) ? configured : undefined;
41+
}
42+
const path = expandPath(configured);
43+
let mtimeMs: number;
44+
try {
45+
mtimeMs = statSync(path).mtimeMs;
46+
} catch {
47+
return undefined; // missing/unreadable -> boring fallback
48+
}
49+
const cached = fileCache.get(path);
50+
if (cached && cached.mtimeMs === mtimeMs) {
51+
return cached.template;
52+
}
53+
let template: UsageBarTemplate | undefined;
54+
try {
55+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
56+
template = isUsableTemplate(parsed) ? parsed : undefined;
57+
} catch {
58+
template = undefined;
59+
}
60+
fileCache.set(path, { mtimeMs, template });
61+
return template;
62+
}
63+
64+
export function clearUsageBarTemplateCacheForTest(): void {
65+
fileCache.clear();
66+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildUsageContract } from "./contract.js";
3+
import { renderUsageBar, type UsageBarTemplate } from "./translator.js";
4+
5+
const SCALES = {
6+
braille: "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
7+
moon: "🌑🌘🌗🌖🌕",
8+
weather: ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
9+
plants: ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"],
10+
};
11+
12+
function tpl(pieces: unknown[]): UsageBarTemplate {
13+
return {
14+
scales: SCALES,
15+
aliases: { models: { "claude-opus-4-6": "opus46" }, reasoning: { medium: "med" } },
16+
output: { sep: "", surfaces: { discord: pieces } },
17+
};
18+
}
19+
20+
function render(pieces: unknown[], contract: Record<string, unknown>): string {
21+
return renderUsageBar(tpl(pieces), { surface: "discord", ...contract });
22+
}
23+
24+
describe("usage-bar verbs", () => {
25+
it("num — compact counts", () => {
26+
expect(render([{ text: "{usage.input_tokens|num}" }], { usage: { input_tokens: 3000 } })).toBe(
27+
"3.0k",
28+
);
29+
expect(render([{ text: "{x|num}" }], { x: 272000 })).toBe("272k");
30+
expect(render([{ text: "{x|num}" }], { x: 128 })).toBe("128");
31+
});
32+
33+
it("dur — seconds to reset", () => {
34+
expect(render([{ text: "{x|dur}" }], { x: 14820 })).toBe("4h07m");
35+
expect(render([{ text: "{x|dur}" }], { x: 449280 })).toBe("5.2d");
36+
expect(render([{ text: "{x|dur}" }], { x: 1980 })).toBe("33m");
37+
});
38+
39+
it("pct and inv", () => {
40+
expect(render([{ text: "{x|pct}" }], { x: 96 })).toBe("96%");
41+
expect(render([{ text: "{x|inv|pct}" }], { x: 75 })).toBe("25%");
42+
});
43+
44+
it("meter — multi-cell braille bar", () => {
45+
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 75 })).toBe("[⣿⣿⣿⣧⠐]");
46+
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 0 })).toBe("[⠐⠐⠐⠐⠐]");
47+
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 100 })).toBe("[⣿⣿⣿⣿⣿]");
48+
});
49+
50+
it("meter:1 — single glyph, codepoint-correct for astral scales", () => {
51+
expect(render([{ text: "{x|meter:1:moon}" }], { x: 0 })).toBe("🌑");
52+
expect(render([{ text: "{x|meter:1:moon}" }], { x: 50 })).toBe("🌗");
53+
expect(render([{ text: "{x|meter:1:moon}" }], { x: 100 })).toBe("🌕");
54+
});
55+
56+
it("alias — listed shortens, unlisted echoes through", () => {
57+
expect(render([{ text: "{m|alias:models}" }], { m: "claude-opus-4-6" })).toBe("opus46");
58+
expect(render([{ text: "{m|alias:models}" }], { m: "some-new-model" })).toBe("some-new-model");
59+
});
60+
61+
it("fallback when path is missing/empty", () => {
62+
expect(render([{ text: "{identity.emoji|🤖} hi" }], {})).toBe("🤖 hi");
63+
expect(render([{ text: "{identity.emoji|🤖} hi" }], { identity: { emoji: "🩺" } })).toBe(
64+
"🩺 hi",
65+
);
66+
});
67+
});
68+
69+
describe("usage-bar segment forms", () => {
70+
it("when drops on null/false/empty, keeps on 0", () => {
71+
const seg = [{ when: "u.cache_hit_pct", text: "🗄 {u.cache_hit_pct|pct}" }];
72+
expect(render(seg, { u: {} })).toBe("");
73+
expect(render(seg, { u: { cache_hit_pct: 0 } })).toBe("🗄 0%");
74+
});
75+
76+
it("map resolves enum/bool, drops on no match", () => {
77+
const seg = [{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } }];
78+
expect(render(seg, { state: { fast_mode: true } })).toBe("⚡");
79+
expect(render(seg, { state: { fast_mode: false } })).toBe("🐌");
80+
expect(render(seg, { state: {} })).toBe("");
81+
});
82+
83+
it("each with item_scales picks a scale per window by position", () => {
84+
const seg = [
85+
{
86+
text: "📊",
87+
each: "limits.windows",
88+
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
89+
item_scales: ["weather", "plants"],
90+
},
91+
];
92+
const out = render(seg, {
93+
limits: {
94+
windows: [
95+
{ pct_left: 92, resets_in_s: 17100 },
96+
{ pct_left: 70, resets_in_s: 570240 },
97+
],
98+
},
99+
});
100+
expect(out).toBe("📊 ☀️4h45m 🍀6.6d");
101+
});
102+
103+
it("each drops the whole segment when the array is empty", () => {
104+
expect(render([{ text: "📊", each: "limits.windows", item: "{x}" }], { limits: {} })).toBe("");
105+
});
106+
});
107+
108+
describe("usage-bar end-to-end with buildUsageContract", () => {
109+
it("renders a full footer from a reply usage snapshot", () => {
110+
const contract = buildUsageContract(
111+
{
112+
provider: "openai",
113+
model: "claude-opus-4-6",
114+
reasoningEffort: "medium",
115+
fastMode: false,
116+
fallbackUsed: false,
117+
contextTokenBudget: 272000,
118+
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
119+
limits: {
120+
available: true,
121+
source: "core",
122+
windows: [
123+
{ label: "5h", used_pct: 8, pct_left: 92, resets_in_s: 17100 },
124+
{ label: "week", used_pct: 30, pct_left: 70, resets_in_s: 570240 },
125+
],
126+
},
127+
},
128+
"discord",
129+
);
130+
const pieces = [
131+
{ text: "{model.display_name|alias:models}" },
132+
{ map: "model.is_fallback", cases: { true: "🔄" } },
133+
{ text: " | " },
134+
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
135+
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
136+
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
137+
{
138+
text: " | 📊",
139+
each: "limits.windows",
140+
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
141+
item_scales: ["weather", "plants"],
142+
},
143+
];
144+
expect(renderUsageBar(tpl(pieces), contract)).toBe(
145+
"opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | 📊 ☀️4h45m 🍀6.6d",
146+
);
147+
});
148+
});

0 commit comments

Comments
 (0)