diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d39b7248..d09e96dcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. +- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. - Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931. - Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594) - Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index e8427448ba..671dcb583a 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -371,4 +371,57 @@ describe("session cost usage", () => { } } }); + + it("preserves totals and cumulative values when downsampling timeseries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-timeseries-downsample-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "sess-downsample.jsonl"); + + const entries = Array.from({ length: 10 }, (_, i) => { + const idx = i + 1; + return { + type: "message", + timestamp: new Date(Date.UTC(2026, 1, 12, 10, idx, 0)).toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: idx, + output: idx * 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: idx * 3, + cost: { total: idx * 0.001 }, + }, + }, + }; + }); + + await fs.writeFile( + sessionFile, + entries.map((entry) => JSON.stringify(entry)).join("\n"), + "utf-8", + ); + + const timeseries = await loadSessionUsageTimeSeries({ + sessionFile, + maxPoints: 3, + }); + + expect(timeseries).toBeTruthy(); + expect(timeseries?.points.length).toBe(3); + + const points = timeseries?.points ?? []; + const totalTokens = points.reduce((sum, point) => sum + point.totalTokens, 0); + const totalCost = points.reduce((sum, point) => sum + point.cost, 0); + const lastPoint = points[points.length - 1]; + + // Full-series totals: sum(1..10)*3 = 165 tokens, sum(1..10)*0.001 = 0.055 cost. + expect(totalTokens).toBe(165); + expect(totalCost).toBeCloseTo(0.055, 8); + expect(lastPoint?.cumulativeTokens).toBe(165); + expect(lastPoint?.cumulativeCost).toBeCloseTo(0.055, 8); + }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 6114f31531..96bebb9a96 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -2,15 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import type { NormalizedUsage, UsageLike } from "../agents/usage.js"; -import { normalizeUsage } from "../agents/usage.js"; import type { OpenClawConfig } from "../config/config.js"; -import { - resolveSessionFilePath, - resolveSessionTranscriptsDirForAgent, -} from "../config/sessions/paths.js"; import type { SessionEntry } from "../config/sessions/types.js"; -import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; -import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; import type { CostBreakdown, CostUsageTotals, @@ -31,6 +24,13 @@ import type { SessionUsageTimePoint, SessionUsageTimeSeries, } from "./session-cost-usage.types.js"; +import { normalizeUsage } from "../agents/usage.js"; +import { + resolveSessionFilePath, + resolveSessionTranscriptsDirForAgent, +} from "../config/sessions/paths.js"; +import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; +import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; export type { CostUsageDailyEntry, @@ -799,12 +799,44 @@ export async function loadSessionUsageTimeSeries(params: { if (sortedPoints.length > maxPoints) { const step = Math.ceil(sortedPoints.length / maxPoints); const downsampled: SessionUsageTimePoint[] = []; + let downsampledCumulativeTokens = 0; + let downsampledCumulativeCost = 0; for (let i = 0; i < sortedPoints.length; i += step) { - downsampled.push(sortedPoints[i]); - } - // Always include the last point - if (downsampled[downsampled.length - 1] !== sortedPoints[sortedPoints.length - 1]) { - downsampled.push(sortedPoints[sortedPoints.length - 1]); + const bucket = sortedPoints.slice(i, i + step); + const bucketLast = bucket[bucket.length - 1]; + if (!bucketLast) { + continue; + } + + let bucketInput = 0; + let bucketOutput = 0; + let bucketCacheRead = 0; + let bucketCacheWrite = 0; + let bucketTotalTokens = 0; + let bucketCost = 0; + for (const point of bucket) { + bucketInput += point.input; + bucketOutput += point.output; + bucketCacheRead += point.cacheRead; + bucketCacheWrite += point.cacheWrite; + bucketTotalTokens += point.totalTokens; + bucketCost += point.cost; + } + + downsampledCumulativeTokens += bucketTotalTokens; + downsampledCumulativeCost += bucketCost; + + downsampled.push({ + timestamp: bucketLast.timestamp, + input: bucketInput, + output: bucketOutput, + cacheRead: bucketCacheRead, + cacheWrite: bucketCacheWrite, + totalTokens: bucketTotalTokens, + cost: bucketCost, + cumulativeTokens: downsampledCumulativeTokens, + cumulativeCost: downsampledCumulativeCost, + }); } return { sessionId: params.sessionId, points: downsampled }; } diff --git a/ui/src/ui/views/usage-styles/usageStyles-part3.ts b/ui/src/ui/views/usage-styles/usageStyles-part3.ts index 9a7b60572b..8a114ab69f 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part3.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part3.ts @@ -513,7 +513,7 @@ export const usageStylesPart3 = ` /* ===== CHART AXIS ===== */ .ts-axis-label { font-size: 5px; - fill: var(--text-muted); + fill: var(--muted); } /* ===== RANGE SELECTION HANDLES ===== */ @@ -537,7 +537,7 @@ export const usageStylesPart3 = ` border-radius: 999px; padding: 2px 10px; font-size: 11px; - color: var(--text-muted); + color: var(--muted); cursor: pointer; transition: all 0.15s ease; margin-left: 8px; diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts index 7a2e3d1bed..207d14dc54 100644 --- a/ui/src/ui/views/usage.ts +++ b/ui/src/ui/views/usage.ts @@ -86,7 +86,7 @@ export function renderUsage(props: UsageProps) {