mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 19:40:42 +00:00
326 lines
9.3 KiB
TypeScript
326 lines
9.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
buildSessionContext,
|
|
CURRENT_SESSION_VERSION,
|
|
migrateSessionEntries,
|
|
parseSessionEntries,
|
|
type FileEntry,
|
|
type SessionContext,
|
|
type SessionEntry,
|
|
type SessionHeader,
|
|
} from "@mariozechner/pi-coding-agent";
|
|
import { appendRegularFile } from "../../infra/fs-safe.js";
|
|
import { privateFileStore } from "../../infra/private-file-store.js";
|
|
|
|
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" }>;
|
|
|
|
function isSessionEntry(entry: FileEntry): entry is SessionEntry {
|
|
return entry.type !== "session";
|
|
}
|
|
|
|
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: FileEntry[]): string {
|
|
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
}
|
|
|
|
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 leafId: string | null = null;
|
|
|
|
constructor(params: {
|
|
header: SessionHeader | null;
|
|
entries: SessionEntry[];
|
|
migrated?: boolean;
|
|
}) {
|
|
this.header = params.header;
|
|
this.entries = [...params.entries];
|
|
this.migrated = params.migrated === true;
|
|
this.rebuildIndex();
|
|
}
|
|
|
|
private rebuildIndex(): void {
|
|
this.byId.clear();
|
|
this.labelsById.clear();
|
|
this.labelTimestampsById.clear();
|
|
this.leafId = null;
|
|
for (const entry of this.entries) {
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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[] = [];
|
|
let current = (fromId ?? this.leafId) ? this.byId.get((fromId ?? this.leafId)!) : undefined;
|
|
while (current) {
|
|
branch.push(current);
|
|
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
|
}
|
|
branch.reverse();
|
|
return branch;
|
|
}
|
|
|
|
buildSessionContext(): SessionContext {
|
|
return buildSessionContext(this.entries, this.leafId, this.byId);
|
|
}
|
|
|
|
branch(branchFromId: string): void {
|
|
if (!this.byId.has(branchFromId)) {
|
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
}
|
|
this.leafId = branchFromId;
|
|
}
|
|
|
|
resetLeaf(): void {
|
|
this.leafId = null;
|
|
}
|
|
|
|
appendMessage(message: SessionMessageEntry["message"]): SessionMessageEntry {
|
|
return this.appendEntry({
|
|
type: "message",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
message,
|
|
});
|
|
}
|
|
|
|
appendThinkingLevelChange(thinkingLevel: string): ThinkingLevelChangeEntry {
|
|
return this.appendEntry({
|
|
type: "thinking_level_change",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
thinkingLevel,
|
|
});
|
|
}
|
|
|
|
appendModelChange(provider: string, modelId: string): ModelChangeEntry {
|
|
return this.appendEntry({
|
|
type: "model_change",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
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.leafId,
|
|
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.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
appendSessionInfo(name: string): SessionInfoEntry {
|
|
return this.appendEntry({
|
|
type: "session_info",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
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.leafId,
|
|
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.leafId,
|
|
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;
|
|
return this.appendEntry({
|
|
type: "branch_summary",
|
|
id: generateEntryId(this.byId),
|
|
parentId: branchFromId,
|
|
timestamp: new Date().toISOString(),
|
|
fromId: branchFromId ?? "root",
|
|
summary,
|
|
details,
|
|
fromHook,
|
|
});
|
|
}
|
|
|
|
private appendEntry<T extends SessionEntry>(entry: T): T {
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = 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);
|
|
}
|
|
}
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
export async function readTranscriptFileState(sessionFile: string): Promise<TranscriptFileState> {
|
|
const raw = await fs.readFile(sessionFile, "utf-8");
|
|
const fileEntries = parseSessionEntries(raw);
|
|
const headerBeforeMigration =
|
|
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
|
const migrated = sessionHeaderVersion(headerBeforeMigration) < CURRENT_SESSION_VERSION;
|
|
migrateSessionEntries(fileEntries);
|
|
const header =
|
|
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
|
const entries = fileEntries.filter(isSessionEntry);
|
|
return new TranscriptFileState({ header, entries, migrated });
|
|
}
|
|
|
|
export async function writeTranscriptFileAtomic(
|
|
filePath: string,
|
|
entries: Array<SessionHeader | SessionEntry>,
|
|
): Promise<void> {
|
|
await privateFileStore(path.dirname(filePath)).writeText(
|
|
path.basename(filePath),
|
|
serializeTranscriptFileEntries(entries),
|
|
);
|
|
}
|
|
|
|
export async function persistTranscriptStateMutation(params: {
|
|
sessionFile: string;
|
|
state: TranscriptFileState;
|
|
appendedEntries: SessionEntry[];
|
|
}): Promise<void> {
|
|
if (params.appendedEntries.length === 0 && !params.state.migrated) {
|
|
return;
|
|
}
|
|
if (params.state.migrated) {
|
|
await writeTranscriptFileAtomic(params.sessionFile, [
|
|
...(params.state.header ? [params.state.header] : []),
|
|
...params.state.entries,
|
|
]);
|
|
return;
|
|
}
|
|
await appendRegularFile({
|
|
filePath: params.sessionFile,
|
|
content: `${params.appendedEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
|
|
rejectSymlinkParents: true,
|
|
});
|
|
}
|