import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { formatSessionArchiveTimestamp, parseSessionArchiveTimestamp, type SessionArchiveReason, resolveSessionFilePath, resolveSessionTranscriptPath, resolveSessionTranscriptPathInDir, } from "../config/sessions.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.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 SessionTitleFieldsCacheEntry = SessionTitleFields & { mtimeMs: number; size: number; }; const sessionTitleFieldsCache = new Map(); const MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES = 5000; function readSessionTitleFieldsCacheKey( filePath: string, opts?: { includeInterSession?: boolean }, ) { const includeInterSession = opts?.includeInterSession === true ? "1" : "0"; return `${filePath}\t${includeInterSession}`; } function getCachedSessionTitleFields(cacheKey: string, stat: fs.Stats): SessionTitleFields | null { const cached = sessionTitleFieldsCache.get(cacheKey); if (!cached) { return null; } if (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) { sessionTitleFieldsCache.delete(cacheKey); return null; } // LRU bump sessionTitleFieldsCache.delete(cacheKey); sessionTitleFieldsCache.set(cacheKey, cached); return { firstUserMessage: cached.firstUserMessage, lastMessagePreview: cached.lastMessagePreview, }; } function setCachedSessionTitleFields(cacheKey: string, stat: fs.Stats, value: SessionTitleFields) { sessionTitleFieldsCache.set(cacheKey, { ...value, mtimeMs: stat.mtimeMs, size: stat.size, }); while (sessionTitleFieldsCache.size > MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES) { const oldestKey = sessionTitleFieldsCache.keys().next().value; if (typeof oldestKey !== "string" || !oldestKey) { break; } sessionTitleFieldsCache.delete(oldestKey); } } export function readSessionMessages( sessionId: string, storePath: string | undefined, sessionFile?: string, ): unknown[] { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) { return []; } const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); const messages: unknown[] = []; for (const line of lines) { if (!line.trim()) { continue; } try { const parsed = JSON.parse(line); if (parsed?.message) { messages.push(parsed.message); continue; } // Compaction entries are not "message" records, but they're useful context for debugging. // Emit a lightweight synthetic message that the Web UI can render as a divider. if (parsed?.type === "compaction") { const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : Number.NaN; const timestamp = Number.isFinite(ts) ? ts : Date.now(); messages.push({ role: "system", content: [{ type: "text", text: "Compaction" }], timestamp, __openclaw: { kind: "compaction", id: typeof parsed.id === "string" ? parsed.id : undefined, }, }); } } catch { // ignore bad lines } } return messages; } export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, ): string[] { const candidates: string[] = []; const pushCandidate = (resolve: () => string): void => { try { candidates.push(resolve()); } catch { // Ignore invalid paths/IDs and keep scanning other safe candidates. } }; if (storePath) { const sessionsDir = path.dirname(storePath); if (sessionFile) { pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }), ); } pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); } else if (sessionFile) { if (agentId) { pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); } else { const trimmed = sessionFile.trim(); if (trimmed) { candidates.push(path.resolve(trimmed)); } } } if (agentId) { pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); } const home = resolveRequiredHomeDir(process.env, os.homedir); const legacyDir = path.join(home, ".openclaw", "sessions"); pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); return Array.from(new Set(candidates)); } export type ArchiveFileReason = SessionArchiveReason; function canonicalizePathForComparison(filePath: string): string { const resolved = path.resolve(filePath); try { return fs.realpathSync(resolved); } catch { return resolved; } } export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { const ts = formatSessionArchiveTimestamp(); const archived = `${filePath}.${reason}.${ts}`; fs.renameSync(filePath, archived); return archived; } /** * Archives all transcript files for a given session. * Best-effort: silently skips files that don't exist or fail to rename. */ export function archiveSessionTranscripts(opts: { sessionId: string; storePath: string | undefined; sessionFile?: string; agentId?: string; reason: "reset" | "deleted"; /** * When true, only archive files resolved under the session store directory. * This prevents maintenance operations from mutating paths outside the agent sessions dir. */ restrictToStoreDir?: boolean; }): string[] { const archived: string[] = []; const storeDir = opts.restrictToStoreDir && opts.storePath ? canonicalizePathForComparison(path.dirname(opts.storePath)) : null; for (const candidate of resolveSessionTranscriptCandidates( opts.sessionId, opts.storePath, opts.sessionFile, opts.agentId, )) { const candidatePath = canonicalizePathForComparison(candidate); if (storeDir) { const relative = path.relative(storeDir, candidatePath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { continue; } } if (!fs.existsSync(candidatePath)) { continue; } try { archived.push(archiveFileOnDisk(candidatePath, opts.reason)); } catch { // Best-effort. } } return archived; } export async function cleanupArchivedSessionTranscripts(opts: { directories: string[]; olderThanMs: number; reason?: ArchiveFileReason; nowMs?: number; }): Promise<{ removed: number; scanned: number }> { if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) { return { removed: 0, scanned: 0 }; } const now = opts.nowMs ?? Date.now(); const reason: ArchiveFileReason = opts.reason ?? "deleted"; const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); let removed = 0; let scanned = 0; for (const dir of directories) { const entries = await fs.promises.readdir(dir).catch(() => []); for (const entry of entries) { const timestamp = parseSessionArchiveTimestamp(entry, reason); if (timestamp == null) { continue; } scanned += 1; if (now - timestamp <= opts.olderThanMs) { continue; } const fullPath = path.join(dir, entry); const stat = await fs.promises.stat(fullPath).catch(() => null); if (!stat?.isFile()) { continue; } await fs.promises.rm(fullPath).catch(() => undefined); removed += 1; } } return { removed, scanned }; } function jsonUtf8Bytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), "utf8"); } catch { return Buffer.byteLength(String(value), "utf8"); } } export function capArrayByJsonBytes( 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 }; } const MAX_LINES_TO_SCAN = 10; type TranscriptMessage = { role?: string; content?: string | Array<{ type: string; text?: string }>; provenance?: unknown; }; export function readSessionTitleFieldsFromTranscript( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, opts?: { includeInterSession?: boolean }, ): SessionTitleFields { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) { return { firstUserMessage: null, lastMessagePreview: null }; } let stat: fs.Stats; try { stat = fs.statSync(filePath); } catch { return { firstUserMessage: null, lastMessagePreview: null }; } const cacheKey = readSessionTitleFieldsCacheKey(filePath, opts); const cached = getCachedSessionTitleFields(cacheKey, stat); if (cached) { return cached; } if (stat.size === 0) { const empty = { firstUserMessage: null, lastMessagePreview: null }; setCachedSessionTitleFields(cacheKey, stat, empty); return empty; } let fd: number | null = null; try { fd = fs.openSync(filePath, "r"); const size = stat.size; // Head (first user message) let firstUserMessage: string | null = null; try { const chunk = readTranscriptHeadChunk(fd); if (chunk) { firstUserMessage = extractFirstUserMessageFromTranscriptChunk(chunk, opts); } } catch { // ignore head read errors } // Tail (last message preview) let lastMessagePreview: string | null = null; try { lastMessagePreview = readLastMessagePreviewFromOpenTranscript({ fd, size }); } catch { // ignore tail read errors } const result = { firstUserMessage, lastMessagePreview }; setCachedSessionTitleFields(cacheKey, stat, result); return result; } catch { return { firstUserMessage: null, lastMessagePreview: null }; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { /* ignore */ } } } } 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 readTranscriptHeadChunk(fd: number, maxBytes = 8192): string | null { const buf = Buffer.alloc(maxBytes); const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0); if (bytesRead <= 0) { return null; } return buf.toString("utf-8", 0, bytesRead); } function extractFirstUserMessageFromTranscriptChunk( chunk: string, opts?: { includeInterSession?: boolean }, ): string | null { const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN); for (const line of lines) { if (!line.trim()) { continue; } try { const parsed = JSON.parse(line); const msg = parsed?.message as TranscriptMessage | undefined; if (msg?.role !== "user") { continue; } if (opts?.includeInterSession !== true && hasInterSessionUserProvenance(msg)) { continue; } const text = extractTextFromContent(msg.content); if (text) { return text; } } catch { // skip malformed lines } } return null; } function findExistingTranscriptPath( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, ): string | null { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); return candidates.find((p) => fs.existsSync(p)) ?? null; } function withOpenTranscriptFd(filePath: string, read: (fd: number) => T | null): T | null { let fd: number | null = null; try { fd = fs.openSync(filePath, "r"); return read(fd); } catch { // file read error } finally { if (fd !== null) { fs.closeSync(fd); } } return null; } export function readFirstUserMessageFromTranscript( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, opts?: { includeInterSession?: boolean }, ): string | null { const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId); if (!filePath) { return null; } return withOpenTranscriptFd(filePath, (fd) => { const chunk = readTranscriptHeadChunk(fd); if (!chunk) { return null; } return extractFirstUserMessageFromTranscriptChunk(chunk, opts); }); } const LAST_MSG_MAX_BYTES = 16384; const LAST_MSG_MAX_LINES = 20; function readLastMessagePreviewFromOpenTranscript(params: { fd: number; size: number; }): string | null { const readStart = Math.max(0, params.size - LAST_MSG_MAX_BYTES); const readLen = Math.min(params.size, LAST_MSG_MAX_BYTES); const buf = Buffer.alloc(readLen); fs.readSync(params.fd, buf, 0, readLen, readStart); const chunk = buf.toString("utf-8"); const lines = chunk.split(/\r?\n/).filter((l) => l.trim()); const tailLines = lines.slice(-LAST_MSG_MAX_LINES); for (let i = tailLines.length - 1; i >= 0; i--) { const line = tailLines[i]; try { const parsed = JSON.parse(line); const msg = parsed?.message as TranscriptMessage | undefined; if (msg?.role !== "user" && msg?.role !== "assistant") { continue; } const text = extractTextFromContent(msg.content); if (text) { return text; } } catch { // skip malformed } } return null; } export function readLastMessagePreviewFromTranscript( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, ): string | null { const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId); if (!filePath) { return null; } return withOpenTranscriptFd(filePath, (fd) => { const stat = fs.fstatSync(fd); const size = stat.size; if (size === 0) { return null; } return readLastMessagePreviewFromOpenTranscript({ fd, size }); }); } const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024]; const PREVIEW_MAX_LINES = 200; 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 ((role ?? "").toLowerCase()) { 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 { 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); } function extractToolNames(message: TranscriptPreviewMessage): string[] { return extractToolCallNames(message as Record); } function extractMediaSummary(message: TranscriptPreviewMessage): string | null { if (!Array.isArray(message.content)) { return null; } for (const entry of message.content) { const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : ""; 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 readRecentMessagesFromTranscript( filePath: string, maxMessages: number, readBytes: number, ): TranscriptPreviewMessage[] { let fd: number | null = null; try { fd = fs.openSync(filePath, "r"); const stat = fs.fstatSync(fd); const size = stat.size; if (size === 0) { return []; } const readStart = Math.max(0, size - readBytes); const readLen = Math.min(size, readBytes); const buf = Buffer.alloc(readLen); fs.readSync(fd, buf, 0, readLen, readStart); const chunk = buf.toString("utf-8"); const lines = chunk.split(/\r?\n/).filter((l) => l.trim()); const tailLines = lines.slice(-PREVIEW_MAX_LINES); const collected: TranscriptPreviewMessage[] = []; for (let i = tailLines.length - 1; i >= 0; i--) { const line = tailLines[i]; try { const parsed = JSON.parse(line); const msg = parsed?.message as TranscriptPreviewMessage | undefined; if (msg && typeof msg === "object") { collected.push(msg); if (collected.length >= maxMessages) { break; } } } catch { // skip malformed lines } } return collected.toReversed(); } catch { return []; } finally { if (fd !== null) { fs.closeSync(fd); } } } export function readSessionPreviewItemsFromTranscript( sessionId: string, storePath: string | undefined, sessionFile: string | undefined, agentId: string | undefined, maxItems: number, maxChars: number, ): SessionPreviewItem[] { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) { return []; } const boundedItems = Math.max(1, Math.min(maxItems, 50)); const boundedChars = Math.max(20, Math.min(maxChars, 2000)); for (const readSize of PREVIEW_READ_SIZES) { const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize); if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) { return buildPreviewItems(messages, boundedItems, boundedChars); } } return []; }