Skip to content

Commit 77794cf

Browse files
committed
fix(usage): tighten usage footer template handling
1 parent 16d3ca0 commit 77794cf

10 files changed

Lines changed: 64 additions & 151 deletions

File tree

docs/concepts/usage-tracking.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ title: "Usage tracking"
3030
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
3131
- macOS menu bar: "Usage" section under Context (only if available).
3232

33+
## Custom `/usage full` footer
34+
35+
Set `messages.usageTemplate` to customize the per-response `/usage full`
36+
footer. The value can be an inline template object or a JSON file path:
37+
38+
```json
39+
{
40+
"messages": {
41+
"usageTemplate": "~/.openclaw/usage-footer.json"
42+
}
43+
}
44+
```
45+
46+
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
47+
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
48+
unreadable, invalid, or empty templates fall back to the built-in usage line.
49+
3350
## Providers + credentials
3451

3552
- **Anthropic (Claude)**: OAuth tokens in auth profiles.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
7070
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
7171
import { SILENT_REPLY_TOKEN } from "../tokens.js";
7272
import type { GetReplyOptions, ReplyPayload } from "../types.js";
73+
import { buildUsageContract } from "../usage-bar/contract.js";
74+
import { loadUsageBarTemplate } from "../usage-bar/template.js";
75+
import { renderUsageBar } from "../usage-bar/translator.js";
7376
import {
7477
buildKnownAgentRunFailureReplyPayload,
7578
runAgentTurnWithFallback,
@@ -90,9 +93,6 @@ import {
9093
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
9194
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
9295
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";
9696
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
9797
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
9898
import {

src/auto-reply/usage-bar/contract.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
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.
51
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
62
import type { UsageContract } from "./translator.js";
73

@@ -16,15 +12,10 @@ export function buildUsageContract(
1612
const cacheWrite = usage.cacheWrite;
1713
const total = usage.total;
1814

19-
// cache_hit_pct: cacheRead only (writes are misses being cached). Matches
20-
// core status-message.ts.
2115
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
2216
const cacheHitPct =
2317
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
2418

25-
// Last-call usage (final model call only) so templates can render the last
26-
// exchange instead of the turn aggregate. Its cache_hit_pct is computed over
27-
// the last call's prompt total, same formula as the turn-level one.
2819
const last = state.lastUsage;
2920
const lastPromptTotal = last
3021
? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0)
@@ -35,11 +26,6 @@ export function buildUsageContract(
3526
: undefined;
3627

3728
const maxTokens = state.contextTokenBudget;
38-
// Context occupancy is a point-in-time STATE, never an aggregate. Prefer the
39-
// turn's real end-of-turn context size (final call's prompt tokens); fall back
40-
// to the aggregate prompt total only for harnesses that don't report it
41-
// (single-call turns, where the two coincide). The aggregate over a multi-call
42-
// tool loop overstates occupancy — often past the window — pinning the gauge.
4329
const usedTokens =
4430
typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0
4531
? state.contextUsedTokens
@@ -58,7 +44,6 @@ export function buildUsageContract(
5844
return {
5945
schema: "openclaw.usageLine.v1",
6046
surface: surface ?? null,
61-
// agentId is exposed flat so templates can key per-agent (e.g. emoji map).
6247
agentId: state.agentId ?? null,
6348
chat_type: state.chatType ?? null,
6449
model: {
@@ -79,15 +64,12 @@ export function buildUsageContract(
7964
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
8065
},
8166
usage: {
82-
// Turn aggregate: summed across every model call in the turn's tool loop.
8367
input_tokens: input,
8468
output_tokens: output,
8569
cache_read_tokens: cacheRead,
8670
cache_write_tokens: cacheWrite,
8771
total_tokens: total,
8872
cache_hit_pct: cacheHitPct,
89-
// Final model call only. Templates choose `{usage.input_tokens}` (turn)
90-
// vs `{usage.last.input_tokens}` (last exchange). Absent → segment drops.
9173
last: last
9274
? {
9375
input_tokens: last.input,
@@ -117,6 +99,5 @@ export function buildUsageContract(
11799
avatar: state.identity?.avatar ?? null,
118100
},
119101
session: { id: state.sessionId ?? null },
120-
...(state.limits ? { limits: state.limits } : {}),
121102
};
122103
}

src/auto-reply/usage-bar/template.test.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { join } from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
55
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
66

7-
// Two structurally-valid templates (isUsableTemplate accepts either an `output`
8-
// object or a `segments` array) so we can tell which one resolved.
97
const tplA = { segments: [{ text: "A" }] };
108
const tplB = { output: { lines: [] } };
119

@@ -46,25 +44,23 @@ describe("loadUsageBarTemplate", () => {
4644
expect(loadUsageBarTemplate(path)).toBeUndefined();
4745
});
4846

49-
it("does not cache a missing file, so a later-created template is picked up", () => {
47+
it("caches a missing path as no template", () => {
5048
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
5149
const missing = join(dir, "missing.json");
5250
expect(loadUsageBarTemplate(missing)).toBeUndefined();
5351
writeFileSync(missing, JSON.stringify(tplB));
52+
expect(loadUsageBarTemplate(missing)).toBeUndefined();
53+
clearUsageBarTemplateCacheForTest();
5454
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
5555
});
5656

57-
it("serves the cached template on the hot path without re-reading the file", () => {
57+
it("serves the cached template without re-reading the file", () => {
5858
const path = tmpFile("t.json", JSON.stringify(tplA));
59-
expect(loadUsageBarTemplate(path)).toMatchObject(tplA); // first load caches
59+
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
6060

61-
// Change the file on disk. The reply path must NOT re-read synchronously, so
62-
// the very next call still returns the cached value (the watcher refresh is
63-
// async and has not fired within this synchronous sequence).
6461
writeFileSync(path, JSON.stringify(tplB));
6562
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
6663

67-
// After an explicit cache reset the fresh content loads.
6864
clearUsageBarTemplateCacheForTest();
6965
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
7066
});

src/auto-reply/usage-bar/template.ts

Lines changed: 23 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
// Resolve the usage-bar template from config (`messages.usageTemplate`): either
2-
// an inline template object, or a path to a JSON file. For a path, the template
3-
// is read ONCE into memory and then kept fresh by a filesystem watcher, so the
4-
// per-reply render path never touches disk — no synchronous stat/read in the
5-
// latency-sensitive reply-delivery path. When no usable template resolves, the
6-
// caller falls back to the built-in (boring) usage line.
71
import { type FSWatcher, readFileSync, watch } from "node:fs";
82
import { homedir } from "node:os";
93
import { isAbsolute, resolve } from "node:path";
@@ -12,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js";
126
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
137

148
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
15-
// Keyed by resolved path. A present entry means the file was read at least once;
16-
// the reply path then serves `template` synchronously with zero filesystem
17-
// access, and a watcher refreshes it off the hot path on change.
189
const fileCache = new Map<string, CacheEntry>();
1910

2011
function expandPath(p: string): string {
@@ -27,7 +18,6 @@ function expandPath(p: string): string {
2718
return isAbsolute(p) ? p : resolve(p);
2819
}
2920

30-
// A usable template must carry a layout the engine understands.
3121
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
3222
if (typeof value !== "object" || value === null) {
3323
return false;
@@ -37,22 +27,38 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate {
3727
return hasOutput || Array.isArray(obj.segments);
3828
}
3929

40-
// Read + parse a template file into a usable template, or undefined for
41-
// unreadable/invalid contents. Only called off the reply path: at first load and
42-
// from the watcher callback.
4330
function readTemplateFile(path: string): UsageBarTemplate | undefined {
4431
let raw: string;
4532
try {
4633
raw = readFileSync(path, "utf8");
4734
} catch {
48-
return undefined; // removed/unreadable -> boring fallback
35+
return undefined;
4936
}
5037
try {
5138
const parsed: unknown = JSON.parse(raw);
5239
return isUsableTemplate(parsed) ? parsed : undefined;
5340
} catch {
54-
return undefined; // invalid JSON -> boring fallback
41+
return undefined;
42+
}
43+
}
44+
45+
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
46+
const entry: CacheEntry = { template: readTemplateFile(path) };
47+
if (entry.template) {
48+
try {
49+
const watcher = watch(path, { persistent: false }, () => {
50+
entry.template = readTemplateFile(path);
51+
});
52+
watcher.on("error", () => {
53+
watcher.close();
54+
});
55+
entry.watcher = watcher;
56+
} catch {
57+
// Cache remains valid without live refresh.
58+
}
5559
}
60+
fileCache.set(path, entry);
61+
return entry.template;
5662
}
5763

5864
export function loadUsageBarTemplate(
@@ -67,44 +73,9 @@ export function loadUsageBarTemplate(
6773
const path = expandPath(configured);
6874
const cached = fileCache.get(path);
6975
if (cached) {
70-
return cached.template; // hot path: in-memory, no filesystem access
71-
}
72-
// First resolution for this path. Probe once; if the file is missing/unreadable
73-
// we do NOT cache, so a later-created template is still picked up on a
74-
// subsequent call (the only path that stats per reply is the misconfigured
75-
// "configured but absent" one, never the normal one).
76-
let raw: string;
77-
try {
78-
raw = readFileSync(path, "utf8");
79-
} catch {
80-
return undefined;
81-
}
82-
let template: UsageBarTemplate | undefined;
83-
try {
84-
const parsed: unknown = JSON.parse(raw);
85-
template = isUsableTemplate(parsed) ? parsed : undefined;
86-
} catch {
87-
template = undefined;
76+
return cached.template;
8877
}
89-
// The file exists and was read once; from here the reply path is filesystem
90-
// free. Keep the in-memory copy fresh via a watcher (off the hot path). A watch
91-
// failure (unsupported FS, race) just leaves the one-time load with no live
92-
// refresh — still strictly better than a stat on every reply.
93-
const entry: CacheEntry = { template };
94-
try {
95-
const watcher = watch(path, { persistent: false }, () => {
96-
entry.template = readTemplateFile(path);
97-
});
98-
watcher.on("error", () => {
99-
// Best-effort: keep the last-known template rather than throwing on a
100-
// watch error (e.g. the file being removed).
101-
});
102-
entry.watcher = watcher;
103-
} catch {
104-
// Unwatchable path: cache the one-time load anyway (no refresh until restart).
105-
}
106-
fileCache.set(path, entry);
107-
return template;
78+
return cacheTemplateFile(path);
10879
}
10980

11081
export function clearUsageBarTemplateCacheForTest(): void {

src/auto-reply/usage-bar/translator.test.ts

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -90,25 +90,23 @@ describe("usage-bar segment forms", () => {
9090
it("each with item_scales picks a scale per window by position", () => {
9191
const seg = [
9292
{
93-
text: "📊",
94-
each: "limits.windows",
93+
text: "W",
94+
each: "windows",
9595
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
9696
item_scales: ["weather", "plants"],
9797
},
9898
];
9999
const out = render(seg, {
100-
limits: {
101-
windows: [
102-
{ pct_left: 92, resets_in_s: 17100 },
103-
{ pct_left: 70, resets_in_s: 570240 },
104-
],
105-
},
100+
windows: [
101+
{ pct_left: 92, resets_in_s: 17100 },
102+
{ pct_left: 70, resets_in_s: 570240 },
103+
],
106104
});
107-
expect(out).toBe("📊 ☀️4h45m 🍀6.6d");
105+
expect(out).toBe("W ☀️4h45m 🍀6.6d");
108106
});
109107

110108
it("each drops the whole segment when the array is empty", () => {
111-
expect(render([{ text: "📊", each: "limits.windows", item: "{x}" }], { limits: {} })).toBe("");
109+
expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe("");
112110
});
113111
});
114112

@@ -122,15 +120,9 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
122120
fastMode: false,
123121
fallbackUsed: false,
124122
contextTokenBudget: 272000,
123+
contextUsedTokens: 204000,
125124
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
126-
limits: {
127-
available: true,
128-
source: "core",
129-
windows: [
130-
{ label: "5h", used_pct: 8, pct_left: 92, resets_in_s: 17100 },
131-
{ label: "week", used_pct: 30, pct_left: 70, resets_in_s: 570240 },
132-
],
133-
},
125+
turnUsd: 0.03771985,
134126
},
135127
"discord",
136128
);
@@ -141,15 +133,8 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
141133
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
142134
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
143135
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
144-
{
145-
text: " | 📊",
146-
each: "limits.windows",
147-
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
148-
item_scales: ["weather", "plants"],
149-
},
136+
{ text: " | ${cost.turn_usd|fixed:4}" },
150137
];
151-
expect(renderUsageBar(tpl(pieces), contract)).toBe(
152-
"opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | 📊 ☀️4h45m 🍀6.6d",
153-
);
138+
expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377");
154139
});
155140
});

0 commit comments

Comments
 (0)