godcrm/pes-core/core/pes.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

672 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PES v2 — Main Controller (Level-Unlock Architecture)
*
* The BODY of PES. Wires all subsystems together:
* store (MemoryStore) + emotions (EmotionState) + babble (BabbleEngine)
* + tick loop (heartbeat) + level-unlock controller
*
* ES module. No triggers.js, symbols.js, or expression-engine.js.
* All expression logic handled by babble-engine; telegram adapter handles stickers.
*/
import { EventEmitter } from 'node:events';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { EmotionState, STATES, buildUnlockedSet } from '../soul/emotions.js';
import { BabbleEngine } from '../soul/babble-engine.js';
import MemoryStore from '../memory/store.js';
// ── XP Awards ────────────────────────────────────────────────
const XP = {
message: 1,
praise: 3,
scold: -1,
sticker: 2,
reaction: 1,
command_learned: 8,
dialogue_bonus: 2,
};
// ── Tick Intervals (ms) ──────────────────────────────────────
const TICK_INTERVALS = {
active: 30_000, // 30s — owner present
idle: 5 * 60_000, // 5min — nearby but nothing
sleeping: 15 * 60_000, // 15min — dreams
away: 30 * 60_000, // 30min — owner left
};
// ── Silence thresholds for auto-mode (ms) ────────────────────
const SILENCE = {
toIdle: 2 * 60_000, // 2min silence → idle
toSleeping: 15 * 60_000, // 15min silence → sleeping
toAway: 60 * 60_000, // 1h silence → away
};
// ── Trait seeding profiles ───────────────────────────────────
const TRAIT_PROFILES = [
{ max: 0.2, traits: { courage: 0.85, aggression: 0.7, playfulness: 0.4, sass: 0.3, curiosity_trait: 0.5 } },
{ max: 0.4, traits: { curiosity_trait: 0.85, playfulness: 0.8, courage: 0.5, drama: 0.3, loyalty: 0.5 } },
{ max: 0.6, traits: { courage: 0.5, curiosity_trait: 0.5, loyalty: 0.5, playfulness: 0.5, drama: 0.4 } },
{ max: 0.8, traits: { loyalty: 0.85, stubbornness: 0.75, courage: 0.6, playfulness: 0.4, sass: 0.3 } },
{ max: 1.01, traits: { drama: 0.85, sass: 0.8, playfulness: 0.6, curiosity_trait: 0.6, stubbornness: 0.4 } },
];
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
class Pes extends EventEmitter {
/**
* @param {object} config
* @param {string} config.name — e.g. 'Тор'
* @param {string} config.ownerId — telegram user id
* @param {string} config.dbPath — path to SQLite file
* @param {string} config.breed — e.g. 'corgi'
* @param {number} config.seed — 0-1, personality seed
* @param {boolean} [config.autoTick=true]
*/
constructor(config) {
super();
this.name = config.name;
this.ownerId = config.ownerId;
this.dbPath = config.dbPath;
this.breed = config.breed || 'corgi';
this.seed = clamp(config.seed ?? 0.5, 0, 1);
this.autoTick = config.autoTick !== false;
// Subsystems — initialized in birth() or wake()
this.store = null;
this.emotions = null;
this.babble = null;
// Tick state
this._tickMode = 'idle';
this._tickTimer = null;
this._lastEventAt = Date.now();
this._alive = false;
this.silentMode = false; // toggled by /silent command
}
// ── Compatibility getters (telegram.js expects these) ────
get level() {
return this.store?.getStats()?.level || 0;
}
get mood() {
return this.emotions?.mood ?? 0.5;
}
get traits() {
if (!this.store) return {};
const stats = this.store.getStats();
return stats?.traits || {};
}
/** Compat: telegram.js checks pes.engine?.silentMode */
get engine() {
return { silentMode: this.silentMode };
}
/** Compat: telegram.js checks pes.identity?.seed */
get identity() {
return { seed: this.seed };
}
// ════════════════════════════════════════════════════════════
// LIFECYCLE
// ════════════════════════════════════════════════════════════
/**
* First-time init: create DB, seed traits, start ticking.
*/
birth() {
const dir = dirname(this.dbPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
this.store = new MemoryStore(this.dbPath);
this.store.init();
// Seed initial stats if fresh
const stats = this.store.getStats();
if (!stats || stats.xp === 0) {
this._seedTraits();
this.store.setConfig('name', this.name);
this.store.setConfig('breed', this.breed);
this.store.setConfig('owner_id', this.ownerId);
this.store.setConfig('seed', String(this.seed));
this.store.setConfig('born_at', new Date().toISOString());
}
// Init emotion engine with level-0 unlocks
const level = stats?.level || 0;
this.emotions = new EmotionState(buildUnlockedSet(level));
this._seedEmotionTraits();
// Init babble engine
const unlockedSounds = this._getUnlockedSounds();
this.babble = new BabbleEngine(this.seed, unlockedSounds);
this._alive = true;
if (this.autoTick) this._startTick('active');
this.emit('born', { name: this.name, breed: this.breed });
return this;
}
/**
* Resume from saved state: load emotions, apply passive time decay.
*/
wake() {
const dir = dirname(this.dbPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
this.store = new MemoryStore(this.dbPath);
this.store.init();
const stats = this.store.getStats();
const level = stats?.level || 0;
// Restore emotions
const savedEmotion = this.store.getConfig('emotion_state');
if (savedEmotion) {
try {
const data = JSON.parse(savedEmotion);
this.emotions = EmotionState.deserialize(data, buildUnlockedSet(level));
} catch {
this.emotions = new EmotionState(buildUnlockedSet(level));
}
} else {
this.emotions = new EmotionState(buildUnlockedSet(level));
}
// Restore babble
const unlockedSounds = this._getUnlockedSounds();
const savedBabble = this.store.getConfig('babble_state');
if (savedBabble) {
try {
const data = JSON.parse(savedBabble);
this.babble = BabbleEngine.deserialize(data, this.seed);
this.babble.setUnlockedSounds(unlockedSounds);
} catch {
this.babble = new BabbleEngine(this.seed, unlockedSounds);
}
} else {
this.babble = new BabbleEngine(this.seed, unlockedSounds);
}
// Apply passive time decay since last checkpoint
const minutesSleeping = this._calcSleepMinutes();
if (minutesSleeping > 0) {
this.emotions.passiveTick(minutesSleeping);
}
this._alive = true;
if (this.autoTick) this._startTick('idle');
this.emit('wake', { name: this.name, minutesSleeping });
return this;
}
/**
* Save state, stop ticking.
*/
sleep() {
this._checkpoint();
this._stopTick();
this._alive = false;
this.emit('sleep', {});
}
/**
* Cleanup intervals, close DB.
*/
destroy() {
this._stopTick();
this._alive = false;
if (this.store) {
try { this._checkpoint(); } catch (e) { console.error('[PES] checkpoint on destroy failed:', e.message); }
this.store.close();
this.store = null;
}
this.removeAllListeners();
}
// ════════════════════════════════════════════════════════════
// CORE API
// ════════════════════════════════════════════════════════════
/**
* Main event entry point — trigger event → emotion change → XP → emit expression.
* @param {{ type: string, from: string, data: any }} evt
*/
event(evt) {
if (!this._alive) return null;
// Compat: crm-bridge sends {type, source, detail}, telegram sends {type, from, content}
const type = evt.type;
const from = evt.from || evt.source || this.ownerId;
const data = evt.data || evt.detail || evt.content;
this._lastEventAt = Date.now();
this._ensureTickMode('active');
// Emotion interaction tick
this.emotions.interactionTick(type);
this.emotions.reduceLoneliness(0.1);
// XP
const xpAmount = XP[type] ?? XP.message;
const xpResult = this.store.addXP(xpAmount, type, typeof data === 'string' ? data : null);
// Log interaction
const emoBefore = this.emotions.state;
this.store.logInteraction({
actor: from || this.ownerId,
action_type: type,
action_detail: typeof data === 'string' ? data.slice(0, 200) : null,
emotion_before: emoBefore,
emotion_after: this.emotions.state,
xp_gained: xpAmount,
});
// Update relationship
if (from) {
this.store.upsertRelationship(from, {
trust: type === 'praise' ? 0.02 : 0.005,
affection: type === 'praise' ? 0.03 : 0.01,
});
}
// Check level-up and unlocks
if (xpResult.levelUp) {
this._onLevelUp(xpResult.level, xpResult.phase);
}
// Daily streak
let streakResult = null;
try { streakResult = this.store.updateStreak(); } catch (e) { console.error('[PES] streak error:', e.message); }
// Streak bonus XP (new day = +5 XP bonus per streak day, max +25)
if (streakResult?.isNew && streakResult.current > 1) {
const bonus = Math.min(streakResult.current * 5, 25);
this.store.addXP(bonus, 'streak_bonus', `streak:${streakResult.current}`);
}
// Check achievements
let newAchievements = [];
try { newAchievements = this.store.checkAchievements(); } catch (e) { console.error('[PES] achievements error:', e.message); }
// Emit achievement events
for (const ach of newAchievements) {
this.emit('achievement', ach);
}
// Generate expression
const expression = this._express();
this.emit('expression', expression);
return { xp: xpResult, expression, streak: streakResult, achievements: newAchievements };
}
/**
* Positive feedback → mood up, trust up, XP+3.
*/
praise() {
this.emotions.adjustMood(0.15);
this.emotions.nudgeTrait('loyalty', 0.02, 'praise');
this.emotions.nudgeTrait('playfulness', 0.01, 'praise');
this.emotions.transitionTo('happy', null, 'praise')
|| this.emotions.transitionTo('butt_wiggle', null, 'praise');
return this.event({ type: 'praise', from: this.ownerId, data: 'praise' });
}
/**
* Negative feedback → mood down, XP-1.
*/
scold() {
this.emotions.adjustMood(-0.1);
this.emotions.nudgeTrait('courage', -0.01, 'scold');
this.emotions.transitionTo('scared', null, 'scold')
|| this.emotions.transitionTo('sulking', null, 'scold');
return this.event({ type: 'scold', from: this.ownerId, data: 'scold' });
}
/**
* Feed with data/task → hunger down.
* @param {number|object} quality — 0 to 1, or {type, content} for compat
*/
feed(quality = 0.5) {
// Compat: telegram.js passes {type: 'text', content: '...'}
const q = typeof quality === 'object' ? 0.5 : quality;
this.emotions.feed(q);
return this.event({ type: 'message', from: this.ownerId, data: 'feed' });
}
/**
* Collect feedback/reaction from owner.
* @param {{ reaction_type: string, reaction_value: string, platform: string }} opts
*/
react({ reaction_type, reaction_value, platform }) {
this.store.logReaction({
pes_action: this.emotions.state,
reactor: this.ownerId,
reaction_type,
reaction_value,
weight: reaction_value === 'positive' ? 1 : reaction_value === 'negative' ? -1 : 0,
platform,
});
return this.event({ type: 'reaction', from: this.ownerId, data: reaction_type });
}
/**
* Teach or use a command.
* @param {string} input — command text
* @param {boolean|object} success — true/false or {target: '...'} for compat
*/
command(input, success = false) {
// Compat: telegram.js passes {target: '...'} as second arg
const isSuccess = typeof success === 'boolean' ? success : false;
const cmd = this.store.learnCommand(input);
if (isSuccess && cmd && !cmd.understood) {
this.store.markCommandLearned(cmd.id);
return this.event({ type: 'command_learned', from: this.ownerId, data: input });
}
return this.event({ type: 'message', from: this.ownerId, data: `cmd:${input}` });
}
/**
* Full status snapshot.
* @returns {{ name, level, phase, emotion, vitals, traits, unlocks, ... }}
*/
status() {
const stats = this.store?.getStats() || {};
const emo = this.emotions || {};
const unlocks = this.store?.getUnlockedFeatures() || [];
const next = this.store?.getNextUnlock(stats.level) || null;
const ageDays = this.store?.getAgeDays() || 0;
// Flat structure for telegram.js compat
return {
name: this.name,
breed: this.breed,
level: stats.level || 0,
xp: stats.xp || 0,
phase: stats.phase || 'puppy',
// Flat vitals (telegram.js expects s.mood, s.energy, etc.)
mood: emo.mood ?? 0.5,
energy: emo.energy ?? 0.8,
hunger: emo.hunger ?? 0.2,
curiosity: emo.curiosity ?? 0.5,
loneliness: emo.loneliness ?? 0,
// Emotion
emotion: emo.state || 'idle',
intensity: emo.intensity || 0.5,
// Nested vitals (for code that uses s.vitals.mood)
vitals: {
mood: emo.mood ?? 0.5,
energy: emo.energy ?? 0.8,
hunger: emo.hunger ?? 0.2,
curiosity: emo.curiosity ?? 0.5,
loneliness: emo.loneliness ?? 0,
},
traits: emo.traits || {},
age: ageDays,
unlocks,
nextUnlock: next ? { feature: next.feature, level: next.level_req } : null,
tickMode: this._tickMode,
alive: this._alive,
streak: this.store?.getStreak() || { current: 0, longest: 0, total: 0 },
achievements: this.store?.getAchievementProgress() || { total: 0, unlocked: 0, achievements: [] },
};
}
// ════════════════════════════════════════════════════════════
// TICK SYSTEM (heartbeat)
// ════════════════════════════════════════════════════════════
_startTick(mode) {
this._stopTick();
this._tickMode = mode;
const interval = TICK_INTERVALS[mode] || TICK_INTERVALS.idle;
this._tickTimer = setInterval(() => this._tick(), interval);
}
_stopTick() {
if (this._tickTimer) {
clearInterval(this._tickTimer);
this._tickTimer = null;
}
}
_tick() {
if (!this._alive || !this.emotions) return;
const silenceMs = Date.now() - this._lastEventAt;
// Auto-mode transition based on silence duration
const newMode = this._silenceToMode(silenceMs);
if (newMode !== this._tickMode) {
this._startTick(newMode);
}
// Passive tick — cap at 5 minutes per tick to avoid vitals spike
const minutesPassed = Math.floor(silenceMs / 60_000);
if (minutesPassed > 0) {
this.emotions.passiveTick(Math.min(minutesPassed, 5));
}
// Periodic checkpoint
if (this.emotions.tickCount % 10 === 0) {
this._checkpoint();
}
}
_silenceToMode(ms) {
if (ms < SILENCE.toIdle) return 'active';
if (ms < SILENCE.toSleeping) return 'idle';
if (ms < SILENCE.toAway) return 'sleeping';
return 'away';
}
_ensureTickMode(mode) {
if (this._tickMode !== mode) {
this._startTick(mode);
}
}
// ════════════════════════════════════════════════════════════
// LEVEL-UNLOCK CONTROLLER
// ════════════════════════════════════════════════════════════
/**
* Called on every level-up. Checks for new unlocks, updates subsystems.
*/
_onLevelUp(level, phase) {
const floorLevel = Math.floor(level);
this.emit('level_up', { level: floorLevel, phase });
// Find features that just became available at this exact level
const allUnlocks = this.store.getUnlocks();
const justUnlocked = allUnlocks.filter(u =>
u.unlocked === 1 && u.level_req === floorLevel
);
for (const u of justUnlocked) {
this.emit('unlock', {
feature: u.feature,
level: u.level_req,
category: u.category,
});
}
// Update emotion FSM with newly unlocked emotions
const newEmotionSet = buildUnlockedSet(floorLevel);
this.emotions.setUnlockedEmotions(newEmotionSet);
// Update babble engine with newly unlocked sound tiers
const sounds = this._getUnlockedSounds();
this.babble.setUnlockedSounds(sounds);
// End critical period at level 5 (traits become resistant to change)
if (floorLevel >= 5 && !this.emotions.criticalPeriodOver) {
this.emotions.endCriticalPeriod();
}
}
/**
* Get list of unlocked sound-tier feature names for babble engine.
*/
_getUnlockedSounds() {
if (!this.store) return [];
const features = this.store.getUnlockedFeatures();
const soundFeatures = [
'babble_tier0', 'combo_phrases', 'babble_tier1', 'babble_tier2',
'babble_tier3', 'sound_invention', 'advanced_babble', 'crypto_language',
];
return features.filter(f => soundFeatures.includes(f));
}
// ════════════════════════════════════════════════════════════
// EXPRESSION GENERATOR
// ════════════════════════════════════════════════════════════
/**
* Generate an expression object from current emotional state.
* Emitted as 'expression' event for platform adapters (telegram).
*/
_express() {
const stats = this.store.getStats();
const level = Math.floor(stats?.level || 0);
const emoState = this.emotions.state;
const intensity = this.emotions.intensity;
const babble = this.babble.generate(level, emoState, intensity);
const stickerHint = this._stickerHint(emoState, intensity);
return {
emotion: emoState,
intensity,
babble,
sticker_hint: stickerHint,
};
}
/**
* Map emotion + intensity to a sticker hint category.
* The telegram adapter uses this to pick from learned sticker sets.
*/
_stickerHint(emotion, intensity) {
const cat = STATES[emotion]?.category || 'baseline';
if (cat === 'happy' && intensity > 0.6) return 'excited';
if (cat === 'happy') return 'happy';
if (cat === 'social') return 'social';
if (cat === 'working') return 'focused';
if (cat === 'dramatic') return 'dramatic';
if (cat === 'stress') return 'scared';
if (cat === 'rest') return 'sleepy';
if (cat === 'rare' && emotion === 'food_obsessed') return 'hungry';
if (cat === 'rare') return 'sneaky';
return 'neutral';
}
// ════════════════════════════════════════════════════════════
// TRAIT SEEDING
// ════════════════════════════════════════════════════════════
/**
* Seed initial trait values from config.seed deterministically.
*/
_seedTraits() {
const profile = TRAIT_PROFILES.find(p => this.seed < p.max) || TRAIT_PROFILES[2];
const stats = this.store.getStats();
if (!stats) return;
const updates = {};
for (const [key, val] of Object.entries(profile.traits)) {
// Deterministic jitter from seed
const jitter = (this.seed * 7.31 % 0.2) - 0.1;
updates[key] = clamp(val + jitter, 0, 1);
}
this.store.updateStats(updates);
}
/**
* Copy stored traits into emotion engine (used on birth/wake).
*/
_seedEmotionTraits() {
if (!this.emotions || !this.store) return;
const stats = this.store.getStats();
if (!stats?.traits) return;
const mapping = {
courage: 'courage',
curiosity_trait: 'curiosity',
loyalty: 'loyalty',
stubbornness: 'stubbornness',
playfulness: 'playfulness',
drama: 'drama',
food_obsession: 'foodDrive',
sass: 'sassiness',
};
for (const [storeKey, emotionKey] of Object.entries(mapping)) {
if (stats.traits[storeKey] !== undefined) {
this.emotions.setTrait(emotionKey, stats.traits[storeKey]);
}
}
}
// ════════════════════════════════════════════════════════════
// STATE PERSISTENCE
// ════════════════════════════════════════════════════════════
/**
* Save current state to DB (emotions, babble, vitals).
*/
_checkpoint() {
if (!this.store || !this.emotions) return;
try {
// Emotion state
this.store.setConfig('emotion_state', JSON.stringify(this.emotions.serialize()));
// Babble state
if (this.babble) {
this.store.setConfig('babble_state', JSON.stringify(this.babble.serialize()));
}
// Timestamp for sleep-duration calc
this.store.setConfig('last_checkpoint', new Date().toISOString());
// Vitals to stats table
const emo = this.emotions.current;
this.store.updateStats({
mood: emo.mood,
energy: emo.energy,
hunger: emo.hunger,
loneliness: emo.loneliness,
});
} catch (e) { console.error('[PES] _checkpoint failed:', e.message); }
}
/**
* Calculate how many minutes PES was sleeping since last checkpoint.
*/
_calcSleepMinutes() {
if (!this.store) return 0;
const lastStr = this.store.getConfig('last_checkpoint');
if (!lastStr) return 0;
const last = new Date(lastStr).getTime();
if (isNaN(last)) return 0;
return Math.max(0, Math.floor((Date.now() - last) / 60_000));
}
}
export default Pes;
export { Pes, XP, TICK_INTERVALS, TRAIT_PROFILES };