Fix token usage history gaps and add dev detail popup (#386)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-10 17:20:10 +08:00
committed by GitHub
Unverified
parent d3960a3d0f
commit 80e89ddc5c
12 changed files with 406 additions and 29 deletions

View File

@@ -2513,8 +2513,7 @@ function registerFileHandlers(): void {
* Performs a soft-delete of a session's JSONL transcript on disk.
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list and token-usage
* (both already filter out filenames containing ".deleted.").
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
*/
function registerSessionHandlers(): void {
ipcMain.handle('session:delete', async (_, sessionKey: string) => {

View File

@@ -130,12 +130,37 @@ async function discoverAgentIds(): Promise<string[]> {
// ── OpenClaw Config Helpers ──────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
}
function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>): void {
const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown>
: null);
if (!agents) return;
const defaults = (agents.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: null);
if (!defaults) return;
const compaction = (defaults.compaction && typeof defaults.compaction === 'object'
? defaults.compaction as Record<string, unknown>
: null);
if (!compaction) return;
const mode = compaction.mode;
if (typeof mode === 'string' && mode.length > 0 && !VALID_COMPACTION_MODES.has(mode)) {
compaction.mode = 'default';
}
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
normalizeAgentsDefaultsCompactionMode(config);
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
const commands = (
config.commands && typeof config.commands === 'object'

View File

@@ -4,6 +4,7 @@ export interface TokenUsageHistoryEntry {
agentId: string;
model?: string;
provider?: string;
content?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
@@ -12,6 +13,14 @@ export interface TokenUsageHistoryEntry {
costUsd?: number;
}
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
return fileName
.replace(/\.jsonl\.reset\..+$/, '')
.replace(/\.deleted\.jsonl$/, '')
.replace(/\.jsonl$/, '');
}
interface TranscriptUsageShape {
input?: number;
output?: number;
@@ -35,9 +44,59 @@ interface TranscriptLineShape {
modelRef?: string;
provider?: string;
usage?: TranscriptUsageShape;
details?: {
provider?: string;
model?: string;
usage?: TranscriptUsageShape;
content?: unknown;
externalContent?: {
provider?: string;
};
};
};
}
function normalizeUsageContent(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const chunks = value
.map((item) => normalizeUsageContent(item))
.filter((item): item is string => Boolean(item));
if (chunks.length === 0) return undefined;
return chunks.join('\n\n');
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.text === 'string') {
const trimmed = record.text.trim();
if (trimmed.length > 0) return trimmed;
}
if (typeof record.content === 'string') {
const trimmed = record.content.trim();
if (trimmed.length > 0) return trimmed;
}
if (Array.isArray(record.content)) {
return normalizeUsageContent(record.content);
}
if (typeof record.thinking === 'string') {
const trimmed = record.thinking.trim();
if (trimmed.length > 0) return trimmed;
}
try {
return JSON.stringify(record, null, 2);
} catch {
return undefined;
}
}
return undefined;
}
export function parseUsageEntriesFromJsonl(
content: string,
context: { sessionId: string; agentId: string },
@@ -58,18 +117,66 @@ export function parseUsageEntriesFromJsonl(
}
const message = parsed.message;
if (!message || message.role !== 'assistant' || !message.usage || !parsed.timestamp) {
if (!message || !parsed.timestamp) {
continue;
}
const usage = message.usage;
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
const cacheReadTokens = usage.cacheRead ?? 0;
const cacheWriteTokens = usage.cacheWrite ?? 0;
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
if (message.role === 'assistant' && message.usage) {
const usage = message.usage;
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
const cacheReadTokens = usage.cacheRead ?? 0;
const cacheWriteTokens = usage.cacheWrite ?? 0;
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
if (totalTokens <= 0 && !usage.cost?.total) {
if (totalTokens <= 0) {
continue;
}
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens,
costUsd: usage.cost?.total,
});
continue;
}
if (message.role !== 'toolResult') {
continue;
}
const details = message.details;
if (!details) {
continue;
}
const usage = details.usage;
const inputTokens = usage?.input ?? usage?.promptTokens ?? 0;
const outputTokens = usage?.output ?? usage?.completionTokens ?? 0;
const cacheReadTokens = usage?.cacheRead ?? 0;
const cacheWriteTokens = usage?.cacheWrite ?? 0;
const totalTokens = usage?.total ?? usage?.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
if (!provider && !model) {
continue;
}
if (totalTokens <= 0) {
continue;
}
@@ -77,14 +184,15 @@ export function parseUsageEntriesFromJsonl(
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
model,
provider,
...(contentText ? { content: contentText } : {}),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens,
costUsd: usage.cost?.total,
costUsd: usage?.cost?.total,
});
}

View File

@@ -2,10 +2,18 @@ import { readdir, readFile, stat } from 'fs/promises';
import { join } from 'path';
import { getOpenClawConfigDir } from './paths';
import { logger } from './logger';
import {
extractSessionIdFromTranscriptFileName,
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
import { listConfiguredAgentIds } from './agent-config';
import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
export {
extractSessionIdFromTranscriptFileName,
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
const openclawDir = getOpenClawConfigDir();
@@ -21,13 +29,14 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
const sessionEntries = await readdir(sessionsDir);
for (const fileName of sessionEntries) {
if (!fileName.endsWith('.jsonl') || fileName.includes('.deleted.')) continue;
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
if (!sessionId) continue;
const filePath = join(sessionsDir, fileName);
try {
const fileStat = await stat(filePath);
files.push({
filePath,
sessionId: fileName.replace(/\.jsonl$/, ''),
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
});