SPB Git

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%
5.9 KB · 168 lines typescript
Raw Blame History
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