import type { AgentState } from "@mariozechner/pi-agent"; import { randomBytes } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs"; import { homedir } from "os"; import { join, resolve } from "path"; function uuidv4(): string { const bytes = randomBytes(16); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = bytes.toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } export interface SessionHeader { type: "session"; id: string; timestamp: string; cwd: string; provider: string; modelId: string; thinkingLevel: string; } export interface SessionMessageEntry { type: "message"; timestamp: string; message: any; // AppMessage from agent state } export interface ThinkingLevelChangeEntry { type: "thinking_level_change"; timestamp: string; thinkingLevel: string; } export interface ModelChangeEntry { type: "model_change"; timestamp: string; provider: string; modelId: string; } export class SessionManager { private sessionId!: string; private sessionFile!: string; private sessionDir: string; private enabled: boolean = true; private sessionInitialized: boolean = false; private pendingMessages: any[] = []; constructor(continueSession: boolean = false, customSessionPath?: string) { this.sessionDir = this.getSessionDirectory(); if (customSessionPath) { // Use custom session file path this.sessionFile = resolve(customSessionPath); this.loadSessionId(); // Mark as initialized since we're loading an existing session this.sessionInitialized = existsSync(this.sessionFile); } else if (continueSession) { const mostRecent = this.findMostRecentlyModifiedSession(); if (mostRecent) { this.sessionFile = mostRecent; this.loadSessionId(); // Mark as initialized since we're loading an existing session this.sessionInitialized = true; } else { this.initNewSession(); } } else { this.initNewSession(); } } /** Disable session saving (for --no-session mode) */ disable() { this.enabled = false; } private getSessionDirectory(): string { const cwd = process.cwd(); // Replace all path separators and colons (for Windows drive letters) with dashes const safePath = "--" + cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-") + "--"; const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".pi/agent/")); const sessionDir = join(configDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir; } private initNewSession(): void { this.sessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); } private findMostRecentlyModifiedSession(): string | null { try { const files = readdirSync(this.sessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => ({ name: f, path: join(this.sessionDir, f), mtime: statSync(join(this.sessionDir, f)).mtime, })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; } catch { return null; } } private loadSessionId(): void { if (!existsSync(this.sessionFile)) return; const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session") { this.sessionId = entry.id; return; } } catch { // Skip malformed lines } } this.sessionId = uuidv4(); } startSession(state: AgentState): void { if (!this.enabled || this.sessionInitialized) return; this.sessionInitialized = true; const entry: SessionHeader = { type: "session", id: this.sessionId, timestamp: new Date().toISOString(), cwd: process.cwd(), provider: state.model.provider, modelId: state.model.id, thinkingLevel: state.thinkingLevel, }; appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n"); // Write any queued messages for (const msg of this.pendingMessages) { appendFileSync(this.sessionFile, JSON.stringify(msg) + "\n"); } this.pendingMessages = []; } saveMessage(message: any): void { if (!this.enabled) return; const entry: SessionMessageEntry = { type: "message", timestamp: new Date().toISOString(), message, }; if (!this.sessionInitialized) { this.pendingMessages.push(entry); } else { appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n"); } } saveThinkingLevelChange(thinkingLevel: string): void { if (!this.enabled) return; const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", timestamp: new Date().toISOString(), thinkingLevel, }; if (!this.sessionInitialized) { this.pendingMessages.push(entry); } else { appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n"); } } saveModelChange(provider: string, modelId: string): void { if (!this.enabled) return; const entry: ModelChangeEntry = { type: "model_change", timestamp: new Date().toISOString(), provider, modelId, }; if (!this.sessionInitialized) { this.pendingMessages.push(entry); } else { appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n"); } } loadMessages(): any[] { if (!existsSync(this.sessionFile)) return []; const messages: any[] = []; const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "message") { messages.push(entry.message); } } catch { // Skip malformed lines } } return messages; } loadThinkingLevel(): string { if (!existsSync(this.sessionFile)) return "off"; const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); // Find the most recent thinking level (from session header or change event) let lastThinkingLevel = "off"; for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session" && entry.thinkingLevel) { lastThinkingLevel = entry.thinkingLevel; } else if (entry.type === "thinking_level_change" && entry.thinkingLevel) { lastThinkingLevel = entry.thinkingLevel; } } catch { // Skip malformed lines } } return lastThinkingLevel; } loadModel(): { provider: string; modelId: string } | null { if (!existsSync(this.sessionFile)) return null; const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); // Find the most recent model (from session header or change event) let lastProvider: string | null = null; let lastModelId: string | null = null; for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session" && entry.provider && entry.modelId) { lastProvider = entry.provider; lastModelId = entry.modelId; } else if (entry.type === "model_change" && entry.provider && entry.modelId) { lastProvider = entry.provider; lastModelId = entry.modelId; } } catch { // Skip malformed lines } } if (lastProvider && lastModelId) { return { provider: lastProvider, modelId: lastModelId }; } return null; } getSessionId(): string { return this.sessionId; } getSessionFile(): string { return this.sessionFile; } /** * Load all sessions for the current directory with metadata */ loadAllSessions(): Array<{ path: string; id: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string; }> { const sessions: Array<{ path: string; id: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string; }> = []; try { const files = readdirSync(this.sessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(this.sessionDir, f)); for (const file of files) { try { const stats = statSync(file); const content = readFileSync(file, "utf8"); const lines = content.trim().split("\n"); let sessionId = ""; let created = stats.birthtime; let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; for (const line of lines) { try { const entry = JSON.parse(line); // Extract session ID from first session entry if (entry.type === "session" && !sessionId) { sessionId = entry.id; created = new Date(entry.timestamp); } // Count messages and collect all text if (entry.type === "message") { messageCount++; // Extract text from user and assistant messages 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); // Get first user message for display if (!firstMessage && entry.message.role === "user") { firstMessage = textContent; } } } } } catch { // Skip malformed lines } } sessions.push({ path: file, id: sessionId || "unknown", created, modified: stats.mtime, messageCount, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), }); } catch (error) { // Skip files that can't be read console.error(`Failed to read session file ${file}:`, error); } } // Sort by modified date (most recent first) sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); } catch (error) { console.error("Failed to load sessions:", error); } return sessions; } /** * Set the session file to an existing session */ setSessionFile(path: string): void { this.sessionFile = path; this.loadSessionId(); // Mark as initialized since we're loading an existing session this.sessionInitialized = existsSync(path); } /** * Check if we should initialize the session based on message history. * Session is initialized when we have at least 1 user message and 1 assistant message. */ shouldInitializeSession(messages: any[]): boolean { if (this.sessionInitialized) return false; const userMessages = messages.filter((m) => m.role === "user"); const assistantMessages = messages.filter((m) => m.role === "assistant"); return userMessages.length >= 1 && assistantMessages.length >= 1; } /** * Create a branched session from a specific message index. * If branchFromIndex is -1, creates an empty session. * Returns the new session file path. */ createBranchedSession(state: any, branchFromIndex: number): string { // Create a new session ID for the branch const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); // Write session header const entry: SessionHeader = { type: "session", id: newSessionId, timestamp: new Date().toISOString(), cwd: process.cwd(), provider: state.model.provider, modelId: state.model.id, thinkingLevel: state.thinkingLevel, }; appendFileSync(newSessionFile, JSON.stringify(entry) + "\n"); // Write messages up to and including the branch point (if >= 0) if (branchFromIndex >= 0) { const messagesToWrite = state.messages.slice(0, branchFromIndex + 1); for (const message of messagesToWrite) { const messageEntry: SessionMessageEntry = { type: "message", timestamp: new Date().toISOString(), message, }; appendFileSync(newSessionFile, JSON.stringify(messageEntry) + "\n"); } } return newSessionFile; } }