UNPKG

claude-code-tamagotchi

Version:

A virtual pet that lives in your Claude Code statusline

355 lines (306 loc) 10.3 kB
import * as fs from 'fs'; import * as path from 'path'; import { config } from '../utils/config'; export interface PetState { // Core identity name: string; type: 'dog' | 'cat' | 'dragon' | 'robot'; birthTime: number; age: number; // in minutes // Vital stats (0-100) happiness: number; hunger: number; energy: number; health: number; cleanliness: number; // Timestamps lastUpdate: number; lastFed: number; lastPlayed: number; lastPetted: number; lastCleaned: number; lastSlept: number; // Current state currentAnimation: string; animationFrame: number; animationStartTime: number; isAsleep: boolean; isSick: boolean; // Activity tracking (NEW) sessionUpdateCount: number; // Updates in current session totalUpdateCount: number; // Lifetime update counter lastUpdateTimestamp: number; // For gap detection recentUpdateTimestamps: number[]; // Last 30 updates for activity rate sessionStartTime: number; // When current session began previousSessionEnd: number; // End of last session sessionsToday: number; // Number of coding sessions // Animation state (NEW) breathingState: boolean; // Simple toggle for breathing microAnimationFrame: number; // For small animations animationFrame: number; // Current frame in animation sequence (0-n) currentAnimationName: string; // Name of current animation being played // Mood and patterns (NEW) currentMood: 'normal' | 'debugging' | 'celebrating' | 'tired' | 'focused' | 'sleeping' | 'proud' | 'excited' | 'concerned' | 'confused' | 'suspicious' | 'annoyed' | 'frustrated' | 'angry' | 'furious'; recentKeywords: string[]; // Recent keywords from conversation // Feedback system fields (NEW) claudeBehaviorScore: number; // 0-100, how well Claude is behaving recentViolations: number; // Count of recent issues feedbackHistory: Array<{ type: string; severity: string; remark: string; timestamp: number; }>; lastTranscriptCheck?: string; // Last message UUID checked currentFeedback?: { // Current feedback being displayed icon: string; remark: string; timestamp: number; }; // Legacy activity tracking foodEaten: string[]; totalFeedings: number; totalPlaySessions: number; favoriteFood: string; tricks: string[]; // Special states pendingAction?: { type: 'eating' | 'playing' | 'sleeping' | 'bathing'; item?: string; startTime: number; duration: number; updateCount?: number; }; // System messages for user systemMessage?: string; messageTimestamp?: number; // Thought system fields (NEW) currentThought?: string; // Current thought being displayed thoughtTimestamp?: number; // When thought was generated lastThoughtUpdate: number; // Last update count when thought changed thoughtHistory: string[]; // Recent thought history thoughtCategoryFatigue: Record<string, number>; // Fatigue for each category thoughtEscalation: Record<string, number>; // Escalation tracking for needs // Evolution evolutionStage: number; careLevelPoints: number; } export class StateManager { private stateFile: string; private lockFile: string; private state: PetState | null = null; constructor() { this.stateFile = config.stateFile; this.lockFile = `${this.stateFile}.lock`; } private createDefaultState(): PetState { const now = Date.now(); return { name: config.petName, type: config.petType, birthTime: now, age: 0, happiness: 80, hunger: 70, energy: 90, health: 100, cleanliness: 100, lastUpdate: now, lastFed: now, lastPlayed: now, lastPetted: now, lastCleaned: now, lastSlept: now, currentAnimation: 'idle', animationFrame: 0, animationStartTime: now, isAsleep: false, isSick: false, // Activity tracking sessionUpdateCount: 0, totalUpdateCount: 0, lastUpdateTimestamp: now, recentUpdateTimestamps: [], sessionStartTime: now, previousSessionEnd: now, sessionsToday: 1, // Animation state breathingState: false, microAnimationFrame: 0, animationFrame: 0, currentAnimationName: 'idle', // Mood currentMood: 'normal', recentKeywords: [], // Feedback system claudeBehaviorScore: 80, recentViolations: 0, feedbackHistory: [], lastTranscriptCheck: undefined, currentFeedback: undefined, // Legacy foodEaten: [], totalFeedings: 0, totalPlaySessions: 0, favoriteFood: 'cookie', tricks: [], // Thought system currentThought: undefined, thoughtTimestamp: undefined, lastThoughtUpdate: 0, thoughtHistory: [], thoughtCategoryFatigue: {}, thoughtEscalation: {}, evolutionStage: 0, careLevelPoints: 0, }; } private async waitForLock(maxAttempts = 2): Promise<boolean> { for (let i = 0; i < maxAttempts; i++) { // Check if lock file exists and is stale (older than 2 seconds) if (fs.existsSync(this.lockFile)) { try { const stats = fs.statSync(this.lockFile); const age = Date.now() - stats.mtimeMs; if (age > 2000) { // Stale lock, remove it fs.unlinkSync(this.lockFile); } } catch { // Ignore errors checking lock file } } if (!fs.existsSync(this.lockFile)) { try { fs.writeFileSync(this.lockFile, process.pid.toString()); return true; } catch { // Another process got the lock first } } // Only wait if this isn't the last attempt (max 20ms wait) if (i < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, 20)); } } return false; } private releaseLock(): void { try { if (fs.existsSync(this.lockFile)) { fs.unlinkSync(this.lockFile); } } catch { // Ignore errors } } async load(): Promise<PetState> { if (this.state && Date.now() - this.state.lastUpdate < 100) { return this.state; } const locked = await this.waitForLock(); if (!locked) { // Fallback to cached state or default return this.state || this.createDefaultState(); } try { if (fs.existsSync(this.stateFile)) { const data = fs.readFileSync(this.stateFile, 'utf-8'); this.state = JSON.parse(data); // Validate state if (!this.state || typeof this.state !== 'object') { throw new Error('Invalid state file'); } // Update age if (this.state) { const ageMinutes = Math.floor((Date.now() - this.state.birthTime) / 60000); this.state.age = ageMinutes; // Migrate old state to include new fields const now = Date.now(); if (this.state.sessionUpdateCount === undefined) { this.state.sessionUpdateCount = 0; this.state.totalUpdateCount = 0; this.state.lastUpdateTimestamp = this.state.lastUpdate || now; this.state.recentUpdateTimestamps = []; this.state.sessionStartTime = now; this.state.previousSessionEnd = now; this.state.sessionsToday = 1; this.state.breathingState = false; this.state.microAnimationFrame = 0; this.state.currentMood = 'normal'; this.state.recentKeywords = []; } // Migrate animation frame tracking if (this.state.animationFrame === undefined) { this.state.animationFrame = 0; this.state.currentAnimationName = 'idle'; } // Migrate thought system fields if (this.state.lastThoughtUpdate === undefined) { this.state.lastThoughtUpdate = 0; this.state.thoughtHistory = []; this.state.thoughtCategoryFatigue = {}; this.state.thoughtEscalation = {}; } // Migrate feedback system fields if (this.state.claudeBehaviorScore === undefined) { this.state.claudeBehaviorScore = 80; this.state.recentViolations = 0; this.state.feedbackHistory = []; this.state.lastTranscriptCheck = undefined; this.state.currentFeedback = undefined; } } } else { this.state = this.createDefaultState(); await this.save(this.state); } } catch (error) { // State file corrupted, create new pet if (config.debugMode) { console.error('State file corrupted, creating new pet:', error); } this.state = this.createDefaultState(); await this.save(this.state); } finally { this.releaseLock(); } return this.state!; } async save(state: PetState): Promise<void> { // Ensure the directory exists FIRST const dir = path.dirname(this.stateFile); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const locked = await this.waitForLock(); if (!locked) { // Still try to save even without lock - better than losing data if (config.debugMode) { console.error('Warning: Saving without lock'); } } try { // Update timestamp state.lastUpdate = Date.now(); // Write to temp file first (atomic write) const tempFile = `${this.stateFile}.tmp`; fs.writeFileSync(tempFile, JSON.stringify(state, null, 2)); // Rename atomically fs.renameSync(tempFile, this.stateFile); this.state = state; } catch (error) { if (config.debugMode) { console.error('Failed to save state:', error); } } finally { this.releaseLock(); } } async reset(): Promise<void> { const newState = this.createDefaultState(); await this.save(newState); } }