mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 23:11:37 +00:00
feat: show transcript file size in session status
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
This commit is contained in:
committed by
Peter Steinberger
parent
fc6d53c895
commit
15dd2cda20
@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||||
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.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 { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
@@ -458,6 +458,13 @@ export function createSessionStatusTool(opts?: {
|
|||||||
showDetails: queueOverrides,
|
showDetails: queueOverrides,
|
||||||
},
|
},
|
||||||
includeTranscriptUsage: false,
|
includeTranscriptUsage: false,
|
||||||
|
transcriptInfo: getTranscriptInfo({
|
||||||
|
sessionId: resolved.entry?.sessionId,
|
||||||
|
sessionEntry: resolved.entry,
|
||||||
|
agentId,
|
||||||
|
sessionKey: resolved.key,
|
||||||
|
storePath,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
resolveUsageProviderId,
|
resolveUsageProviderId,
|
||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
import { normalizeGroupActivation } from "../group-activation.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 { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||||
import { resolveSubagentLabel } from "./subagents-utils.js";
|
import { resolveSubagentLabel } from "./subagents-utils.js";
|
||||||
|
|
||||||
@@ -247,6 +247,13 @@ export async function buildStatusReply(params: {
|
|||||||
subagentsLine,
|
subagentsLine,
|
||||||
mediaDecisions: params.mediaDecisions,
|
mediaDecisions: params.mediaDecisions,
|
||||||
includeTranscriptUsage: false,
|
includeTranscriptUsage: false,
|
||||||
|
transcriptInfo: getTranscriptInfo({
|
||||||
|
sessionId: sessionEntry?.sessionId,
|
||||||
|
sessionEntry,
|
||||||
|
agentId: statusAgentId,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { text: statusText };
|
return { text: statusText };
|
||||||
|
|||||||
112
src/auto-reply/status.transcript.test.ts
Normal file
112
src/auto-reply/status.transcript.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,15 @@ type QueueStatus = {
|
|||||||
showDetails?: boolean;
|
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 = {
|
type StatusArgs = {
|
||||||
config?: OpenClawConfig;
|
config?: OpenClawConfig;
|
||||||
agent: AgentConfig;
|
agent: AgentConfig;
|
||||||
@@ -75,6 +84,7 @@ type StatusArgs = {
|
|||||||
mediaDecisions?: MediaUnderstandingDecision[];
|
mediaDecisions?: MediaUnderstandingDecision[];
|
||||||
subagentsLine?: string;
|
subagentsLine?: string;
|
||||||
includeTranscriptUsage?: boolean;
|
includeTranscriptUsage?: boolean;
|
||||||
|
transcriptInfo?: TranscriptInfo;
|
||||||
now?: number;
|
now?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,6 +334,74 @@ const formatVoiceModeLine = (
|
|||||||
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
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 {
|
export function buildStatusMessage(args: StatusArgs): string {
|
||||||
const now = args.now ?? Date.now();
|
const now = args.now ?? Date.now();
|
||||||
const entry = args.sessionEntry;
|
const entry = args.sessionEntry;
|
||||||
@@ -472,6 +550,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||||
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
||||||
|
const transcriptLine = formatTranscriptLine(args.transcriptInfo);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
versionLine,
|
versionLine,
|
||||||
@@ -479,6 +558,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
modelLine,
|
modelLine,
|
||||||
usageCostLine,
|
usageCostLine,
|
||||||
`📚 ${contextLine}`,
|
`📚 ${contextLine}`,
|
||||||
|
transcriptLine,
|
||||||
mediaLine,
|
mediaLine,
|
||||||
args.usageLine,
|
args.usageLine,
|
||||||
`🧵 ${sessionLine}`,
|
`🧵 ${sessionLine}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user