mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 01:41:35 +00:00
996 lines
32 KiB
TypeScript
996 lines
32 KiB
TypeScript
/**
|
|
* Reads, mutates, and atomically writes embedded session transcript files.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js";
|
|
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
|
|
import { appendRegularFile } from "../../infra/fs-safe.js";
|
|
import { privateFileStore } from "../../infra/private-file-store.js";
|
|
import {
|
|
buildSessionContext,
|
|
migrateSessionEntries,
|
|
parseSessionEntries,
|
|
type FileEntry,
|
|
type SessionContext,
|
|
type SessionEntry,
|
|
type SessionHeader,
|
|
} from "../sessions/index.js";
|
|
|
|
// Mutable view over a session JSONL transcript. The runner uses this layer to
|
|
// tolerate old or partially malformed rows before appending new canonical rows.
|
|
type BranchSummaryEntry = Extract<SessionEntry, { type: "branch_summary" }>;
|
|
type CompactionEntry = Extract<SessionEntry, { type: "compaction" }>;
|
|
type CustomEntry = Extract<SessionEntry, { type: "custom" }>;
|
|
type CustomMessageEntry = Extract<SessionEntry, { type: "custom_message" }>;
|
|
type LabelEntry = Extract<SessionEntry, { type: "label" }>;
|
|
type ModelChangeEntry = Extract<SessionEntry, { type: "model_change" }>;
|
|
type SessionInfoEntry = Extract<SessionEntry, { type: "session_info" }>;
|
|
type SessionMessageEntry = Extract<SessionEntry, { type: "message" }>;
|
|
type ThinkingLevelChangeEntry = Extract<SessionEntry, { type: "thinking_level_change" }>;
|
|
|
|
type TranscriptLeafControlEntry = {
|
|
type: "leaf";
|
|
id: string;
|
|
parentId: string | null;
|
|
timestamp: string;
|
|
targetId: string | null;
|
|
appendParentId?: string | null;
|
|
appendMode?: "side";
|
|
};
|
|
|
|
export type TranscriptPersistedEntry = SessionEntry | TranscriptLeafControlEntry;
|
|
|
|
const sessionEntryTypes = new Set<string>([
|
|
"branch_summary",
|
|
"compaction",
|
|
"custom",
|
|
"custom_message",
|
|
"label",
|
|
"message",
|
|
"model_change",
|
|
"session_info",
|
|
"thinking_level_change",
|
|
] satisfies SessionEntry["type"][]);
|
|
|
|
const repairableToolCallContentTypes = new Set([
|
|
"functionCall",
|
|
"function_call",
|
|
"toolCall",
|
|
"toolUse",
|
|
"tool_call",
|
|
"tool_use",
|
|
]);
|
|
|
|
const invalidJsonlSlotType = "__openclaw_invalid_jsonl_slot";
|
|
|
|
function isString(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim() !== "";
|
|
}
|
|
|
|
function isOptionalString(value: unknown): boolean {
|
|
return value === undefined || typeof value === "string";
|
|
}
|
|
|
|
function isTextContent(value: unknown): boolean {
|
|
return (
|
|
isRecord(value) &&
|
|
value.type === "text" &&
|
|
typeof value.text === "string" &&
|
|
isOptionalString(value.textSignature)
|
|
);
|
|
}
|
|
|
|
function isThinkingContent(value: unknown): boolean {
|
|
return (
|
|
isRecord(value) &&
|
|
value.type === "thinking" &&
|
|
typeof value.thinking === "string" &&
|
|
isOptionalString(value.thinkingSignature) &&
|
|
(value.redacted === undefined || typeof value.redacted === "boolean")
|
|
);
|
|
}
|
|
|
|
function isImageContent(value: unknown): boolean {
|
|
return (
|
|
isRecord(value) &&
|
|
value.type === "image" &&
|
|
typeof value.data === "string" &&
|
|
typeof value.mimeType === "string"
|
|
);
|
|
}
|
|
|
|
function hasToolCallId(value: Record<string, unknown>): boolean {
|
|
return (
|
|
isString(value.id) ||
|
|
isString(value.call_id) ||
|
|
isString(value.toolCallId) ||
|
|
isString(value.toolUseId) ||
|
|
isString(value.tool_call_id) ||
|
|
isString(value.tool_use_id)
|
|
);
|
|
}
|
|
|
|
function isToolCallPayload(value: unknown): boolean {
|
|
return value === null || isRecord(value) || typeof value === "string";
|
|
}
|
|
|
|
function isToolCallContent(value: unknown): boolean {
|
|
return (
|
|
isRecord(value) &&
|
|
typeof value.type === "string" &&
|
|
repairableToolCallContentTypes.has(value.type) &&
|
|
hasToolCallId(value) &&
|
|
isString(value.name) &&
|
|
(value.arguments === undefined || isToolCallPayload(value.arguments)) &&
|
|
(value.input === undefined || isToolCallPayload(value.input)) &&
|
|
isOptionalString(value.thoughtSignature)
|
|
);
|
|
}
|
|
|
|
function isPersistedContentBlock(value: unknown): boolean {
|
|
if (!isRecord(value) || !isString(value.type)) {
|
|
return false;
|
|
}
|
|
switch (value.type) {
|
|
case "text":
|
|
return isTextContent(value);
|
|
case "thinking":
|
|
return isThinkingContent(value);
|
|
case "image":
|
|
return isImageContent(value);
|
|
default:
|
|
if (repairableToolCallContentTypes.has(value.type)) {
|
|
return isToolCallContent(value);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function isUserContent(value: unknown): boolean {
|
|
return (
|
|
typeof value === "string" ||
|
|
(Array.isArray(value) && value.every((item) => isPersistedContentBlock(item)))
|
|
);
|
|
}
|
|
|
|
function isAssistantContent(value: unknown): boolean {
|
|
return (
|
|
typeof value === "string" ||
|
|
(Array.isArray(value) && value.every((item) => isPersistedContentBlock(item)))
|
|
);
|
|
}
|
|
|
|
function isToolResultContent(value: unknown): boolean {
|
|
return Array.isArray(value) && value.every((item) => isPersistedContentBlock(item));
|
|
}
|
|
|
|
function isOptionalBoolean(value: unknown): boolean {
|
|
return value === undefined || typeof value === "boolean";
|
|
}
|
|
|
|
function isBashExecutionMessage(value: Record<string, unknown>): boolean {
|
|
return (
|
|
isString(value.command) &&
|
|
typeof value.output === "string" &&
|
|
(value.exitCode === undefined || typeof value.exitCode === "number") &&
|
|
typeof value.cancelled === "boolean" &&
|
|
typeof value.truncated === "boolean" &&
|
|
isOptionalString(value.fullOutputPath) &&
|
|
isOptionalBoolean(value.excludeFromContext)
|
|
);
|
|
}
|
|
|
|
function isAgentMessage(value: unknown): boolean {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
switch (value.role) {
|
|
case "assistant":
|
|
return isAssistantContent(value.content);
|
|
case "bashExecution":
|
|
return isBashExecutionMessage(value);
|
|
case "custom":
|
|
return isString(value.customType) && isUserContent(value.content);
|
|
case "toolResult":
|
|
return (
|
|
isString(value.toolCallId) &&
|
|
isString(value.toolName) &&
|
|
typeof value.isError === "boolean" &&
|
|
isToolResultContent(value.content)
|
|
);
|
|
case "user":
|
|
return isUserContent(value.content);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hasSessionEntryBase(entry: FileEntry): boolean {
|
|
const candidate = entry as {
|
|
id?: unknown;
|
|
parentId?: unknown;
|
|
timestamp?: unknown;
|
|
};
|
|
return (
|
|
isString(candidate.id) &&
|
|
(candidate.parentId === undefined ||
|
|
candidate.parentId === null ||
|
|
isString(candidate.parentId)) &&
|
|
(candidate.timestamp === undefined || isString(candidate.timestamp))
|
|
);
|
|
}
|
|
|
|
function isSessionEntry(entry: FileEntry): entry is SessionEntry {
|
|
if (
|
|
entry.type === "session" ||
|
|
!sessionEntryTypes.has(entry.type) ||
|
|
!hasSessionEntryBase(entry)
|
|
) {
|
|
return false;
|
|
}
|
|
switch (entry.type) {
|
|
case "branch_summary": {
|
|
const candidate = entry as { fromId?: unknown; summary?: unknown };
|
|
return isString(candidate.fromId) && typeof candidate.summary === "string";
|
|
}
|
|
case "compaction": {
|
|
const candidate = entry as {
|
|
firstKeptEntryId?: unknown;
|
|
summary?: unknown;
|
|
tokensBefore?: unknown;
|
|
};
|
|
return (
|
|
isString(candidate.firstKeptEntryId) &&
|
|
typeof candidate.summary === "string" &&
|
|
typeof candidate.tokensBefore === "number"
|
|
);
|
|
}
|
|
case "custom":
|
|
return isString((entry as { customType?: unknown }).customType);
|
|
case "custom_message": {
|
|
const candidate = entry as {
|
|
content?: unknown;
|
|
customType?: unknown;
|
|
display?: unknown;
|
|
};
|
|
return (
|
|
isString(candidate.customType) &&
|
|
isUserContent(candidate.content) &&
|
|
typeof candidate.display === "boolean"
|
|
);
|
|
}
|
|
case "label": {
|
|
const candidate = entry as { label?: unknown; targetId?: unknown };
|
|
return (
|
|
isString(candidate.targetId) &&
|
|
(candidate.label === undefined || typeof candidate.label === "string")
|
|
);
|
|
}
|
|
case "message": {
|
|
return isAgentMessage((entry as { message?: unknown }).message);
|
|
}
|
|
case "model_change": {
|
|
const candidate = entry as { modelId?: unknown; provider?: unknown };
|
|
return isString(candidate.provider) && isString(candidate.modelId);
|
|
}
|
|
case "session_info": {
|
|
const candidate = entry as { name?: unknown };
|
|
return candidate.name === undefined || typeof candidate.name === "string";
|
|
}
|
|
case "thinking_level_change":
|
|
return isString((entry as { thinkingLevel?: unknown }).thinkingLevel);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function parseLeafControlEntry(entry: unknown):
|
|
| {
|
|
id: string;
|
|
parentId: string | null;
|
|
targetId: string | null;
|
|
appendParentId?: string | null;
|
|
appendMode?: "side";
|
|
}
|
|
| undefined {
|
|
if (!isRecord(entry) || entry.type !== "leaf") {
|
|
return undefined;
|
|
}
|
|
const candidate = entry as {
|
|
id?: unknown;
|
|
parentId?: unknown;
|
|
targetId?: unknown;
|
|
appendParentId?: unknown;
|
|
appendMode?: unknown;
|
|
timestamp?: unknown;
|
|
};
|
|
if (
|
|
!isString(candidate.id) ||
|
|
(candidate.parentId !== undefined &&
|
|
candidate.parentId !== null &&
|
|
!isString(candidate.parentId)) ||
|
|
(candidate.timestamp !== undefined && !isString(candidate.timestamp)) ||
|
|
(candidate.targetId !== null && typeof candidate.targetId !== "string") ||
|
|
(candidate.appendParentId !== undefined &&
|
|
candidate.appendParentId !== null &&
|
|
typeof candidate.appendParentId !== "string") ||
|
|
(candidate.appendMode !== undefined && candidate.appendMode !== "side")
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
id: candidate.id,
|
|
parentId: candidate.parentId ?? null,
|
|
targetId: candidate.targetId,
|
|
...(candidate.appendParentId !== undefined ? { appendParentId: candidate.appendParentId } : {}),
|
|
...(candidate.appendMode === "side" ? { appendMode: candidate.appendMode } : {}),
|
|
};
|
|
}
|
|
|
|
type ReadableSessionState = {
|
|
entries: SessionEntry[];
|
|
leafId: string | null;
|
|
appendParentId: string | null;
|
|
appendMode?: "side";
|
|
opaqueParentsById: Map<string, string | null>;
|
|
logicalParentsById: Map<string, string | null>;
|
|
};
|
|
|
|
// Keep every readable entry while repairing links through rejected rows. This
|
|
// preserves usable branches from partially written or migrated transcripts.
|
|
function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
|
|
const entries: SessionEntry[] = [];
|
|
const acceptedIds = new Set<string>();
|
|
const acceptedEntryById = new Map<string, SessionEntry>();
|
|
const rejectedIds = new Set<string>();
|
|
const rejectedParentById = new Map<string, string | null>();
|
|
const logicalParentsById = new Map<string, string | null>();
|
|
const invalidLeafIds = new Set<string>();
|
|
const firstReadableDescendantByRejectedId = new Map<string, string>();
|
|
const rejectedAncestorsByAcceptedId = new Map<string, string[]>();
|
|
let effectiveLeafId: string | null = null;
|
|
let effectiveAppendParentId: string | null = null;
|
|
let effectiveAppendMode: "side" | undefined;
|
|
const acceptedPath = (leafId: string | null | undefined): SessionEntry[] => {
|
|
const pathLocal: SessionEntry[] = [];
|
|
let id = leafId ?? null;
|
|
const seen = new Set<string>();
|
|
while (id !== null) {
|
|
if (seen.has(id)) {
|
|
break;
|
|
}
|
|
seen.add(id);
|
|
const entry = acceptedEntryById.get(id);
|
|
if (!entry) {
|
|
break;
|
|
}
|
|
pathLocal.unshift(entry);
|
|
id = entry.parentId;
|
|
}
|
|
return pathLocal;
|
|
};
|
|
const firstReadableDescendantOnBranch = (
|
|
rejectedId: string,
|
|
leafId: string | null | undefined,
|
|
): string | undefined => {
|
|
for (const entry of acceptedPath(leafId)) {
|
|
if (rejectedAncestorsByAcceptedId.get(entry.id)?.includes(rejectedId)) {
|
|
return entry.id;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const rejectedParentChain = (parentId: string | null | undefined): string[] => {
|
|
const chain: string[] = [];
|
|
let resolved = parentId ?? null;
|
|
const seen = new Set<string>();
|
|
while (resolved !== null && rejectedParentById.has(resolved)) {
|
|
if (seen.has(resolved)) {
|
|
break;
|
|
}
|
|
seen.add(resolved);
|
|
chain.push(resolved);
|
|
resolved = rejectedParentById.get(resolved) ?? null;
|
|
}
|
|
return chain;
|
|
};
|
|
const resolveRejectedParent = (parentId: string | null | undefined): string | null => {
|
|
let resolved = parentId ?? null;
|
|
const seen = new Set<string>();
|
|
while (resolved !== null && rejectedParentById.has(resolved)) {
|
|
if (seen.has(resolved)) {
|
|
return null;
|
|
}
|
|
seen.add(resolved);
|
|
resolved = rejectedParentById.get(resolved) ?? null;
|
|
}
|
|
return resolved;
|
|
};
|
|
const repairEntryLinks = (entry: SessionEntry): SessionEntry => {
|
|
const rejectedAncestors = rejectedParentChain(entry.parentId);
|
|
const resolvedRejectedParent =
|
|
rejectedAncestors.length > 0 ? resolveRejectedParent(entry.parentId) : undefined;
|
|
const parentId =
|
|
resolvedRejectedParent !== undefined
|
|
? resolvedRejectedParent !== null && acceptedIds.has(resolvedRejectedParent)
|
|
? resolvedRejectedParent
|
|
: null
|
|
: (entry.parentId ?? null);
|
|
let repaired = parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry);
|
|
if (repaired.type === "compaction" && rejectedIds.has(repaired.firstKeptEntryId)) {
|
|
// A rejected first-kept row would make compaction summaries unusable.
|
|
// Prefer the closest readable parent, then a readable descendant on the
|
|
// same branch, before falling back to the repaired parent.
|
|
const resolvedFirstKeptParent = resolveRejectedParent(repaired.firstKeptEntryId);
|
|
const firstKeptEntryId =
|
|
(resolvedFirstKeptParent !== null && acceptedIds.has(resolvedFirstKeptParent)
|
|
? resolvedFirstKeptParent
|
|
: undefined) ??
|
|
firstReadableDescendantOnBranch(repaired.firstKeptEntryId, parentId) ??
|
|
firstReadableDescendantByRejectedId.get(repaired.firstKeptEntryId) ??
|
|
parentId;
|
|
if (firstKeptEntryId !== null && firstKeptEntryId !== repaired.firstKeptEntryId) {
|
|
repaired = { ...repaired, firstKeptEntryId } as SessionEntry;
|
|
}
|
|
}
|
|
if (repaired.type !== "compaction") {
|
|
for (const rejectedId of rejectedAncestors) {
|
|
if (!firstReadableDescendantByRejectedId.has(rejectedId)) {
|
|
firstReadableDescendantByRejectedId.set(rejectedId, repaired.id);
|
|
}
|
|
}
|
|
if (rejectedAncestors.length > 0) {
|
|
rejectedAncestorsByAcceptedId.set(repaired.id, rejectedAncestors);
|
|
}
|
|
}
|
|
return repaired;
|
|
};
|
|
for (const rawEntry of fileEntries) {
|
|
if (!isRecord(rawEntry)) {
|
|
continue;
|
|
}
|
|
const rawRecord = rawEntry as unknown as Record<string, unknown>;
|
|
const entry = rawEntry as FileEntry;
|
|
const id = rawRecord.id;
|
|
const rawType = rawRecord.type;
|
|
const rawParentId = rawRecord.parentId;
|
|
const leafEntry = parseLeafControlEntry(rawRecord);
|
|
if (leafEntry) {
|
|
rejectedIds.add(leafEntry.id);
|
|
const targetIsKnown =
|
|
leafEntry.targetId === null ||
|
|
acceptedIds.has(leafEntry.targetId) ||
|
|
(rejectedParentById.has(leafEntry.targetId) && !invalidLeafIds.has(leafEntry.targetId));
|
|
const appendParentIsKnown =
|
|
leafEntry.appendParentId === undefined ||
|
|
leafEntry.appendParentId === null ||
|
|
acceptedIds.has(leafEntry.appendParentId) ||
|
|
(rejectedParentById.has(leafEntry.appendParentId) &&
|
|
!invalidLeafIds.has(leafEntry.appendParentId));
|
|
if (!targetIsKnown || !appendParentIsKnown) {
|
|
// Ignore corrupt navigation state, but keep the marker transparent so
|
|
// descendants can still repair through the serialized raw branch.
|
|
invalidLeafIds.add(leafEntry.id);
|
|
rejectedParentById.set(leafEntry.id, leafEntry.parentId);
|
|
continue;
|
|
}
|
|
rejectedParentById.set(leafEntry.id, leafEntry.targetId);
|
|
const resolvedTargetId = resolveRejectedParent(leafEntry.targetId);
|
|
effectiveLeafId =
|
|
resolvedTargetId !== null && acceptedIds.has(resolvedTargetId) ? resolvedTargetId : null;
|
|
effectiveAppendParentId =
|
|
leafEntry.appendParentId === undefined ? effectiveLeafId : leafEntry.appendParentId;
|
|
effectiveAppendMode = leafEntry.appendMode;
|
|
continue;
|
|
}
|
|
if (rawType === "leaf") {
|
|
if (isString(id)) {
|
|
rejectedIds.add(id);
|
|
invalidLeafIds.add(id);
|
|
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
|
|
}
|
|
continue;
|
|
}
|
|
if (!isSessionEntry(entry)) {
|
|
if (isString(id)) {
|
|
rejectedIds.add(id);
|
|
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
|
|
const isParentLinkedOpaque =
|
|
typeof rawType === "string" &&
|
|
rawType !== "session" &&
|
|
!id.startsWith("__openclaw_invalid_jsonl_slot_") &&
|
|
!sessionEntryTypes.has(rawType) &&
|
|
Object.hasOwn(rawRecord, "parentId") &&
|
|
(rawParentId === null || isString(rawParentId));
|
|
if (isParentLinkedOpaque) {
|
|
effectiveAppendParentId = id;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (entry.type === "label" && !acceptedIds.has(entry.targetId)) {
|
|
rejectedIds.add(entry.id);
|
|
rejectedParentById.set(entry.id, entry.parentId);
|
|
continue;
|
|
}
|
|
if (acceptedIds.has(entry.id)) {
|
|
continue;
|
|
}
|
|
const hasSerializedParent = Object.hasOwn(rawRecord, "parentId");
|
|
if (
|
|
!hasSerializedParent ||
|
|
(!isSessionTranscriptSideAppendEntry(rawRecord) &&
|
|
entry.parentId === effectiveAppendParentId &&
|
|
effectiveLeafId !== effectiveAppendParentId)
|
|
) {
|
|
logicalParentsById.set(entry.id, effectiveLeafId);
|
|
}
|
|
const repaired = repairEntryLinks(entry);
|
|
entries.push(repaired);
|
|
acceptedIds.add(repaired.id);
|
|
acceptedEntryById.set(repaired.id, repaired);
|
|
effectiveAppendParentId = repaired.id;
|
|
if (isSessionTranscriptSideAppendEntry(rawRecord)) {
|
|
effectiveAppendMode = "side";
|
|
} else {
|
|
effectiveLeafId = repaired.id;
|
|
effectiveAppendMode = undefined;
|
|
}
|
|
}
|
|
return {
|
|
entries,
|
|
leafId: effectiveLeafId,
|
|
appendParentId: effectiveAppendParentId,
|
|
...(effectiveAppendMode ? { appendMode: effectiveAppendMode } : {}),
|
|
opaqueParentsById: rejectedParentById,
|
|
logicalParentsById,
|
|
};
|
|
}
|
|
|
|
function sessionHeaderVersion(header: SessionHeader | null): number {
|
|
return typeof header?.version === "number" ? header.version : 1;
|
|
}
|
|
|
|
function generateEntryId(byId: { has(id: string): boolean }): string {
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
const id = randomUUID().slice(0, 8);
|
|
if (!byId.has(id)) {
|
|
return id;
|
|
}
|
|
}
|
|
return randomUUID();
|
|
}
|
|
|
|
function serializeTranscriptFileEntries(entries: readonly unknown[]): string {
|
|
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
}
|
|
|
|
function fileEntryOrMigrationSlot(value: unknown, index: number): FileEntry {
|
|
if (isRecord(value)) {
|
|
return value as unknown as FileEntry;
|
|
}
|
|
return {
|
|
type: invalidJsonlSlotType,
|
|
id: `__openclaw_invalid_jsonl_slot_${index}`,
|
|
parentId: null,
|
|
timestamp: "1970-01-01T00:00:00.000Z",
|
|
} as unknown as FileEntry;
|
|
}
|
|
|
|
/** In-memory transcript state with branch, label, and append helpers. */
|
|
export class TranscriptFileState {
|
|
readonly header: SessionHeader | null;
|
|
readonly entries: SessionEntry[];
|
|
readonly migrated: boolean;
|
|
private readonly byId = new Map<string, SessionEntry>();
|
|
private readonly labelsById = new Map<string, string>();
|
|
private readonly labelTimestampsById = new Map<string, string>();
|
|
private readonly opaqueParentsById = new Map<string, string | null>();
|
|
private readonly logicalParentsById = new Map<string, string | null>();
|
|
private leafId: string | null = null;
|
|
private appendParentId: string | null = null;
|
|
private appendMode: "side" | undefined;
|
|
|
|
constructor(params: {
|
|
header: SessionHeader | null;
|
|
entries: SessionEntry[];
|
|
leafId?: string | null;
|
|
appendParentId?: string | null;
|
|
appendMode?: "side";
|
|
opaqueParentsById?: ReadonlyMap<string, string | null>;
|
|
logicalParentsById?: ReadonlyMap<string, string | null>;
|
|
migrated?: boolean;
|
|
}) {
|
|
this.header = params.header;
|
|
this.entries = [...params.entries];
|
|
this.migrated = params.migrated === true;
|
|
for (const [id, parentId] of params.opaqueParentsById ?? []) {
|
|
this.opaqueParentsById.set(id, parentId);
|
|
}
|
|
for (const [id, parentId] of params.logicalParentsById ?? []) {
|
|
this.logicalParentsById.set(id, parentId);
|
|
}
|
|
this.rebuildIndex(params.leafId, params.appendParentId);
|
|
this.appendMode = params.appendMode;
|
|
}
|
|
|
|
private resolveCanonicalParentId(parentId: string | null): string | null {
|
|
const seen = new Set<string>();
|
|
let currentId = parentId;
|
|
while (currentId !== null && this.opaqueParentsById.has(currentId)) {
|
|
if (seen.has(currentId)) {
|
|
return null;
|
|
}
|
|
seen.add(currentId);
|
|
currentId = this.opaqueParentsById.get(currentId) ?? null;
|
|
}
|
|
return currentId;
|
|
}
|
|
|
|
private rebuildIndex(leafId?: string | null, appendParentId?: string | null): void {
|
|
this.byId.clear();
|
|
this.labelsById.clear();
|
|
this.labelTimestampsById.clear();
|
|
this.leafId = null;
|
|
this.appendParentId = null;
|
|
for (const entry of this.entries) {
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = entry.id;
|
|
this.appendParentId = entry.id;
|
|
if (entry.type === "label") {
|
|
if (entry.label) {
|
|
this.labelsById.set(entry.targetId, entry.label);
|
|
this.labelTimestampsById.set(entry.targetId, entry.timestamp);
|
|
} else {
|
|
this.labelsById.delete(entry.targetId);
|
|
this.labelTimestampsById.delete(entry.targetId);
|
|
}
|
|
}
|
|
}
|
|
if (leafId !== undefined) {
|
|
this.leafId = leafId;
|
|
}
|
|
if (appendParentId !== undefined) {
|
|
this.appendParentId = appendParentId;
|
|
} else if (leafId !== undefined) {
|
|
this.appendParentId = leafId;
|
|
}
|
|
}
|
|
|
|
getCwd(): string {
|
|
return this.header?.cwd ?? process.cwd();
|
|
}
|
|
|
|
getHeader(): SessionHeader | null {
|
|
return this.header;
|
|
}
|
|
|
|
getEntries(): SessionEntry[] {
|
|
return [...this.entries];
|
|
}
|
|
|
|
getLeafId(): string | null {
|
|
return this.leafId;
|
|
}
|
|
|
|
getAppendParentId(): string | null {
|
|
return this.appendParentId;
|
|
}
|
|
|
|
getAppendMode(): "side" | undefined {
|
|
return this.appendMode;
|
|
}
|
|
|
|
getLeafEntry(): SessionEntry | undefined {
|
|
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
|
}
|
|
|
|
getLabel(id: string): string | undefined {
|
|
return this.labelsById.get(id);
|
|
}
|
|
|
|
getBranch(fromId?: string): SessionEntry[] {
|
|
const branch: SessionEntry[] = [];
|
|
const seen = new Set<string>();
|
|
let currentId = fromId ?? this.leafId;
|
|
while (currentId && !seen.has(currentId)) {
|
|
const current = this.byId.get(currentId);
|
|
if (!current) {
|
|
break;
|
|
}
|
|
seen.add(current.id);
|
|
const resolvedParentId = this.logicalParentsById.has(current.id)
|
|
? (this.logicalParentsById.get(current.id) ?? null)
|
|
: this.resolveCanonicalParentId(current.parentId);
|
|
const parentId =
|
|
resolvedParentId === current.id || (resolvedParentId && seen.has(resolvedParentId))
|
|
? null
|
|
: resolvedParentId;
|
|
branch.push(
|
|
parentId === current.parentId ? current : ({ ...current, parentId } as SessionEntry),
|
|
);
|
|
currentId = parentId;
|
|
}
|
|
branch.reverse();
|
|
return branch;
|
|
}
|
|
|
|
buildSessionContext(): SessionContext {
|
|
const entries = this.getBranch();
|
|
const leafId = entries.at(-1)?.id ?? null;
|
|
return buildSessionContext(entries, leafId, new Map(entries.map((entry) => [entry.id, entry])));
|
|
}
|
|
|
|
/** Move the active leaf to an existing entry without appending a row. */
|
|
branch(branchFromId: string): void {
|
|
if (!this.byId.has(branchFromId)) {
|
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
}
|
|
this.leafId = branchFromId;
|
|
this.appendParentId = branchFromId;
|
|
this.appendMode = undefined;
|
|
}
|
|
|
|
/** Clear the active leaf so the next append starts a root branch. */
|
|
resetLeaf(): void {
|
|
this.leafId = null;
|
|
this.appendParentId = null;
|
|
this.appendMode = undefined;
|
|
}
|
|
|
|
appendMessage(message: SessionMessageEntry["message"]): SessionMessageEntry {
|
|
return this.appendEntry({
|
|
type: "message",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
message,
|
|
});
|
|
}
|
|
|
|
appendThinkingLevelChange(thinkingLevel: string): ThinkingLevelChangeEntry {
|
|
return this.appendEntry({
|
|
type: "thinking_level_change",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
thinkingLevel,
|
|
});
|
|
}
|
|
|
|
appendModelChange(provider: string, modelId: string): ModelChangeEntry {
|
|
return this.appendEntry({
|
|
type: "model_change",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
provider,
|
|
modelId,
|
|
});
|
|
}
|
|
|
|
appendCompaction(
|
|
summary: string,
|
|
firstKeptEntryId: string,
|
|
tokensBefore: number,
|
|
details?: unknown,
|
|
fromHook?: boolean,
|
|
): CompactionEntry {
|
|
return this.appendEntry({
|
|
type: "compaction",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
summary,
|
|
firstKeptEntryId,
|
|
tokensBefore,
|
|
details,
|
|
fromHook,
|
|
});
|
|
}
|
|
|
|
appendCustomEntry(customType: string, data?: unknown): CustomEntry {
|
|
return this.appendEntry({
|
|
type: "custom",
|
|
customType,
|
|
data,
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
appendSessionInfo(name: string): SessionInfoEntry {
|
|
return this.appendEntry({
|
|
type: "session_info",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
name: name.trim(),
|
|
});
|
|
}
|
|
|
|
appendCustomMessageEntry(
|
|
customType: string,
|
|
content: CustomMessageEntry["content"],
|
|
display: boolean,
|
|
details?: unknown,
|
|
): CustomMessageEntry {
|
|
return this.appendEntry({
|
|
type: "custom_message",
|
|
customType,
|
|
content,
|
|
display,
|
|
details,
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
appendLabelChange(targetId: string, label: string | undefined): LabelEntry {
|
|
if (!this.byId.has(targetId)) {
|
|
throw new Error(`Entry ${targetId} not found`);
|
|
}
|
|
return this.appendEntry({
|
|
type: "label",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId,
|
|
label,
|
|
});
|
|
}
|
|
|
|
branchWithSummary(
|
|
branchFromId: string | null,
|
|
summary: string,
|
|
details?: unknown,
|
|
fromHook?: boolean,
|
|
): BranchSummaryEntry {
|
|
if (branchFromId !== null && !this.byId.has(branchFromId)) {
|
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
}
|
|
this.leafId = branchFromId;
|
|
this.appendParentId = branchFromId;
|
|
return this.appendEntry({
|
|
type: "branch_summary",
|
|
id: generateEntryId(this.byId),
|
|
parentId: branchFromId,
|
|
timestamp: new Date().toISOString(),
|
|
fromId: branchFromId ?? "root",
|
|
summary,
|
|
details,
|
|
fromHook,
|
|
});
|
|
}
|
|
|
|
appendLeafControl(params: {
|
|
targetId: string | null;
|
|
appendParentId: string | null;
|
|
appendMode?: "side";
|
|
}): TranscriptLeafControlEntry {
|
|
if (params.targetId !== null && !this.byId.has(params.targetId)) {
|
|
throw new Error(`Entry ${params.targetId} not found`);
|
|
}
|
|
if (
|
|
params.appendParentId !== null &&
|
|
!this.byId.has(params.appendParentId) &&
|
|
!this.opaqueParentsById.has(params.appendParentId)
|
|
) {
|
|
throw new Error(`Entry ${params.appendParentId} not found`);
|
|
}
|
|
const entry: TranscriptLeafControlEntry = {
|
|
type: "leaf",
|
|
id: generateEntryId({
|
|
has: (id) => this.byId.has(id) || this.opaqueParentsById.has(id),
|
|
}),
|
|
parentId: this.appendParentId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId: params.targetId,
|
|
...(params.appendParentId !== params.targetId
|
|
? { appendParentId: params.appendParentId }
|
|
: {}),
|
|
...(params.appendMode ? { appendMode: params.appendMode } : {}),
|
|
};
|
|
this.opaqueParentsById.set(entry.id, params.targetId);
|
|
this.leafId = params.targetId;
|
|
this.appendParentId = params.appendParentId;
|
|
this.appendMode = params.appendMode;
|
|
return entry;
|
|
}
|
|
|
|
private appendEntry<T extends SessionEntry>(entry: T): T {
|
|
if (
|
|
!isSessionTranscriptSideAppendEntry(entry) &&
|
|
entry.parentId === this.appendParentId &&
|
|
this.leafId !== this.appendParentId
|
|
) {
|
|
this.logicalParentsById.set(entry.id, this.leafId);
|
|
}
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
this.appendParentId = entry.id;
|
|
if (isSessionTranscriptSideAppendEntry(entry)) {
|
|
this.appendMode = "side";
|
|
} else {
|
|
this.leafId = entry.id;
|
|
this.appendMode = undefined;
|
|
}
|
|
if (entry.type === "label") {
|
|
if (entry.label) {
|
|
this.labelsById.set(entry.targetId, entry.label);
|
|
this.labelTimestampsById.set(entry.targetId, entry.timestamp);
|
|
} else {
|
|
this.labelsById.delete(entry.targetId);
|
|
this.labelTimestampsById.delete(entry.targetId);
|
|
}
|
|
}
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
/** Read a transcript file, migrate old rows, and drop only unrecoverable entries. */
|
|
export async function readTranscriptFileState(sessionFile: string): Promise<TranscriptFileState> {
|
|
const raw = await fs.readFile(sessionFile, "utf-8");
|
|
const fileEntries = (parseSessionEntries(raw) as unknown[]).map(fileEntryOrMigrationSlot);
|
|
const headerBeforeMigration =
|
|
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
|
const headerVersionBeforeMigration = sessionHeaderVersion(headerBeforeMigration);
|
|
const migrated = headerVersionBeforeMigration < CURRENT_SESSION_VERSION;
|
|
migrateSessionEntries(fileEntries);
|
|
const header =
|
|
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
|
const readable = readableSessionState(fileEntries);
|
|
return new TranscriptFileState({
|
|
header,
|
|
entries: readable.entries,
|
|
leafId: readable.leafId,
|
|
appendParentId: migrated ? readable.leafId : readable.appendParentId,
|
|
...(!migrated && readable.appendMode ? { appendMode: readable.appendMode } : {}),
|
|
opaqueParentsById: readable.opaqueParentsById,
|
|
logicalParentsById: readable.logicalParentsById,
|
|
migrated,
|
|
});
|
|
}
|
|
|
|
/** Rewrite the full transcript through the private-file store. */
|
|
export async function writeTranscriptFileAtomic(
|
|
filePath: string,
|
|
entries: Array<SessionHeader | TranscriptPersistedEntry>,
|
|
): Promise<void> {
|
|
await privateFileStore(path.dirname(filePath)).writeText(
|
|
path.basename(filePath),
|
|
serializeTranscriptFileEntries(entries),
|
|
);
|
|
}
|
|
|
|
/** Persist a state mutation using append-only writes unless migration forced a rewrite. */
|
|
export async function persistTranscriptStateMutation(params: {
|
|
sessionFile: string;
|
|
state: TranscriptFileState;
|
|
appendedEntries: TranscriptPersistedEntry[];
|
|
}): Promise<void> {
|
|
if (params.appendedEntries.length === 0 && !params.state.migrated) {
|
|
return;
|
|
}
|
|
if (params.state.migrated) {
|
|
const appendedLeafControls = params.appendedEntries.filter(
|
|
(entry): entry is TranscriptLeafControlEntry => entry.type === "leaf",
|
|
);
|
|
await writeTranscriptFileAtomic(params.sessionFile, [
|
|
...(params.state.header ? [params.state.header] : []),
|
|
...params.state.entries,
|
|
...appendedLeafControls,
|
|
]);
|
|
return;
|
|
}
|
|
await appendRegularFile({
|
|
filePath: params.sessionFile,
|
|
content: `${params.appendedEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
|
|
rejectSymlinkParents: true,
|
|
});
|
|
}
|