import fs from "node:fs"; import path from "node:path"; import { indexSession, updateSessionStatus, type SessionHistoryRow, } from "../config/sessions/history-db.js"; import { initHistoryDbWithMigration } from "../config/sessions/history-migration.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { readFirstUserMessageFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; export type ArchiveSessionParams = { sessionEntry: SessionEntry; sessionsDir: string; agentId: string; reason?: string; }; export type RestoreSessionParams = { sessionId: string; sessionsDir: string; agentId: string; }; export type ExtractedTranscriptMetadata = { messageCount: number; firstMessage?: string; totalTokens?: number; createdAt?: number; updatedAt?: number; }; /** * Ensure the archive directory exists */ export function ensureArchiveDir(sessionsDir: string): void { const archiveDir = path.join(sessionsDir, "archive"); if (!fs.existsSync(archiveDir)) { fs.mkdirSync(archiveDir, { recursive: true }); } } /** * Extract metadata from a transcript file */ export function extractTranscriptMetadata( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, ): ExtractedTranscriptMetadata { // Count messages by reading all messages const messages = readSessionMessages(sessionId, storePath, sessionFile); const messageCount = messages.length; // Extract first user message const firstMessage = readFirstUserMessageFromTranscript( sessionId, storePath, sessionFile, agentId, { includeInterSession: false }, ); // Try to extract timestamps from session header (first line of transcript) let createdAt: number | undefined; let updatedAt: number | undefined; const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); const filePath = candidates.find((p) => fs.existsSync(p)); if (filePath) { try { const firstLine = fs.readFileSync(filePath, "utf-8").split(/\r?\n/)[0]; if (firstLine?.trim()) { const parsed = JSON.parse(firstLine); if (parsed?.type === "session") { const timestamp = parsed.timestamp; if (typeof timestamp === "string") { createdAt = Date.parse(timestamp); if (Number.isNaN(createdAt)) { createdAt = undefined; } } } } // Use file mtime as fallback for updatedAt const stat = fs.statSync(filePath); updatedAt = stat.mtimeMs; } catch { // Ignore errors, use fallbacks } } // Use current time as fallbacks const now = Date.now(); createdAt = createdAt || now; updatedAt = updatedAt || now; return { messageCount, firstMessage: firstMessage || undefined, totalTokens: undefined, // TODO: Extract from transcript if available createdAt, updatedAt, }; } /** * Archive a session: move transcript to archive/ folder and index in SQLite */ export function archiveSessionToHistory(params: ArchiveSessionParams): void { const { sessionEntry, sessionsDir, agentId } = params; // Ensure archive directory exists ensureArchiveDir(sessionsDir); // Initialize/get the history database const historyDb = initHistoryDbWithMigration(sessionsDir, agentId); // Find the current transcript file const storePath = path.join(sessionsDir, "sessions.json"); const candidates = resolveSessionTranscriptCandidates( sessionEntry.sessionId, storePath, sessionEntry.sessionFile, agentId, ); const currentTranscriptPath = candidates.find((p) => fs.existsSync(p)); if (!currentTranscriptPath) { // No transcript file found, but still create a database record const historyEntry: SessionHistoryRow = { sessionId: sessionEntry.sessionId, agentId, sessionKey: "", // Will need to derive this from context displayName: sessionEntry.displayName, createdAt: Date.now(), updatedAt: sessionEntry.updatedAt || Date.now(), archivedAt: Date.now(), messageCount: 0, filePath: "", // No file firstMessage: undefined, channel: sessionEntry.lastChannel, chatType: sessionEntry.chatType || "direct", totalTokens: undefined, status: "archived", }; indexSession(historyDb, historyEntry); return; } // Extract metadata from transcript const metadata = extractTranscriptMetadata( sessionEntry.sessionId, storePath, sessionEntry.sessionFile, agentId, ); // Determine new archive path const transcriptFileName = path.basename(currentTranscriptPath); const archiveTranscriptPath = path.join(sessionsDir, "archive", transcriptFileName); try { // Move transcript file to archive directory if (currentTranscriptPath !== archiveTranscriptPath) { fs.renameSync(currentTranscriptPath, archiveTranscriptPath); } // Create history database entry const historyEntry: SessionHistoryRow = { sessionId: sessionEntry.sessionId, agentId, sessionKey: "", // Will need to derive this from the session context displayName: sessionEntry.displayName, createdAt: metadata.createdAt || Date.now(), updatedAt: metadata.updatedAt || sessionEntry.updatedAt || Date.now(), archivedAt: Date.now(), messageCount: metadata.messageCount, filePath: archiveTranscriptPath, firstMessage: metadata.firstMessage, channel: sessionEntry.lastChannel, chatType: sessionEntry.chatType || "direct", totalTokens: metadata.totalTokens, status: "archived", }; indexSession(historyDb, historyEntry); } catch (error) { // If move fails, log but don't throw - we don't want to break session creation console.error(`Failed to archive session ${sessionEntry.sessionId}:`, error); } } /** * Restore a session from archive: move transcript back and update database */ export function restoreSessionFromArchive(params: RestoreSessionParams): SessionHistoryRow | null { const { sessionId, sessionsDir, agentId } = params; // Get the history database const historyDb = initHistoryDbWithMigration(sessionsDir, agentId); // Find the session in history const stmt = historyDb.prepare(`SELECT * FROM session_history WHERE sessionId = ?`); const historyEntry = stmt.get(sessionId) as SessionHistoryRow | undefined; if (!historyEntry) { return null; } // Check if the archived file exists if (!historyEntry.filePath || !fs.existsSync(historyEntry.filePath)) { return null; } try { // Determine the active session file name (restore to original location) const archiveFileName = path.basename(historyEntry.filePath); const activeTranscriptPath = path.join(sessionsDir, archiveFileName); // Move file back from archive to active sessions directory if (historyEntry.filePath !== activeTranscriptPath) { fs.renameSync(historyEntry.filePath, activeTranscriptPath); } // Update the database record const updatedEntry: SessionHistoryRow = { ...historyEntry, filePath: activeTranscriptPath, status: "active", updatedAt: Date.now(), archivedAt: undefined, }; updateSessionStatus(historyDb, sessionId, "active"); return updatedEntry; } catch (error) { console.error(`Failed to restore session ${sessionId}:`, error); return null; } } /** * Index an existing session as active (for new sessions) */ export function indexActiveSession(params: { sessionEntry: SessionEntry; sessionKey: string; sessionsDir: string; agentId: string; }): void { const { sessionEntry, sessionKey, sessionsDir, agentId } = params; // Initialize/get the history database const historyDb = initHistoryDbWithMigration(sessionsDir, agentId); // Create active session record const historyEntry: SessionHistoryRow = { sessionId: sessionEntry.sessionId, agentId, sessionKey, displayName: sessionEntry.displayName, createdAt: Date.now(), updatedAt: sessionEntry.updatedAt || Date.now(), archivedAt: undefined, messageCount: 0, // New session starts with 0 messages filePath: "", // Will be set when transcript file is created firstMessage: undefined, channel: sessionEntry.lastChannel, chatType: sessionEntry.chatType || "direct", totalTokens: undefined, status: "active", }; indexSession(historyDb, historyEntry); }