import * as fs from "node:fs";
import * as path from "node:path";
import { buildSessionTraceFromSnapshots } from "@oh-my-pi/omp-stats/trace";
import type { SessionTrace } from "@oh-my-pi/omp-stats/types";
import type { AgentState } from "@oh-my-pi/pi-agent-core";
import { APP_NAME, isCompiledBinary, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
import { getResolvedThemeColors, getThemeExportColors } from "@oh-my-pi/pi-tui/theme";
import type { SessionEntry, SessionHeader } from "../../session/session-entries";
import { SessionManager } from "../../session/session-manager";
import { collectSubSessions, type SubSession } from "../../session/sub-sessions";
import type { ExportThemeNames } from "./args";
import { writeBase64Json } from "./payload";
import templateHtml from "./template.html.txt" with { type: "text" };
import viewerAsset from "./viewer.generated.json" with { type: "json" };
import { exportThemeStyles, webExportThemeVars } from "./web-palette";
export { type ExportThemeNames, parseExportArgs } from "./args";
let cachedTemplate: string | undefined;
const SESSION_DATA_MARKER = "{{SESSION_DATA}}";
/** Create an export document with a checkout viewer or an integrity-pinned release viewer. */
export function getTemplate(): string {
if (cachedTemplate) return cachedTemplate;
const sourceCheckout =
!isCompiledBinary() &&
!process.env.PI_BUNDLED &&
!import.meta.dir.split(/[\\/]/).includes("node_modules") &&
fs.existsSync(path.join(import.meta.dir, "shell.html"));
let viewerScript: string;
if (sourceCheckout) {
const viewerPath = path.resolve(import.meta.dir, "../../../export-assets", viewerAsset.file);
let viewer: string;
try {
viewer = fs.readFileSync(viewerPath, "utf8");
} catch (err) {
if (isEnoent(err)) {
throw new Error(
`Development export viewer not found: ${viewerPath}. Run bun gen:export-viewer (packages/collab-web/scripts/build-export-viewer.ts).`,
);
}
throw err;
}
// Inline classic scripts run before deferred external scripts. Wait for
// both shared libraries, and prevent embedded text from closing the tag.
const escaped = viewer.replace(/<\/script/gi, match => `<\\/${match.slice(2)}`);
viewerScript = ``;
} else {
const viewerUrl = `https://build.stencil.so/web/omp/${encodeURIComponent(VERSION)}/${viewerAsset.file}`;
viewerScript = ``;
}
cachedTemplate = templateHtml
.replace("{{MARKED_URL}}", () => "https://stencil.so/assets/omp/marked.333561b9.js")
.replace("{{MARKED_INTEGRITY}}", () => "sha256-MzVhuUicTTOmBQF03OgI9HBhlGcICMhDRSPIdD9aYmM=")
.replace("{{HIGHLIGHT_URL}}", () => "https://stencil.so/assets/omp/highlight.837a6fa5.js")
.replace("{{HIGHLIGHT_INTEGRITY}}", () => "sha256-g3pvpbDHNrUrveKythkPMF2j/J7UFoHbUyFQcFe1yEY=")
.replace("", () => viewerScript);
return cachedTemplate;
}
export interface ExportOptions {
outputPath?: string;
/** `"web"` uses the hosted web themes; `"theme"` embeds TUI theme overrides. */
palette?: "web" | "theme";
/** Legacy single TUI theme name. Prefer `themeNames` for dual-theme exports. */
themeName?: string;
/** Dark and light TUI themes to bundle when `palette` is `"theme"`. */
themeNames?: ExportThemeNames;
/** Embed subagent session transcripts found next to the session file (default true). */
includeSubSessions?: boolean;
}
/** Parse a color string to RGB values. */
function parseColor(color: string): { r: number; g: number; b: number } | undefined {
const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
if (hexMatch) {
return {
r: Number.parseInt(hexMatch[1], 16),
g: Number.parseInt(hexMatch[2], 16),
b: Number.parseInt(hexMatch[3], 16),
};
}
const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (rgbMatch) {
return {
r: Number.parseInt(rgbMatch[1], 10),
g: Number.parseInt(rgbMatch[2], 10),
b: Number.parseInt(rgbMatch[3], 10),
};
}
return undefined;
}
/** Calculate relative luminance of a color (0-1, higher = lighter). */
function getLuminance(r: number, g: number, b: number): number {
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/** Adjust color brightness. */
function adjustBrightness(color: string, factor: number): string {
const parsed = parseColor(color);
if (!parsed) return color;
const adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));
return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;
}
/** Derive export background colors from a base color. */
function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {
const parsed = parseColor(baseColor);
if (!parsed) {
return { pageBg: "rgb(24, 24, 30)", cardBg: "rgb(30, 30, 36)", infoBg: "rgb(60, 55, 40)" };
}
const luminance = getLuminance(parsed.r, parsed.g, parsed.b);
if (luminance > 0.5) {
return {
pageBg: adjustBrightness(baseColor, 0.96),
cardBg: baseColor,
infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,
};
}
return {
pageBg: adjustBrightness(baseColor, 0.7),
cardBg: adjustBrightness(baseColor, 0.85),
infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,
};
}
/**
* Generate CSS custom properties for one export theme.
*
* The single-argument theme-name form remains available to callers that need
* one TUI palette. Standalone HTML uses `generateThemeStyles()` below.
*/
export async function generateThemeVars(
palette: "web" | "theme" | (string & {}) = "web",
themeName?: string,
): Promise {
if (palette !== "web" && palette !== "theme") return generateThemeVars("theme", palette);
if (palette === "web") return webExportThemeVars("dark");
const colors = await getResolvedThemeColors(themeName);
const lines = Object.entries(colors).map(([key, value]) => `--${key}: ${value};`);
const themeExport = await getThemeExportColors(themeName);
const derived = deriveExportColors(colors.userMessageBg || "#343541");
lines.push(`--body-bg: ${themeExport.pageBg ?? derived.pageBg};`);
lines.push(`--container-bg: ${themeExport.cardBg ?? derived.cardBg};`);
lines.push(`--info-bg: ${themeExport.infoBg ?? derived.infoBg};`);
return lines.join(" ");
}
/** Generate dark, light, and auto-following CSS rules for a standalone viewer. */
export async function generateThemeStyles(
palette: "web" | "theme",
themeNames?: ExportThemeNames,
legacyThemeName?: string,
): Promise {
const [dark, light] =
palette === "web"
? [webExportThemeVars("dark"), webExportThemeVars("light")]
: await Promise.all([
generateThemeVars("theme", themeNames?.dark ?? legacyThemeName ?? "dark"),
generateThemeVars("theme", themeNames?.light ?? legacyThemeName ?? "light"),
]);
return exportThemeStyles(dark, light);
}
export interface SessionData {
header: SessionHeader | null;
entries: SessionEntry[];
leafId: string | null;
systemPrompt?: string;
tools?: { name: string; description: string }[];
subSessions?: Record;
/** Offline trace for the embedded transcripts; absent in older share snapshots. */
trace?: SessionTrace;
}
function sessionHeaderForExport(header: SessionHeader | null): SessionHeader | null {
if (!header) return null;
const exported = { ...header };
delete exported.previousSessionFiles;
delete exported.providerPromptCacheKey;
delete exported.taskPromptCacheKey;
return exported;
}
/** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */
export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData {
return {
header: sessionHeaderForExport(sm.getHeader()),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: state?.systemPrompt.join("\n\n"),
tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
};
}
/** All trace transcripts next to `sessionFile`, with export-only header fields stripped. */
async function collectExportSubSessions(sessionFile: string): Promise> {
const subSessions = await collectSubSessions(sessionFile, { includeAdvisors: true, includeCompressed: true });
for (const sub of Object.values(subSessions)) sub.header = sessionHeaderForExport(sub.header);
return subSessions;
}
/**
* Write the export document for `sessionData` to `outputPath`, streaming the
* base64 payload so no string ever holds the whole session (#17741). A failed
* write removes the partial file.
*/
async function writeExportHtml(sessionData: SessionData, outputPath: string, opts: ExportOptions): Promise {
const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
const themeStyles = palette === "theme" ? await generateThemeStyles(palette, opts.themeNames, opts.themeName) : "";
// Function replacement so `$'`, `$&`, `$$`, `$n` in the CSS are not substitution patterns.
const page = getTemplate().replace("", () => (themeStyles ? `` : ""));
const marker = page.indexOf(SESSION_DATA_MARKER);
// Unlike `Bun.write`, a `FileSink` does not create missing parent directories.
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const writer = Bun.file(outputPath).writer();
try {
writer.write(page.slice(0, marker));
await writeBase64Json(sessionData, writer);
writer.write(page.slice(marker + SESSION_DATA_MARKER.length));
await writer.end();
} catch (err) {
// Close the handle so the partial file can be removed; the original failure is the one to report.
await Promise.try(() => writer.end()).catch(() => undefined);
await fs.promises.rm(outputPath, { force: true });
throw err;
}
}
/** Attach sub-sessions and the trace, then write the export; returns the output path. */
async function exportSessionData(sessionData: SessionData, sessionFile: string, opts: ExportOptions): Promise {
if (opts.includeSubSessions !== false) {
const subSessions = await collectExportSubSessions(sessionFile);
if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
}
sessionData.trace = buildSessionTraceFromSnapshots(sessionData, sessionData.subSessions);
// Resolve once: Bun on Windows fails to mkdir relative paths that normalize to cwd (oven-sh/bun#45059).
const outputPath = path.resolve(
opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`,
);
await writeExportHtml(sessionData, outputPath, opts);
return outputPath;
}
/** Export session to HTML using SessionManager and AgentState. */
export async function exportSessionToHtml(
sm: SessionManager,
state?: AgentState,
options?: ExportOptions | string,
): Promise {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sm.getSessionFile();
if (!sessionFile) throw new Error("Cannot export in-memory session to HTML");
return exportSessionData(buildSessionData(sm, state), sessionFile, opts);
}
/** Export session file to HTML (standalone). */
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
let sm: SessionManager;
try {
sm = await SessionManager.open(inputPath, undefined, undefined, {
suppressBreadcrumb: true,
throwIfMissing: true,
});
} catch (err) {
if (isEnoent(err)) throw new Error(`File not found: ${inputPath}`);
throw err;
}
return exportSessionData(buildSessionData(sm), inputPath, opts);
}