spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/session/fork.ts4 * Description: Session forking — JSONL prefix copy + meta.json lineage (parent/forkPoint) and checkpoint discovery (v2 design §2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdir, readFile, writeFile } from "node:fs/promises";11import { join } from "node:path";12import { KhaelorError, ulid } from "../shared/index.js";13import type { DurableEvent } from "./events.js";1415/** Lineage sidecar: <sessionId>.meta.json next to the JSONL (v2 §2). */16export interface SessionMeta {17 parent: string | null;18 forkPoint: number | null;19 /** Set when this session is a /replay of another session. */20 replayOf?: string;21 createdAt: number;22}2324export function metaFilePath(sessionsDir: string, projectHash: string, sessionId: string): string {25 return join(sessionsDir, projectHash, `${sessionId}.meta.json`);26}2728export async function readSessionMeta(29 sessionsDir: string,30 projectHash: string,31 sessionId: string,32): Promise<SessionMeta | null> {33 try {34 const raw = JSON.parse(35 await readFile(metaFilePath(sessionsDir, projectHash, sessionId), "utf8"),36 ) as Record<string, unknown>;37 return {38 parent: typeof raw["parent"] === "string" ? raw["parent"] : null,39 forkPoint: typeof raw["forkPoint"] === "number" ? raw["forkPoint"] : null,40 ...(typeof raw["replayOf"] === "string" ? { replayOf: raw["replayOf"] } : {}),41 createdAt: typeof raw["createdAt"] === "number" ? raw["createdAt"] : 0,42 };43 } catch {44 return null;45 }46}4748export async function writeSessionMeta(49 sessionsDir: string,50 projectHash: string,51 sessionId: string,52 meta: SessionMeta,53): Promise<void> {54 const path = metaFilePath(sessionsDir, projectHash, sessionId);55 await mkdir(join(sessionsDir, projectHash), { recursive: true });56 await writeFile(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8");57}5859// ───────────────────────── checkpoints ─────────────────────────6061export interface ForkCheckpoint {62 seq: number;63 kind: "user-turn" | "design-approved" | "compaction";64 label: string;65}6667/**68 * The natural fork points already present in the log: every user turn, every69 * approved design, every structured checkpoint (v2 §2).70 */71export function listForkCheckpoints(events: readonly DurableEvent[]): ForkCheckpoint[] {72 const checkpoints: ForkCheckpoint[] = [];73 for (const event of events) {74 if (event.type === "user.message-created") {75 const preview = event.payload.text.replace(/\s+/g, " ").slice(0, 48);76 checkpoints.push({ seq: event.seq, kind: "user-turn", label: `❯ ${preview}` });77 } else if (event.type === "phase.approved" && event.payload.phase === "design") {78 checkpoints.push({ seq: event.seq, kind: "design-approved", label: "✓ design approved" });79 } else if (event.type === "context.compacted") {80 checkpoints.push({ seq: event.seq, kind: "compaction", label: "⊟ context checkpoint" });81 }82 }83 return checkpoints;84}8586// ───────────────────────── fork ─────────────────────────8788export interface ForkOptions {89 sessionsDir: string;90 projectHash: string;91 sourceSessionId: string;92 /** Copy events up to and including this seq. Omitted → the whole log. */93 uptoSeq?: number;94 newSessionId?: string;95}9697export interface ForkResult {98 sessionId: string;99 filePath: string;100 copiedEvents: number;101 forkPoint: number;102}103104/**105 * Fork = copy the JSONL prefix into a fresh session file and record lineage.106 * Envelope sessionIds are rewritten so the child log is self-consistent;107 * seq/ids/payloads are byte-preserved otherwise. Resume of the fork replays108 * the prefix exactly (v2 §2 — "resume = replay du JSONL, donc fork est109 * presque gratuit").110 */111export async function forkSession(options: ForkOptions): Promise<ForkResult> {112 const dir = join(options.sessionsDir, options.projectHash);113 const sourcePath = join(dir, `${options.sourceSessionId}.jsonl`);114 let content: string;115 try {116 content = await readFile(sourcePath, "utf8");117 } catch (error) {118 throw new KhaelorError("session-log-io", `Cannot read session log: ${sourcePath}`, {119 cause: String(error),120 });121 }122123 const newSessionId = options.newSessionId ?? ulid();124 const outLines: string[] = [];125 let lastSeq = 0;126 for (const line of content.split("\n")) {127 if (line.length === 0) continue;128 let parsed: Record<string, unknown>;129 try {130 parsed = JSON.parse(line) as Record<string, unknown>;131 } catch {132 break; // torn tail — the prefix up to here is still a valid fork base133 }134 const seq = parsed["seq"];135 if (typeof seq !== "number") break;136 if (options.uptoSeq !== undefined && seq > options.uptoSeq) break;137 parsed["sessionId"] = newSessionId;138 outLines.push(JSON.stringify(parsed));139 lastSeq = seq;140 }141142 if (outLines.length === 0) {143 throw new KhaelorError(144 "invalid-event",145 `Fork of ${options.sourceSessionId} at seq ${options.uptoSeq ?? 0} would be empty.`,146 );147 }148149 const filePath = join(dir, `${newSessionId}.jsonl`);150 await mkdir(dir, { recursive: true });151 await writeFile(filePath, `${outLines.join("\n")}\n`, { flag: "wx", encoding: "utf8" });152 await writeSessionMeta(options.sessionsDir, options.projectHash, newSessionId, {153 parent: options.sourceSessionId,154 forkPoint: lastSeq,155 createdAt: Date.now(),156 });157 return { sessionId: newSessionId, filePath, copiedEvents: outLines.length, forkPoint: lastSeq };158}159160/** The user turns of a session, in order — the /replay input (v2 §2). */161export function extractUserTurns(events: readonly DurableEvent[]): string[] {162 const turns: string[] = [];163 for (const event of events) {164 if (event.type === "user.message-created") turns.push(event.payload.text);165 }166 return turns;167}168