/** * KHAELOR * File: src/session/fork.ts * Description: Session forking — JSONL prefix copy + meta.json lineage (parent/forkPoint) and checkpoint discovery (v2 design §2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { KhaelorError, ulid } from "../shared/index.js"; import type { DurableEvent } from "./events.js"; /** Lineage sidecar: .meta.json next to the JSONL (v2 §2). */ export interface SessionMeta { parent: string | null; forkPoint: number | null; /** Set when this session is a /replay of another session. */ replayOf?: string; createdAt: number; } export function metaFilePath(sessionsDir: string, projectHash: string, sessionId: string): string { return join(sessionsDir, projectHash, `${sessionId}.meta.json`); } export async function readSessionMeta( sessionsDir: string, projectHash: string, sessionId: string, ): Promise { try { const raw = JSON.parse( await readFile(metaFilePath(sessionsDir, projectHash, sessionId), "utf8"), ) as Record; return { parent: typeof raw["parent"] === "string" ? raw["parent"] : null, forkPoint: typeof raw["forkPoint"] === "number" ? raw["forkPoint"] : null, ...(typeof raw["replayOf"] === "string" ? { replayOf: raw["replayOf"] } : {}), createdAt: typeof raw["createdAt"] === "number" ? raw["createdAt"] : 0, }; } catch { return null; } } export async function writeSessionMeta( sessionsDir: string, projectHash: string, sessionId: string, meta: SessionMeta, ): Promise { const path = metaFilePath(sessionsDir, projectHash, sessionId); await mkdir(join(sessionsDir, projectHash), { recursive: true }); await writeFile(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); } // ───────────────────────── checkpoints ───────────────────────── export interface ForkCheckpoint { seq: number; kind: "user-turn" | "design-approved" | "compaction"; label: string; } /** * The natural fork points already present in the log: every user turn, every * approved design, every structured checkpoint (v2 §2). */ export function listForkCheckpoints(events: readonly DurableEvent[]): ForkCheckpoint[] { const checkpoints: ForkCheckpoint[] = []; for (const event of events) { if (event.type === "user.message-created") { const preview = event.payload.text.replace(/\s+/g, " ").slice(0, 48); checkpoints.push({ seq: event.seq, kind: "user-turn", label: `❯ ${preview}` }); } else if (event.type === "phase.approved" && event.payload.phase === "design") { checkpoints.push({ seq: event.seq, kind: "design-approved", label: "✓ design approved" }); } else if (event.type === "context.compacted") { checkpoints.push({ seq: event.seq, kind: "compaction", label: "⊟ context checkpoint" }); } } return checkpoints; } // ───────────────────────── fork ───────────────────────── export interface ForkOptions { sessionsDir: string; projectHash: string; sourceSessionId: string; /** Copy events up to and including this seq. Omitted → the whole log. */ uptoSeq?: number; newSessionId?: string; } export interface ForkResult { sessionId: string; filePath: string; copiedEvents: number; forkPoint: number; } /** * Fork = copy the JSONL prefix into a fresh session file and record lineage. * Envelope sessionIds are rewritten so the child log is self-consistent; * seq/ids/payloads are byte-preserved otherwise. Resume of the fork replays * the prefix exactly (v2 §2 — "resume = replay du JSONL, donc fork est * presque gratuit"). */ export async function forkSession(options: ForkOptions): Promise { const dir = join(options.sessionsDir, options.projectHash); const sourcePath = join(dir, `${options.sourceSessionId}.jsonl`); let content: string; try { content = await readFile(sourcePath, "utf8"); } catch (error) { throw new KhaelorError("session-log-io", `Cannot read session log: ${sourcePath}`, { cause: String(error), }); } const newSessionId = options.newSessionId ?? ulid(); const outLines: string[] = []; let lastSeq = 0; for (const line of content.split("\n")) { if (line.length === 0) continue; let parsed: Record; try { parsed = JSON.parse(line) as Record; } catch { break; // torn tail — the prefix up to here is still a valid fork base } const seq = parsed["seq"]; if (typeof seq !== "number") break; if (options.uptoSeq !== undefined && seq > options.uptoSeq) break; parsed["sessionId"] = newSessionId; outLines.push(JSON.stringify(parsed)); lastSeq = seq; } if (outLines.length === 0) { throw new KhaelorError( "invalid-event", `Fork of ${options.sourceSessionId} at seq ${options.uptoSeq ?? 0} would be empty.`, ); } const filePath = join(dir, `${newSessionId}.jsonl`); await mkdir(dir, { recursive: true }); await writeFile(filePath, `${outLines.join("\n")}\n`, { flag: "wx", encoding: "utf8" }); await writeSessionMeta(options.sessionsDir, options.projectHash, newSessionId, { parent: options.sourceSessionId, forkPoint: lastSeq, createdAt: Date.now(), }); return { sessionId: newSessionId, filePath, copiedEvents: outLines.length, forkPoint: lastSeq }; } /** The user turns of a session, in order — the /replay input (v2 §2). */ export function extractUserTurns(events: readonly DurableEvent[]): string[] { const turns: string[] = []; for (const event of events) { if (event.type === "user.message-created") turns.push(event.payload.text); } return turns; }