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/cli/sessions.ts4 * Description: Session directory helpers — project hash, session listing, and resumed-transcript rendering.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { createHash } from "node:crypto";11import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";12import { join } from "node:path";13import type { DurableEvent } from "../session/index.js";1415/** Stable per-project hash: sessions/<hash>/ (ARCHITECTURE.md §5.1). */16export function projectHash(projectRoot: string): string {17 return createHash("sha256").update(projectRoot).digest("hex").slice(0, 16);18}1920export interface SessionListing {21 sessionId: string;22 /** First user message (or session title), truncated — the human handle. */23 preview: string;24 updatedAt: number;25 sizeBytes: number;26}2728const PREVIEW_SCAN_BYTES = 64 * 1024;2930/** Extract a human preview from the head of a session log without replaying it. */31function previewFromHead(filePath: string): string {32 let fd: number | null = null;33 try {34 fd = openSync(filePath, "r");35 const buffer = Buffer.alloc(PREVIEW_SCAN_BYTES);36 const bytes = readSync(fd, buffer, 0, PREVIEW_SCAN_BYTES, 0);37 const head = buffer.subarray(0, bytes).toString("utf8");38 for (const line of head.split("\n")) {39 if (line.length === 0) continue;40 let parsed: unknown;41 try {42 parsed = JSON.parse(line);43 } catch {44 break; // torn tail of the scanned window45 }46 const event = parsed as { type?: string; payload?: { text?: string; title?: string } };47 if (event.type === "user.message-created" && typeof event.payload?.text === "string") {48 return event.payload.text.replace(/\s+/g, " ").slice(0, 60);49 }50 if (event.type === "session.renamed" && typeof event.payload?.title === "string") {51 return event.payload.title.slice(0, 60);52 }53 }54 } catch {55 // unreadable file — listed without a preview56 } finally {57 if (fd !== null) closeSync(fd);58 }59 return "(no messages yet)";60}6162/** List sessions for a project, newest first. Never throws — an empty dir is an empty list. */63export function listSessions(sessionsDir: string, hash: string): SessionListing[] {64 const dir = join(sessionsDir, hash);65 let entries: string[];66 try {67 entries = readdirSync(dir);68 } catch {69 return [];70 }71 const sessions: SessionListing[] = [];72 for (const name of entries) {73 if (!name.endsWith(".jsonl")) continue;74 const filePath = join(dir, name);75 try {76 const stat = statSync(filePath);77 sessions.push({78 sessionId: name.slice(0, -".jsonl".length),79 preview: previewFromHead(filePath),80 updatedAt: stat.mtimeMs,81 sizeBytes: stat.size,82 });83 } catch {84 // raced deletion — skip85 }86 }87 sessions.sort((a, b) => b.updatedAt - a.updatedAt);88 return sessions;89}9091/**92 * Compact plain-text transcript of a replayed session — printed after resume93 * so the user re-grounds without a full timeline re-render.94 */95export function renderResumedTranscript(96 events: readonly DurableEvent[],97 options: { maxEntries?: number; width?: number } = {},98): string[] {99 const maxEntries = options.maxEntries ?? 12;100 const width = Math.max(24, (options.width ?? 80) - 4);101 const entries: string[] = [];102 for (const event of events) {103 if (event.type === "user.message-created") {104 entries.push(` ❯ ${firstLine(event.payload.text, width)}`);105 } else if (event.type === "model.text-block-completed") {106 entries.push(` ${firstLine(event.payload.text, width)}`);107 } else if (event.type === "tool.completed") {108 entries.push(` ▸ ${firstLine(event.payload.ui.summary, width)}`);109 }110 }111 const shown = entries.slice(-maxEntries);112 const omitted = entries.length - shown.length;113 const lines: string[] = [""];114 if (omitted > 0) lines.push(` … ${omitted} earlier entries`);115 lines.push(...shown);116 return lines;117}118119function firstLine(text: string, width: number): string {120 const line = text.split("\n", 1)[0] ?? "";121 return line.length > width ? `${line.slice(0, width - 1)}…` : line;122}123