import fs from "node:fs" ;
import os from "node:os" ;
import path from "node:path" ;
import {
formatSessionArchiveTimestamp,
parseSessionArchiveTimestamp,
type SessionArchiveReason,
} from "../config/sessions/artifacts.js" ;
import {
resolveSessionFilePath,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
} from "../config/sessions/paths.js" ;
import { resolveRequiredHomeDir } from "../infra/home-dir.js" ;
export type ArchiveFileReason = SessionArchiveReason;
export type ArchivedSessionTranscript = {
sourcePath: string;
archivedPath: string;
};
function classifySessionTranscriptCandidate(
sessionId: string,
sessionFile?: string,
): "current" | "stale" | "custom" {
const transcriptSessionId = extractGeneratedTranscriptSessionId(sessionFile);
if (!transcriptSessionId) {
return "custom" ;
}
return transcriptSessionId === sessionId ? "current" : "stale" ;
}
function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined {
const trimmed = sessionFile?.trim();
if (!trimmed) {
return undefined;
}
const base = path.basename(trimmed);
if (!base.endsWith(".jsonl" )) {
return undefined;
}
const withoutExt = base.slice(0 , -".jsonl" .length);
const topicIndex = withoutExt.indexOf("-topic-" );
if (topicIndex > 0 ) {
const topicSessionId = withoutExt.slice(0 , topicIndex);
return looksLikeGeneratedSessionId(topicSessionId) ? topicSessionId : undefined;
}
const forkMatch = withoutExt.match(
/^(\d{4 }-\d{2 }-\d{2 }T[\w-]+(?:Z|[+-]\d{2 }(?:-\d{2 })?)?)_(.+)$/,
);
if (forkMatch?.[2 ]) {
return looksLikeGeneratedSessionId(forkMatch[2 ]) ? forkMatch[2 ] : undefined;
}
return looksLikeGeneratedSessionId(withoutExt) ? withoutExt : undefined;
}
function looksLikeGeneratedSessionId(value: string): boolean {
return /^[0 -9 a-f]{8 }-[0 -9 a-f]{4 }-[1 -8 ][0 -9 a-f]{3 }-[89 ab][0 -9 a-f]{3 }-[0 -9 a-f]{12 }$/i.test(value);
}
function canonicalizePathForComparison(filePath: string): string {
const resolved = path.resolve(filePath);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
export function resolveSessionTranscriptCandidates(
sessionId: string,
storePath: string | undefined,
sessionFile?: string,
agentId?: string,
): string[] {
const candidates: string[] = [];
const sessionFileState = classifySessionTranscriptCandidate(sessionId, sessionFile);
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 && sessionFileState !== "stale" ) {
pushCandidate(() =>
resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }),
);
}
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
if (sessionFile && sessionFileState === "stale" ) {
pushCandidate(() =>
resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }),
);
}
} else if (sessionFile) {
if (agentId) {
if (sessionFileState !== "stale" ) {
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId }));
}
} else {
const trimmed = sessionFile.trim();
if (trimmed) {
candidates.push(path.resolve(trimmed));
}
}
}
if (agentId) {
pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
if (sessionFile && sessionFileState === "stale" ) {
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { 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 function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string {
const ts = formatSessionArchiveTimestamp();
const archived = `${filePath}.${reason}.${ts}`;
fs.renameSync(filePath, archived);
return archived;
}
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[] {
return archiveSessionTranscriptsDetailed(opts).map((entry) => entry.archivedPath);
}
export function archiveSessionTranscriptsDetailed(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 ;
}): ArchivedSessionTranscript[] {
const archived: ArchivedSessionTranscript[] = [];
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({
sourcePath: candidatePath,
archivedPath: archiveFileOnDisk(candidatePath, opts.reason),
});
} catch {
// Best-effort.
}
}
return archived;
}
export function resolveStableSessionEndTranscript(params: {
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
agentId?: string;
archivedTranscripts?: ArchivedSessionTranscript[];
}): { sessionFile?: string; transcriptArchived?: boolean } {
const archivedTranscripts = params.archivedTranscripts ?? [];
if (archivedTranscripts.length > 0 ) {
const preferredPath = params.sessionFile?.trim()
? canonicalizePathForComparison(params.sessionFile)
: undefined;
const archivedMatch =
preferredPath == null
? undefined
: archivedTranscripts.find(
(entry) => canonicalizePathForComparison(entry.sourcePath) === preferredPath,
);
const archivedPath = archivedMatch?.archivedPath ?? archivedTranscripts[0 ]?.archivedPath;
if (archivedPath) {
return { sessionFile: archivedPath, transcriptArchived: true };
}
}
for (const candidate of resolveSessionTranscriptCandidates(
params.sessionId,
params.storePath,
params.sessionFile,
params.agentId,
)) {
const candidatePath = canonicalizePathForComparison(candidate);
if (fs.existsSync(candidatePath)) {
return { sessionFile: candidatePath, transcriptArchived: false };
}
}
return {};
}
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 };
}
Messung V0.5 in Prozent C=95 H=97 G=95
¤ Dauer der Verarbeitung: 0.9 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland