375 lines
9.8 KiB
JavaScript
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 }; |