/** * KHAELOR * File: src/cli/sessions.ts * Description: Session directory helpers — project hash, session listing, and resumed-transcript rendering. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { createHash } from "node:crypto"; import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import type { DurableEvent } from "../session/index.js"; /** Stable per-project hash: sessions// (ARCHITECTURE.md §5.1). */ export function projectHash(projectRoot: string): string { return createHash("sha256").update(projectRoot).digest("hex").slice(0, 16); } export interface SessionListing { sessionId: string; /** First user message (or session title), truncated — the human handle. */ preview: string; updatedAt: number; sizeBytes: number; } const PREVIEW_SCAN_BYTES = 64 * 1024; /** Extract a human preview from the head of a session log without replaying it. */ function previewFromHead(filePath: string): string { let fd: number | null = null; try { fd = openSync(filePath, "r"); const buffer = Buffer.alloc(PREVIEW_SCAN_BYTES); const bytes = readSync(fd, buffer, 0, PREVIEW_SCAN_BYTES, 0); const head = buffer.subarray(0, bytes).toString("utf8"); for (const line of head.split("\n")) { if (line.length === 0) continue; let parsed: unknown; try { parsed = JSON.parse(line); } catch { break; // torn tail of the scanned window } const event = parsed as { type?: string; payload?: { text?: string; title?: string } }; if (event.type === "user.message-created" && typeof event.payload?.text === "string") { return event.payload.text.replace(/\s+/g, " ").slice(0, 60); } if (event.type === "session.renamed" && typeof event.payload?.title === "string") { return event.payload.title.slice(0, 60); } } } catch { // unreadable file — listed without a preview } finally { if (fd !== null) closeSync(fd); } return "(no messages yet)"; } /** List sessions for a project, newest first. Never throws — an empty dir is an empty list. */ export function listSessions(sessionsDir: string, hash: string): SessionListing[] { const dir = join(sessionsDir, hash); let entries: string[]; try { entries = readdirSync(dir); } catch { return []; } const sessions: SessionListing[] = []; for (const name of entries) { if (!name.endsWith(".jsonl")) continue; const filePath = join(dir, name); try { const stat = statSync(filePath); sessions.push({ sessionId: name.slice(0, -".jsonl".length), preview: previewFromHead(filePath), updatedAt: stat.mtimeMs, sizeBytes: stat.size, }); } catch { // raced deletion — skip } } sessions.sort((a, b) => b.updatedAt - a.updatedAt); return sessions; } /** * Compact plain-text transcript of a replayed session — printed after resume * so the user re-grounds without a full timeline re-render. */ export function renderResumedTranscript( events: readonly DurableEvent[], options: { maxEntries?: number; width?: number } = {}, ): string[] { const maxEntries = options.maxEntries ?? 12; const width = Math.max(24, (options.width ?? 80) - 4); const entries: string[] = []; for (const event of events) { if (event.type === "user.message-created") { entries.push(` ❯ ${firstLine(event.payload.text, width)}`); } else if (event.type === "model.text-block-completed") { entries.push(` ${firstLine(event.payload.text, width)}`); } else if (event.type === "tool.completed") { entries.push(` ▸ ${firstLine(event.payload.ui.summary, width)}`); } } const shown = entries.slice(-maxEntries); const omitted = entries.length - shown.length; const lines: string[] = [""]; if (omitted > 0) lines.push(` … ${omitted} earlier entries`); lines.push(...shown); return lines; } function firstLine(text: string, width: number): string { const line = text.split("\n", 1)[0] ?? ""; return line.length > width ? `${line.slice(0, width - 1)}…` : line; }