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