mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 13:04:08 +00:00
977 lines
28 KiB
TypeScript
977 lines
28 KiB
TypeScript
import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../agents/usage.js";
|
|
import {
|
|
countSqliteSessionTranscriptDisplayMessages,
|
|
hasSqliteSessionTranscriptEvents,
|
|
loadSqliteSessionTranscriptEvents,
|
|
loadSqliteSessionTranscriptTailEvents,
|
|
resolveSqliteSessionTranscriptScope,
|
|
} from "../config/sessions/transcript-store.sqlite.js";
|
|
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
|
|
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
|
|
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
|
|
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
|
import { stripEnvelope } from "./chat-sanitize.js";
|
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
|
|
|
type SessionTitleFields = {
|
|
firstUserMessage: string | null;
|
|
lastMessagePreview: string | null;
|
|
};
|
|
|
|
type TailTranscriptRecord = {
|
|
id?: string;
|
|
parentId?: string | null;
|
|
record: Record<string, unknown>;
|
|
};
|
|
|
|
export type ReadRecentSessionMessagesOptions = {
|
|
maxMessages: number;
|
|
maxBytes?: number;
|
|
maxLines?: number;
|
|
};
|
|
|
|
export type SessionTranscriptReadScope = {
|
|
agentId?: string;
|
|
path?: string;
|
|
sessionId: string;
|
|
};
|
|
|
|
export type ReadSessionMessagesAsyncOptions =
|
|
| {
|
|
mode: "full";
|
|
reason: string;
|
|
}
|
|
| ({
|
|
mode: "recent";
|
|
} & ReadRecentSessionMessagesOptions);
|
|
|
|
type ReadRecentSessionMessagesResult = {
|
|
messages: unknown[];
|
|
totalMessages: number;
|
|
};
|
|
|
|
function normalizeTailEntryString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function loadScopedTranscriptEvents(params: {
|
|
agentId?: string;
|
|
path?: string;
|
|
sessionId: string;
|
|
}): unknown[] | undefined {
|
|
if (!params.sessionId.trim()) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const scope = resolveSqliteSessionTranscriptScope({
|
|
agentId: params.agentId,
|
|
path: params.path,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!scope || !hasSqliteSessionTranscriptEvents(scope)) {
|
|
return undefined;
|
|
}
|
|
return loadSqliteSessionTranscriptEvents(scope).map((entry) => entry.event);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function loadScopedTranscriptTailEvents(params: {
|
|
agentId?: string;
|
|
path?: string;
|
|
maxBytes?: number;
|
|
maxEvents: number;
|
|
sessionId: string;
|
|
}): unknown[] | undefined {
|
|
if (!params.sessionId.trim()) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const scope = resolveSqliteSessionTranscriptScope({
|
|
agentId: params.agentId,
|
|
path: params.path,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!scope || !hasSqliteSessionTranscriptEvents(scope)) {
|
|
return undefined;
|
|
}
|
|
return loadSqliteSessionTranscriptTailEvents({
|
|
...scope,
|
|
maxEvents: params.maxEvents,
|
|
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
|
|
}).map((entry) => entry.event);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function sqliteTranscriptEventToRecord(event: unknown): TailTranscriptRecord | null {
|
|
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
return null;
|
|
}
|
|
const record = event as Record<string, unknown>;
|
|
return {
|
|
...(normalizeTailEntryString(record.id) ? { id: normalizeTailEntryString(record.id) } : {}),
|
|
...(record.parentId === null
|
|
? { parentId: null }
|
|
: normalizeTailEntryString(record.parentId)
|
|
? { parentId: normalizeTailEntryString(record.parentId) }
|
|
: {}),
|
|
record,
|
|
};
|
|
}
|
|
|
|
function loadScopedTranscriptRecords(params: {
|
|
agentId?: string;
|
|
sessionId: string;
|
|
}): TailTranscriptRecord[] | undefined {
|
|
return loadScopedTranscriptEvents(params)?.flatMap((event) => {
|
|
const record = sqliteTranscriptEventToRecord(event);
|
|
return record && record.record.type !== "session" ? [record] : [];
|
|
});
|
|
}
|
|
|
|
function loadScopedTranscriptTailRecords(params: {
|
|
agentId?: string;
|
|
maxBytes?: number;
|
|
maxEvents: number;
|
|
sessionId: string;
|
|
}): TailTranscriptRecord[] | undefined {
|
|
return loadScopedTranscriptTailEvents(params)?.flatMap((event) => {
|
|
const record = sqliteTranscriptEventToRecord(event);
|
|
return record && record.record.type !== "session" ? [record] : [];
|
|
});
|
|
}
|
|
|
|
function tailRecordHasTreeLink(entry: TailTranscriptRecord): boolean {
|
|
return (
|
|
entry.record.type !== "session" &&
|
|
typeof entry.id === "string" &&
|
|
Object.hasOwn(entry.record, "parentId")
|
|
);
|
|
}
|
|
|
|
function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTranscriptRecord[] {
|
|
const byId = new Map<string, TailTranscriptRecord>();
|
|
let leafId: string | undefined;
|
|
for (const entry of entries) {
|
|
if (entry.id) {
|
|
byId.set(entry.id, entry);
|
|
}
|
|
if (tailRecordHasTreeLink(entry) && entry.id) {
|
|
leafId = entry.id;
|
|
}
|
|
}
|
|
if (!leafId) {
|
|
return entries;
|
|
}
|
|
|
|
const selected: TailTranscriptRecord[] = [];
|
|
const seen = new Set<string>();
|
|
let currentId: string | undefined = leafId;
|
|
while (currentId) {
|
|
if (seen.has(currentId)) {
|
|
return [];
|
|
}
|
|
seen.add(currentId);
|
|
const entry = byId.get(currentId);
|
|
if (!entry) {
|
|
break;
|
|
}
|
|
selected.push(entry);
|
|
currentId = entry.parentId ?? undefined;
|
|
}
|
|
const activeBranch = selected.toReversed();
|
|
const firstActiveRecord = activeBranch[0];
|
|
const firstActiveIndex = firstActiveRecord ? entries.indexOf(firstActiveRecord) : -1;
|
|
if (firstActiveIndex > 0) {
|
|
for (let index = firstActiveIndex - 1; index >= 0; index -= 1) {
|
|
const entry = entries[index];
|
|
if (entry?.record.type === "compaction") {
|
|
return [entry, ...activeBranch];
|
|
}
|
|
}
|
|
}
|
|
return activeBranch;
|
|
}
|
|
|
|
function selectActiveTranscriptRecords(records: TailTranscriptRecord[]): TailTranscriptRecord[] {
|
|
return records.some(tailRecordHasTreeLink) ? selectBoundedActiveTailRecords(records) : records;
|
|
}
|
|
|
|
function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
const entry = parsed as Record<string, unknown>;
|
|
if (entry.message) {
|
|
return attachOpenClawTranscriptMeta(entry.message, {
|
|
...(typeof entry.id === "string" ? { id: entry.id } : {}),
|
|
seq,
|
|
});
|
|
}
|
|
if (entry.type === "compaction") {
|
|
const ts = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
|
|
const timestamp = Number.isFinite(ts) ? ts : Date.now();
|
|
return {
|
|
role: "system",
|
|
content: [{ type: "text", text: "Compaction" }],
|
|
timestamp,
|
|
__openclaw: {
|
|
kind: "compaction",
|
|
id: typeof entry.id === "string" ? entry.id : undefined,
|
|
seq,
|
|
},
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function transcriptRecordsToMessages(records: TailTranscriptRecord[]): unknown[] {
|
|
const messages: unknown[] = [];
|
|
let messageSeq = 0;
|
|
for (const entry of records) {
|
|
const message = parsedSessionEntryToMessage(entry.record, messageSeq + 1);
|
|
if (message) {
|
|
messageSeq += 1;
|
|
messages.push(message);
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
function loadScopedSessionMessages(params: {
|
|
agentId?: string;
|
|
sessionId: string;
|
|
}): unknown[] | undefined {
|
|
const records = loadScopedTranscriptRecords(params);
|
|
return records ? transcriptRecordsToMessages(selectActiveTranscriptRecords(records)) : undefined;
|
|
}
|
|
|
|
function loadScopedRecentSessionMessages(params: {
|
|
agentId?: string;
|
|
maxBytes?: number;
|
|
maxMessages: number;
|
|
maxLines?: number;
|
|
sessionId: string;
|
|
}): unknown[] | undefined {
|
|
const maxEvents = Math.max(
|
|
params.maxMessages,
|
|
Math.floor(params.maxLines ?? params.maxMessages * 20 + 20),
|
|
);
|
|
const records = loadScopedTranscriptTailRecords({
|
|
agentId: params.agentId,
|
|
maxEvents,
|
|
sessionId: params.sessionId,
|
|
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
|
|
});
|
|
return records
|
|
? transcriptRecordsToMessages(selectActiveTranscriptRecords(records)).slice(-params.maxMessages)
|
|
: undefined;
|
|
}
|
|
|
|
function countScopedSessionMessages(params: {
|
|
agentId?: string;
|
|
sessionId: string;
|
|
}): number | undefined {
|
|
if (!params.sessionId.trim()) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const scope = resolveSqliteSessionTranscriptScope({
|
|
agentId: params.agentId,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!scope || !hasSqliteSessionTranscriptEvents(scope)) {
|
|
return undefined;
|
|
}
|
|
return countSqliteSessionTranscriptDisplayMessages(scope);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function attachOpenClawTranscriptMeta(
|
|
message: unknown,
|
|
meta: Record<string, unknown>,
|
|
): unknown {
|
|
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
return message;
|
|
}
|
|
const record = message as Record<string, unknown>;
|
|
const existing =
|
|
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
|
|
? (record.__openclaw as Record<string, unknown>)
|
|
: {};
|
|
return {
|
|
...record,
|
|
__openclaw: {
|
|
...existing,
|
|
...meta,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function readSessionMessages(scope: SessionTranscriptReadScope): unknown[] {
|
|
return loadScopedSessionMessages(scope) ?? [];
|
|
}
|
|
|
|
export function readRecentSessionMessages(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: ReadRecentSessionMessagesOptions,
|
|
): unknown[] {
|
|
const maxMessages = Math.max(0, Math.floor(opts?.maxMessages ?? 0));
|
|
if (maxMessages === 0) {
|
|
return [];
|
|
}
|
|
return (
|
|
loadScopedRecentSessionMessages({
|
|
agentId: scope.agentId,
|
|
sessionId: scope.sessionId,
|
|
maxMessages,
|
|
...(opts?.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {}),
|
|
...(opts?.maxLines !== undefined ? { maxLines: opts.maxLines } : {}),
|
|
}) ?? []
|
|
);
|
|
}
|
|
|
|
export function visitSessionMessages(
|
|
scope: SessionTranscriptReadScope,
|
|
visit: (message: unknown, seq: number) => void,
|
|
): number {
|
|
const messages = loadScopedSessionMessages(scope) ?? [];
|
|
for (const [index, message] of messages.entries()) {
|
|
visit(message, index + 1);
|
|
}
|
|
return messages.length;
|
|
}
|
|
|
|
export function readSessionMessageCount(scope: SessionTranscriptReadScope): number {
|
|
return countScopedSessionMessages(scope) ?? 0;
|
|
}
|
|
|
|
export async function readSessionMessagesAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
opts: ReadSessionMessagesAsyncOptions,
|
|
): Promise<unknown[]> {
|
|
if (opts.mode === "recent") {
|
|
return readRecentSessionMessages(scope, opts);
|
|
}
|
|
const messages = loadScopedSessionMessages(scope) ?? [];
|
|
return messages;
|
|
}
|
|
|
|
export async function visitSessionMessagesAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
visit: (message: unknown, seq: number) => void,
|
|
opts: { mode: "full"; reason: string },
|
|
): Promise<number> {
|
|
void opts.mode;
|
|
void opts.reason;
|
|
const messages = loadScopedSessionMessages(scope) ?? [];
|
|
for (const [index, message] of messages.entries()) {
|
|
visit(message, index + 1);
|
|
}
|
|
return messages.length;
|
|
}
|
|
|
|
export async function readSessionMessageCountAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
): Promise<number> {
|
|
return loadScopedSessionMessages(scope)?.length ?? 0;
|
|
}
|
|
|
|
export function readRecentSessionMessagesWithStats(
|
|
scope: SessionTranscriptReadScope,
|
|
opts: ReadRecentSessionMessagesOptions,
|
|
): ReadRecentSessionMessagesResult {
|
|
const totalMessages = readSessionMessageCount(scope);
|
|
const messages = readRecentSessionMessages(scope, opts);
|
|
const firstSeq = Math.max(1, totalMessages - messages.length + 1);
|
|
const messagesWithSeq = messages.map((message, index) =>
|
|
attachOpenClawTranscriptMeta(message, { seq: firstSeq + index }),
|
|
);
|
|
return { messages: messagesWithSeq, totalMessages };
|
|
}
|
|
|
|
export async function readRecentSessionMessagesAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: ReadRecentSessionMessagesOptions,
|
|
): Promise<unknown[]> {
|
|
return readRecentSessionMessages(scope, opts);
|
|
}
|
|
|
|
export async function readRecentSessionMessagesWithStatsAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
opts: ReadRecentSessionMessagesOptions,
|
|
): Promise<ReadRecentSessionMessagesResult> {
|
|
return readRecentSessionMessagesWithStats(scope, opts);
|
|
}
|
|
|
|
export function readRecentSessionTranscriptEvents(params: {
|
|
sessionId: string;
|
|
agentId?: string;
|
|
path?: string;
|
|
maxEvents: number;
|
|
}): { events: unknown[]; totalEvents: number } | null {
|
|
const events = loadScopedTranscriptEvents({
|
|
agentId: params.agentId,
|
|
path: params.path,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!events) {
|
|
return null;
|
|
}
|
|
const maxEvents = Math.max(1, Math.floor(params.maxEvents));
|
|
return {
|
|
events: events.slice(-maxEvents),
|
|
totalEvents: events.length,
|
|
};
|
|
}
|
|
|
|
export function capArrayByJsonBytes<T>(
|
|
items: T[],
|
|
maxBytes: number,
|
|
): { items: T[]; bytes: number } {
|
|
if (items.length === 0) {
|
|
return { items, bytes: 2 };
|
|
}
|
|
const parts = items.map((item) => jsonUtf8Bytes(item));
|
|
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
|
|
let start = 0;
|
|
while (bytes > maxBytes && start < items.length - 1) {
|
|
bytes -= parts[start] + 1;
|
|
start += 1;
|
|
}
|
|
const next = start > 0 ? items.slice(start) : items;
|
|
return { items: next, bytes };
|
|
}
|
|
|
|
type TranscriptMessage = {
|
|
role?: string;
|
|
content?: string | Array<{ type: string; text?: string }>;
|
|
provenance?: unknown;
|
|
};
|
|
|
|
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
|
|
if (typeof content === "string") {
|
|
const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim();
|
|
return normalized || null;
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return null;
|
|
}
|
|
for (const part of content) {
|
|
if (!part || typeof part.text !== "string") {
|
|
continue;
|
|
}
|
|
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
|
|
const normalized = stripInlineDirectiveTagsForDisplay(part.text).text.trim();
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractFirstUserMessageFromTranscriptEvents(
|
|
events: unknown[],
|
|
opts?: { includeInterSession?: boolean },
|
|
): string | null {
|
|
for (const event of events) {
|
|
const msg =
|
|
event && typeof event === "object" && !Array.isArray(event)
|
|
? (event as { message?: TranscriptMessage }).message
|
|
: undefined;
|
|
if (msg?.role !== "user") {
|
|
continue;
|
|
}
|
|
if (opts?.includeInterSession !== true && hasInterSessionUserProvenance(msg)) {
|
|
continue;
|
|
}
|
|
const text = extractTextFromContent(msg.content);
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractLastMessagePreviewFromTranscriptEvents(events: unknown[]): string | null {
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
const event = events[i];
|
|
const msg =
|
|
event && typeof event === "object" && !Array.isArray(event)
|
|
? (event as { message?: TranscriptMessage }).message
|
|
: undefined;
|
|
if (msg?.role !== "user" && msg?.role !== "assistant") {
|
|
continue;
|
|
}
|
|
const text = extractTextFromContent(msg.content);
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readSessionTitleFieldsFromScopedTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: { includeInterSession?: boolean },
|
|
): SessionTitleFields {
|
|
const events = loadScopedTranscriptEvents(scope);
|
|
if (!events) {
|
|
return { firstUserMessage: null, lastMessagePreview: null };
|
|
}
|
|
return {
|
|
firstUserMessage: extractFirstUserMessageFromTranscriptEvents(events, opts),
|
|
lastMessagePreview: extractLastMessagePreviewFromTranscriptEvents(events),
|
|
};
|
|
}
|
|
|
|
export function readSessionTitleFieldsFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: { includeInterSession?: boolean },
|
|
): SessionTitleFields {
|
|
return readSessionTitleFieldsFromScopedTranscript(scope, opts);
|
|
}
|
|
|
|
export async function readSessionTitleFieldsFromTranscriptAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: { includeInterSession?: boolean },
|
|
): Promise<SessionTitleFields> {
|
|
return readSessionTitleFieldsFromTranscript(scope, opts);
|
|
}
|
|
|
|
export function readFirstUserMessageFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
opts?: { includeInterSession?: boolean },
|
|
): string | null {
|
|
const events = loadScopedTranscriptEvents(scope);
|
|
return events ? extractFirstUserMessageFromTranscriptEvents(events, opts) : null;
|
|
}
|
|
|
|
export function readLastMessagePreviewFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
): string | null {
|
|
const events = loadScopedTranscriptEvents(scope);
|
|
return events ? extractLastMessagePreviewFromTranscriptEvents(events) : null;
|
|
}
|
|
|
|
type SessionTranscriptUsageSnapshot = {
|
|
modelProvider?: string;
|
|
model?: string;
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
cacheRead?: number;
|
|
cacheWrite?: number;
|
|
totalTokens?: number;
|
|
totalTokensFresh?: boolean;
|
|
costUsd?: number;
|
|
};
|
|
|
|
function extractTranscriptUsageCost(raw: unknown): number | undefined {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return undefined;
|
|
}
|
|
const cost = (raw as { cost?: unknown }).cost;
|
|
if (!cost || typeof cost !== "object" || Array.isArray(cost)) {
|
|
return undefined;
|
|
}
|
|
const total = (cost as { total?: unknown }).total;
|
|
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined;
|
|
}
|
|
|
|
function resolvePositiveUsageNumber(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
}
|
|
|
|
function extractUsageSnapshotFromTranscriptEvent(
|
|
event: unknown,
|
|
): SessionTranscriptUsageSnapshot | null {
|
|
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
return null;
|
|
}
|
|
const parsed = event as Record<string, unknown>;
|
|
const message =
|
|
parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message)
|
|
? (parsed.message as Record<string, unknown>)
|
|
: undefined;
|
|
if (!message) {
|
|
return null;
|
|
}
|
|
const role = typeof message.role === "string" ? message.role : undefined;
|
|
if (role && role !== "assistant") {
|
|
return null;
|
|
}
|
|
const usageRaw =
|
|
message.usage && typeof message.usage === "object" && !Array.isArray(message.usage)
|
|
? message.usage
|
|
: parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)
|
|
? parsed.usage
|
|
: undefined;
|
|
const usage = normalizeUsage(usageRaw);
|
|
const totalTokens = resolvePositiveUsageNumber(deriveSessionTotalTokens({ usage }));
|
|
const costUsd = extractTranscriptUsageCost(usageRaw);
|
|
const modelProvider =
|
|
typeof message.provider === "string"
|
|
? message.provider.trim()
|
|
: typeof parsed.provider === "string"
|
|
? parsed.provider.trim()
|
|
: undefined;
|
|
const model =
|
|
typeof message.model === "string"
|
|
? message.model.trim()
|
|
: typeof parsed.model === "string"
|
|
? parsed.model.trim()
|
|
: undefined;
|
|
const isDeliveryMirror = modelProvider === "openclaw" && model === "delivery-mirror";
|
|
const hasMeaningfulUsage =
|
|
hasNonzeroUsage(usage) ||
|
|
typeof totalTokens === "number" ||
|
|
(typeof costUsd === "number" && Number.isFinite(costUsd));
|
|
const hasModelIdentity = Boolean(modelProvider || model);
|
|
if (!hasMeaningfulUsage && !hasModelIdentity) {
|
|
return null;
|
|
}
|
|
if (isDeliveryMirror && !hasMeaningfulUsage) {
|
|
return null;
|
|
}
|
|
|
|
const snapshot: SessionTranscriptUsageSnapshot = {};
|
|
if (!isDeliveryMirror) {
|
|
if (modelProvider) {
|
|
snapshot.modelProvider = modelProvider;
|
|
}
|
|
if (model) {
|
|
snapshot.model = model;
|
|
}
|
|
}
|
|
if (typeof usage?.input === "number" && Number.isFinite(usage.input)) {
|
|
snapshot.inputTokens = usage.input;
|
|
}
|
|
if (typeof usage?.output === "number" && Number.isFinite(usage.output)) {
|
|
snapshot.outputTokens = usage.output;
|
|
}
|
|
if (typeof usage?.cacheRead === "number" && Number.isFinite(usage.cacheRead)) {
|
|
snapshot.cacheRead = usage.cacheRead;
|
|
}
|
|
if (typeof usage?.cacheWrite === "number" && Number.isFinite(usage.cacheWrite)) {
|
|
snapshot.cacheWrite = usage.cacheWrite;
|
|
}
|
|
if (typeof totalTokens === "number") {
|
|
snapshot.totalTokens = totalTokens;
|
|
snapshot.totalTokensFresh = true;
|
|
}
|
|
if (typeof costUsd === "number" && Number.isFinite(costUsd)) {
|
|
snapshot.costUsd = costUsd;
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function extractAggregateUsageFromTranscriptEvents(
|
|
events: Iterable<unknown>,
|
|
): SessionTranscriptUsageSnapshot | null {
|
|
const snapshot: SessionTranscriptUsageSnapshot = {};
|
|
let sawSnapshot = false;
|
|
let inputTokens = 0;
|
|
let outputTokens = 0;
|
|
let cacheRead = 0;
|
|
let cacheWrite = 0;
|
|
let sawInputTokens = false;
|
|
let sawOutputTokens = false;
|
|
let sawCacheRead = false;
|
|
let sawCacheWrite = false;
|
|
let costUsdTotal = 0;
|
|
let sawCost = false;
|
|
|
|
for (const event of events) {
|
|
const current = extractUsageSnapshotFromTranscriptEvent(event);
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
sawSnapshot = true;
|
|
if (current.modelProvider) {
|
|
snapshot.modelProvider = current.modelProvider;
|
|
}
|
|
if (current.model) {
|
|
snapshot.model = current.model;
|
|
}
|
|
if (typeof current.inputTokens === "number") {
|
|
inputTokens += current.inputTokens;
|
|
sawInputTokens = true;
|
|
}
|
|
if (typeof current.outputTokens === "number") {
|
|
outputTokens += current.outputTokens;
|
|
sawOutputTokens = true;
|
|
}
|
|
if (typeof current.cacheRead === "number") {
|
|
cacheRead += current.cacheRead;
|
|
sawCacheRead = true;
|
|
}
|
|
if (typeof current.cacheWrite === "number") {
|
|
cacheWrite += current.cacheWrite;
|
|
sawCacheWrite = true;
|
|
}
|
|
if (typeof current.totalTokens === "number") {
|
|
snapshot.totalTokens = current.totalTokens;
|
|
snapshot.totalTokensFresh = true;
|
|
}
|
|
if (typeof current.costUsd === "number" && Number.isFinite(current.costUsd)) {
|
|
costUsdTotal += current.costUsd;
|
|
sawCost = true;
|
|
}
|
|
}
|
|
|
|
if (!sawSnapshot) {
|
|
return null;
|
|
}
|
|
if (sawInputTokens) {
|
|
snapshot.inputTokens = inputTokens;
|
|
}
|
|
if (sawOutputTokens) {
|
|
snapshot.outputTokens = outputTokens;
|
|
}
|
|
if (sawCacheRead) {
|
|
snapshot.cacheRead = cacheRead;
|
|
}
|
|
if (sawCacheWrite) {
|
|
snapshot.cacheWrite = cacheWrite;
|
|
}
|
|
if (sawCost) {
|
|
snapshot.costUsd = costUsdTotal;
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function extractLatestUsageFromTranscriptEvents(
|
|
events: Iterable<unknown>,
|
|
): SessionTranscriptUsageSnapshot | null {
|
|
let latest: SessionTranscriptUsageSnapshot | null = null;
|
|
for (const event of events) {
|
|
latest = extractUsageSnapshotFromTranscriptEvent(event) ?? latest;
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function loadUsageEvents(params: { sessionId: string; agentId?: string }): unknown[] | undefined {
|
|
return loadScopedTranscriptEvents(params);
|
|
}
|
|
|
|
export function readLatestSessionUsageFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
): SessionTranscriptUsageSnapshot | null {
|
|
const events = loadUsageEvents(scope);
|
|
return events ? extractAggregateUsageFromTranscriptEvents(events) : null;
|
|
}
|
|
|
|
export async function readLatestSessionUsageFromTranscriptAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
): Promise<SessionTranscriptUsageSnapshot | null> {
|
|
return readLatestSessionUsageFromTranscript(scope);
|
|
}
|
|
|
|
export async function readRecentSessionUsageFromTranscriptAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
maxBytes: number,
|
|
): Promise<SessionTranscriptUsageSnapshot | null> {
|
|
void maxBytes;
|
|
const events = loadUsageEvents(scope);
|
|
return events ? extractLatestUsageFromTranscriptEvents(events) : null;
|
|
}
|
|
|
|
export async function readLatestRecentSessionUsageFromTranscriptAsync(
|
|
scope: SessionTranscriptReadScope,
|
|
maxBytes: number,
|
|
): Promise<SessionTranscriptUsageSnapshot | null> {
|
|
return readRecentSessionUsageFromTranscriptAsync(scope, maxBytes);
|
|
}
|
|
|
|
export function readRecentSessionUsageFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
maxBytes: number,
|
|
): SessionTranscriptUsageSnapshot | null {
|
|
void maxBytes;
|
|
const events = loadUsageEvents(scope);
|
|
return events ? extractAggregateUsageFromTranscriptEvents(events) : null;
|
|
}
|
|
|
|
type TranscriptContentEntry = {
|
|
type?: string;
|
|
text?: string;
|
|
name?: string;
|
|
};
|
|
|
|
type TranscriptPreviewMessage = {
|
|
role?: string;
|
|
content?: string | TranscriptContentEntry[];
|
|
text?: string;
|
|
toolName?: string;
|
|
tool_name?: string;
|
|
};
|
|
|
|
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
|
|
if (isTool) {
|
|
return "tool";
|
|
}
|
|
switch (normalizeLowercaseStringOrEmpty(role)) {
|
|
case "user":
|
|
return "user";
|
|
case "assistant":
|
|
return "assistant";
|
|
case "system":
|
|
return "system";
|
|
case "tool":
|
|
return "tool";
|
|
default:
|
|
return "other";
|
|
}
|
|
}
|
|
|
|
function truncatePreviewText(text: string, maxChars: number): string {
|
|
if (maxChars <= 0 || text.length <= maxChars) {
|
|
return text;
|
|
}
|
|
if (maxChars <= 3) {
|
|
return text.slice(0, maxChars);
|
|
}
|
|
return `${text.slice(0, maxChars - 3)}...`;
|
|
}
|
|
|
|
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
|
const role = normalizeLowercaseStringOrEmpty(message.role);
|
|
if (role === "assistant") {
|
|
const assistantText = extractAssistantVisibleText(message);
|
|
if (assistantText) {
|
|
const normalized = stripInlineDirectiveTagsForDisplay(assistantText).text.trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof message.content === "string") {
|
|
const normalized = stripInlineDirectiveTagsForDisplay(message.content).text.trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
if (Array.isArray(message.content)) {
|
|
const parts = message.content
|
|
.map((entry) =>
|
|
typeof entry?.text === "string" ? stripInlineDirectiveTagsForDisplay(entry.text).text : "",
|
|
)
|
|
.filter((text) => text.trim().length > 0);
|
|
if (parts.length > 0) {
|
|
return parts.join("\n").trim();
|
|
}
|
|
}
|
|
if (typeof message.text === "string") {
|
|
const normalized = stripInlineDirectiveTagsForDisplay(message.text).text.trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
|
return hasToolCall(message as Record<string, unknown>);
|
|
}
|
|
|
|
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
|
return extractToolCallNames(message as Record<string, unknown>);
|
|
}
|
|
|
|
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
|
if (!Array.isArray(message.content)) {
|
|
return null;
|
|
}
|
|
for (const entry of message.content) {
|
|
const raw = normalizeLowercaseStringOrEmpty(entry?.type);
|
|
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") {
|
|
continue;
|
|
}
|
|
return `[${raw}]`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildPreviewItems(
|
|
messages: TranscriptPreviewMessage[],
|
|
maxItems: number,
|
|
maxChars: number,
|
|
): SessionPreviewItem[] {
|
|
const items: SessionPreviewItem[] = [];
|
|
for (const message of messages) {
|
|
const toolCall = isToolCall(message);
|
|
const role = normalizeRole(message.role, toolCall);
|
|
let text = extractPreviewText(message);
|
|
if (!text) {
|
|
const toolNames = extractToolNames(message);
|
|
if (toolNames.length > 0) {
|
|
const shown = toolNames.slice(0, 2);
|
|
const overflow = toolNames.length - shown.length;
|
|
text = `call ${shown.join(", ")}`;
|
|
if (overflow > 0) {
|
|
text += ` +${overflow}`;
|
|
}
|
|
}
|
|
}
|
|
if (!text) {
|
|
text = extractMediaSummary(message);
|
|
}
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
let trimmed = text.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
if (role === "user") {
|
|
trimmed = stripEnvelope(trimmed);
|
|
}
|
|
trimmed = truncatePreviewText(trimmed, maxChars);
|
|
items.push({ role, text: trimmed });
|
|
}
|
|
|
|
if (items.length <= maxItems) {
|
|
return items;
|
|
}
|
|
return items.slice(-maxItems);
|
|
}
|
|
|
|
function readRecentMessagesFromScopedTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
maxMessages: number,
|
|
): TranscriptPreviewMessage[] | undefined {
|
|
const events = loadScopedTranscriptEvents(scope);
|
|
if (!events) {
|
|
return undefined;
|
|
}
|
|
const collected: TranscriptPreviewMessage[] = [];
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
const event = events[i];
|
|
const msg =
|
|
event && typeof event === "object" && !Array.isArray(event)
|
|
? (event as { message?: TranscriptPreviewMessage }).message
|
|
: undefined;
|
|
if (msg && typeof msg === "object") {
|
|
collected.push(msg);
|
|
if (collected.length >= maxMessages) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return collected.toReversed();
|
|
}
|
|
|
|
export function readSessionPreviewItemsFromTranscript(
|
|
scope: SessionTranscriptReadScope,
|
|
maxItems: number,
|
|
maxChars: number,
|
|
): SessionPreviewItem[] {
|
|
const boundedItems = Math.max(1, Math.min(maxItems, 50));
|
|
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
|
|
const scopedMessages = readRecentMessagesFromScopedTranscript(scope, boundedItems);
|
|
return scopedMessages ? buildPreviewItems(scopedMessages, boundedItems, boundedChars) : [];
|
|
}
|