mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 11:02:26 +00:00
fix(usage): simplify default full footer
This commit is contained in:
@@ -32,8 +32,13 @@ title: "Usage tracking"
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
`messages.usageTemplate` customizes the per-response `/usage full` footer. The
|
||||
value is a JSON file path (supports `~`) or an inline object:
|
||||
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
|
||||
context window, final-call tokens, cache, and cost when those fields are
|
||||
available. No template file is required.
|
||||
|
||||
`messages.usageTemplate` is only for advanced custom layouts. The value is a
|
||||
JSON file path (supports `~`) or an inline object, and it replaces the built-in
|
||||
footer when valid:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -43,35 +48,30 @@ value is a JSON file path (supports `~`) or an inline object:
|
||||
}
|
||||
```
|
||||
|
||||
Set it to the string `"default"` to use OpenClaw's built-in footer as-is:
|
||||
|
||||
```json
|
||||
{ "messages": { "usageTemplate": "default" } }
|
||||
```
|
||||
|
||||
When unset, the legacy single-line footer is used; `"default"` opts into the
|
||||
richer built-in footer (model, reasoning, fast/slow, context-window bar, last-call
|
||||
tokens, cache, cost).
|
||||
|
||||
Your template is **merged over that built-in footer**, the same way other config
|
||||
objects layer over defaults: nested objects (`scales`, `aliases`) extend
|
||||
key-by-key, while an `output.surfaces.<channel>` piece list replaces that
|
||||
channel's default. So a template only needs to contain what it adds or changes —
|
||||
start from `"default"` and override just the pieces you want. A missing,
|
||||
unreadable, or invalid template falls back to the built-in line.
|
||||
Missing, unreadable, invalid, or empty templates fall back to the built-in
|
||||
footer.
|
||||
|
||||
### Shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": "openclaw.usageBar.v1",
|
||||
"scales": { "<name>": "low→high glyphs" }, // string (1 glyph/char) or array
|
||||
"scales": { "<name>": "low-to-high glyphs" }, // string (1 glyph/char) or array
|
||||
"aliases": { "<table>": { "<value>": "<label>" } },
|
||||
"output": {
|
||||
"sep": "", // joins surviving pieces
|
||||
"default": [ /* pieces */ ], // fallback for any surface
|
||||
"surfaces": { "discord": [ /* pieces */ ], "telegram": [ /* pieces */ ] }
|
||||
}
|
||||
"sep": "", // joins surviving pieces
|
||||
"default": [
|
||||
/* pieces */
|
||||
], // fallback for any surface
|
||||
"surfaces": {
|
||||
"discord": [
|
||||
/* pieces */
|
||||
],
|
||||
"telegram": [
|
||||
/* pieces */
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,47 +79,47 @@ Each surface is an ordered list of **pieces**; the engine renders each, drops
|
||||
empties, and joins survivors with `sep`. A surface with no entry uses
|
||||
`output.default`.
|
||||
|
||||
### Contract — the `{paths}` you can read
|
||||
### Contract Paths
|
||||
|
||||
A piece reads values from the per-turn contract by dot-path. Absent values are
|
||||
empty (so a `when` guard or a `|fallback` keeps the piece clean).
|
||||
|
||||
| Path | Meaning |
|
||||
| --- | --- |
|
||||
| `surface` | channel id (`discord`/`telegram`/…) |
|
||||
| `model.provider` · `model.display_name` | provider id · model id |
|
||||
| `model.reasoning` | effort (`off`…`xhigh`) |
|
||||
| `model.is_fallback` · `model.is_override` | bool — fallback used · model pinned |
|
||||
| `state.fast_mode` | bool — fast vs slow |
|
||||
| `context.max_tokens` · `context.pct_used` | window budget · 0–100 used |
|
||||
| `usage.input_tokens` · `usage.output_tokens` · `usage.cache_hit_pct` | turn aggregate |
|
||||
| `usage.last.input_tokens` · `usage.last.output_tokens` · `usage.last.cache_hit_pct` | final model call only |
|
||||
| `cost.turn_usd` | estimated turn cost |
|
||||
| `timing.duration_ms` | wall-clock ms |
|
||||
| `identity.name` · `identity.emoji` | agent name · chosen emoji |
|
||||
| Path | Meaning |
|
||||
| ----------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| `surface` | channel id (`discord`/`telegram`/etc.) |
|
||||
| `model.provider` / `model.display_name` | provider id / model id |
|
||||
| `model.reasoning` | effort (`off` through `xhigh`) |
|
||||
| `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned |
|
||||
| `state.fast_mode` | bool: fast vs slow |
|
||||
| `context.max_tokens` / `context.pct_used` | window budget / 0-100 used |
|
||||
| `usage.input_tokens` / `usage.output_tokens` / `usage.cache_hit_pct` | turn aggregate |
|
||||
| `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only |
|
||||
| `cost.turn_usd` | estimated turn cost |
|
||||
| `timing.duration_ms` | wall-clock ms |
|
||||
| `identity.name` / `identity.emoji` | agent name / chosen emoji |
|
||||
|
||||
(Provider rate-limit windows are **not** in this contract.)
|
||||
|
||||
### Verbs — `{path|verb:arg|fallback}`
|
||||
### Verbs
|
||||
|
||||
Pipe a value through verbs left→right; a non-verb segment is the fallback.
|
||||
Pipe a value through verbs left to right; a non-verb segment is the fallback.
|
||||
|
||||
| Verb | Effect | Example |
|
||||
| --- | --- | --- |
|
||||
| `num` | compact count | `272000 → 272k` |
|
||||
| `fixed:N` | N decimals (default 2) | `0.0377` |
|
||||
| `dur` | seconds → duration | `14820 → 4h07m` |
|
||||
| `pct` | append `%` | `96 → 96%` |
|
||||
| `inv` | `100 − x` | for used→remaining |
|
||||
| `alias:TABLE` | lookup in `aliases`, echo if unlisted | `medium → 🌗` |
|
||||
| `meter:W:SCALE` | W-cell glyph bar over a 0–100 value | `[⣿⣿⠐⠐⠐]` (`meter:1` = one glyph) |
|
||||
| Verb | Effect | Example |
|
||||
| --------------- | ------------------------------------- | --------------------------------- |
|
||||
| `num` | compact count | `272000 -> 272k` |
|
||||
| `fixed:N` | N decimals (default 2) | `0.0377` |
|
||||
| `dur` | seconds to duration | `14820 -> 4h07m` |
|
||||
| `pct` | append `%` | `96 -> 96%` |
|
||||
| `inv` | `100 - x` | for used to remaining |
|
||||
| `alias:TABLE` | lookup in `aliases`, echo if unlisted | `medium -> 🌗` |
|
||||
| `meter:W:SCALE` | W-cell glyph bar over a 0-100 value | `[⣿⣿⠐⠐⠐]` (`meter:1` = one glyph) |
|
||||
|
||||
### Piece forms
|
||||
|
||||
- `{ "text": "📚 {context.max_tokens|num}" }` — literal + interpolation.
|
||||
- `{ "when": "<path>", "text": … }` — render only if the path is truthy.
|
||||
- `{ "map": "<path>", "cases": { "true": "⚡", "false": "🐌", "_default": "?" } }` — value→glyph.
|
||||
- `{ "each": "limits.windows", "item": "{label}", "item_scales": ["weather"] }` — iterate an array; `*` in `item` selects the per-item scale.
|
||||
- `{ "text": "📚 {context.max_tokens|num}" }`: literal + interpolation.
|
||||
- `{ "when": "<path>", "text": "..." }`: render only if the path is truthy.
|
||||
- `{ "map": "<path>", "cases": { "true": "⚡", "false": "🐌" } }`: value to glyph.
|
||||
- `{ "each": "limits.windows", "item": "{label}" }`: iterate an array.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -134,11 +134,13 @@ Pipe a value through verbs left→right; a non-verb segment is the fallback.
|
||||
{ "text": "{model.display_name}" },
|
||||
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
|
||||
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
|
||||
{ "when": "context.max_tokens",
|
||||
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" }
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"when": "context.max_tokens",
|
||||
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
import type { UsageBarTemplate } from "./translator.js";
|
||||
|
||||
/**
|
||||
* Built-in `/usage full` footer template, used when `messages.usageTemplate` is
|
||||
* set to the sentinel string `"default"`. Opt-in and intentionally undocumented
|
||||
* in the config schema/help for now — a path or inline object still overrides.
|
||||
*
|
||||
* It is the same `openclaw.usageBar.v1` DSL a user template uses, kept in source
|
||||
* (rather than a shipped JSON) so the default stays in lockstep with the engine.
|
||||
*
|
||||
* DSL recap (see translator.ts for the full verb set):
|
||||
* - Each surface in `output.surfaces` is an ordered piece list; the engine
|
||||
* renders each piece, drops empties, and joins survivors with `output.sep`.
|
||||
* - `{path|verb:arg|fallback}` resolves `path` against the contract, applies
|
||||
* verbs left→right, and uses `fallback` when the value is absent.
|
||||
* - Verbs: num (compact count) · fixed:N (decimals) · dur (seconds→4h07m/5.2d)
|
||||
* · pct · inv (100−x) · alias:TABLE (lookup in `aliases`, echo if unlisted)
|
||||
* · meter:W:SCALE (glyph bar — used for the 📚 context-window bar).
|
||||
* - `{ when }` shows a piece only if a path is truthy; `{ map, cases }` maps a
|
||||
* value to a glyph; `{ each, item }` iterates an array.
|
||||
*
|
||||
* Contract paths used below (built by buildUsageContract in contract.ts):
|
||||
* model.{provider,display_name,reasoning,is_fallback,is_override}
|
||||
* identity.emoji · state.fast_mode · context.{pct_used,max_tokens}
|
||||
* usage.last.{input_tokens,output_tokens,cache_hit_pct} · cost.turn_usd
|
||||
*
|
||||
* `when` guards wrap the optional segments so an absent field drops the whole
|
||||
* piece (no dangling separators or empty glyphs).
|
||||
*/
|
||||
export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
|
||||
schema: "openclaw.usageBar.v1",
|
||||
// Full glyph-ramp palette shipped by default; a user template can add more
|
||||
// or override by name. Each is low→high (string = one glyph per char).
|
||||
scales: {
|
||||
braille: "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
|
||||
block: "░▏▎▍▌▋▊▉█",
|
||||
@@ -53,10 +24,8 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
|
||||
},
|
||||
output: {
|
||||
sep: "",
|
||||
// Surfaces without an explicit entry (telegram, web, …) fall back to this.
|
||||
// The engine reads `output.default`, NOT `output.surfaces.default`.
|
||||
default: [
|
||||
{ text: "{model.display_name|alias:models}" },
|
||||
{ text: "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
|
||||
{ map: "model.is_fallback", cases: { true: " 🔄" } },
|
||||
{ map: "model.is_override", cases: { true: " 📌" } },
|
||||
{ when: "model.reasoning", text: " {model.reasoning|alias:reasoning}" },
|
||||
@@ -65,6 +34,12 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
|
||||
when: "context.max_tokens",
|
||||
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
{
|
||||
when: "usage.last",
|
||||
text: " ↕️ {usage.last.input_tokens|num}/{usage.last.output_tokens|num}",
|
||||
},
|
||||
{ when: "usage.last.cache_hit_pct", text: " 🗄 {usage.last.cache_hit_pct|pct}" },
|
||||
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
|
||||
],
|
||||
surfaces: {
|
||||
discord: [
|
||||
|
||||
@@ -2,10 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
|
||||
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
|
||||
|
||||
const tplA = { segments: [{ text: "A" }] };
|
||||
const tplB = { output: { lines: [] } };
|
||||
const tplB = { output: { default: [{ text: "B" }] } };
|
||||
|
||||
let dir: string | undefined;
|
||||
|
||||
@@ -25,36 +26,16 @@ function tmpFile(name: string, contents: string): string {
|
||||
}
|
||||
|
||||
describe("loadUsageBarTemplate", () => {
|
||||
it("returns undefined when unset", () => {
|
||||
expect(loadUsageBarTemplate(undefined)).toBeUndefined();
|
||||
it("returns the built-in template when unset", () => {
|
||||
expect(loadUsageBarTemplate(undefined)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
});
|
||||
|
||||
it('resolves the "default" sentinel to the built-in usable template', () => {
|
||||
const tpl = loadUsageBarTemplate("default");
|
||||
expect(tpl).toBeDefined();
|
||||
expect((tpl as { output?: unknown }).output).toBeDefined();
|
||||
it("returns an inline template object when usable", () => {
|
||||
expect(loadUsageBarTemplate(tplA)).toBe(tplA);
|
||||
});
|
||||
|
||||
it("merges an inline override over the default (vocab extends, surfaces replace)", () => {
|
||||
const merged = loadUsageBarTemplate({
|
||||
scales: { mine: "ab" },
|
||||
output: { surfaces: { discord: [{ text: "X" }] } },
|
||||
}) as {
|
||||
scales: Record<string, unknown>;
|
||||
output: { surfaces: Record<string, unknown>; default?: unknown };
|
||||
};
|
||||
// added scale, and the default palette is still present
|
||||
expect(merged.scales.mine).toBe("ab");
|
||||
expect(merged.scales.braille).toBeDefined();
|
||||
// the overridden channel is replaced; the default fallback survives
|
||||
expect(merged.output.surfaces.discord).toEqual([{ text: "X" }]);
|
||||
expect(merged.output.default).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not mutate the shared default when merging an override", () => {
|
||||
loadUsageBarTemplate({ scales: { mine: "ab" } });
|
||||
const bare = loadUsageBarTemplate("default") as { scales: Record<string, unknown> };
|
||||
expect(bare.scales.mine).toBeUndefined();
|
||||
it("falls back to the built-in template for an unusable inline object", () => {
|
||||
expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
});
|
||||
|
||||
it("loads and parses a template file", () => {
|
||||
@@ -62,22 +43,22 @@ describe("loadUsageBarTemplate", () => {
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
|
||||
});
|
||||
|
||||
it("falls back (undefined) for invalid JSON", () => {
|
||||
it("falls back to the built-in template for invalid JSON", () => {
|
||||
const path = tmpFile("bad.json", "{ not json");
|
||||
expect(loadUsageBarTemplate(path)).toBeUndefined();
|
||||
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
});
|
||||
|
||||
it("reloads a path after an initial miss", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
|
||||
const missing = join(dir, "missing.json");
|
||||
expect(loadUsageBarTemplate(missing)).toBeUndefined();
|
||||
expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
writeFileSync(missing, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
|
||||
});
|
||||
|
||||
it("reloads a path after invalid JSON is fixed", () => {
|
||||
const path = tmpFile("bad.json", "{ not json");
|
||||
expect(loadUsageBarTemplate(path)).toBeUndefined();
|
||||
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
writeFileSync(path, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
|
||||
});
|
||||
|
||||
@@ -6,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js";
|
||||
|
||||
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
|
||||
|
||||
/** Sentinel value of `messages.usageTemplate` that selects the built-in default. */
|
||||
const DEFAULT_SENTINEL = "default";
|
||||
|
||||
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
|
||||
const fileCache = new Map<string, CacheEntry>();
|
||||
|
||||
@@ -26,24 +23,12 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge a user override OVER the built-in default, like other openclaw
|
||||
* config objects: nested objects are merged key-by-key (so `scales`/`aliases`
|
||||
* extend rather than replace), while arrays and scalars from the override win
|
||||
* (a `output.surfaces.<channel>` piece-list replaces that channel's default).
|
||||
* Never mutates `base` — each level is cloned.
|
||||
*/
|
||||
function mergeTemplate(
|
||||
base: Record<string, unknown>,
|
||||
override: Record<string, unknown>,
|
||||
): UsageBarTemplate {
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
const prev = out[key];
|
||||
out[key] =
|
||||
isPlainObject(prev) && isPlainObject(value) ? mergeTemplate(prev, value) : value;
|
||||
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
return out;
|
||||
const hasOutput = typeof value.output === "object" && value.output !== null;
|
||||
return hasOutput || Array.isArray(value.segments);
|
||||
}
|
||||
|
||||
function readTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
@@ -55,7 +40,7 @@ function readTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isPlainObject(parsed) ? parsed : undefined;
|
||||
return isUsableTemplate(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -80,30 +65,20 @@ function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
return entry.template;
|
||||
}
|
||||
|
||||
export function loadUsageBarTemplate(
|
||||
configured: UsageTemplateConfig,
|
||||
): UsageBarTemplate | undefined {
|
||||
export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarTemplate {
|
||||
if (!configured) {
|
||||
return undefined;
|
||||
}
|
||||
// The bare default, no override.
|
||||
if (configured === DEFAULT_SENTINEL) {
|
||||
return DEFAULT_USAGE_BAR_TEMPLATE;
|
||||
}
|
||||
// Inline override object → merged over the default.
|
||||
if (typeof configured === "object") {
|
||||
return isPlainObject(configured)
|
||||
? mergeTemplate(DEFAULT_USAGE_BAR_TEMPLATE, configured)
|
||||
: undefined;
|
||||
return isUsableTemplate(configured) ? configured : DEFAULT_USAGE_BAR_TEMPLATE;
|
||||
}
|
||||
// File path → parsed override merged over the default. A missing/invalid file
|
||||
// yields no override (undefined), so the caller falls back to the built-in line.
|
||||
const path = expandPath(configured);
|
||||
const cached = fileCache.get(path);
|
||||
const override = cached
|
||||
? (cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path)))
|
||||
: cacheTemplateFile(path);
|
||||
return override ? mergeTemplate(DEFAULT_USAGE_BAR_TEMPLATE, override) : undefined;
|
||||
return (
|
||||
(cached
|
||||
? (cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path)))
|
||||
: cacheTemplateFile(path)) ?? DEFAULT_USAGE_BAR_TEMPLATE
|
||||
);
|
||||
}
|
||||
|
||||
export function clearUsageBarTemplateCacheForTest(): void {
|
||||
|
||||
Reference in New Issue
Block a user