import type { Message } from "./loop"; import { getLocalJeoDir } from "./state"; import * as fs from "node:fs/promises"; import * as path from "node:path"; export interface SessionHeader { type: "session"; version: number; id: string; timestamp: string; cwd: string; title?: string; /** Model id pinned to this session; restored on resume so each session can carry * its own model independent of the global default (per-session model selection). */ model?: string; /** Unsent input-box draft at last save, restored into the prompt on `/resume` * (gajae-code parity: an in-progress cursor survives a quit + resume instead * of being silently lost). Absent/empty when there was nothing to restore. */ draft?: string; /** Source system marker for imported sessions (read-only session importer layer). */ sourceSystem?: "gjc"; /** Source session id for imported sessions. */ sourceSessionId?: string; /** Source leaf id for imported sessions. */ sourceLeafId?: string; /** SHA-256 hex of the exact imported source bytes. */ sourceSha256?: string; /** Import timestamp for imported sessions (ISO-8601 UTC string). */ importTimestamp?: string; } export interface SessionEntry { type: "message"; timestamp: string; message: Message; } export interface CompactionEntry { type: "compaction"; timestamp: string; seq: number; summary: string; replacesThrough: number; } export interface SessionSummary { id: string; timestamp: string; cwd: string; messageCount: number; preview: string; mtimeMs?: number; /** Session file size in bytes (for the resume picker's metadata line). */ sizeBytes?: number; title?: string; /** Model id pinned to this session (header `model`), if any. */ model?: string; /** GJC import provenance (present for imported sessions). */ sourceSystem?: "gjc"; sourceSessionId?: string; sourceLeafId?: string; sourceSha256?: string; importTimestamp?: string; } export interface ImportedSessionLookup { sourceSystem: "gjc"; sourceSessionId: string; sourceLeafId: string; sourceSha256: string; } export const SESSION_VERSION = 1; export function newSessionId(): string { return crypto.randomUUID(); } export function sessionsDir(cwd = process.cwd()): string { return path.join(getLocalJeoDir(cwd), "sessions"); } export function sessionPath(id: string, cwd = process.cwd()): string { return path.join(sessionsDir(cwd), `${id}.jsonl`); } export async function createSession( cwd = process.cwd(), id = newSessionId(), model?: string ): Promise<{ id: string; path: string }> { const dir = sessionsDir(cwd); await fs.mkdir(dir, { recursive: true }); const header: SessionHeader = { type: "session", version: SESSION_VERSION, id, timestamp: new Date().toISOString(), cwd, ...(model ? { model } : {}), }; const file = sessionPath(id, cwd); await fs.writeFile(file, JSON.stringify(header) + "\n", "utf8"); return { id, path: file }; } export async function appendMessage( id: string, message: Message, cwd = process.cwd() ): Promise { const file = sessionPath(id, cwd); const entry: SessionEntry = { type: "message", timestamp: new Date().toISOString(), message, }; await fs.appendFile(file, JSON.stringify(entry) + "\n", "utf8"); } /** Append a batch of messages with ONE fs append (turn-end persistence: a long * turn previously issued one sequential appendFile per intermediate message). */ export async function appendMessages( id: string, messages: readonly Message[], cwd = process.cwd() ): Promise { if (messages.length === 0) return; const file = sessionPath(id, cwd); const timestamp = new Date().toISOString(); const chunk = messages .map(message => JSON.stringify({ type: "message", timestamp, message } satisfies SessionEntry)) .join("\n") + "\n"; await fs.appendFile(file, chunk, "utf8"); } export async function appendCompaction( id: string, seq: number, summary: string, replacesThrough: number, cwd = process.cwd() ): Promise { const file = sessionPath(id, cwd); const entry: CompactionEntry = { type: "compaction", timestamp: new Date().toISOString(), seq, summary, replacesThrough, }; await fs.appendFile(file, JSON.stringify(entry) + "\n", "utf8"); } export async function loadSession( id: string, cwd = process.cwd() ): Promise<{ header: SessionHeader; messages: Message[] }> { const file = sessionPath(id, cwd); let content: string; try { content = await fs.readFile(file, "utf8"); } catch (err: any) { if (err.code === "ENOENT") { throw new Error(`Session ${id} not found: ${err.message}`); } throw err; } const lines = content.split("\n"); let header: SessionHeader | undefined; const rawMessages: Message[] = []; const compactions: CompactionEntry[] = []; for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry && typeof entry === "object") { if (entry.type === "session" && !header) { header = entry as SessionHeader; } else if (entry.type === "message") { rawMessages.push(entry.message); } else if (entry.type === "compaction") { compactions.push(entry as CompactionEntry); } } } catch (err) { if (!header) { throw err; } continue; } } if (!header) { throw new Error(`Session header missing in session ${id}`); } let messages = rawMessages; if (compactions.length > 0) { const lastComp = compactions[compactions.length - 1]; const { summary, replacesThrough } = lastComp; const hasSystem = rawMessages.length > 0 && rawMessages[0].role === "system"; const systemPrompt = hasSystem ? [rawMessages[0]] : []; const summaryMessage: Message = { role: "user", content: `[Earlier conversation summary]\n${summary}`, }; const remaining = rawMessages.slice(replacesThrough + 1); messages = [...systemPrompt, summaryMessage, ...remaining]; } return { header, messages }; } export async function listSessions(cwd = process.cwd()): Promise { const dir = sessionsDir(cwd); let files: string[]; try { files = await fs.readdir(dir); } catch (err: any) { if (err.code === "ENOENT") { return []; } throw err; } const jsonlFiles = files.filter(f => f.endsWith(".jsonl")); const summaries: SessionSummary[] = []; for (const file of jsonlFiles) { try { const filePath = path.join(dir, file); const stat = await fs.stat(filePath); const content = await fs.readFile(filePath, "utf8"); const lines = content.split("\n"); let header: SessionHeader | undefined; // 1. 헤더만 JSON.parse하여 획득 (가장 첫 valid JSON) for (const line of lines) { if (!line.trim()) continue; if (line.includes('"type":"session"')) { try { header = JSON.parse(line); break; } catch { // continue } } } if (!header) { continue; } // 2. 마지막 compaction 마커 라인을 역순 탐색 let lastCompaction: CompactionEntry | undefined; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (line.includes('"type":"compaction"')) { try { lastCompaction = JSON.parse(line); break; } catch { // continue } } } // 3. JSON.parse 오버헤드 최소화하며 카운팅 및 프리뷰 추출 let messageCount = 0; let firstUserMessageContent: string | undefined; if (lastCompaction) { let msgIndex = 0; for (const line of lines) { if (!line.trim()) continue; if (line.includes('"type":"message"')) { if (msgIndex > lastCompaction.replacesThrough) { messageCount++; } msgIndex++; } } messageCount += 1; // summary message } else { for (const line of lines) { if (!line.trim()) continue; if (line.includes('"type":"message"')) { messageCount++; } } } // 4. preview를 위한 첫 user message 추출 for (const line of lines) { if (line.includes('"type":"message"') && line.includes('"role":"user"')) { try { const parsed = JSON.parse(line); if (parsed?.message?.content) { firstUserMessageContent = parsed.message.content; break; } } catch { // continue } } } const preview = firstUserMessageContent ? firstUserMessageContent.slice(0, 60) : ""; summaries.push({ id: header.id, timestamp: header.timestamp, cwd: header.cwd, messageCount, preview, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, title: header.title, model: header.model, sourceSystem: header.sourceSystem, sourceSessionId: header.sourceSessionId, sourceLeafId: header.sourceLeafId, sourceSha256: header.sourceSha256, importTimestamp: header.importTimestamp, }); } catch { // Tolerate malformed files (skip them) continue; } } summaries.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0)); return summaries; } export async function latestSessionId(cwd = process.cwd()): Promise { const list = await listSessions(cwd); return list[0]?.id; } /** Result of resolving a full session id or a short id prefix. */ export type SessionRefResolution = | { kind: "ok"; id: string } | { kind: "ambiguous"; matches: string[] } | { kind: "not-found" }; /** Resolve a full session id OR a short id PREFIX (gjc-parity: `--resume `, * `/session resume `) to exactly one session id. Exact match wins immediately * (fast path, no directory scan) before falling back to a prefix scan of listSessions(). */ export async function resolveSessionRef(idOrPrefix: string, cwd = process.cwd()): Promise { const trimmed = idOrPrefix.trim(); if (!trimmed) return { kind: "not-found" }; // Fast path: exact file exists. try { await fs.access(sessionPath(trimmed, cwd)); return { kind: "ok", id: trimmed }; } catch { // fall through to prefix scan } const all = await listSessions(cwd); const lower = trimmed.toLowerCase(); const matches = all.filter(s => s.id.toLowerCase().startsWith(lower)).map(s => s.id); if (matches.length === 1) return { kind: "ok", id: matches[0]! }; if (matches.length > 1) return { kind: "ambiguous", matches }; return { kind: "not-found" }; } /** * Locate an imported session by exact provenance match. */ export async function findImportedSessionByProvenance( cwd = process.cwd(), lookup: ImportedSessionLookup, ): Promise { const all = await listSessions(cwd); return all.find(s => s.sourceSystem === lookup.sourceSystem && s.sourceSessionId === lookup.sourceSessionId && s.sourceLeafId === lookup.sourceLeafId && s.sourceSha256 === lookup.sourceSha256 ); } /** * Locate the session's JSONL header, apply `mutate`, and rewrite the file in place. * `mutate` returns false to signal "no change needed" (skips the write). Shared by * {@link renameSession} and {@link updateSessionModel}. Throws a clear Error when the * session file or its header is missing. */ async function rewriteSessionHeader( id: string, mutate: (header: SessionHeader) => boolean, cwd: string, ): Promise { const file = sessionPath(id, cwd); let content: string; try { content = await fs.readFile(file, "utf8"); } catch (err: any) { if (err.code === "ENOENT") { throw new Error(`Session ${id} does not exist: ${err.message}`); } throw err; } const lines = content.split("\n"); let headerIndex = -1; let header: SessionHeader | undefined; for (let i = 0; i < lines.length; i++) { if (lines[i].trim()) { try { const parsed = JSON.parse(lines[i]); if (parsed && typeof parsed === "object" && parsed.type === "session") { header = parsed as SessionHeader; headerIndex = i; break; } } catch { // tolerate parsing error or check next line } } } if (headerIndex === -1 || !header) { throw new Error(`Session header missing in session ${id}`); } if (!mutate(header)) return; lines[headerIndex] = JSON.stringify(header); await fs.writeFile(file, lines.join("\n"), "utf8"); } /** * Rename a session by updating the title in its JSONL header. * Throws a clear Error if the session file does not exist. */ export async function renameSession(id: string, title: string, cwd = process.cwd()): Promise { await rewriteSessionHeader(id, header => { header.title = title; return true; }, cwd); } /** * Pin a model to a session by updating the `model` field in its JSONL header so a * later `/resume` restores it. No-op (no write) when the header already names that * model. Throws a clear Error if the session file does not exist. */ export async function updateSessionModel(id: string, model: string, cwd = process.cwd()): Promise { await rewriteSessionHeader(id, header => { if (header.model === model) return false; header.model = model; return true; }, cwd); } /** * Persist (or clear) the unsent input-box draft in a session's JSONL header so a * later `/resume` restores it into the prompt (gajae-code parity). Empty text * clears any stale draft rather than writing an empty string. No-op (no write) * when the header's current draft already matches. Throws a clear Error if the * session file does not exist. */ export async function updateSessionDraft(id: string, draft: string, cwd = process.cwd()): Promise { const trimmed = draft.trim(); const next = trimmed ? trimmed : undefined; await rewriteSessionHeader(id, header => { if ((header.draft ?? undefined) === next) return false; if (next === undefined) delete header.draft; else header.draft = next; return true; }, cwd); } /** * Delete a session file. * Returns false on ENOENT, true on success. */ export async function deleteSession(id: string, cwd = process.cwd()): Promise { const file = sessionPath(id, cwd); try { await fs.unlink(file); return true; } catch (err: any) { if (err.code === "ENOENT") { return false; } throw err; } } export interface ExportOptions { /** Include system messages in the export (default false — they're boilerplate). */ includeSystem?: boolean; } /** * Render a saved session to Markdown or JSON for handoff, bug reports, and audit * trails. Reuses `loadSession` (which tolerates a malformed trailing line). */ export async function exportSession( id: string, format: "markdown" | "json" = "markdown", cwd = process.cwd(), opts: ExportOptions = {}, ): Promise { const { header, messages } = await loadSession(id, cwd); const picked = opts.includeSystem ? messages : messages.filter(m => m.role !== "system"); if (format === "json") { return JSON.stringify( { id: header.id, timestamp: header.timestamp, cwd: header.cwd, messageCount: picked.length, messages: picked }, null, 2, ); } const lines: string[] = [ `# jeo session ${header.id}`, "", `- Started: ${header.timestamp}`, `- Workspace: ${header.cwd}`, `- Messages: ${picked.length}`, "", ]; for (const m of picked) { const role = m.role.charAt(0).toUpperCase() + m.role.slice(1); // Fence longer than the longest backtick run in the body (CommonMark) so message // content containing ``` doesn't prematurely close the code fence. const fenceFor = (s: string) => "`".repeat(Math.max(3, (s.match(/`+/g) ?? []).reduce((mx, r) => Math.max(mx, r.length), 0) + 1)); lines.push(`## ${role}`, ""); // Persisted thinking (gjc "think → answer"): include it in the durable export so the // markdown record matches the in-app transcript and the JSON export. if (m.role === "assistant" && m.reasoning?.trim()) { const tf = fenceFor(m.reasoning); lines.push("### Thinking", "", tf, m.reasoning, tf, ""); } const fence = fenceFor(m.content); lines.push(fence, m.content, fence, ""); } return lines.join("\n"); }