From 15dd2cda209ccabc9febc25e16eec620137ae744 Mon Sep 17 00:00:00 2001 From: Hubert Date: Mon, 16 Feb 2026 21:27:14 +0000 Subject: [PATCH] feat: show transcript file size in session status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transcript size monitoring to /status and session_status tool. Displays file size and message count (e.g. '๐Ÿ“„ Transcript: 1.2 MB, 627 messages'). Shows โš ๏ธ warning when transcript exceeds 1 MB, which helps catch sessions approaching the compaction death spiral described in #13624. - getTranscriptInfo() reads JSONL file stat + line count - Wired into both /status command and session_status tool - 8 new tests covering file reading, formatting, and edge cases --- src/agents/tools/session-status-tool.ts | 9 +- src/auto-reply/reply/commands-status.ts | 9 +- src/auto-reply/status.transcript.test.ts | 112 +++++++++++++++++++++++ src/auto-reply/status.ts | 80 ++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 src/auto-reply/status.transcript.test.ts diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 2eb20cbbe..9be2c9f73 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { AnyAgentTool } from "./common.js"; import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js"; -import { buildStatusMessage } from "../../auto-reply/status.js"; +import { buildStatusMessage, getTranscriptInfo } from "../../auto-reply/status.js"; import { loadConfig } from "../../config/config.js"; import { loadSessionStore, @@ -458,6 +458,13 @@ export function createSessionStatusTool(opts?: { showDetails: queueOverrides, }, includeTranscriptUsage: false, + transcriptInfo: getTranscriptInfo({ + sessionId: resolved.entry?.sessionId, + sessionEntry: resolved.entry, + agentId, + sessionKey: resolved.key, + storePath, + }), }); return { diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index bf4d0c4da..36714c196 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -28,7 +28,7 @@ import { resolveUsageProviderId, } from "../../infra/provider-usage.js"; import { normalizeGroupActivation } from "../group-activation.js"; -import { buildStatusMessage } from "../status.js"; +import { buildStatusMessage, getTranscriptInfo } from "../status.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; import { resolveSubagentLabel } from "./subagents-utils.js"; @@ -247,6 +247,13 @@ export async function buildStatusReply(params: { subagentsLine, mediaDecisions: params.mediaDecisions, includeTranscriptUsage: false, + transcriptInfo: getTranscriptInfo({ + sessionId: sessionEntry?.sessionId, + sessionEntry, + agentId: statusAgentId, + sessionKey, + storePath, + }), }); return { text: statusText }; diff --git a/src/auto-reply/status.transcript.test.ts b/src/auto-reply/status.transcript.test.ts new file mode 100644 index 000000000..d49578d94 --- /dev/null +++ b/src/auto-reply/status.transcript.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config/sessions.js", () => ({ + resolveSessionFilePath: vi.fn((_sessionId: string) => ""), + resolveSessionFilePathOptions: vi.fn(() => ({})), + resolveMainSessionKey: vi.fn(() => "main"), +})); + +const sessions = await import("../config/sessions.js"); +const resolveSessionFilePathMock = vi.mocked(sessions.resolveSessionFilePath); + +const { getTranscriptInfo, buildStatusMessage } = await import("./status.js"); + +describe("getTranscriptInfo", () => { + let tmpDir: string; + let testFilePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); + testFilePath = path.join(tmpDir, "test-session.jsonl"); + resolveSessionFilePathMock.mockReturnValue(testFilePath); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns undefined when sessionId is missing", () => { + expect(getTranscriptInfo({})).toBeUndefined(); + }); + + it("returns undefined when file does not exist", () => { + resolveSessionFilePathMock.mockReturnValue(path.join(tmpDir, "nonexistent.jsonl")); + expect(getTranscriptInfo({ sessionId: "abc" })).toBeUndefined(); + }); + + it("returns size and message count for a transcript file", () => { + const lines = [ + JSON.stringify({ message: { role: "user", content: "hello" } }), + JSON.stringify({ message: { role: "assistant", content: "hi" } }), + "", + ]; + fs.writeFileSync(testFilePath, lines.join("\n")); + const info = getTranscriptInfo({ sessionId: "abc" }); + expect(info).toBeDefined(); + expect(info!.messageCount).toBe(2); + expect(info!.sizeBytes).toBeGreaterThan(0); + expect(info!.filePath).toBe(testFilePath); + }); + + it("counts only non-empty lines", () => { + const content = '{"a":1}\n\n\n{"b":2}\n{"c":3}\n\n'; + fs.writeFileSync(testFilePath, content); + const info = getTranscriptInfo({ sessionId: "abc" }); + expect(info!.messageCount).toBe(3); + }); +}); + +describe("transcript line in buildStatusMessage", () => { + it("includes transcript line when transcriptInfo is provided", () => { + const info = { + sizeBytes: 512_000, + messageCount: 42, + filePath: "/tmp/test.jsonl", + }; + const result = buildStatusMessage({ + agent: {}, + transcriptInfo: info, + }); + expect(result).toContain("๐Ÿ“„ Transcript:"); + expect(result).toContain("500.0 KB"); + expect(result).toContain("42 messages"); + }); + + it("shows warning emoji for large transcripts", () => { + const info = { + sizeBytes: 2 * 1024 * 1024, + messageCount: 600, + filePath: "/tmp/test.jsonl", + }; + const result = buildStatusMessage({ + agent: {}, + transcriptInfo: info, + }); + expect(result).toContain("โš ๏ธ"); + expect(result).toContain("2.0 MB"); + }); + + it("omits transcript line when transcriptInfo is undefined", () => { + const result = buildStatusMessage({ + agent: {}, + }); + expect(result).not.toContain("๐Ÿ“„ Transcript:"); + }); + + it("handles singular message count", () => { + const info = { + sizeBytes: 100, + messageCount: 1, + filePath: "/tmp/test.jsonl", + }; + const result = buildStatusMessage({ + agent: {}, + transcriptInfo: info, + }); + expect(result).toContain("1 message"); + expect(result).not.toContain("1 messages"); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 7b147053a..4bf646665 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -55,6 +55,15 @@ type QueueStatus = { showDetails?: boolean; }; +export type TranscriptInfo = { + /** File size in bytes. */ + sizeBytes: number; + /** Number of non-empty lines (messages) in the transcript. */ + messageCount: number; + /** Absolute path to the transcript file. */ + filePath: string; +}; + type StatusArgs = { config?: OpenClawConfig; agent: AgentConfig; @@ -75,6 +84,7 @@ type StatusArgs = { mediaDecisions?: MediaUnderstandingDecision[]; subagentsLine?: string; includeTranscriptUsage?: boolean; + transcriptInfo?: TranscriptInfo; now?: number; }; @@ -324,6 +334,74 @@ const formatVoiceModeLine = ( return `๐Ÿ”Š Voice: ${autoMode} ยท provider=${provider} ยท limit=${maxLength} ยท summary=${summarize}`; }; +/** + * Read transcript file metadata (size + line count) for a session. + * Returns `undefined` when the file does not exist or cannot be read. + */ +export function getTranscriptInfo(params: { + sessionId?: string; + sessionEntry?: SessionEntry; + agentId?: string; + sessionKey?: string; + storePath?: string; +}): TranscriptInfo | undefined { + if (!params.sessionId) { + return undefined; + } + let logPath: string; + try { + const resolvedAgentId = + params.agentId ?? + (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + logPath = resolveSessionFilePath( + params.sessionId, + params.sessionEntry, + resolveSessionFilePathOptions({ agentId: resolvedAgentId, storePath: params.storePath }), + ); + } catch { + return undefined; + } + try { + const stat = fs.statSync(logPath); + if (!stat.isFile()) { + return undefined; + } + // Count non-empty lines for message count. + const content = fs.readFileSync(logPath, "utf-8"); + let messageCount = 0; + for (const line of content.split("\n")) { + if (line.trim()) { + messageCount += 1; + } + } + return { sizeBytes: stat.size, messageCount, filePath: logPath }; + } catch { + return undefined; + } +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Size threshold (bytes) above which a warning emoji is shown. Default: 1 MB. */ +const TRANSCRIPT_SIZE_WARNING_BYTES = 1024 * 1024; + +function formatTranscriptLine(info: TranscriptInfo | undefined): string | null { + if (!info) { + return null; + } + const sizeLabel = formatFileSize(info.sizeBytes); + const warning = info.sizeBytes >= TRANSCRIPT_SIZE_WARNING_BYTES ? " โš ๏ธ" : ""; + return `๐Ÿ“„ Transcript: ${sizeLabel}, ${info.messageCount} message${info.messageCount === 1 ? "" : "s"}${warning}`; +} + export function buildStatusMessage(args: StatusArgs): string { const now = args.now ?? Date.now(); const entry = args.sessionEntry; @@ -472,6 +550,7 @@ export function buildStatusMessage(args: StatusArgs): string { usagePair && costLine ? `${usagePair} ยท ${costLine}` : (usagePair ?? costLine); const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions); const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry); + const transcriptLine = formatTranscriptLine(args.transcriptInfo); return [ versionLine, @@ -479,6 +558,7 @@ export function buildStatusMessage(args: StatusArgs): string { modelLine, usageCostLine, `๐Ÿ“š ${contextLine}`, + transcriptLine, mediaLine, args.usageLine, `๐Ÿงต ${sessionLine}`,