import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; import { randomUUID } from "crypto"; import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "fs"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; import { type BashExecutionMessage, createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage, type HookMessage, } from "./messages.js"; export const CURRENT_SESSION_VERSION = 2; export interface SessionHeader { type: "session"; version?: number; // v1 sessions don't have this id: string; timestamp: string; cwd: string; branchedFrom?: string; } export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string; } export interface SessionMessageEntry extends SessionEntryBase { type: "message"; message: AgentMessage; } export interface ThinkingLevelChangeEntry extends SessionEntryBase { type: "thinking_level_change"; thinkingLevel: string; } export interface ModelChangeEntry extends SessionEntryBase { type: "model_change"; provider: string; modelId: string; } export interface CompactionEntry extends SessionEntryBase { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; } export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; /** Hook-specific data (not sent to LLM) */ details?: T; /** True if generated by a hook, false if pi-generated */ fromHook?: boolean; } /** * Custom entry for hooks to store hook-specific data in the session. * Use customType to identify your hook's entries. * * Purpose: Persist hook state across session reloads. On reload, hooks can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). * For injecting content into context, see CustomMessageEntry. */ export interface CustomEntry extends SessionEntryBase { type: "custom"; customType: string; data?: T; } /** Label entry for user-defined bookmarks/markers on entries. */ export interface LabelEntry extends SessionEntryBase { type: "label"; targetId: string; label: string | undefined; } /** * Custom message entry for hooks to inject messages into LLM context. * Use customType to identify your hook's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). * Use details for hook-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely * - true: rendered with distinct styling (different from user messages) */ export interface CustomMessageEntry extends SessionEntryBase { type: "custom_message"; customType: string; content: string | (TextContent | ImageContent)[]; details?: T; display: boolean; } /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; /** Tree node for getTree() - defensive copy of session structure */ export interface SessionTreeNode { entry: SessionEntry; children: SessionTreeNode[]; /** Resolved label for this entry, if any */ label?: string; } export interface SessionContext { messages: AgentMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } export interface SessionInfo { path: string; id: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string; } export type ReadonlySessionManager = Pick< SessionManager, | "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" | "getBranch" | "getHeader" | "getEntries" | "getTree" >; /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { const id = randomUUID().slice(0, 8); if (!byId.has(id)) return id; } // Fallback to full UUID if somehow we have collisions return randomUUID(); } /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ function migrateV1ToV2(entries: FileEntry[]): void { const ids = new Set(); let prevId: string | null = null; for (const entry of entries) { if (entry.type === "session") { entry.version = 2; continue; } entry.id = generateId(ids); entry.parentId = prevId; prevId = entry.id; // Convert firstKeptEntryIndex to firstKeptEntryId for compaction if (entry.type === "compaction") { const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; if (typeof comp.firstKeptEntryIndex === "number") { const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { comp.firstKeptEntryId = targetEntry.id; } delete comp.firstKeptEntryIndex; } } } } // Add future migrations here: // function migrateV2ToV3(entries: FileEntry[]): void { ... } /** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */ function migrateToCurrentVersion(entries: FileEntry[]): boolean { const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const version = header?.version ?? 1; if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); // if (version < 3) migrateV2ToV3(entries); return true; } /** Exported for testing */ export function migrateSessionEntries(entries: FileEntry[]): void { migrateToCurrentVersion(entries); } /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { const entries: FileEntry[] = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as FileEntry; entries.push(entry); } catch { // Skip malformed lines } } return entries; } export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { return entries[i] as CompactionEntry; } } return null; } /** * Build the session context from entries using tree traversal. * If leafId is provided, walks from that entry to root. * Handles compaction and branch summaries along the path. */ export function buildSessionContext( entries: SessionEntry[], leafId?: string | null, byId?: Map, ): SessionContext { // Build uuid index if not available if (!byId) { byId = new Map(); for (const entry of entries) { byId.set(entry.id, entry); } } // Find leaf let leaf: SessionEntry | undefined; if (leafId === null) { // Explicitly null - return no messages (navigated to before first entry) return { messages: [], thinkingLevel: "off", model: null }; } if (leafId) { leaf = byId.get(leafId); } if (!leaf) { // Fallback to last entry (when leafId is undefined) leaf = entries[entries.length - 1]; } if (!leaf) { return { messages: [], thinkingLevel: "off", model: null }; } // Walk from leaf to root, collecting path const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { path.unshift(current); current = current.parentId ? byId.get(current.parentId) : undefined; } // Extract settings and find compaction let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; let compaction: CompactionEntry | null = null; for (const entry of path) { if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel; } else if (entry.type === "model_change") { model = { provider: entry.provider, modelId: entry.modelId }; } else if (entry.type === "message" && entry.message.role === "assistant") { model = { provider: entry.message.provider, modelId: entry.message.model }; } else if (entry.type === "compaction") { compaction = entry; } } // Build messages and collect corresponding entries // When there's a compaction, we need to: // 1. Emit summary first (entry = compaction) // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AgentMessage[] = []; const appendMessage = (entry: SessionEntry) => { if (entry.type === "message") { messages.push(entry.message); } else if (entry.type === "custom_message") { messages.push( createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), ); } else if (entry.type === "branch_summary" && entry.summary) { messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); } }; if (compaction) { // Emit summary first messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); // Emit kept messages (before compaction, starting from firstKeptEntryId) let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept) { appendMessage(entry); } } // Emit messages after compaction for (let i = compactionIdx + 1; i < path.length; i++) { const entry = path[i]; appendMessage(entry); } } else { // No compaction - emit all messages, handle branch summaries and custom messages for (const entry of path) { appendMessage(entry); } } return { messages, thinkingLevel, model }; } /** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ function getDefaultSessionDir(cwd: string): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const sessionDir = join(getDefaultAgentDir(), "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir; } /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { if (!existsSync(filePath)) return []; const content = readFileSync(filePath, "utf8"); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as FileEntry; entries.push(entry); } catch { // Skip malformed lines } } // Validate session header if (entries.length === 0) return entries; const header = entries[0]; if (header.type !== "session" || typeof (header as any).id !== "string") { return []; } return entries; } function isValidSessionFile(filePath: string): boolean { try { const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(512); const bytesRead = readSync(fd, buffer, 0, 512, 0); closeSync(fd); const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; if (!firstLine) return false; const header = JSON.parse(firstLine); return header.type === "session" && typeof header.id === "string"; } catch { return false; } } /** Exported for testing */ export function findMostRecentSession(sessionDir: string): string | null { try { const files = readdirSync(sessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(sessionDir, f)) .filter(isValidSessionFile) .map((path) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; } catch { return null; } } /** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */ export class SessionManager { private sessionId: string = ""; private sessionFile: string | undefined; private sessionDir: string; private cwd: string; private persist: boolean; private flushed: boolean = false; private fileEntries: FileEntry[] = []; private byId: Map = new Map(); private labelsById: Map = new Map(); private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; this.persist = persist; if (persist && sessionDir && !existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } if (sessionFile) { this.setSessionFile(sessionFile); } else { this.newSession(); } } /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile: string): void { this.sessionFile = resolve(sessionFile); if (existsSync(this.sessionFile)) { this.fileEntries = loadEntriesFromFile(this.sessionFile); const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; this.sessionId = header?.id ?? randomUUID(); if (migrateToCurrentVersion(this.fileEntries)) { this._rewriteFile(); } this._buildIndex(); this.flushed = true; } else { this.newSession(); } } newSession(): string | undefined { this.sessionId = randomUUID(); const timestamp = new Date().toISOString(); const header: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: this.sessionId, timestamp, cwd: this.cwd, }; this.fileEntries = [header]; this.byId.clear(); this.leafId = null; this.flushed = false; // Only generate filename if persisting and not already set (e.g., via --session flag) if (this.persist && !this.sessionFile) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } return this.sessionFile; } private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; if (entry.type === "label") { if (entry.label) { this.labelsById.set(entry.targetId, entry.label); } else { this.labelsById.delete(entry.targetId); } } } } private _rewriteFile(): void { if (!this.persist || !this.sessionFile) return; const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; writeFileSync(this.sessionFile, content); } isPersisted(): boolean { return this.persist; } getCwd(): string { return this.cwd; } getSessionDir(): string { return this.sessionDir; } getSessionId(): string { return this.sessionId; } getSessionFile(): string | undefined { return this.sessionFile; } _persist(entry: SessionEntry): void { if (!this.persist || !this.sessionFile) return; const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) return; if (!this.flushed) { for (const e of this.fileEntries) { appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); } this.flushed = true; } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); } } private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this._persist(entry); } /** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ appendMessage(message: Message | HookMessage | BashExecutionMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), message, }; this._appendEntry(entry); return entry.id; } /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ appendThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), thinkingLevel, }; this._appendEntry(entry); return entry.id; } /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ appendModelChange(provider: string, modelId: string): string { const entry: ModelChangeEntry = { type: "model_change", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), provider, modelId, }; this._appendEntry(entry); return entry.id; } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ appendCompaction( summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T, fromHook?: boolean, ): string { const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), summary, firstKeptEntryId, tokensBefore, details, fromHook, }; this._appendEntry(entry); return entry.id; } /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType: string, data?: unknown): string { const entry: CustomEntry = { type: "custom", customType, data, id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); return entry.id; } /** * Append a custom message entry (for hooks) that participates in LLM context. * @param customType Hook identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) * @param details Optional hook-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details?: T, ): string { const entry: CustomMessageEntry = { type: "custom_message", customType, content, display, details, id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); return entry.id; } // ========================================================================= // Tree Traversal // ========================================================================= getLeafId(): string | null { return this.leafId; } getLeafEntry(): SessionEntry | undefined { return this.leafId ? this.byId.get(this.leafId) : undefined; } getEntry(id: string): SessionEntry | undefined { return this.byId.get(id); } /** * Get all direct children of an entry. */ getChildren(parentId: string): SessionEntry[] { const children: SessionEntry[] = []; for (const entry of this.byId.values()) { if (entry.parentId === parentId) { children.push(entry); } } return children; } /** * Get the label for an entry, if any. */ getLabel(id: string): string | undefined { return this.labelsById.get(id); } /** * Set or clear a label on an entry. * Labels are user-defined markers for bookmarking/navigation. * Pass undefined or empty string to clear the label. */ appendLabelChange(targetId: string, label: string | undefined): string { if (!this.byId.has(targetId)) { throw new Error(`Entry ${targetId} not found`); } const entry: LabelEntry = { type: "label", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), targetId, label, }; this._appendEntry(entry); if (label) { this.labelsById.set(targetId, label); } else { this.labelsById.delete(targetId); } return entry.id; } /** * Walk from entry to root, returning all entries in path order. * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ getBranch(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; while (current) { path.unshift(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; } return path; } /** * Build the session context (what gets sent to the LLM). * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { return buildSessionContext(this.getEntries(), this.leafId, this.byId); } /** * Get session header. */ getHeader(): SessionHeader | null { const h = this.fileEntries.find((e) => e.type === "session"); return h ? (h as SessionHeader) : null; } /** * Get all session entries (excludes header). Returns a shallow copy. * The session is append-only: use appendXXX() to add entries, branch() to * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries(): SessionEntry[] { return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); } /** * Get the session as a tree structure. Returns a shallow defensive copy of all entries. * A well-formed session has exactly one root (first entry with parentId === null). * Orphaned entries (broken parent chain) are also returned as roots. */ getTree(): SessionTreeNode[] { const entries = this.getEntries(); const nodeMap = new Map(); const roots: SessionTreeNode[] = []; // Create nodes with resolved labels for (const entry of entries) { const label = this.labelsById.get(entry.id); nodeMap.set(entry.id, { entry, children: [], label }); } // Build tree for (const entry of entries) { const node = nodeMap.get(entry.id)!; if (entry.parentId === null || entry.parentId === entry.id) { roots.push(node); } else { const parent = nodeMap.get(entry.parentId); if (parent) { parent.children.push(node); } else { // Orphan - treat as root roots.push(node); } } } // Sort children by timestamp (oldest first, newest at bottom) // Use iterative approach to avoid stack overflow on deep trees const stack: SessionTreeNode[] = [...roots]; while (stack.length > 0) { const node = stack.pop()!; node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); stack.push(...node.children); } return roots; } // ========================================================================= // Branching // ========================================================================= /** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */ branch(branchFromId: string): void { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; } /** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */ resetLeaf(): void { this.leafId = null; } /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId: branchFromId ?? "root", summary, details, fromHook, }; this._appendEntry(entry); return entry.id; } /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { const path = this.getBranch(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); } // Filter out LabelEntry from path - we'll recreate them from the resolved map const pathWithoutLabels = path.filter((e) => e.type !== "label"); const newSessionId = randomUUID(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); const header: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: this.cwd, branchedFrom: this.persist ? this.sessionFile : undefined, }; // Collect labels for entries in the path const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); const labelsToWrite: Array<{ targetId: string; label: string }> = []; for (const [targetId, label] of this.labelsById) { if (pathEntryIds.has(targetId)) { labelsToWrite.push({ targetId, label }); } } if (this.persist) { appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`); for (const entry of pathWithoutLabels) { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } // Write fresh label entries at the end const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; let parentId = lastEntryId; const labelEntries: LabelEntry[] = []; for (const { targetId, label } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set(pathEntryIds)), parentId, timestamp: new Date().toISOString(), targetId, label, }; appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`); pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); return newSessionFile; } // In-memory mode: replace current session with the path + labels const labelEntries: LabelEntry[] = []; let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; for (const { targetId, label } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), parentId, timestamp: new Date().toISOString(), targetId, label, }; labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); return undefined; } /** * Create a new session. * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static create(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ?? getDefaultSessionDir(cwd); return new SessionManager(cwd, dir, undefined, true); } /** * Open a specific session file. * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. */ static open(path: string, sessionDir?: string): SessionManager { // Extract cwd from session header if possible, otherwise use process.cwd() const entries = loadEntriesFromFile(path); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ?? resolve(path, ".."); return new SessionManager(cwd, dir, path, true); } /** * Continue the most recent session, or create new if none. * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ?? getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } return new SessionManager(cwd, dir, undefined, true); } /** Create an in-memory session (no file persistence) */ static inMemory(cwd: string = process.cwd()): SessionManager { return new SessionManager(cwd, "", undefined, false); } /** * List all sessions. * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static list(cwd: string, sessionDir?: string): SessionInfo[] { const dir = sessionDir ?? getDefaultSessionDir(cwd); const sessions: SessionInfo[] = []; try { const files = readdirSync(dir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(dir, f)); for (const file of files) { try { const content = readFileSync(file, "utf8"); const lines = content.trim().split("\n"); if (lines.length === 0) continue; // Check first line for valid session header let header: { type: string; id: string; timestamp: string } | null = null; try { const first = JSON.parse(lines[0]); if (first.type === "session" && first.id) { header = first; } } catch { // Not valid JSON } if (!header) continue; const stats = statSync(file); let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; for (let i = 1; i < lines.length; i++) { try { const entry = JSON.parse(lines[i]); if (entry.type === "message") { messageCount++; if (entry.message.role === "user" || entry.message.role === "assistant") { const textContent = entry.message.content .filter((c: any) => c.type === "text") .map((c: any) => c.text) .join(" "); if (textContent) { allMessages.push(textContent); if (!firstMessage && entry.message.role === "user") { firstMessage = textContent; } } } } } catch { // Skip malformed lines } } sessions.push({ path: file, id: header.id, created: new Date(header.timestamp), modified: stats.mtime, messageCount, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), }); } catch { // Skip files that can't be read } } sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); } catch { // Return empty list on error } return sessions; } }