Fix token usage history gaps and add dev detail popup (#386)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d3960a3d0f
commit
80e89ddc5c
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user