mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 01:31:39 +00:00
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1219 lines
39 KiB
TypeScript
1219 lines
39 KiB
TypeScript
// Trajectory export helpers package recorded trajectories for diagnostics.
|
|
import fsp from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js";
|
|
import type { AgentMessage } from "../agents/runtime/index.js";
|
|
import { parseSessionFileEntriesWithWarnings } from "../agents/sessions/session-file-parser.js";
|
|
import type { FileEntry, SessionEntry, SessionHeader } from "../agents/sessions/session-manager.js";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import { loadTranscriptEvents } from "../config/sessions/session-accessor.js";
|
|
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
|
import {
|
|
isCanonicalSessionTranscriptEntry,
|
|
scanSessionTranscriptTree,
|
|
} from "../config/sessions/transcript-tree.js";
|
|
import {
|
|
jsonSupportBundleFile,
|
|
jsonlSupportBundleFile,
|
|
supportBundleContents,
|
|
textSupportBundleFile,
|
|
writeSupportBundleDirectory,
|
|
type DiagnosticSupportBundleContent,
|
|
type DiagnosticSupportBundleFile,
|
|
} from "../logging/diagnostic-support-bundle.js";
|
|
import {
|
|
redactSupportString,
|
|
type SupportRedactionContext,
|
|
} from "../logging/diagnostic-support-redaction.js";
|
|
import { redactSecrets, redactToolPayloadText } from "../logging/redact.js";
|
|
import { safeJsonStringify } from "../utils/safe-json.js";
|
|
import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, safeTrajectorySessionFileName } from "./paths.js";
|
|
import { isRegularNonSymlinkFile, resolveTrajectoryRuntimeFile } from "./runtime-file.js";
|
|
import { loadSqliteTrajectoryRuntimeEvents } from "./runtime-store.sqlite.js";
|
|
import type {
|
|
TrajectoryBundleManifest,
|
|
TrajectoryBundleWarning,
|
|
TrajectoryEvent,
|
|
TrajectoryToolDefinition,
|
|
} from "./types.js";
|
|
|
|
// Trajectory bundle exporter: joins persisted session JSONL with runtime
|
|
// trace JSONL, redacts local/support-sensitive data, and writes a portable
|
|
// support bundle for debugging agent behavior.
|
|
type BuildTrajectoryBundleParams = {
|
|
outputDir: string;
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
workspaceDir: string;
|
|
runtimeFile?: string;
|
|
systemPrompt?: string;
|
|
tools?: TrajectoryToolDefinition[];
|
|
maxTotalEvents?: number;
|
|
};
|
|
|
|
type RuntimeTrajectoryContext = {
|
|
systemPrompt?: string;
|
|
tools?: TrajectoryToolDefinition[];
|
|
};
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type TrajectoryExportRedaction = SupportRedactionContext & {
|
|
workspaceDir: string;
|
|
};
|
|
|
|
type JsonlParseWarning = Omit<TrajectoryBundleWarning, "count" | "rows"> & {
|
|
row: number;
|
|
};
|
|
|
|
type SessionEntryCandidateRow = {
|
|
row: number;
|
|
value: unknown;
|
|
};
|
|
|
|
const MAX_TRAJECTORY_RUNTIME_EVENTS = 200_000;
|
|
const MAX_TRAJECTORY_TOTAL_EVENTS = 250_000;
|
|
const MAX_TRAJECTORY_SESSION_FILE_BYTES = 50 * 1024 * 1024;
|
|
const MAX_TRAJECTORY_WARNING_ROWS = 20;
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function isSessionFileEntry(value: unknown): value is FileEntry {
|
|
if (!isRecord(value) || typeof value.type !== "string") {
|
|
return false;
|
|
}
|
|
if (value.type !== "message") {
|
|
return true;
|
|
}
|
|
const message = value.message;
|
|
return isRecord(message) && typeof message.role === "string";
|
|
}
|
|
|
|
function formatSessionParseWarnings(
|
|
warnings: ReturnType<typeof parseSessionFileEntriesWithWarnings>["warnings"],
|
|
): JsonlParseWarning[] {
|
|
return warnings.map((warning) => ({
|
|
source: "session",
|
|
code: warning.code,
|
|
row: warning.row,
|
|
message:
|
|
warning.code === "invalid-session-json"
|
|
? "Skipped a session JSONL row that is not valid JSON."
|
|
: "Skipped a session JSONL row that is not a session entry object.",
|
|
}));
|
|
}
|
|
|
|
function collectSessionEntries(
|
|
rows: readonly SessionEntryCandidateRow[],
|
|
warnings: JsonlParseWarning[] = [],
|
|
): {
|
|
entries: FileEntry[];
|
|
warnings: JsonlParseWarning[];
|
|
rowByEntry: Map<FileEntry, number>;
|
|
} {
|
|
const entries: FileEntry[] = [];
|
|
const rowByEntry = new Map<FileEntry, number>();
|
|
for (const row of rows) {
|
|
if (!isSessionFileEntry(row.value)) {
|
|
warnings.push({
|
|
source: "session",
|
|
code: "invalid-session-row",
|
|
row: row.row,
|
|
message: "Skipped a session JSONL row that is not a session entry object.",
|
|
});
|
|
continue;
|
|
}
|
|
entries.push(row.value);
|
|
rowByEntry.set(row.value, row.row);
|
|
}
|
|
return { entries, warnings, rowByEntry };
|
|
}
|
|
|
|
function migrateLegacySessionEntries(entries: FileEntry[]): void {
|
|
const header = entries.find((entry): entry is SessionHeader => entry.type === "session");
|
|
const version = header?.version ?? 1;
|
|
if (version < 2) {
|
|
// Older session logs predate entry ids. Synthetic ids preserve branch order
|
|
// long enough to export the reachable suffix without mutating source files.
|
|
let previousId: string | null = null;
|
|
let index = 0;
|
|
for (const entry of entries) {
|
|
if (entry.type === "session") {
|
|
entry.version = 2;
|
|
continue;
|
|
}
|
|
const mutable = entry as unknown as Record<string, unknown>;
|
|
if (typeof mutable.id !== "string") {
|
|
mutable.id = `legacy-${index++}`;
|
|
}
|
|
mutable.parentId = previousId;
|
|
const entryId = mutable.id;
|
|
previousId = typeof entryId === "string" ? entryId : null;
|
|
if (entry.type === "compaction" && typeof mutable.firstKeptEntryIndex === "number") {
|
|
const target = entries[mutable.firstKeptEntryIndex];
|
|
if (target && target.type !== "session") {
|
|
mutable.firstKeptEntryId = (target as unknown as Record<string, unknown>).id;
|
|
}
|
|
delete mutable.firstKeptEntryIndex;
|
|
}
|
|
}
|
|
}
|
|
if (version < 3) {
|
|
for (const entry of entries) {
|
|
if (entry.type === "session") {
|
|
entry.version = 3;
|
|
continue;
|
|
}
|
|
if (entry.type === "message") {
|
|
const message = (entry as { message?: { role?: string } }).message;
|
|
if (message?.role === "hookMessage") {
|
|
message.role = "custom";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readSessionEntries(params: {
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
}): Promise<{
|
|
entries: FileEntry[];
|
|
warnings: JsonlParseWarning[];
|
|
rowByEntry: Map<FileEntry, number>;
|
|
}> {
|
|
const marker = parseSqliteSessionFileMarker(params.sessionFile);
|
|
if (!marker) {
|
|
const { entries, warnings, rowByEntry } = parseSessionFileEntriesWithWarnings(
|
|
await fsp.readFile(params.sessionFile, "utf8"),
|
|
);
|
|
return {
|
|
entries,
|
|
warnings: formatSessionParseWarnings(warnings),
|
|
rowByEntry,
|
|
};
|
|
}
|
|
return collectSessionEntries(
|
|
(
|
|
await loadTranscriptEvents({
|
|
agentId: marker.agentId,
|
|
sessionId: params.sessionId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
storePath: marker.storePath,
|
|
})
|
|
).map((value, index) => ({ row: index + 1, value })),
|
|
);
|
|
}
|
|
|
|
async function readSessionBranch(params: {
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
}): Promise<{
|
|
header: SessionHeader | null;
|
|
leafId: string | null;
|
|
branchEntries: SessionEntry[];
|
|
warnings: JsonlParseWarning[];
|
|
}> {
|
|
const { entries: fileEntries, warnings, rowByEntry } = await readSessionEntries(params);
|
|
migrateLegacySessionEntries(fileEntries);
|
|
const header =
|
|
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
|
const entries = fileEntries.filter(
|
|
(entry): entry is SessionEntry =>
|
|
entry.type !== "session" &&
|
|
isCanonicalSessionTranscriptEntry(entry) &&
|
|
typeof (entry as { id?: unknown }).id === "string",
|
|
);
|
|
const tree = scanSessionTranscriptTree(fileEntries);
|
|
if (!tree.hasLeafUpdate) {
|
|
return {
|
|
header,
|
|
leafId: entries.at(-1)?.id ?? null,
|
|
branchEntries: entries,
|
|
warnings,
|
|
};
|
|
}
|
|
const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
const branchEntries: SessionEntry[] = [];
|
|
const seen = new Set<string>();
|
|
let descendantEntry: SessionEntry | undefined;
|
|
let currentId = tree.leafId;
|
|
while (currentId) {
|
|
if (seen.has(currentId)) {
|
|
const cycleEntry = tree.byId.get(currentId)?.entry;
|
|
warnings.push({
|
|
source: "session",
|
|
code: "cyclic-session-branch",
|
|
row: cycleEntry ? (rowByEntry.get(cycleEntry) ?? 0) : 0,
|
|
message: "Stopped trajectory session branch export at a cyclic parent link.",
|
|
});
|
|
break;
|
|
}
|
|
seen.add(currentId);
|
|
const current = tree.byId.get(currentId);
|
|
if (!current) {
|
|
warnings.push({
|
|
source: "session",
|
|
code: "incomplete-session-branch",
|
|
row: 0,
|
|
message: "Exported the reachable session branch suffix after a missing parent link.",
|
|
});
|
|
break;
|
|
}
|
|
const visibleEntry = entriesById.get(currentId);
|
|
if (visibleEntry) {
|
|
const normalizedEntry = { ...visibleEntry, parentId: current.parentId };
|
|
if (descendantEntry) {
|
|
descendantEntry.parentId = normalizedEntry.id;
|
|
}
|
|
branchEntries.unshift(normalizedEntry);
|
|
descendantEntry = normalizedEntry;
|
|
}
|
|
if (current.parentId && !tree.byId.has(current.parentId)) {
|
|
warnings.push({
|
|
source: "session",
|
|
code: "incomplete-session-branch",
|
|
row: rowByEntry.get(current.entry) ?? 0,
|
|
message: "Exported the reachable session branch suffix after a missing parent link.",
|
|
});
|
|
break;
|
|
}
|
|
currentId = current.parentId;
|
|
}
|
|
return { header, leafId: tree.leafId, branchEntries, warnings };
|
|
}
|
|
|
|
async function parseJsonlFile<T>(
|
|
filePath: string,
|
|
params: {
|
|
maxBytes: number;
|
|
maxEvents: number;
|
|
include?: (value: T) => boolean;
|
|
validate?: (value: unknown) => value is T;
|
|
},
|
|
): Promise<{ events: T[]; warnings: JsonlParseWarning[] }> {
|
|
let stat;
|
|
try {
|
|
stat = await fsp.stat(filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return { events: [], warnings: [] };
|
|
}
|
|
throw error;
|
|
}
|
|
if (!stat.isFile()) {
|
|
return { events: [], warnings: [] };
|
|
}
|
|
if (stat.size > params.maxBytes) {
|
|
throw new Error(
|
|
`Trajectory runtime file is too large to export (${stat.size} bytes; limit ${params.maxBytes})`,
|
|
);
|
|
}
|
|
const rows = (await fsp.readFile(filePath, "utf8")).split(/\r?\n/u);
|
|
const parsed: T[] = [];
|
|
const warnings: JsonlParseWarning[] = [];
|
|
for (const [index, rawLine] of rows.entries()) {
|
|
const row = rawLine.trim();
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
if (parsed.length >= params.maxEvents) {
|
|
throw new Error(
|
|
`Trajectory runtime file has too many events to export (limit ${params.maxEvents})`,
|
|
);
|
|
}
|
|
try {
|
|
const value = JSON.parse(row) as unknown;
|
|
if (!params.validate || params.validate(value)) {
|
|
const typedValue = value as T;
|
|
if (!params.include || params.include(typedValue)) {
|
|
parsed.push(typedValue);
|
|
}
|
|
} else {
|
|
warnings.push({
|
|
source: "runtime",
|
|
code: "invalid-runtime-event",
|
|
row: index + 1,
|
|
message: "Skipped a runtime trajectory JSONL row that does not match the session schema.",
|
|
});
|
|
}
|
|
} catch {
|
|
warnings.push({
|
|
source: "runtime",
|
|
code: "invalid-runtime-json",
|
|
row: index + 1,
|
|
message: "Skipped a runtime trajectory JSONL row that is not valid JSON.",
|
|
});
|
|
}
|
|
}
|
|
return { events: parsed, warnings };
|
|
}
|
|
|
|
async function readRuntimeTrajectoryEvents(params: {
|
|
runtimeFile?: string;
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
}): Promise<{
|
|
events: TrajectoryEvent[];
|
|
runtimeFile?: string;
|
|
warnings: JsonlParseWarning[];
|
|
}> {
|
|
const marker = parseSqliteSessionFileMarker(params.sessionFile);
|
|
if (marker && marker.sessionId === params.sessionId) {
|
|
const events = await loadSqliteTrajectoryRuntimeEvents({
|
|
agentId: marker.agentId,
|
|
sessionId: marker.sessionId,
|
|
storePath: marker.storePath,
|
|
});
|
|
if (events.length > MAX_TRAJECTORY_RUNTIME_EVENTS) {
|
|
throw new Error(
|
|
`Trajectory runtime store has too many events to export (limit ${MAX_TRAJECTORY_RUNTIME_EVENTS})`,
|
|
);
|
|
}
|
|
return { events, warnings: [] };
|
|
}
|
|
|
|
const runtimeFile = await resolveTrajectoryRuntimeFile({
|
|
runtimeFile: params.runtimeFile,
|
|
sessionFile: params.sessionFile,
|
|
sessionId: params.sessionId,
|
|
});
|
|
if (!runtimeFile) {
|
|
return { events: [], warnings: [] };
|
|
}
|
|
const parsed = await parseJsonlFile<TrajectoryEvent>(runtimeFile, {
|
|
maxBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES,
|
|
maxEvents: MAX_TRAJECTORY_RUNTIME_EVENTS,
|
|
include: (value) => value.sessionId === params.sessionId,
|
|
validate: isRuntimeTrajectoryEvent,
|
|
});
|
|
return { ...parsed, runtimeFile };
|
|
}
|
|
|
|
function isRuntimeTrajectoryEvent(value: unknown): value is TrajectoryEvent {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
return (
|
|
value.traceSchema === "openclaw-trajectory" &&
|
|
value.schemaVersion === 1 &&
|
|
value.source === "runtime" &&
|
|
typeof value.type === "string" &&
|
|
typeof value.ts === "string" &&
|
|
!Number.isNaN(Date.parse(value.ts)) &&
|
|
isFiniteNumber(value.seq) &&
|
|
typeof value.sessionId === "string" &&
|
|
(!("data" in value) || value.data === undefined || isRecord(value.data))
|
|
);
|
|
}
|
|
|
|
function summarizeJsonlWarnings(warnings: JsonlParseWarning[]): TrajectoryBundleWarning[] {
|
|
const byKey = new Map<string, TrajectoryBundleWarning>();
|
|
for (const warning of warnings) {
|
|
const key = `${warning.source}:${warning.code}`;
|
|
const existing = byKey.get(key);
|
|
if (existing) {
|
|
existing.count += 1;
|
|
if (existing.rows.length < MAX_TRAJECTORY_WARNING_ROWS) {
|
|
existing.rows.push(warning.row);
|
|
}
|
|
continue;
|
|
}
|
|
byKey.set(key, {
|
|
source: warning.source,
|
|
code: warning.code,
|
|
count: 1,
|
|
rows: [warning.row],
|
|
message: warning.message,
|
|
});
|
|
}
|
|
return [...byKey.values()];
|
|
}
|
|
|
|
function normalizeTimestamp(value: unknown): string {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
const parsed = new Date(value);
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
return parsed.toISOString();
|
|
}
|
|
}
|
|
if (typeof value === "string") {
|
|
const parsed = new Date(value);
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
return parsed.toISOString();
|
|
}
|
|
}
|
|
return new Date(0).toISOString();
|
|
}
|
|
|
|
function resolveMessageEventType(message: AgentMessage): string {
|
|
if (message.role === "user") {
|
|
return "user.message";
|
|
}
|
|
if (message.role === "assistant") {
|
|
return "assistant.message";
|
|
}
|
|
if (message.role === "toolResult") {
|
|
return "tool.result";
|
|
}
|
|
return `message.${message.role}`;
|
|
}
|
|
|
|
function extractAssistantToolCalls(
|
|
message: AgentMessage,
|
|
): Array<{ id?: string; name?: string; arguments?: unknown; index: number }> {
|
|
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
return [];
|
|
}
|
|
return message.content.flatMap((block, index) => {
|
|
if (!block || typeof block !== "object") {
|
|
return [];
|
|
}
|
|
const typedBlock = block as {
|
|
type?: unknown;
|
|
id?: unknown;
|
|
name?: unknown;
|
|
arguments?: unknown;
|
|
input?: unknown;
|
|
parameters?: unknown;
|
|
};
|
|
const blockType =
|
|
typeof typedBlock.type === "string" ? typedBlock.type.trim().toLowerCase() : "";
|
|
if (blockType !== "toolcall" && blockType !== "tooluse" && blockType !== "functioncall") {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
id: typeof typedBlock.id === "string" ? typedBlock.id : undefined,
|
|
name: typeof typedBlock.name === "string" ? typedBlock.name : undefined,
|
|
arguments: typedBlock.arguments ?? typedBlock.input ?? typedBlock.parameters,
|
|
index,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function sanitizeTrajectoryExportValue<T>(value: T): T {
|
|
return redactSecrets(sanitizeDiagnosticPayload(value)) as T;
|
|
}
|
|
|
|
function buildTranscriptEvents(params: {
|
|
entries: SessionEntry[];
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
workspaceDir: string;
|
|
traceId: string;
|
|
}): TrajectoryEvent[] {
|
|
const events: TrajectoryEvent[] = [];
|
|
let seq = 0;
|
|
for (const entry of params.entries) {
|
|
const push = (type: string, data?: Record<string, unknown>) => {
|
|
events.push({
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
traceId: params.traceId,
|
|
source: "transcript",
|
|
type,
|
|
ts: normalizeTimestamp(entry.timestamp),
|
|
seq: 0,
|
|
sourceSeq: (seq += 1),
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
workspaceDir: params.workspaceDir,
|
|
entryId: entry.id,
|
|
parentEntryId: entry.parentId,
|
|
data,
|
|
});
|
|
};
|
|
|
|
switch (entry.type) {
|
|
case "message": {
|
|
push(resolveMessageEventType(entry.message), {
|
|
message: sanitizeDiagnosticPayload(entry.message),
|
|
});
|
|
for (const toolCall of extractAssistantToolCalls(entry.message)) {
|
|
push("tool.call", {
|
|
toolCallId: toolCall.id,
|
|
name: toolCall.name,
|
|
arguments: sanitizeDiagnosticPayload(toolCall.arguments),
|
|
assistantEntryId: entry.id,
|
|
blockIndex: toolCall.index,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "compaction":
|
|
push("session.compaction", {
|
|
summary: entry.summary,
|
|
firstKeptEntryId: entry.firstKeptEntryId,
|
|
tokensBefore: entry.tokensBefore,
|
|
details: sanitizeDiagnosticPayload(entry.details),
|
|
fromHook: entry.fromHook ?? false,
|
|
});
|
|
break;
|
|
case "branch_summary":
|
|
push("session.branch_summary", {
|
|
fromId: entry.fromId,
|
|
summary: entry.summary,
|
|
details: sanitizeDiagnosticPayload(entry.details),
|
|
fromHook: entry.fromHook ?? false,
|
|
});
|
|
break;
|
|
case "custom":
|
|
push("session.custom", {
|
|
customType: entry.customType,
|
|
data: sanitizeDiagnosticPayload(entry.data),
|
|
});
|
|
break;
|
|
case "custom_message":
|
|
push("session.custom_message", {
|
|
customType: entry.customType,
|
|
content: sanitizeDiagnosticPayload(entry.content),
|
|
details: sanitizeDiagnosticPayload(entry.details),
|
|
display: entry.display,
|
|
});
|
|
break;
|
|
case "thinking_level_change":
|
|
push("session.thinking_level_change", {
|
|
thinkingLevel: entry.thinkingLevel,
|
|
});
|
|
break;
|
|
case "model_change":
|
|
push("session.model_change", {
|
|
provider: entry.provider,
|
|
modelId: entry.modelId,
|
|
});
|
|
break;
|
|
case "label":
|
|
push("session.label", {
|
|
targetId: entry.targetId,
|
|
label: entry.label,
|
|
});
|
|
break;
|
|
case "session_info":
|
|
push("session.info", {
|
|
name: entry.name,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
function sortTrajectoryEvents(events: TrajectoryEvent[]): TrajectoryEvent[] {
|
|
const sourceOrder: Record<TrajectoryEvent["source"], number> = {
|
|
runtime: 0,
|
|
transcript: 1,
|
|
export: 2,
|
|
};
|
|
const sorted = events.toSorted((left, right) => {
|
|
const byTs = left.ts.localeCompare(right.ts);
|
|
if (byTs !== 0) {
|
|
return byTs;
|
|
}
|
|
const bySource = sourceOrder[left.source] - sourceOrder[right.source];
|
|
if (bySource !== 0) {
|
|
return bySource;
|
|
}
|
|
return (left.sourceSeq ?? left.seq) - (right.sourceSeq ?? right.seq);
|
|
});
|
|
for (const [index, event] of sorted.entries()) {
|
|
event.seq = index + 1;
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function trajectoryJsonlFile(
|
|
pathName: string,
|
|
events: TrajectoryEvent[],
|
|
): DiagnosticSupportBundleFile {
|
|
const lines = events
|
|
.map((event) => safeJsonStringify(event))
|
|
.filter((line): line is string => Boolean(line));
|
|
return jsonlSupportBundleFile(pathName, lines);
|
|
}
|
|
|
|
function redactTrajectoryBundleFileContent(
|
|
file: DiagnosticSupportBundleFile,
|
|
): DiagnosticSupportBundleFile {
|
|
return {
|
|
...file,
|
|
content: redactToolPayloadText(file.content),
|
|
};
|
|
}
|
|
|
|
function buildTrajectoryExportRedaction(params: {
|
|
workspaceDir: string;
|
|
}): TrajectoryExportRedaction {
|
|
const env = process.env;
|
|
return {
|
|
env,
|
|
stateDir: resolveStateDir(env),
|
|
workspaceDir: path.resolve(params.workspaceDir),
|
|
};
|
|
}
|
|
|
|
function redactWorkspacePathString(value: string, redaction: TrajectoryExportRedaction): string {
|
|
const workspaceDir = redaction.workspaceDir;
|
|
if (!workspaceDir) {
|
|
return value;
|
|
}
|
|
const normalizedWorkspaceDir = workspaceDir.replaceAll("\\", "/");
|
|
let next = value;
|
|
for (const candidate of new Set([workspaceDir, normalizedWorkspaceDir])) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
next = next.replace(new RegExp(`${escaped}(?=$|[\\\\/])`, "gu"), "$WORKSPACE_DIR");
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function maybeRedactPathString(value: string, redaction: TrajectoryExportRedaction): string {
|
|
const workspaceRedacted = redactWorkspacePathString(value, redaction);
|
|
// Redact only strings that look path-like after workspace substitution. This
|
|
// keeps ordinary model text readable while still removing local host details.
|
|
if (
|
|
workspaceRedacted !== value ||
|
|
path.isAbsolute(workspaceRedacted) ||
|
|
workspaceRedacted.includes(redaction.stateDir) ||
|
|
(redaction.env.HOME ? workspaceRedacted.includes(redaction.env.HOME) : false) ||
|
|
(redaction.env.USERPROFILE ? workspaceRedacted.includes(redaction.env.USERPROFILE) : false)
|
|
) {
|
|
return redactSupportString(workspaceRedacted, redaction);
|
|
}
|
|
return workspaceRedacted;
|
|
}
|
|
|
|
function redactLocalPathValues(value: unknown, redaction: TrajectoryExportRedaction): unknown {
|
|
if (typeof value === "string") {
|
|
return maybeRedactPathString(value, redaction);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => redactLocalPathValues(entry, redaction));
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return value;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const next: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(record)) {
|
|
next[key] = redactLocalPathValues(entry, redaction);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function uniqueRedactedObjectKey(key: string, usedKeys: Set<string>): string {
|
|
if (!usedKeys.has(key)) {
|
|
usedKeys.add(key);
|
|
return key;
|
|
}
|
|
let index = 2;
|
|
while (usedKeys.has(`${key}#${index}`)) {
|
|
index += 1;
|
|
}
|
|
const unique = `${key}#${index}`;
|
|
usedKeys.add(unique);
|
|
return unique;
|
|
}
|
|
|
|
function redactTrajectoryExportObjectKeys(
|
|
value: unknown,
|
|
redaction: TrajectoryExportRedaction,
|
|
): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => redactTrajectoryExportObjectKeys(entry, redaction));
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return value;
|
|
}
|
|
const usedKeys = new Set<string>();
|
|
const next: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const redactedKey = redactToolPayloadText(maybeRedactPathString(key, redaction));
|
|
// Object keys can contain file paths or tool payload snippets too. Preserve
|
|
// all entries even when redaction collapses two original keys together.
|
|
next[uniqueRedactedObjectKey(redactedKey, usedKeys)] = redactTrajectoryExportObjectKeys(
|
|
entry,
|
|
redaction,
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function redactTrajectoryExportValue(
|
|
value: unknown,
|
|
redaction: TrajectoryExportRedaction,
|
|
): unknown {
|
|
const redactedValue = sanitizeTrajectoryExportValue(redactLocalPathValues(value, redaction));
|
|
return redactTrajectoryExportObjectKeys(redactedValue, redaction);
|
|
}
|
|
|
|
function redactEventForExport(
|
|
event: TrajectoryEvent,
|
|
redaction: TrajectoryExportRedaction,
|
|
): TrajectoryEvent {
|
|
return redactTrajectoryExportValue(event, redaction) as TrajectoryEvent;
|
|
}
|
|
|
|
function resolveRuntimeContext(runtimeEvents: TrajectoryEvent[]): RuntimeTrajectoryContext {
|
|
const latestContext = runtimeEvents
|
|
.slice()
|
|
.toReversed()
|
|
.find((event) => event.type === "context.compiled");
|
|
const runtimeData = latestContext?.data;
|
|
const toolsValue = Array.isArray(runtimeData?.tools)
|
|
? (runtimeData.tools as TrajectoryToolDefinition[])
|
|
: undefined;
|
|
return {
|
|
systemPrompt:
|
|
typeof runtimeData?.systemPrompt === "string" ? runtimeData.systemPrompt : undefined,
|
|
tools: toolsValue,
|
|
};
|
|
}
|
|
|
|
function resolveLatestRuntimeEventData(
|
|
runtimeEvents: TrajectoryEvent[],
|
|
type: string,
|
|
): JsonRecord | undefined {
|
|
const event = runtimeEvents
|
|
.slice()
|
|
.toReversed()
|
|
.find((candidate) => candidate.type === type);
|
|
return event?.data;
|
|
}
|
|
|
|
function normalizePathForMatch(value: string): string {
|
|
return value.replaceAll("\\", "/").trim().toLowerCase();
|
|
}
|
|
|
|
function collectPotentialPathStrings(value: unknown): string[] {
|
|
const found = new Set<string>();
|
|
const visit = (input: unknown) => {
|
|
if (!input || typeof input !== "object") {
|
|
return;
|
|
}
|
|
if (Array.isArray(input)) {
|
|
for (const entry of input) {
|
|
visit(entry);
|
|
}
|
|
return;
|
|
}
|
|
for (const [key, entry] of Object.entries(input)) {
|
|
if (
|
|
typeof entry === "string" &&
|
|
(key.toLowerCase().includes("path") ||
|
|
entry.endsWith("SKILL.md") ||
|
|
entry.endsWith("skill.md"))
|
|
) {
|
|
found.add(entry);
|
|
} else {
|
|
visit(entry);
|
|
}
|
|
}
|
|
};
|
|
visit(value);
|
|
return [...found];
|
|
}
|
|
|
|
function markInvokedSkills(params: { skills: unknown; events: TrajectoryEvent[] }): unknown {
|
|
if (!params.skills || typeof params.skills !== "object") {
|
|
return params.skills;
|
|
}
|
|
const skillsRecord = params.skills as {
|
|
entries?: Array<Record<string, unknown>>;
|
|
};
|
|
if (!Array.isArray(skillsRecord.entries) || skillsRecord.entries.length === 0) {
|
|
return params.skills;
|
|
}
|
|
const invokedPaths = new Set(
|
|
params.events.flatMap((event) => {
|
|
if (event.type !== "tool.call") {
|
|
return [];
|
|
}
|
|
return collectPotentialPathStrings(event.data?.arguments);
|
|
}),
|
|
);
|
|
// Skill invocation is inferred from tool-call file paths in captured prompts;
|
|
// this keeps the export self-contained without re-reading skill state later.
|
|
const normalizedInvokedPaths = new Set(
|
|
[...invokedPaths].map((value) => normalizePathForMatch(value)),
|
|
);
|
|
const entries = skillsRecord.entries.map((entry) => {
|
|
const rawPath = typeof entry.filePath === "string" ? entry.filePath : undefined;
|
|
const normalizedPath = rawPath ? normalizePathForMatch(rawPath) : undefined;
|
|
const skillDirName =
|
|
rawPath?.replaceAll("\\", "/").split("/").slice(-2, -1)[0]?.toLowerCase() ?? undefined;
|
|
const invoked = normalizedPath
|
|
? [...normalizedInvokedPaths].some(
|
|
(candidate) =>
|
|
candidate === normalizedPath ||
|
|
candidate.endsWith(normalizedPath) ||
|
|
(skillDirName ? candidate.endsWith(`/${skillDirName}/skill.md`) : false),
|
|
)
|
|
: false;
|
|
return invoked
|
|
? {
|
|
...entry,
|
|
invoked,
|
|
invocationDetectedBy: "tool-call-file-path",
|
|
}
|
|
: {
|
|
...entry,
|
|
invoked: false,
|
|
};
|
|
});
|
|
return {
|
|
...skillsRecord,
|
|
entries,
|
|
};
|
|
}
|
|
|
|
function buildMetadataCapture(params: {
|
|
manifest: TrajectoryBundleManifest;
|
|
runtimeEvents: TrajectoryEvent[];
|
|
events: TrajectoryEvent[];
|
|
}): JsonRecord | undefined {
|
|
const runtimeMetadata = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.metadata");
|
|
if (!runtimeMetadata) {
|
|
return undefined;
|
|
}
|
|
const modelFallback = (() => {
|
|
const latest = params.runtimeEvents
|
|
.slice()
|
|
.toReversed()
|
|
.find((event) => event.provider || event.modelId || event.modelApi);
|
|
if (!latest?.provider && !latest?.modelId && !latest?.modelApi) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
provider: latest.provider,
|
|
name: latest.modelId,
|
|
api: latest.modelApi,
|
|
};
|
|
})();
|
|
return {
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
traceId: params.manifest.traceId,
|
|
sessionId: params.manifest.sessionId,
|
|
sessionKey: params.manifest.sessionKey,
|
|
harness: runtimeMetadata.harness,
|
|
model: runtimeMetadata.model ?? modelFallback,
|
|
config: runtimeMetadata.config,
|
|
plugins: runtimeMetadata.plugins,
|
|
skills: markInvokedSkills({
|
|
skills: runtimeMetadata.skills,
|
|
events: params.events,
|
|
}),
|
|
prompting: runtimeMetadata.prompting,
|
|
redaction: runtimeMetadata.redaction,
|
|
metadata: runtimeMetadata.metadata,
|
|
};
|
|
}
|
|
|
|
function buildArtifactsCapture(params: {
|
|
manifest: TrajectoryBundleManifest;
|
|
runtimeEvents: TrajectoryEvent[];
|
|
}): JsonRecord | undefined {
|
|
const runtimeArtifacts = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.artifacts");
|
|
const runtimeCompletion = resolveLatestRuntimeEventData(params.runtimeEvents, "model.completed");
|
|
const runtimeEnd = resolveLatestRuntimeEventData(params.runtimeEvents, "session.ended");
|
|
if (!runtimeArtifacts && !runtimeCompletion && !runtimeEnd) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
traceId: params.manifest.traceId,
|
|
sessionId: params.manifest.sessionId,
|
|
sessionKey: params.manifest.sessionKey,
|
|
finalStatus: runtimeArtifacts?.finalStatus ?? runtimeEnd?.status,
|
|
aborted: runtimeArtifacts?.aborted ?? runtimeEnd?.aborted,
|
|
externalAbort: runtimeArtifacts?.externalAbort ?? runtimeEnd?.externalAbort,
|
|
timedOut: runtimeArtifacts?.timedOut ?? runtimeEnd?.timedOut,
|
|
idleTimedOut: runtimeArtifacts?.idleTimedOut ?? runtimeEnd?.idleTimedOut,
|
|
timedOutDuringCompaction:
|
|
runtimeArtifacts?.timedOutDuringCompaction ?? runtimeEnd?.timedOutDuringCompaction,
|
|
timedOutDuringToolExecution:
|
|
runtimeArtifacts?.timedOutDuringToolExecution ?? runtimeEnd?.timedOutDuringToolExecution,
|
|
timedOutByRunBudget: runtimeArtifacts?.timedOutByRunBudget ?? runtimeEnd?.timedOutByRunBudget,
|
|
promptError:
|
|
runtimeArtifacts?.promptError ?? runtimeEnd?.promptError ?? runtimeCompletion?.promptError,
|
|
promptErrorSource: runtimeArtifacts?.promptErrorSource ?? runtimeCompletion?.promptErrorSource,
|
|
terminalError:
|
|
runtimeArtifacts?.terminalError ??
|
|
runtimeEnd?.terminalError ??
|
|
runtimeCompletion?.terminalError,
|
|
usage: runtimeArtifacts?.usage ?? runtimeCompletion?.usage,
|
|
promptCache: runtimeArtifacts?.promptCache ?? runtimeCompletion?.promptCache,
|
|
compactionCount: runtimeArtifacts?.compactionCount ?? runtimeCompletion?.compactionCount,
|
|
assistantTexts: runtimeArtifacts?.assistantTexts ?? runtimeCompletion?.assistantTexts,
|
|
finalPromptText: runtimeArtifacts?.finalPromptText ?? runtimeCompletion?.finalPromptText,
|
|
itemLifecycle: runtimeArtifacts?.itemLifecycle,
|
|
toolMetas: runtimeArtifacts?.toolMetas,
|
|
didSendViaMessagingTool: runtimeArtifacts?.didSendViaMessagingTool,
|
|
successfulCronAdds: runtimeArtifacts?.successfulCronAdds,
|
|
messagingToolSentTexts: runtimeArtifacts?.messagingToolSentTexts,
|
|
messagingToolSentMediaUrls: runtimeArtifacts?.messagingToolSentMediaUrls,
|
|
messagingToolSentTargets: runtimeArtifacts?.messagingToolSentTargets,
|
|
lastToolError: runtimeArtifacts?.lastToolError,
|
|
};
|
|
}
|
|
|
|
function buildPromptsCapture(params: {
|
|
manifest: TrajectoryBundleManifest;
|
|
runtimeEvents: TrajectoryEvent[];
|
|
runtimeContext: RuntimeTrajectoryContext;
|
|
}): JsonRecord | undefined {
|
|
const runtimeMetadata = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.metadata");
|
|
const latestCompiled = resolveLatestRuntimeEventData(params.runtimeEvents, "context.compiled");
|
|
const submittedPrompts = params.runtimeEvents
|
|
.filter((event) => event.type === "prompt.submitted")
|
|
.map((event) => event.data?.prompt)
|
|
.filter((prompt): prompt is string => typeof prompt === "string");
|
|
const systemPrompt =
|
|
(typeof latestCompiled?.systemPrompt === "string" ? latestCompiled.systemPrompt : undefined) ??
|
|
params.runtimeContext.systemPrompt;
|
|
const skillsPrompt =
|
|
runtimeMetadata?.prompting &&
|
|
typeof runtimeMetadata.prompting === "object" &&
|
|
typeof (runtimeMetadata.prompting as JsonRecord).skillsPrompt === "string"
|
|
? ((runtimeMetadata.prompting as JsonRecord).skillsPrompt as string)
|
|
: undefined;
|
|
const userPromptPrefixText =
|
|
runtimeMetadata?.prompting &&
|
|
typeof runtimeMetadata.prompting === "object" &&
|
|
typeof (runtimeMetadata.prompting as JsonRecord).userPromptPrefixText === "string"
|
|
? ((runtimeMetadata.prompting as JsonRecord).userPromptPrefixText as string)
|
|
: undefined;
|
|
const promptReport =
|
|
runtimeMetadata?.prompting &&
|
|
typeof runtimeMetadata.prompting === "object" &&
|
|
typeof (runtimeMetadata.prompting as JsonRecord).systemPromptReport === "object"
|
|
? (runtimeMetadata.prompting as JsonRecord).systemPromptReport
|
|
: undefined;
|
|
if (!systemPrompt && submittedPrompts.length === 0 && !skillsPrompt && !userPromptPrefixText) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
traceId: params.manifest.traceId,
|
|
sessionId: params.manifest.sessionId,
|
|
sessionKey: params.manifest.sessionKey,
|
|
system: systemPrompt,
|
|
submittedPrompts,
|
|
latestSubmittedPrompt: submittedPrompts.at(-1),
|
|
skillsPrompt,
|
|
userPromptPrefixText,
|
|
systemPromptReport: promptReport,
|
|
};
|
|
}
|
|
|
|
export function resolveDefaultTrajectoryExportDir(params: {
|
|
workspaceDir: string;
|
|
sessionId: string;
|
|
now?: Date;
|
|
}): string {
|
|
const timestamp = (params.now ?? new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
const sessionFileName = safeTrajectorySessionFileName(params.sessionId);
|
|
return path.join(
|
|
params.workspaceDir,
|
|
".openclaw",
|
|
"trajectory-exports",
|
|
`openclaw-trajectory-${sessionFileName.slice(0, 8)}-${timestamp}`,
|
|
);
|
|
}
|
|
|
|
// Public export API used by CLI/tests. The bundle is intentionally sanitized
|
|
// before writing so sharing it should not expose credentials or local paths.
|
|
export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): Promise<{
|
|
manifest: TrajectoryBundleManifest;
|
|
outputDir: string;
|
|
events: TrajectoryEvent[];
|
|
header: SessionHeader | null;
|
|
runtimeFile?: string;
|
|
supplementalFiles: string[];
|
|
}> {
|
|
const redaction = buildTrajectoryExportRedaction({
|
|
workspaceDir: params.workspaceDir,
|
|
});
|
|
if (!parseSqliteSessionFileMarker(params.sessionFile)) {
|
|
const sessionStat = await fsp.stat(params.sessionFile);
|
|
if (sessionStat.size > MAX_TRAJECTORY_SESSION_FILE_BYTES) {
|
|
throw new Error(
|
|
`Trajectory session file is too large to export (${sessionStat.size} bytes; limit ${MAX_TRAJECTORY_SESSION_FILE_BYTES})`,
|
|
);
|
|
}
|
|
}
|
|
const {
|
|
header,
|
|
leafId,
|
|
branchEntries,
|
|
warnings: sessionWarnings,
|
|
} = await readSessionBranch({
|
|
sessionFile: params.sessionFile,
|
|
sessionId: params.sessionId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
});
|
|
const runtimeParse = await readRuntimeTrajectoryEvents({
|
|
runtimeFile: params.runtimeFile,
|
|
sessionFile: params.sessionFile,
|
|
sessionId: params.sessionId,
|
|
});
|
|
const runtimeFile = runtimeParse.runtimeFile;
|
|
const runtimeEvents = runtimeParse.events;
|
|
const transcriptEvents = buildTranscriptEvents({
|
|
entries: branchEntries,
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
workspaceDir: params.workspaceDir,
|
|
traceId: params.sessionId,
|
|
});
|
|
const maxTotalEvents = params.maxTotalEvents ?? MAX_TRAJECTORY_TOTAL_EVENTS;
|
|
const totalEventCount = runtimeEvents.length + transcriptEvents.length;
|
|
if (totalEventCount > maxTotalEvents) {
|
|
throw new Error(
|
|
`Trajectory export has too many events (${totalEventCount}; limit ${maxTotalEvents})`,
|
|
);
|
|
}
|
|
const rawEvents = sortTrajectoryEvents([...runtimeEvents, ...transcriptEvents]);
|
|
const events = rawEvents.map((event) => redactEventForExport(event, redaction));
|
|
const manifest: TrajectoryBundleManifest = {
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
traceId: params.sessionId,
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
workspaceDir: maybeRedactPathString(params.workspaceDir, redaction),
|
|
leafId,
|
|
eventCount: events.length,
|
|
runtimeEventCount: runtimeEvents.length,
|
|
transcriptEventCount: transcriptEvents.length,
|
|
sourceFiles: {
|
|
session: maybeRedactPathString(params.sessionFile, redaction),
|
|
runtime:
|
|
runtimeFile && (await isRegularNonSymlinkFile(runtimeFile))
|
|
? maybeRedactPathString(runtimeFile, redaction)
|
|
: undefined,
|
|
},
|
|
};
|
|
const warnings = summarizeJsonlWarnings([...sessionWarnings, ...runtimeParse.warnings]);
|
|
if (warnings.length > 0) {
|
|
manifest.warnings = warnings;
|
|
}
|
|
|
|
const bundleRuntimeContext = resolveRuntimeContext(runtimeEvents);
|
|
const files: DiagnosticSupportBundleFile[] = [];
|
|
const supplementalFiles: string[] = [];
|
|
const metadataCapture = buildMetadataCapture({
|
|
manifest,
|
|
runtimeEvents,
|
|
events: rawEvents,
|
|
});
|
|
const artifactsCapture = buildArtifactsCapture({
|
|
manifest,
|
|
runtimeEvents,
|
|
});
|
|
const promptsCapture = buildPromptsCapture({
|
|
manifest,
|
|
runtimeEvents,
|
|
runtimeContext: bundleRuntimeContext,
|
|
});
|
|
if (metadataCapture) {
|
|
files.push(
|
|
jsonSupportBundleFile(
|
|
"metadata.json",
|
|
redactTrajectoryExportValue(metadataCapture, redaction),
|
|
),
|
|
);
|
|
supplementalFiles.push("metadata.json");
|
|
}
|
|
if (artifactsCapture) {
|
|
files.push(
|
|
jsonSupportBundleFile(
|
|
"artifacts.json",
|
|
redactTrajectoryExportValue(artifactsCapture, redaction),
|
|
),
|
|
);
|
|
supplementalFiles.push("artifacts.json");
|
|
}
|
|
if (promptsCapture) {
|
|
files.push(
|
|
jsonSupportBundleFile("prompts.json", redactTrajectoryExportValue(promptsCapture, redaction)),
|
|
);
|
|
supplementalFiles.push("prompts.json");
|
|
}
|
|
if (supplementalFiles.length > 0) {
|
|
manifest.supplementalFiles = supplementalFiles;
|
|
}
|
|
|
|
files.push(trajectoryJsonlFile("events.jsonl", events));
|
|
files.push(
|
|
jsonSupportBundleFile(
|
|
"session-branch.json",
|
|
redactTrajectoryExportValue(
|
|
{
|
|
header,
|
|
leafId,
|
|
entries: branchEntries,
|
|
},
|
|
redaction,
|
|
),
|
|
),
|
|
);
|
|
if (bundleRuntimeContext.systemPrompt) {
|
|
files.push(
|
|
textSupportBundleFile(
|
|
"system-prompt.txt",
|
|
redactTrajectoryExportValue(bundleRuntimeContext.systemPrompt, redaction) as string,
|
|
),
|
|
);
|
|
}
|
|
if (bundleRuntimeContext.tools) {
|
|
files.push(
|
|
jsonSupportBundleFile(
|
|
"tools.json",
|
|
redactTrajectoryExportValue(bundleRuntimeContext.tools, redaction),
|
|
),
|
|
);
|
|
}
|
|
|
|
const redactedFiles = files.map(redactTrajectoryBundleFileContent);
|
|
const contents: DiagnosticSupportBundleContent[] = [...supportBundleContents(redactedFiles)];
|
|
manifest.contents = contents;
|
|
const redactedManifest = redactTrajectoryExportValue(
|
|
manifest,
|
|
redaction,
|
|
) as TrajectoryBundleManifest;
|
|
const manifestFile = redactTrajectoryBundleFileContent(
|
|
jsonSupportBundleFile("manifest.json", redactedManifest),
|
|
);
|
|
|
|
await writeSupportBundleDirectory({
|
|
outputDir: params.outputDir,
|
|
files: [manifestFile, ...redactedFiles],
|
|
});
|
|
|
|
return {
|
|
manifest: redactedManifest,
|
|
outputDir: params.outputDir,
|
|
events,
|
|
header,
|
|
runtimeFile:
|
|
runtimeFile && (await isRegularNonSymlinkFile(runtimeFile)) ? runtimeFile : undefined,
|
|
supplementalFiles,
|
|
};
|
|
}
|