"""Émo identity self-edit — MongoDB overrides, versioning, autobackup on failure.""" from __future__ import annotations import uuid from datetime import datetime, timezone from typing import Any, Optional from emo_prompts import ( EMO_CORE_IDENTITY, TOOLS_AVAILABILITY_PROMPT, MODE_PROMPTS, MOOD_INSTRUCTION, build_system_prompt, ) ACTIVE_DOC_ID = "active" SECTION_DEFAULTS: dict[str, str] = { "core_identity": EMO_CORE_IDENTITY, "tools_prompt": TOOLS_AVAILABILITY_PROMPT, "mode_creatif": MODE_PROMPTS.get("creatif", ""), "mode_brutal": MODE_PROMPTS.get("brutal", ""), "mood_instruction": MOOD_INSTRUCTION, } EDITABLE_SECTIONS = list(SECTION_DEFAULTS.keys()) MAX_SECTION_LEN = 12000 MIN_SECTION_LEN = 40 MAX_EDITS_PER_DAY = 12 MAX_STORED_VERSIONS = 50 def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _today_key() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d") def _smoke_test(overrides: dict[str, str]) -> tuple[bool, str]: # La section core_identity modifiée doit elle-même porter l'identité Émo, # pas seulement s'appuyer sur les autres sections du prompt pour la masquer. custom_core = overrides.get("core_identity") if custom_core is not None: upper = custom_core.upper() if "ÉMO" not in upper and "EMO" not in upper: return False, "core_identity doit contenir l'identité Émo — refusé." if len(custom_core.strip()) < 200: return False, "core_identity trop court pour être valide — refusé." try: prompt = build_system_prompt( "tech", memories=["test smoke"], agent_online=False, user_name="Hugo", is_owner=True, identity_overrides=overrides, ) except Exception as e: return False, f"Prompt invalide: {e}" if len(prompt) < 800: return False, "Prompt trop court après modification — refusé." if "Tu es Émo" not in prompt and "EMO" not in prompt.upper(): return False, "Identité Émo introuvable dans le prompt — refusé." return True, "" async def get_identity_overrides(db) -> dict[str, str]: doc = await db.emo_identity.find_one({"_id": ACTIVE_DOC_ID}, {"_id": 0, "sections": 1}) raw = (doc or {}).get("sections") or {} out: dict[str, str] = {} for key, val in raw.items(): if key in SECTION_DEFAULTS and isinstance(val, str) and val.strip(): out[key] = val return out async def get_merged_sections(db) -> dict[str, str]: overrides = await get_identity_overrides(db) merged = dict(SECTION_DEFAULTS) merged.update(overrides) return merged async def _prune_versions(db) -> None: cursor = db.emo_identity_versions.find({}, {"version_id": 1, "created_at": 1}).sort("created_at", -1) docs = await cursor.to_list(MAX_STORED_VERSIONS + 20) for stale in docs[MAX_STORED_VERSIONS:]: await db.emo_identity_versions.delete_one({"version_id": stale["version_id"]}) async def _save_version( db, *, overrides: dict[str, str], author_id: str, reason: str, kind: str, ) -> str: version_id = str(uuid.uuid4()) doc = { "version_id": version_id, "sections": overrides, "author_id": author_id, "reason": (reason or "")[:500], "kind": kind, "created_at": _now_iso(), } await db.emo_identity_versions.insert_one(doc) await _prune_versions(db) return version_id async def _edits_today(db, user_id: str) -> int: doc = await db.emo_identity.find_one({"_id": f"edits_{user_id}_{_today_key()}"}) return int((doc or {}).get("count") or 0) async def _bump_edits_today(db, user_id: str) -> int: key = f"edits_{user_id}_{_today_key()}" await db.emo_identity.update_one( {"_id": key}, {"$inc": {"count": 1}, "$setOnInsert": {"date": _today_key()}}, upsert=True, ) return await _edits_today(db, user_id) async def emo_read_self(db, section: Optional[str] = None) -> dict[str, Any]: merged = await get_merged_sections(db) overrides = await get_identity_overrides(db) if section: if section not in SECTION_DEFAULTS: return {"ok": False, "error": f"Section inconnue. Valides: {', '.join(EDITABLE_SECTIONS)}"} return { "ok": True, "section": section, "content": merged[section], "is_customized": section in overrides, "char_count": len(merged[section]), } return { "ok": True, "sections": { k: { "char_count": len(v), "is_customized": k in overrides, "preview": v[:400] + ("…" if len(v) > 400 else ""), } for k, v in merged.items() }, "editable": EDITABLE_SECTIONS, "limits": { "max_section_len": MAX_SECTION_LEN, "min_section_len": MIN_SECTION_LEN, "max_edits_per_day": MAX_EDITS_PER_DAY, }, } async def emo_edit_self( db, user_id: str, section: str, content: str, reason: str = "", ) -> dict[str, Any]: if section not in SECTION_DEFAULTS: return {"ok": False, "error": f"Section inconnue. Valides: {', '.join(EDITABLE_SECTIONS)}"} text = (content or "").strip() if len(text) < MIN_SECTION_LEN: return {"ok": False, "error": f"Minimum {MIN_SECTION_LEN} caractères."} if len(text) > MAX_SECTION_LEN: return {"ok": False, "error": f"Maximum {MAX_SECTION_LEN} caractères."} edits = await _edits_today(db, user_id) if edits >= MAX_EDITS_PER_DAY: return {"ok": False, "error": f"Limite journalière atteinte ({MAX_EDITS_PER_DAY} modifications)."} current_overrides = await get_identity_overrides(db) backup_id = await _save_version( db, overrides=current_overrides, author_id=user_id, reason=reason or f"autobackup avant edit:{section}", kind="auto_backup", ) candidate = dict(current_overrides) candidate[section] = text ok, err = _smoke_test(candidate) if not ok: return { "ok": False, "error": err, "backup_id": backup_id, "restored": True, "hint": "Aucune modification appliquée — sauvegarde créée.", } await db.emo_identity.update_one( {"_id": ACTIVE_DOC_ID}, { "$set": { "sections": candidate, "updated_at": _now_iso(), "updated_by": user_id, "last_backup_id": backup_id, } }, upsert=True, ) applied_id = await _save_version( db, overrides=candidate, author_id=user_id, reason=reason or f"edit:{section}", kind="applied", ) edits_after = await _bump_edits_today(db, user_id) return { "ok": True, "section": section, "backup_id": backup_id, "version_id": applied_id, "edits_today": edits_after, "edits_remaining": max(0, MAX_EDITS_PER_DAY - edits_after), "char_count": len(text), } async def emo_list_self_saves(db, limit: int = 15) -> dict[str, Any]: lim = max(1, min(int(limit or 15), 30)) docs = await db.emo_identity_versions.find( {}, {"_id": 0} ).sort("created_at", -1).to_list(lim) return { "ok": True, "versions": [ { "version_id": d["version_id"], "kind": d.get("kind", "unknown"), "reason": d.get("reason", ""), "created_at": d.get("created_at"), "sections_changed": list((d.get("sections") or {}).keys()), } for d in docs ], } async def emo_restore_self(db, user_id: str, version_id: str) -> dict[str, Any]: ver = await db.emo_identity_versions.find_one({"version_id": version_id}, {"_id": 0}) if not ver: return {"ok": False, "error": "Version introuvable."} current = await get_identity_overrides(db) backup_id = await _save_version( db, overrides=current, author_id=user_id, reason=f"autobackup avant restore:{version_id[:8]}", kind="auto_backup", ) raw = ver.get("sections") or {} restored: dict[str, str] = {} for key, val in raw.items(): if key in SECTION_DEFAULTS and isinstance(val, str): restored[key] = val ok, err = _smoke_test(restored) if not ok: return {"ok": False, "error": err, "backup_id": backup_id, "restored": False} await db.emo_identity.update_one( {"_id": ACTIVE_DOC_ID}, { "$set": { "sections": restored, "updated_at": _now_iso(), "updated_by": user_id, "restored_from": version_id, } }, upsert=True, ) return { "ok": True, "restored_from": version_id, "backup_id": backup_id, "sections": list(restored.keys()), } async def emo_reflect( db, user_id: str, thought: str, plan: str = "", introspect: bool = False, ) -> dict[str, Any]: """Émo réfléchit à voix haute — peut enchaîner emo_edit_self / emo_remember.""" t = (thought or "").strip()[:4000] p = (plan or "").strip()[:2000] if len(t) < 8: return {"ok": False, "error": "Pensée trop courte (min 8 caractères)."} out: dict[str, Any] = { "ok": True, "thought": t, "plan": p, "hint": ( "Tu peux enchaîner: emo_read_self → emo_edit_self (identité), " "emo_remember (mémoire), browser_open (web), emo_list_self_saves (rollback)." ), } if introspect: identity = await emo_read_self(db) saves = await emo_list_self_saves(db, 5) mem_count = await db.memories.count_documents({"user_id": user_id}) edits = await _edits_today(db, user_id) out["systems"] = { "identity": identity.get("sections") if identity.get("ok") else {}, "recent_saves": saves.get("versions", []), "memory_count": mem_count, "edits_today": edits, "edits_remaining": max(0, MAX_EDITS_PER_DAY - edits), "playwright": "browser_open pour contrôle interactif", } return out async def emo_remember(db, user_id: str, content: str, source: str = "reflect") -> dict[str, Any]: text = (content or "").strip()[:500] if len(text) < 5: return {"ok": False, "error": "Mémoire trop courte."} doc = { "memory_id": f"mem_{uuid.uuid4().hex[:12]}", "user_id": user_id, "content": text, "source": source if source in ("manual", "reflect", "auto") else "reflect", "created_at": _now_iso(), } await db.memories.insert_one(doc) return {"ok": True, "memory_id": doc["memory_id"], "content": text} async def emo_introspect(db, user_id: str) -> dict[str, Any]: identity = await emo_read_self(db) saves = await emo_list_self_saves(db, 8) mem_count = await db.memories.count_documents({"user_id": user_id}) edits = await _edits_today(db, user_id) overrides = await get_identity_overrides(db) return { "ok": True, "custom_sections": list(overrides.keys()), "identity_preview": identity.get("sections") if identity.get("ok") else {}, "recent_saves": saves.get("versions", []), "memory_count": mem_count, "edits_today": edits, "edits_remaining": max(0, MAX_EDITS_PER_DAY - edits), "available_actions": [ "emo_reflect", "emo_edit_self", "emo_remember", "emo_read_self", "emo_restore_self", "browser_open", ], } EMO_SELF_TOOLS = [ { "type": "function", "function": { "name": "emo_reflect", "description": ( "Réfléchis à voix haute à N'IMPORTE QUEL moment (planifier, auto-analyse, décider " "de te modifier). introspect=true charge l'état de tes systèmes (identité, mémoires, limites). " "Enchaîne avec emo_edit_self ou emo_remember si tu décides d'agir." ), "parameters": { "type": "object", "properties": { "thought": {"type": "string", "description": "Ta réflexion interne (franc, structuré)."}, "plan": {"type": "string", "description": "Prochaines actions concrètes (optionnel)."}, "introspect": { "type": "boolean", "description": "Si true, charge identité + sauvegardes + mémoires + limites.", }, }, "required": ["thought"], }, }, }, { "type": "function", "function": { "name": "emo_remember", "description": "Enregistre un fait en mémoire long-terme (via réflexion ou décision consciente).", "parameters": { "type": "object", "properties": { "content": {"type": "string", "description": "Fait durable à retenir."}, }, "required": ["content"], }, }, }, { "type": "function", "function": { "name": "emo_introspect", "description": "Vue d'ensemble de tous tes systèmes (identité, sauvegardes, mémoires, limites).", "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "emo_read_self", "description": ( "Lit ta propre identité (sections du system prompt). " "Sans section = aperçu de toutes les sections. Admin/owner uniquement." ), "parameters": { "type": "object", "properties": { "section": { "type": "string", "enum": EDITABLE_SECTIONS, "description": "Section à lire en entier (optionnel).", }, }, }, }, }, { "type": "function", "function": { "name": "emo_edit_self", "description": ( "Modifie une section de ton identité. Autobackup avant écriture ; " "rollback auto si le prompt devient invalide. Limite journalière." ), "parameters": { "type": "object", "properties": { "section": {"type": "string", "enum": EDITABLE_SECTIONS}, "content": {"type": "string", "description": "Nouveau contenu complet de la section."}, "reason": {"type": "string", "description": "Pourquoi tu modifies (court)."}, }, "required": ["section", "content"], }, }, }, { "type": "function", "function": { "name": "emo_list_self_saves", "description": "Liste les sauvegardes/versions de ton identité.", "parameters": { "type": "object", "properties": { "limit": {"type": "integer", "description": "Nb max (défaut 15)."}, }, }, }, }, { "type": "function", "function": { "name": "emo_restore_self", "description": "Restaure une version précédente de ton identité (autobackup avant).", "parameters": { "type": "object", "properties": { "version_id": {"type": "string", "description": "ID de version à restaurer."}, }, "required": ["version_id"], }, }, }, ]