Files
clawX/electron/api/routes/sessions.ts

473 lines
18 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'http';
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../../utils/session-files';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024;
const RECENT_TRANSCRIPT_MAX_READ_BYTES = 8 * 1024 * 1024;
const RECENT_TRANSCRIPT_MAX_SCAN_LINES = 5_000;
type SessionSummary = {
sessionKey: string;
firstUserText: string | null;
lastTimestamp: number | null;
};
type TranscriptMessage = {
role?: unknown;
content?: unknown;
timestamp?: unknown;
};
type ParsedTranscriptLine = {
type?: string;
message?: TranscriptMessage;
};
function extractMessageText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return (content as Array<{ type?: unknown; text?: unknown }>)
.filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.trim())
.map((block) => String(block.text))
.join('\n')
.trim();
}
function cleanSummaryUserText(text: string): string {
return text
.replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:[^\n]*(?:\n\s*)*/i, '')
.replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*:[^\n]*(?:\n\s*)*/i, '')
.replace(/^```json\n[\s\S]*?```\s*/i, '')
.replace(/^\{[\s\S]*?\}\s*/i, '')
.replace(/\s*\[media attached:[^\]]*\]/g, '')
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
.trim();
}
function isInternalSummaryText(text: string): boolean {
if (!text) return true;
if (/^\s*System\s*\(untrusted\)\s*:/i.test(text)) return true;
if (
/An async command you ran earlier has completed/i.test(text)
&& /Do not relay it to the user unless explicitly requested/i.test(text)
) {
return true;
}
if (
/^\s*Current time\s*:/i.test(text)
&& /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(text)
) {
return true;
}
return false;
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return value < 1e12 ? value * 1000 : value;
}
function parseMessageLine(line: string): TranscriptMessage | null {
try {
const entry = JSON.parse(line) as ParsedTranscriptLine;
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
return null;
}
return entry.message;
} catch {
return null;
}
}
function parseRecentMessagesFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessage[] {
const lines = chunk.split(/\r?\n/);
if (readStart > 0) lines.shift();
const collected: TranscriptMessage[] = [];
let scanned = 0;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line?.trim()) continue;
scanned += 1;
if (scanned > RECENT_TRANSCRIPT_MAX_SCAN_LINES) break;
const message = parseMessageLine(line);
if (message) {
collected.push(message);
if (collected.length >= limit) break;
}
}
return collected.reverse();
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
let fd: number | null = null;
try {
fd = openSync(transcriptPath, 'r');
const size = fstatSync(fd).size;
if (size === 0) return [];
let readBytes = Math.min(size, Math.max(RECENT_TRANSCRIPT_INITIAL_READ_BYTES, boundedLimit * 2048));
while (readBytes <= size) {
const readStart = Math.max(0, size - readBytes);
const readLen = size - readStart;
const buffer = Buffer.allocUnsafe(readLen);
readSync(fd, buffer, 0, readLen, readStart);
const messages = parseRecentMessagesFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
if (
messages.length >= boundedLimit
|| readStart === 0
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
) {
return messages;
}
readBytes = Math.min(size, readBytes * 2);
}
return [];
} finally {
if (fd !== null) closeSync(fd);
}
}
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
return raw.split(/\r?\n/).filter(Boolean).flatMap((line) => {
const message = parseMessageLine(line);
return message ? [message] : [];
});
}
function summarizeTranscriptMessages(sessionKey: string, messages: TranscriptMessage[]): SessionSummary {
let firstUserText: string | null = null;
let lastTimestamp: number | null = null;
for (const message of messages) {
const normalizedTs = normalizeTimestamp(message.timestamp);
if (normalizedTs != null) {
lastTimestamp = normalizedTs;
}
if (firstUserText == null && message.role === 'user') {
const text = cleanSummaryUserText(extractMessageText(message.content));
if (text && !isInternalSummaryText(text)) {
firstUserText = text;
}
}
}
return { sessionKey, firstUserText, lastTimestamp };
}
function parseSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 3) return null;
const agentId = parts[1] || '';
const suffix = parts.slice(2).join(':');
if (!SAFE_SESSION_SEGMENT.test(agentId) || !suffix) return null;
return { agentId, suffix };
}
async function readSessionsJson(agentId: string): Promise<Record<string, unknown>> {
const fsP = await import('node:fs/promises');
const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
}
function resolveSessionTranscriptPathByKey(
sessionKey: string,
sessionsDir: string,
sessionsJson: Record<string, unknown>,
): string | null {
let resolvedSrcPath: string | undefined;
let fileName: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((session) => session.key === sessionKey || session.sessionKey === sessionKey);
if (entry) {
fileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!fileName && typeof entry.id === 'string') {
fileName = `${entry.id}.jsonl`;
}
const absFile = (entry.sessionFile ?? entry.absolutePath) as string | undefined;
if (absFile && (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/))) {
resolvedSrcPath = absFile;
}
}
}
if (!fileName && !resolvedSrcPath && sessionsJson[sessionKey] != null) {
const value = sessionsJson[sessionKey];
if (typeof value === 'string') {
fileName = value;
} else if (typeof value === 'object' && value !== null) {
const entry = value as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
fileName = absFile;
}
} else {
const id = (entry.id ?? entry.sessionId) as string | undefined;
if (id) fileName = id.endsWith('.jsonl') ? id : `${id}.jsonl`;
}
}
}
if (!resolvedSrcPath && fileName) {
resolvedSrcPath = join(sessionsDir, fileName.endsWith('.jsonl') ? fileName : `${fileName}.jsonl`);
}
return resolvedSrcPath ?? null;
}
async function loadSessionSummary(sessionKey: string): Promise<SessionSummary> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
const messages = await readAllTranscriptMessages(transcriptPath);
return summarizeTranscriptMessages(sessionKey, messages);
} catch {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
}
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<unknown[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
return readRecentTranscriptMessages(transcriptPath, limit);
} catch {
return null;
}
}
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/summaries' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKeys?: string[] }>(req);
const sessionKeys = Array.isArray(body.sessionKeys)
? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:'))
: [];
if (sessionKeys.length === 0) {
sendJson(res, 200, { success: true, summaries: [] });
return true;
}
const summaries = await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey)));
sendJson(res, 200, { success: true, summaries });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/sessions/transcript' && req.method === 'GET') {
try {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const limitRaw = Number(url.searchParams.get('limit') ?? '200');
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : 200;
if (sessionKey) {
const messages = await loadSessionTranscriptByKey(sessionKey, limit);
if (!messages) {
sendJson(res, 404, { success: false, error: 'Transcript not found' });
return true;
}
sendJson(res, 200, { success: true, messages });
return true;
}
const agentId = url.searchParams.get('agentId')?.trim() || '';
const sessionId = url.searchParams.get('sessionId')?.trim() || '';
if (!agentId || !sessionId) {
sendJson(res, 400, { success: false, error: 'agentId and sessionId are required' });
return true;
}
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
sendJson(res, 400, { success: false, error: 'Invalid transcript identifier' });
return true;
}
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
const messages = readRecentTranscriptMessages(transcriptPath, limit);
sendJson(res, 200, { success: true, messages });
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
sendJson(res, 404, { success: false, error: 'Transcript not found' });
} else {
sendJson(res, 500, { success: false, error: 'Failed to load transcript' });
}
}
return true;
}
// POST /api/sessions/delete — HTTP mirror of the `session:delete` IPC.
// Both surfaces share electron/utils/session-files.ts so they sweep the
// same set of artefacts: the live transcript, legacy `.deleted.jsonl`,
// `.jsonl.reset.*` snapshots, the trajectory sidecar pair
// (`<id>.trajectory.jsonl` + `<id>.trajectory-path.json`) and — when the
// pointer points outside sessions/ (the OPENCLAW_TRAJECTORY_DIR case) —
// the off-disk runtime trajectory it references.
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
// Defence-in-depth: agentId becomes a path segment under
// ~/.openclaw/agents/. The sibling /api/sessions/transcript route
// applies the same check to its sessionId; mirror it here so a
// malformed key can never steer the unlink loop into another folder.
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
return true;
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
} else {
logger.warn(`[api/sessions/delete] Refusing out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
sendJson(res, 400, { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` });
}
return true;
}
const sweep = await sweepSessionArtefacts(resolution.sessionsDirAbs, resolution.baseId);
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[api/sessions/delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
// POST /api/sessions/rename — update session label in sessions.json.
if (url.pathname === '/api/sessions/rename' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string; label: string }>(req);
const { sessionKey, label } = body;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
if (!label || typeof label !== 'string' || !label.trim()) {
sendJson(res, 400, { success: false, error: 'Label cannot be empty' });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
return true;
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
const trimmedLabel = label.trim();
let found = false;
// Object-keyed format
if (sessionsJson[sessionKey] && typeof sessionsJson[sessionKey] === 'object') {
(sessionsJson[sessionKey] as Record<string, unknown>).label = trimmedLabel;
found = true;
}
// Array format
if (Array.isArray(sessionsJson.sessions)) {
for (const entry of sessionsJson.sessions as Array<Record<string, unknown>>) {
if (entry.key === sessionKey || entry.sessionKey === sessionKey) {
entry.label = trimmedLabel;
found = true;
}
}
}
if (!found) {
sendJson(res, 404, { success: false, error: `Session not found: ${sessionKey}` });
return true;
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(sessionsJson, null, 2), 'utf8');
logger.info(`[api/sessions/rename] key=${sessionKey} label=${trimmedLabel}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}