From d85b1ae52e45cd12edb566aa617f93222bdcb264 Mon Sep 17 00:00:00 2001 From: xcfdszzr Date: Tue, 3 Feb 2026 08:51:37 +0800 Subject: [PATCH] feat: add /sessions command for session history management (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new /sessions command to manage Claude Code session history with alias support for quick access to previous sessions. Features: - List sessions with pagination and filtering (by date, ID) - Load and view session content and metadata - Create memorable aliases for sessions - Remove aliases - Display session statistics (lines, items, size) - List all aliases New libraries: - scripts/lib/session-manager.js - Core session CRUD operations - scripts/lib/session-aliases.js - Alias management with atomic saves New command: - commands/sessions.md - Complete command with embedded scripts Modified: - scripts/lib/utils.js - Add getAliasesPath() export - scripts/hooks/session-start.js - Show available aliases on session start Session format support: - Old: YYYY-MM-DD-session.tmp - New: YYYY-MM-DD--session.tmp Aliases are stored in ~/.claude/session-aliases.json with Windows- compatible atomic writes and backup support. Co-authored-by: 王志坚 Co-authored-by: Claude --- commands/sessions.md | 305 +++++++++++++++++++++++ scripts/hooks/session-start.js | 10 + scripts/lib/session-aliases.js | 433 +++++++++++++++++++++++++++++++++ scripts/lib/session-manager.js | 396 ++++++++++++++++++++++++++++++ scripts/lib/utils.js | 8 + 5 files changed, 1152 insertions(+) create mode 100644 commands/sessions.md create mode 100644 scripts/lib/session-aliases.js create mode 100644 scripts/lib/session-manager.js diff --git a/commands/sessions.md b/commands/sessions.md new file mode 100644 index 0000000..9ff470a --- /dev/null +++ b/commands/sessions.md @@ -0,0 +1,305 @@ +# Sessions Command + +Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/sessions/`. + +## Usage + +`/sessions [list|load|alias|info|help] [options]` + +## Actions + +### List Sessions + +Display all sessions with metadata, filtering, and pagination. + +```bash +/sessions # List all sessions (default) +/sessions list # Same as above +/sessions list --limit 10 # Show 10 sessions +/sessions list --date 2026-02-01 # Filter by date +/sessions list --search abc # Search by session ID +``` + +**Script:** +```bash +node -e " +const sm = require('./scripts/lib/session-manager'); +const aa = require('./scripts/lib/session-aliases'); + +const result = sm.getAllSessions({ limit: 20 }); +const aliases = aa.listAliases(); +const aliasMap = {}; +for (const a of aliases) aliasMap[a.sessionPath] = a.name; + +console.log('Sessions (showing ' + result.sessions.length + ' of ' + result.total + '):'); +console.log(''); +console.log('ID Date Time Size Lines Alias'); +console.log('────────────────────────────────────────────────────'); + +for (const s of result.sessions) { + const alias = aliasMap[s.filename] || ''; + const size = sm.getSessionSize(s.sessionPath); + const stats = sm.getSessionStats(s.sessionPath); + const id = s.shortId === 'no-id' ? '(none)' : s.shortId.slice(0, 8); + const time = s.modifiedTime.toTimeString().slice(0, 5); + + console.log(id.padEnd(8) + ' ' + s.date + ' ' + time + ' ' + size.padEnd(7) + ' ' + String(stats.lineCount).padEnd(5) + ' ' + alias); +} +" +``` + +### Load Session + +Load and display a session's content (by ID or alias). + +```bash +/sessions load # Load session +/sessions load 2026-02-01 # By date (for no-id sessions) +/sessions load a1b2c3d4 # By short ID +/sessions load my-alias # By alias name +``` + +**Script:** +```bash +node -e " +const sm = require('./scripts/lib/session-manager'); +const aa = require('./scripts/lib/session-aliases'); +const id = process.argv[1]; + +// First try to resolve as alias +const resolved = aa.resolveAlias(id); +const sessionId = resolved ? resolved.sessionPath : id; + +const session = sm.getSessionById(sessionId, true); +if (!session) { + console.log('Session not found: ' + id); + process.exit(1); +} + +const stats = sm.getSessionStats(session.sessionPath); +const size = sm.getSessionSize(session.sessionPath); +const aliases = aa.getAliasesForSession(session.filename); + +console.log('Session: ' + session.filename); +console.log('Path: ~/.claude/sessions/' + session.filename); +console.log(''); +console.log('Statistics:'); +console.log(' Lines: ' + stats.lineCount); +console.log(' Total items: ' + stats.totalItems); +console.log(' Completed: ' + stats.completedItems); +console.log(' In progress: ' + stats.inProgressItems); +console.log(' Size: ' + size); +console.log(''); + +if (aliases.length > 0) { + console.log('Aliases: ' + aliases.map(a => a.name).join(', ')); + console.log(''); +} + +if (session.metadata.title) { + console.log('Title: ' + session.metadata.title); + console.log(''); +} + +if (session.metadata.started) { + console.log('Started: ' + session.metadata.started); +} + +if (session.metadata.lastUpdated) { + console.log('Last Updated: ' + session.metadata.lastUpdated); +} +" "$ARGUMENTS" +``` + +### Create Alias + +Create a memorable alias for a session. + +```bash +/sessions alias # Create alias +/sessions alias 2026-02-01 today-work # Create alias named "today-work" +``` + +**Script:** +```bash +node -e " +const sm = require('./scripts/lib/session-manager'); +const aa = require('./scripts/lib/session-aliases'); + +const sessionId = process.argv[1]; +const aliasName = process.argv[2]; + +if (!sessionId || !aliasName) { + console.log('Usage: /sessions alias '); + process.exit(1); +} + +// Get session filename +const session = sm.getSessionById(sessionId); +if (!session) { + console.log('Session not found: ' + sessionId); + process.exit(1); +} + +const result = aa.setAlias(aliasName, session.filename); +if (result.success) { + console.log('✓ Alias created: ' + aliasName + ' → ' + session.filename); +} else { + console.log('✗ Error: ' + result.error); + process.exit(1); +} +" "$ARGUMENTS" +``` + +### Remove Alias + +Delete an existing alias. + +```bash +/sessions alias --remove # Remove alias +/sessions unalias # Same as above +``` + +**Script:** +```bash +node -e " +const aa = require('./scripts/lib/session-aliases'); + +const aliasName = process.argv[1]; +if (!aliasName) { + console.log('Usage: /sessions alias --remove '); + process.exit(1); +} + +const result = aa.deleteAlias(aliasName); +if (result.success) { + console.log('✓ Alias removed: ' + aliasName); +} else { + console.log('✗ Error: ' + result.error); + process.exit(1); +} +" "$ARGUMENTS" +``` + +### Session Info + +Show detailed information about a session. + +```bash +/sessions info # Show session details +``` + +**Script:** +```bash +node -e " +const sm = require('./scripts/lib/session-manager'); +const aa = require('./scripts/lib/session-aliases'); + +const id = process.argv[1]; +const resolved = aa.resolveAlias(id); +const sessionId = resolved ? resolved.sessionPath : id; + +const session = sm.getSessionById(sessionId, true); +if (!session) { + console.log('Session not found: ' + id); + process.exit(1); +} + +const stats = sm.getSessionStats(session.sessionPath); +const size = sm.getSessionSize(session.sessionPath); +const aliases = aa.getAliasesForSession(session.filename); + +console.log('Session Information'); +console.log('════════════════════'); +console.log('ID: ' + (session.shortId === 'no-id' ? '(none)' : session.shortId)); +console.log('Filename: ' + session.filename); +console.log('Date: ' + session.date); +console.log('Modified: ' + session.modifiedTime.toISOString().slice(0, 19).replace('T', ' ')); +console.log(''); +console.log('Content:'); +console.log(' Lines: ' + stats.lineCount); +console.log(' Total items: ' + stats.totalItems); +console.log(' Completed: ' + stats.completedItems); +console.log(' In progress: ' + stats.inProgressItems); +console.log(' Size: ' + size); +if (aliases.length > 0) { + console.log('Aliases: ' + aliases.map(a => a.name).join(', ')); +} +" "$ARGUMENTS" +``` + +### List Aliases + +Show all session aliases. + +```bash +/sessions aliases # List all aliases +``` + +**Script:** +```bash +node -e " +const aa = require('./scripts/lib/session-aliases'); + +const aliases = aa.listAliases(); +console.log('Session Aliases (' + aliases.length + '):'); +console.log(''); + +if (aliases.length === 0) { + console.log('No aliases found.'); +} else { + console.log('Name Session File Title'); + console.log('─────────────────────────────────────────────────────────────'); + for (const a of aliases) { + const name = a.name.padEnd(12); + const file = (a.sessionPath.length > 30 ? a.sessionPath.slice(0, 27) + '...' : a.sessionPath).padEnd(30); + const title = a.title || ''; + console.log(name + ' ' + file + ' ' + title); + } +} +" +``` + +## Arguments + +$ARGUMENTS: +- `list [options]` - List sessions + - `--limit ` - Max sessions to show (default: 50) + - `--date ` - Filter by date + - `--search ` - Search in session ID +- `load ` - Load session content +- `alias ` - Create alias for session +- `alias --remove ` - Remove alias +- `unalias ` - Same as `--remove` +- `info ` - Show session statistics +- `aliases` - List all aliases +- `help` - Show this help + +## Examples + +```bash +# List all sessions +/sessions list + +# Create an alias for today's session +/sessions alias 2026-02-01 today + +# Load session by alias +/sessions load today + +# Show session info +/sessions info today + +# Remove alias +/sessions alias --remove today + +# List all aliases +/sessions aliases +``` + +## Notes + +- Sessions are stored as markdown files in `~/.claude/sessions/` +- Aliases are stored in `~/.claude/session-aliases.json` +- Session IDs can be shortened (first 4-8 characters usually unique enough) +- Use aliases for frequently referenced sessions diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 76fa600..893bb03 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -16,6 +16,7 @@ const { log } = require('../lib/utils'); const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager'); +const { listAliases } = require('../lib/session-aliases'); async function main() { const sessionsDir = getSessionsDir(); @@ -42,6 +43,15 @@ async function main() { log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`); } + // Check for available session aliases + const aliases = listAliases({ limit: 5 }); + + if (aliases.length > 0) { + const aliasNames = aliases.map(a => a.name).join(', '); + log(`[SessionStart] ${aliases.length} session alias(es) available: ${aliasNames}`); + log(`[SessionStart] Use /sessions load to continue a previous session`); + } + // Detect and report package manager const pm = getPackageManager(); log(`[SessionStart] Package manager: ${pm.name} (${pm.source})`); diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js new file mode 100644 index 0000000..867da3a --- /dev/null +++ b/scripts/lib/session-aliases.js @@ -0,0 +1,433 @@ +/** + * Session Aliases Library for Claude Code + * Manages session aliases stored in ~/.claude/session-aliases.json + */ + +const fs = require('fs'); +const path = require('path'); + +const { + getClaudeDir, + ensureDir, + readFile, + writeFile, + log +} = require('./utils'); + +// Aliases file path +function getAliasesPath() { + return path.join(getClaudeDir(), 'session-aliases.json'); +} + +// Current alias storage format version +const ALIAS_VERSION = '1.0'; + +/** + * Default aliases file structure + */ +function getDefaultAliases() { + return { + version: ALIAS_VERSION, + aliases: {}, + metadata: { + totalCount: 0, + lastUpdated: new Date().toISOString() + } + }; +} + +/** + * Load aliases from file + * @returns {object} Aliases object + */ +function loadAliases() { + const aliasesPath = getAliasesPath(); + + if (!fs.existsSync(aliasesPath)) { + return getDefaultAliases(); + } + + const content = readFile(aliasesPath); + if (!content) { + return getDefaultAliases(); + } + + try { + const data = JSON.parse(content); + + // Validate structure + if (!data.aliases || typeof data.aliases !== 'object') { + log('[Aliases] Invalid aliases file structure, resetting'); + return getDefaultAliases(); + } + + // Ensure version field + if (!data.version) { + data.version = ALIAS_VERSION; + } + + // Ensure metadata + if (!data.metadata) { + data.metadata = { + totalCount: Object.keys(data.aliases).length, + lastUpdated: new Date().toISOString() + }; + } + + return data; + } catch (err) { + log(`[Aliases] Error parsing aliases file: ${err.message}`); + return getDefaultAliases(); + } +} + +/** + * Save aliases to file with atomic write + * @param {object} aliases - Aliases object to save + * @returns {boolean} Success status + */ +function saveAliases(aliases) { + const aliasesPath = getAliasesPath(); + const tempPath = aliasesPath + '.tmp'; + const backupPath = aliasesPath + '.bak'; + + try { + // Update metadata + aliases.metadata = { + totalCount: Object.keys(aliases.aliases).length, + lastUpdated: new Date().toISOString() + }; + + const content = JSON.stringify(aliases, null, 2); + + // Ensure directory exists + ensureDir(path.dirname(aliasesPath)); + + // Create backup if file exists + if (fs.existsSync(aliasesPath)) { + fs.copyFileSync(aliasesPath, backupPath); + } + + // Atomic write: write to temp file, then rename + fs.writeFileSync(tempPath, content, 'utf8'); + + // On Windows, we need to delete the target file before renaming + if (fs.existsSync(aliasesPath)) { + fs.unlinkSync(aliasesPath); + } + fs.renameSync(tempPath, aliasesPath); + + // Remove backup on success + if (fs.existsSync(backupPath)) { + fs.unlinkSync(backupPath); + } + + return true; + } catch (err) { + log(`[Aliases] Error saving aliases: ${err.message}`); + + // Restore from backup if exists + if (fs.existsSync(backupPath)) { + try { + fs.copyFileSync(backupPath, aliasesPath); + log('[Aliases] Restored from backup'); + } catch (restoreErr) { + log(`[Aliases] Failed to restore backup: ${restoreErr.message}`); + } + } + + // Clean up temp file + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + + return false; + } +} + +/** + * Resolve an alias to get session path + * @param {string} alias - Alias name to resolve + * @returns {object|null} Alias data or null if not found + */ +function resolveAlias(alias) { + // Validate alias name (alphanumeric, dash, underscore) + if (!/^[a-zA-Z0-9_-]+$/.test(alias)) { + return null; + } + + const data = loadAliases(); + const aliasData = data.aliases[alias]; + + if (!aliasData) { + return null; + } + + return { + alias, + sessionPath: aliasData.sessionPath, + createdAt: aliasData.createdAt, + title: aliasData.title || null + }; +} + +/** + * Set or update an alias for a session + * @param {string} alias - Alias name (alphanumeric, dash, underscore) + * @param {string} sessionPath - Session directory path + * @param {string} title - Optional title for the alias + * @returns {object} Result with success status and message + */ +function setAlias(alias, sessionPath, title = null) { + // Validate alias name + if (!alias || alias.length === 0) { + return { success: false, error: 'Alias name cannot be empty' }; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(alias)) { + return { success: false, error: 'Alias name must contain only letters, numbers, dashes, and underscores' }; + } + + // Reserved alias names + const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set']; + if (reserved.includes(alias.toLowerCase())) { + return { success: false, error: `'${alias}' is a reserved alias name` }; + } + + const data = loadAliases(); + const existing = data.aliases[alias]; + const isNew = !existing; + + data.aliases[alias] = { + sessionPath, + createdAt: existing ? existing.createdAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + title: title || null + }; + + if (saveAliases(data)) { + return { + success: true, + isNew, + alias, + sessionPath, + title: data.aliases[alias].title + }; + } + + return { success: false, error: 'Failed to save alias' }; +} + +/** + * List all aliases + * @param {object} options - Options object + * @param {string} options.search - Filter aliases by name (partial match) + * @param {number} options.limit - Maximum number of aliases to return + * @returns {Array} Array of alias objects + */ +function listAliases(options = {}) { + const { search = null, limit = null } = options; + const data = loadAliases(); + + let aliases = Object.entries(data.aliases).map(([name, info]) => ({ + name, + sessionPath: info.sessionPath, + createdAt: info.createdAt, + updatedAt: info.updatedAt, + title: info.title + })); + + // Sort by updated time (newest first) + aliases.sort((a, b) => new Date(b.updatedAt || b.createdAt) - new Date(a.updatedAt || a.createdAt)); + + // Apply search filter + if (search) { + const searchLower = search.toLowerCase(); + aliases = aliases.filter(a => + a.name.toLowerCase().includes(searchLower) || + (a.title && a.title.toLowerCase().includes(searchLower)) + ); + } + + // Apply limit + if (limit && limit > 0) { + aliases = aliases.slice(0, limit); + } + + return aliases; +} + +/** + * Delete an alias + * @param {string} alias - Alias name to delete + * @returns {object} Result with success status + */ +function deleteAlias(alias) { + const data = loadAliases(); + + if (!data.aliases[alias]) { + return { success: false, error: `Alias '${alias}' not found` }; + } + + const deleted = data.aliases[alias]; + delete data.aliases[alias]; + + if (saveAliases(data)) { + return { + success: true, + alias, + deletedSessionPath: deleted.sessionPath + }; + } + + return { success: false, error: 'Failed to delete alias' }; +} + +/** + * Rename an alias + * @param {string} oldAlias - Current alias name + * @param {string} newAlias - New alias name + * @returns {object} Result with success status + */ +function renameAlias(oldAlias, newAlias) { + const data = loadAliases(); + + if (!data.aliases[oldAlias]) { + return { success: false, error: `Alias '${oldAlias}' not found` }; + } + + if (data.aliases[newAlias]) { + return { success: false, error: `Alias '${newAlias}' already exists` }; + } + + // Validate new alias name + if (!/^[a-zA-Z0-9_-]+$/.test(newAlias)) { + return { success: false, error: 'New alias name must contain only letters, numbers, dashes, and underscores' }; + } + + const aliasData = data.aliases[oldAlias]; + delete data.aliases[oldAlias]; + + aliasData.updatedAt = new Date().toISOString(); + data.aliases[newAlias] = aliasData; + + if (saveAliases(data)) { + return { + success: true, + oldAlias, + newAlias, + sessionPath: aliasData.sessionPath + }; + } + + // Restore old alias on failure + data.aliases[oldAlias] = aliasData; + return { success: false, error: 'Failed to rename alias' }; +} + +/** + * Get session path by alias (convenience function) + * @param {string} aliasOrId - Alias name or session ID + * @returns {string|null} Session path or null if not found + */ +function resolveSessionAlias(aliasOrId) { + // First try to resolve as alias + const resolved = resolveAlias(aliasOrId); + if (resolved) { + return resolved.sessionPath; + } + + // If not an alias, return as-is (might be a session path) + return aliasOrId; +} + +/** + * Update alias title + * @param {string} alias - Alias name + * @param {string} title - New title + * @returns {object} Result with success status + */ +function updateAliasTitle(alias, title) { + const data = loadAliases(); + + if (!data.aliases[alias]) { + return { success: false, error: `Alias '${alias}' not found` }; + } + + data.aliases[alias].title = title; + data.aliases[alias].updatedAt = new Date().toISOString(); + + if (saveAliases(data)) { + return { + success: true, + alias, + title + }; + } + + return { success: false, error: 'Failed to update alias title' }; +} + +/** + * Get all aliases for a specific session + * @param {string} sessionPath - Session path to find aliases for + * @returns {Array} Array of alias names + */ +function getAliasesForSession(sessionPath) { + const data = loadAliases(); + const aliases = []; + + for (const [name, info] of Object.entries(data.aliases)) { + if (info.sessionPath === sessionPath) { + aliases.push({ + name, + createdAt: info.createdAt, + title: info.title + }); + } + } + + return aliases; +} + +/** + * Clean up aliases for non-existent sessions + * @param {Function} sessionExists - Function to check if session exists + * @returns {object} Cleanup result + */ +function cleanupAliases(sessionExists) { + const data = loadAliases(); + const removed = []; + + for (const [name, info] of Object.entries(data.aliases)) { + if (!sessionExists(info.sessionPath)) { + removed.push({ name, sessionPath: info.sessionPath }); + delete data.aliases[name]; + } + } + + if (removed.length > 0) { + saveAliases(data); + } + + return { + totalChecked: Object.keys(data.aliases).length + removed.length, + removed: removed.length, + removedAliases: removed + }; +} + +module.exports = { + getAliasesPath, + loadAliases, + saveAliases, + resolveAlias, + setAlias, + listAliases, + deleteAlias, + renameAlias, + resolveSessionAlias, + updateAliasTitle, + getAliasesForSession, + cleanupAliases +}; diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js new file mode 100644 index 0000000..cc9c3c6 --- /dev/null +++ b/scripts/lib/session-manager.js @@ -0,0 +1,396 @@ +/** + * 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--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]; + // match[2] is undefined for old format (no ID) + const shortId = match[2] || 'no-id'; + + return { + filename, + shortId, + date: dateStr, + // Convert date string to Date object + datetime: new Date(dateStr) + }; +} + +/** + * 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) { + if (!fs.existsSync(sessionPath)) { + return null; + } + + 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} sessionPath - Full path to session file + * @returns {object} Statistics object + */ +function getSessionStats(sessionPath) { + const content = getSessionContent(sessionPath); + 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 = 50, + offset = 0, + date = null, + search = null + } = options; + + 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 + const stats = fs.statSync(sessionPath); + + sessions.push({ + ...metadata, + sessionPath, + hasContent: stats.size > 0, + size: stats.size, + modifiedTime: stats.mtime, + createdTime: stats.birthtime + }); + } + + // 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 = 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); + const stats = fs.statSync(sessionPath); + + const session = { + ...metadata, + sessionPath, + size: stats.size, + modifiedTime: stats.mtime, + createdTime: stats.birthtime + }; + + if (includeContent) { + session.content = getSessionContent(sessionPath); + session.metadata = parseSessionMetadata(session.content); + session.stats = getSessionStats(sessionPath); + } + + 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) { + if (!fs.existsSync(sessionPath)) { + return '0 B'; + } + + const stats = fs.statSync(sessionPath); + 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) { + return fs.existsSync(sessionPath) && fs.statSync(sessionPath).isFile(); +} + +module.exports = { + parseSessionFilename, + getSessionPath, + getSessionContent, + parseSessionMetadata, + getSessionStats, + getSessionTitle, + getSessionSize, + getAllSessions, + getSessionById, + writeSessionContent, + appendSessionContent, + deleteSession, + sessionExists +}; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 1fa4616..5369ba0 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -34,6 +34,13 @@ function getSessionsDir() { return path.join(getClaudeDir(), 'sessions'); } +/** + * Get the session aliases file path + */ +function getAliasesPath() { + return path.join(getClaudeDir(), 'session-aliases.json'); +} + /** * Get the learned skills directory */ @@ -382,6 +389,7 @@ module.exports = { getHomeDir, getClaudeDir, getSessionsDir, + getAliasesPath, getLearnedSkillsDir, getTempDir, ensureDir,