claude-hooks/lib/session-state.js

375 lines
9.8 KiB
JavaScript

/**
* Session State Manager - Tracks session state and creates continuation docs
*/
const fs = require('fs-extra');
const path = require('path');
class SessionStateManager {
constructor(stateDir = '.claude_hooks') {
this.stateDir = path.resolve(stateDir);
this.sessionId = this._generateSessionId();
this.startTime = Date.now();
// Session data
this.modifiedFiles = new Set();
this.commandsExecuted = [];
this.toolUsage = {};
this.backupHistory = [];
this.contextSnapshots = [];
fs.ensureDirSync(this.stateDir);
this._loadPersistentState();
}
/**
* Update state from tool usage
*/
async updateFromToolUse(toolData) {
const tool = toolData.tool || 'Unknown';
// Track tool usage
this.toolUsage[tool] = (this.toolUsage[tool] || 0) + 1;
// Track file modifications
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
const filePath = toolData.parameters?.file_path;
if (filePath) {
this.modifiedFiles.add(filePath);
}
}
// Track commands
if (tool === 'Bash') {
const command = toolData.parameters?.command;
if (command) {
this.commandsExecuted.push({
command,
timestamp: new Date().toISOString(),
success: toolData.success !== false
});
}
}
// Persist state periodically
if (this.commandsExecuted.length % 5 === 0) {
await this._savePersistentState();
}
}
/**
* Add backup to history
*/
async addBackup(backupId, info) {
this.backupHistory.push({
backupId,
timestamp: new Date().toISOString(),
...info
});
await this._savePersistentState();
}
/**
* Add context snapshot
*/
async addContextSnapshot(snapshot) {
this.contextSnapshots.push({
timestamp: new Date().toISOString(),
...snapshot
});
// Keep only last 10 snapshots
if (this.contextSnapshots.length > 10) {
this.contextSnapshots = this.contextSnapshots.slice(-10);
}
}
/**
* Get comprehensive session summary
*/
async getSessionSummary() {
const duration = Date.now() - this.startTime;
const durationMinutes = Math.round(duration / (1000 * 60));
return {
sessionId: this.sessionId,
startTime: new Date(this.startTime).toISOString(),
duration: duration,
modifiedFiles: Array.from(this.modifiedFiles),
commandsExecuted: this.commandsExecuted,
toolUsage: this.toolUsage,
backupHistory: this.backupHistory,
contextSnapshots: this.contextSnapshots,
sessionStats: {
durationMinutes,
totalToolCalls: Object.values(this.toolUsage).reduce((sum, count) => sum + count, 0),
totalCommands: this.commandsExecuted.length,
filesModified: this.modifiedFiles.size
}
};
}
/**
* Create continuation documentation files
*/
async createContinuationDocs() {
try {
const summary = await this.getSessionSummary();
// Create LAST_SESSION.md
await this._createLastSessionDoc(summary);
// Create/update ACTIVE_TODOS.md (if todos exist)
await this._updateActiveTodos();
} catch (error) {
console.error('Error creating continuation docs:', error.message);
}
}
/**
* Create LAST_SESSION.md with session summary
*/
async _createLastSessionDoc(summary) {
let content = `# Last Claude Session Summary
## Session Overview
- **Session ID**: ${summary.sessionId}
- **Started**: ${summary.startTime}
- **Duration**: ${summary.sessionStats.durationMinutes} minutes
- **Total Tools Used**: ${summary.sessionStats.totalToolCalls}
- **Commands Executed**: ${summary.sessionStats.totalCommands}
- **Files Modified**: ${summary.sessionStats.filesModified}
## Files Modified
`;
if (summary.modifiedFiles.length > 0) {
for (const file of summary.modifiedFiles) {
content += `- ${file}\n`;
}
} else {
content += '*No files were modified in this session*\n';
}
content += `
## Tools Used
`;
for (const [tool, count] of Object.entries(summary.toolUsage)) {
content += `- **${tool}**: ${count} times\n`;
}
content += `
## Recent Commands
`;
const recentCommands = summary.commandsExecuted.slice(-10);
if (recentCommands.length > 0) {
for (const cmd of recentCommands) {
const status = cmd.success ? '✅' : '❌';
const time = new Date(cmd.timestamp).toLocaleTimeString();
content += `- ${status} ${time}: \`${cmd.command}\`\n`;
}
} else {
content += '*No commands executed in this session*\n';
}
if (summary.backupHistory.length > 0) {
content += `
## Backups Created
`;
for (const backup of summary.backupHistory) {
const status = backup.success ? '✅' : '❌';
const time = new Date(backup.timestamp).toLocaleTimeString();
content += `- ${status} ${time}: ${backup.backupId} - ${backup.reason}\n`;
}
}
content += `
## Context Usage Timeline
`;
if (summary.contextSnapshots.length > 0) {
for (const snapshot of summary.contextSnapshots) {
const time = new Date(snapshot.timestamp).toLocaleTimeString();
const usage = ((snapshot.usageRatio || 0) * 100).toFixed(1);
content += `- ${time}: ${usage}% (${snapshot.promptCount || 0} prompts, ${snapshot.toolExecutions || 0} tools)\n`;
}
}
content += `
## Quick Recovery
\`\`\`bash
# Check current project status
git status
# View recent changes
git diff
# List backup directories
ls .claude_hooks/backups/
\`\`\`
*Generated by Claude Hooks on ${new Date().toISOString()}*
`;
await fs.writeFile('LAST_SESSION.md', content);
}
/**
* Update ACTIVE_TODOS.md if todos exist
*/
async _updateActiveTodos() {
// Check if there's an existing ACTIVE_TODOS.md or any todo-related files
const todoFiles = ['ACTIVE_TODOS.md', 'TODO.md', 'todos.md'];
for (const todoFile of todoFiles) {
if (await fs.pathExists(todoFile)) {
// File exists, don't overwrite it
return;
}
}
// Look for todo comments in recently modified files
const todos = await this._extractTodosFromFiles();
if (todos.length > 0) {
let content = `# Active TODOs
*Auto-generated from code comments and session analysis*
`;
for (const todo of todos) {
content += `- [ ] ${todo.text} (${todo.file}:${todo.line})\n`;
}
content += `
*Update this file manually or use Claude to manage your todos*
`;
await fs.writeFile('ACTIVE_TODOS.md', content);
}
}
/**
* Extract TODO comments from modified files
*/
async _extractTodosFromFiles() {
const todos = [];
const todoPattern = /(?:TODO|FIXME|HACK|XXX|NOTE):\s*(.+)/gi;
for (const filePath of this.modifiedFiles) {
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
lines.forEach((line, index) => {
const match = todoPattern.exec(line);
if (match) {
todos.push({
text: match[1].trim(),
file: filePath,
line: index + 1
});
}
});
}
} catch (error) {
// Skip files that can't be read
}
}
return todos;
}
/**
* Clean up session resources
*/
async cleanupSession() {
// Save final state
await this._savePersistentState();
// Clean up old session files (keep last 5)
await this._cleanupOldSessions();
}
/**
* Generate unique session ID
*/
_generateSessionId() {
const timestamp = new Date().toISOString()
.replace(/[:-]/g, '')
.replace(/\.\d{3}Z$/, '')
.replace('T', '_');
return `sess_${timestamp}`;
}
/**
* Load persistent state from disk
*/
async _loadPersistentState() {
try {
const stateFile = path.join(this.stateDir, 'session_state.json');
if (await fs.pathExists(stateFile)) {
const state = await fs.readJson(stateFile);
// Only load if session is recent (within 24 hours)
const stateAge = Date.now() - new Date(state.startTime).getTime();
if (stateAge < 24 * 60 * 60 * 1000) {
this.modifiedFiles = new Set(state.modifiedFiles || []);
this.commandsExecuted = state.commandsExecuted || [];
this.toolUsage = state.toolUsage || {};
this.backupHistory = state.backupHistory || [];
this.contextSnapshots = state.contextSnapshots || [];
}
}
} catch (error) {
// Start fresh if loading fails
}
}
/**
* Save persistent state to disk
*/
async _savePersistentState() {
try {
const stateFile = path.join(this.stateDir, 'session_state.json');
const state = {
sessionId: this.sessionId,
startTime: new Date(this.startTime).toISOString(),
modifiedFiles: Array.from(this.modifiedFiles),
commandsExecuted: this.commandsExecuted,
toolUsage: this.toolUsage,
backupHistory: this.backupHistory,
contextSnapshots: this.contextSnapshots,
lastUpdated: new Date().toISOString()
};
await fs.writeJson(stateFile, state, { spaces: 2 });
} catch (error) {
// Don't let save failures break the session
}
}
/**
* Clean up old session state files
*/
async _cleanupOldSessions() {
try {
const statePattern = path.join(this.stateDir, 'session_*.json');
// This is a simple cleanup - in a full implementation,
// you'd use glob patterns to find and clean old files
} catch (error) {
// Ignore cleanup errors
}
}
}
module.exports = { SessionStateManager };