Spaces:
Runtime error
Runtime error
| /** | |
| * Session Manager Library for Claude Code | |
| * Provides core session CRUD operations for listing, loading, and managing sessions | |
| * | |
| * Sessions are stored as markdown files in ~/.claude/sessions/ with format: | |
| * - YYYY-MM-DD-session.tmp (old format) | |
| * - YYYY-MM-DD-<short-id>-session.tmp (new format) | |
| */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { | |
| getSessionsDir, | |
| readFile, | |
| log | |
| } = require('./utils'); | |
| // Session filename pattern: YYYY-MM-DD-[short-id]-session.tmp | |
| // The short-id is optional (old format) and can be 8+ alphanumeric characters | |
| // Matches: "2026-02-01-session.tmp" or "2026-02-01-a1b2c3d4-session.tmp" | |
| const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9]{8,}))?-session\.tmp$/; | |
| /** | |
| * Parse session filename to extract metadata | |
| * @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp") | |
| * @returns {object|null} Parsed metadata or null if invalid | |
| */ | |
| function parseSessionFilename(filename) { | |
| const match = filename.match(SESSION_FILENAME_REGEX); | |
| if (!match) return null; | |
| const dateStr = match[1]; | |
| // Validate date components are calendar-accurate (not just format) | |
| const [year, month, day] = dateStr.split('-').map(Number); | |
| if (month < 1 || month > 12 || day < 1 || day > 31) return null; | |
| // Reject impossible dates like Feb 31, Apr 31 — Date constructor rolls | |
| // over invalid days (e.g., Feb 31 → Mar 3), so check month roundtrips | |
| const d = new Date(year, month - 1, day); | |
| if (d.getMonth() !== month - 1 || d.getDate() !== day) return null; | |
| // match[2] is undefined for old format (no ID) | |
| const shortId = match[2] || 'no-id'; | |
| return { | |
| filename, | |
| shortId, | |
| date: dateStr, | |
| // Use local-time constructor (consistent with validation on line 40) | |
| // new Date(dateStr) interprets YYYY-MM-DD as UTC midnight which shows | |
| // as the previous day in negative UTC offset timezones | |
| datetime: new Date(year, month - 1, day) | |
| }; | |
| } | |
| /** | |
| * Get the full path to a session file | |
| * @param {string} filename - Session filename | |
| * @returns {string} Full path to session file | |
| */ | |
| function getSessionPath(filename) { | |
| return path.join(getSessionsDir(), filename); | |
| } | |
| /** | |
| * Read and parse session markdown content | |
| * @param {string} sessionPath - Full path to session file | |
| * @returns {string|null} Session content or null if not found | |
| */ | |
| function getSessionContent(sessionPath) { | |
| return readFile(sessionPath); | |
| } | |
| /** | |
| * Parse session metadata from markdown content | |
| * @param {string} content - Session markdown content | |
| * @returns {object} Parsed metadata | |
| */ | |
| function parseSessionMetadata(content) { | |
| const metadata = { | |
| title: null, | |
| date: null, | |
| started: null, | |
| lastUpdated: null, | |
| completed: [], | |
| inProgress: [], | |
| notes: '', | |
| context: '' | |
| }; | |
| if (!content) return metadata; | |
| // Extract title from first heading | |
| const titleMatch = content.match(/^#\s+(.+)$/m); | |
| if (titleMatch) { | |
| metadata.title = titleMatch[1].trim(); | |
| } | |
| // Extract date | |
| const dateMatch = content.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/); | |
| if (dateMatch) { | |
| metadata.date = dateMatch[1]; | |
| } | |
| // Extract started time | |
| const startedMatch = content.match(/\*\*Started:\*\*\s*([\d:]+)/); | |
| if (startedMatch) { | |
| metadata.started = startedMatch[1]; | |
| } | |
| // Extract last updated | |
| const updatedMatch = content.match(/\*\*Last Updated:\*\*\s*([\d:]+)/); | |
| if (updatedMatch) { | |
| metadata.lastUpdated = updatedMatch[1]; | |
| } | |
| // Extract completed items | |
| const completedSection = content.match(/### Completed\s*\n([\s\S]*?)(?=###|\n\n|$)/); | |
| if (completedSection) { | |
| const items = completedSection[1].match(/- \[x\]\s*(.+)/g); | |
| if (items) { | |
| metadata.completed = items.map(item => item.replace(/- \[x\]\s*/, '').trim()); | |
| } | |
| } | |
| // Extract in-progress items | |
| const progressSection = content.match(/### In Progress\s*\n([\s\S]*?)(?=###|\n\n|$)/); | |
| if (progressSection) { | |
| const items = progressSection[1].match(/- \[ \]\s*(.+)/g); | |
| if (items) { | |
| metadata.inProgress = items.map(item => item.replace(/- \[ \]\s*/, '').trim()); | |
| } | |
| } | |
| // Extract notes | |
| const notesSection = content.match(/### Notes for Next Session\s*\n([\s\S]*?)(?=###|\n\n|$)/); | |
| if (notesSection) { | |
| metadata.notes = notesSection[1].trim(); | |
| } | |
| // Extract context to load | |
| const contextSection = content.match(/### Context to Load\s*\n```\n([\s\S]*?)```/); | |
| if (contextSection) { | |
| metadata.context = contextSection[1].trim(); | |
| } | |
| return metadata; | |
| } | |
| /** | |
| * Calculate statistics for a session | |
| * @param {string} sessionPathOrContent - Full path to session file, OR | |
| * the pre-read content string (to avoid redundant disk reads when | |
| * the caller already has the content loaded). | |
| * @returns {object} Statistics object | |
| */ | |
| function getSessionStats(sessionPathOrContent) { | |
| // Accept pre-read content string to avoid redundant file reads. | |
| // If the argument looks like a file path (no newlines, ends with .tmp, | |
| // starts with / on Unix or drive letter on Windows), read from disk. | |
| // Otherwise treat it as content. | |
| const looksLikePath = typeof sessionPathOrContent === 'string' && | |
| !sessionPathOrContent.includes('\n') && | |
| sessionPathOrContent.endsWith('.tmp') && | |
| (sessionPathOrContent.startsWith('/') || /^[A-Za-z]:[/\\]/.test(sessionPathOrContent)); | |
| const content = looksLikePath | |
| ? getSessionContent(sessionPathOrContent) | |
| : sessionPathOrContent; | |
| const metadata = parseSessionMetadata(content); | |
| return { | |
| totalItems: metadata.completed.length + metadata.inProgress.length, | |
| completedItems: metadata.completed.length, | |
| inProgressItems: metadata.inProgress.length, | |
| lineCount: content ? content.split('\n').length : 0, | |
| hasNotes: !!metadata.notes, | |
| hasContext: !!metadata.context | |
| }; | |
| } | |
| /** | |
| * Get all sessions with optional filtering and pagination | |
| * @param {object} options - Options object | |
| * @param {number} options.limit - Maximum number of sessions to return | |
| * @param {number} options.offset - Number of sessions to skip | |
| * @param {string} options.date - Filter by date (YYYY-MM-DD format) | |
| * @param {string} options.search - Search in short ID | |
| * @returns {object} Object with sessions array and pagination info | |
| */ | |
| function getAllSessions(options = {}) { | |
| const { | |
| limit: rawLimit = 50, | |
| offset: rawOffset = 0, | |
| date = null, | |
| search = null | |
| } = options; | |
| // Clamp offset and limit to safe non-negative integers. | |
| // Without this, negative offset causes slice() to count from the end, | |
| // and NaN values cause slice() to return empty or unexpected results. | |
| // Note: cannot use `|| default` because 0 is falsy — use isNaN instead. | |
| const offsetNum = Number(rawOffset); | |
| const offset = Number.isNaN(offsetNum) ? 0 : Math.max(0, Math.floor(offsetNum)); | |
| const limitNum = Number(rawLimit); | |
| const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum)); | |
| const sessionsDir = getSessionsDir(); | |
| if (!fs.existsSync(sessionsDir)) { | |
| return { sessions: [], total: 0, offset, limit, hasMore: false }; | |
| } | |
| const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); | |
| const sessions = []; | |
| for (const entry of entries) { | |
| // Skip non-files (only process .tmp files) | |
| if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; | |
| const filename = entry.name; | |
| const metadata = parseSessionFilename(filename); | |
| if (!metadata) continue; | |
| // Apply date filter | |
| if (date && metadata.date !== date) { | |
| continue; | |
| } | |
| // Apply search filter (search in short ID) | |
| if (search && !metadata.shortId.includes(search)) { | |
| continue; | |
| } | |
| const sessionPath = path.join(sessionsDir, filename); | |
| // Get file stats (wrapped in try-catch to handle TOCTOU race where | |
| // file is deleted between readdirSync and statSync) | |
| let stats; | |
| try { | |
| stats = fs.statSync(sessionPath); | |
| } catch { | |
| continue; // File was deleted between readdir and stat | |
| } | |
| sessions.push({ | |
| ...metadata, | |
| sessionPath, | |
| hasContent: stats.size > 0, | |
| size: stats.size, | |
| modifiedTime: stats.mtime, | |
| createdTime: stats.birthtime || stats.ctime | |
| }); | |
| } | |
| // Sort by modified time (newest first) | |
| sessions.sort((a, b) => b.modifiedTime - a.modifiedTime); | |
| // Apply pagination | |
| const paginatedSessions = sessions.slice(offset, offset + limit); | |
| return { | |
| sessions: paginatedSessions, | |
| total: sessions.length, | |
| offset, | |
| limit, | |
| hasMore: offset + limit < sessions.length | |
| }; | |
| } | |
| /** | |
| * Get a single session by ID (short ID or full path) | |
| * @param {string} sessionId - Short ID or session filename | |
| * @param {boolean} includeContent - Include session content | |
| * @returns {object|null} Session object or null if not found | |
| */ | |
| function getSessionById(sessionId, includeContent = false) { | |
| const sessionsDir = getSessionsDir(); | |
| if (!fs.existsSync(sessionsDir)) { | |
| return null; | |
| } | |
| const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; | |
| const filename = entry.name; | |
| const metadata = parseSessionFilename(filename); | |
| if (!metadata) continue; | |
| // Check if session ID matches (short ID or full filename without .tmp) | |
| const shortIdMatch = sessionId.length > 0 && metadata.shortId !== 'no-id' && metadata.shortId.startsWith(sessionId); | |
| const filenameMatch = filename === sessionId || filename === `${sessionId}.tmp`; | |
| const noIdMatch = metadata.shortId === 'no-id' && filename === `${sessionId}-session.tmp`; | |
| if (!shortIdMatch && !filenameMatch && !noIdMatch) { | |
| continue; | |
| } | |
| const sessionPath = path.join(sessionsDir, filename); | |
| let stats; | |
| try { | |
| stats = fs.statSync(sessionPath); | |
| } catch { | |
| return null; // File was deleted between readdir and stat | |
| } | |
| const session = { | |
| ...metadata, | |
| sessionPath, | |
| size: stats.size, | |
| modifiedTime: stats.mtime, | |
| createdTime: stats.birthtime || stats.ctime | |
| }; | |
| if (includeContent) { | |
| session.content = getSessionContent(sessionPath); | |
| session.metadata = parseSessionMetadata(session.content); | |
| // Pass pre-read content to avoid a redundant disk read | |
| session.stats = getSessionStats(session.content || ''); | |
| } | |
| return session; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Get session title from content | |
| * @param {string} sessionPath - Full path to session file | |
| * @returns {string} Title or default text | |
| */ | |
| function getSessionTitle(sessionPath) { | |
| const content = getSessionContent(sessionPath); | |
| const metadata = parseSessionMetadata(content); | |
| return metadata.title || 'Untitled Session'; | |
| } | |
| /** | |
| * Format session size in human-readable format | |
| * @param {string} sessionPath - Full path to session file | |
| * @returns {string} Formatted size (e.g., "1.2 KB") | |
| */ | |
| function getSessionSize(sessionPath) { | |
| let stats; | |
| try { | |
| stats = fs.statSync(sessionPath); | |
| } catch { | |
| return '0 B'; | |
| } | |
| const size = stats.size; | |
| if (size < 1024) return `${size} B`; | |
| if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; | |
| return `${(size / (1024 * 1024)).toFixed(1)} MB`; | |
| } | |
| /** | |
| * Write session content to file | |
| * @param {string} sessionPath - Full path to session file | |
| * @param {string} content - Markdown content to write | |
| * @returns {boolean} Success status | |
| */ | |
| function writeSessionContent(sessionPath, content) { | |
| try { | |
| fs.writeFileSync(sessionPath, content, 'utf8'); | |
| return true; | |
| } catch (err) { | |
| log(`[SessionManager] Error writing session: ${err.message}`); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Append content to a session | |
| * @param {string} sessionPath - Full path to session file | |
| * @param {string} content - Content to append | |
| * @returns {boolean} Success status | |
| */ | |
| function appendSessionContent(sessionPath, content) { | |
| try { | |
| fs.appendFileSync(sessionPath, content, 'utf8'); | |
| return true; | |
| } catch (err) { | |
| log(`[SessionManager] Error appending to session: ${err.message}`); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Delete a session file | |
| * @param {string} sessionPath - Full path to session file | |
| * @returns {boolean} Success status | |
| */ | |
| function deleteSession(sessionPath) { | |
| try { | |
| if (fs.existsSync(sessionPath)) { | |
| fs.unlinkSync(sessionPath); | |
| return true; | |
| } | |
| return false; | |
| } catch (err) { | |
| log(`[SessionManager] Error deleting session: ${err.message}`); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Check if a session exists | |
| * @param {string} sessionPath - Full path to session file | |
| * @returns {boolean} True if session exists | |
| */ | |
| function sessionExists(sessionPath) { | |
| try { | |
| return fs.statSync(sessionPath).isFile(); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| module.exports = { | |
| parseSessionFilename, | |
| getSessionPath, | |
| getSessionContent, | |
| parseSessionMetadata, | |
| getSessionStats, | |
| getSessionTitle, | |
| getSessionSize, | |
| getAllSessions, | |
| getSessionById, | |
| writeSessionContent, | |
| appendSessionContent, | |
| deleteSession, | |
| sessionExists | |
| }; | |