Spaces:
Running
Running
| """ | |
| ContinuousStyleMemory — KNN-based style memory with time-aware retrieval. | |
| Adapted from prototypes/style_memory.py for server use. | |
| Features: | |
| - Context-space KNN retrieval with gravitational mass weighting | |
| - Hawking radiation: memory mass decays exponentially over time | |
| - Crystallization: nearby contexts merge (mass grows), distant create new memories | |
| - Few-shot prompt builder with mass-tagged examples | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sqlite3 | |
| import time | |
| # Context dimension order (Critic output keys, used for KNN retrieval) | |
| CONTEXT_KEYS = [ | |
| 'conflict_level', 'user_emotion', 'user_engagement', 'user_vulnerability', | |
| 'topic_intimacy', 'conversation_depth', 'novelty_level', 'time_of_day', | |
| ] | |
| # Physics constant | |
| HAWKING_GAMMA = 0.001 # Decay rate (per hour): ~29 day half-life | |
| def _l2_distance(vec_a, vec_b): | |
| """Euclidean distance (zero-dependency).""" | |
| return math.sqrt(sum((a - b) ** 2 for a, b in zip(vec_a, vec_b))) | |
| def _context_to_vec(context): | |
| """Convert Critic context dict to ordered vector for KNN retrieval.""" | |
| return [context.get(k, 0.0) for k in CONTEXT_KEYS] | |
| def clean_action_markers(text: str) -> str: | |
| """Remove action/emotion stage directions from text. | |
| Strips *action*, *action*, (action), (action), 「action」 patterns | |
| in both Chinese and English, full-width and half-width. | |
| """ | |
| text = re.sub(r'\*[^*]+\*', '', text) # *sighs* *顿了顿* | |
| text = re.sub(r'*[^*]+*', '', text) # *轻笑* full-width asterisk | |
| text = re.sub(r'([^)]+)', '', text) # (沉默) full-width parens | |
| text = re.sub(r'\([^)]+\)', '', text) # (pauses) half-width parens | |
| text = re.sub(r'「[^」]+」', '', text) # 「沉默」 occasional | |
| return re.sub(r'\s{2,}', ' ', text).strip() | |
| def _hawking_mass(mass_raw, last_used_at, now, gamma=HAWKING_GAMMA): | |
| """ | |
| Hawking radiation: memory mass decays exponentially. | |
| mass_eff = 1.0 + (mass_raw - 1.0) * e^(-γ * Δt_hours) | |
| Base mass 1.0 never decays below (innate genes don't evaporate to 0). | |
| """ | |
| delta_hours = max(0.0, (now - last_used_at) / 3600.0) | |
| excess = max(0.0, mass_raw - 1.0) | |
| decayed_excess = excess * math.exp(-gamma * delta_hours) | |
| return 1.0 + decayed_excess | |
| class ContinuousStyleMemory: | |
| """ | |
| Continuous memory manifold engine v3 (time-arrow + Hawking radiation). | |
| All memories live in a single pool, no public/private distinction. | |
| Mass grows with crystallization, decays with time (Hawking radiation). | |
| Retrieval uses time-decayed effective mass (mass_eff). | |
| """ | |
| def __init__(self, agent_id, db_dir=None, now=None, persona_id=None, hawking_gamma=None, | |
| state_db_path=None): | |
| self.agent_id = agent_id | |
| self.hawking_gamma = hawking_gamma if hawking_gamma is not None else HAWKING_GAMMA | |
| self.db_dir = db_dir or os.path.join( | |
| os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), | |
| ".data", "genome" | |
| ) | |
| os.makedirs(self.db_dir, exist_ok=True) | |
| self._persona_id = persona_id or agent_id | |
| # Derive user_id from agent_id (format: "{persona_id}_{user_id}") | |
| if persona_id and agent_id.startswith(persona_id + "_"): | |
| self._user_id = agent_id[len(persona_id) + 1:] | |
| else: | |
| self._user_id = agent_id | |
| # SQLite for personal memories (fallback to JSON for backward compat) | |
| self._state_db_path = state_db_path or os.path.join( | |
| os.path.dirname(self.db_dir), "openher.db" | |
| ) | |
| self._init_db() | |
| self._now = now or time.time() | |
| # Unified memory pool | |
| self._pool = [] | |
| self._genesis_count = 0 | |
| self._personal_count = 0 | |
| self._load() | |
| def set_clock(self, now): | |
| """Inject external clock (for testing).""" | |
| self._now = now | |
| def _init_db(self): | |
| """Create style_memory and genesis_seed tables if not exists.""" | |
| conn = sqlite3.connect(self._state_db_path) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS style_memory ( | |
| persona_id TEXT NOT NULL, | |
| user_id TEXT NOT NULL, | |
| memories TEXT NOT NULL, | |
| updated_at REAL NOT NULL, | |
| PRIMARY KEY (persona_id, user_id) | |
| ) | |
| """) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS genesis_seed ( | |
| persona_id TEXT PRIMARY KEY, | |
| seeds TEXT NOT NULL, | |
| created_at REAL NOT NULL | |
| ) | |
| """) | |
| conn.commit() | |
| conn.close() | |
| def _auto_import_seeds(self): | |
| """Auto-import all seeds from seeds.bin on first boot (no manual step needed).""" | |
| import gzip | |
| seeds_bin = os.path.join( | |
| os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), | |
| "persona", "seeds.bin" | |
| ) | |
| if not os.path.isfile(seeds_bin): | |
| return | |
| try: | |
| with open(seeds_bin, "rb") as f: | |
| data = json.loads(gzip.decompress(f.read()).decode("utf-8")) | |
| for pid, seeds in data.items(): | |
| ContinuousStyleMemory.save_genesis_to_db(pid, seeds, self._state_db_path) | |
| print(f"[genome] 🧬 auto-imported {len(data)} personas from seeds.bin") | |
| except Exception as e: | |
| print(f"[genome] ⚠️ auto-import failed: {e}") | |
| def _load(self): | |
| """Load innate genes + learned experience into unified pool.""" | |
| self._pool = [] | |
| # Genesis from SQLite genesis_seed table | |
| conn = sqlite3.connect(self._state_db_path) | |
| row = conn.execute( | |
| "SELECT seeds FROM genesis_seed WHERE persona_id = ?", | |
| (self._persona_id,) | |
| ).fetchone() | |
| conn.close() | |
| # Auto-import from seeds.bin if table is empty (first boot after clone) | |
| if not row: | |
| self._auto_import_seeds() | |
| conn = sqlite3.connect(self._state_db_path) | |
| row = conn.execute( | |
| "SELECT seeds FROM genesis_seed WHERE persona_id = ?", | |
| (self._persona_id,) | |
| ).fetchone() | |
| conn.close() | |
| if row: | |
| genesis = json.loads(row[0]) | |
| for mem in genesis: | |
| mem.setdefault('mass', 1.0) | |
| mem.setdefault('created_at', 0.0) | |
| mem.setdefault('last_used_at', 0.0) | |
| self._pool.append(mem) | |
| self._genesis_count = len(genesis) | |
| # Personal memories from SQLite | |
| conn = sqlite3.connect(self._state_db_path) | |
| row = conn.execute( | |
| "SELECT memories FROM style_memory WHERE persona_id = ? AND user_id = ?", | |
| (self._persona_id, self._user_id) | |
| ).fetchone() | |
| conn.close() | |
| if row: | |
| personal = json.loads(row[0]) | |
| for mem in personal: | |
| mem.setdefault('mass', 1.0) | |
| mem.setdefault('created_at', self._now) | |
| mem.setdefault('last_used_at', self._now) | |
| self._pool.append(mem) | |
| self._personal_count = len(personal) | |
| def total_memories(self): | |
| return len(self._pool) | |
| def personal_count(self): | |
| return self._personal_count | |
| def retrieve(self, context, top_k=3, lang_preference=None): | |
| """ | |
| Gravitational mass + Hawking radiation retrieval. | |
| effective_distance = physical_distance / √mass_eff | |
| lang_preference: 'zh' or 'en'. When set, same-language seeds get | |
| a soft distance bonus (cross-language seeds penalized 25%). | |
| Language is auto-detected from monologue text. | |
| """ | |
| target = _context_to_vec(context) | |
| now = self._now | |
| scored = [] | |
| for mem in self._pool: | |
| # Hard language filter: skip cross-language seeds | |
| if lang_preference and mem.get('lang') and mem['lang'] != lang_preference: | |
| continue | |
| physical_dist = _l2_distance(target, mem['vector']) | |
| mass_raw = mem.get('mass', 1.0) | |
| last_used = mem.get('last_used_at', 0.0) | |
| mass_eff = _hawking_mass(mass_raw, last_used, now, gamma=self.hawking_gamma) | |
| effective_dist = physical_dist / math.sqrt(max(mass_eff, 0.01)) | |
| scored.append((effective_dist, physical_dist, mass_eff, mass_raw, mem)) | |
| scored.sort(key=lambda x: x[0]) | |
| results = [] | |
| for eff_dist, phys_dist, mass_eff, mass_raw, mem in scored[:top_k]: | |
| mem['last_used_at'] = now | |
| results.append({ | |
| 'monologue': mem['monologue'], | |
| 'reply': mem['reply'], | |
| 'vector': mem['vector'], | |
| 'distance': round(eff_dist, 4), | |
| 'physical_distance': round(phys_dist, 4), | |
| 'mass_raw': mass_raw, | |
| 'mass_eff': round(mass_eff, 2), | |
| 'user_input': mem.get('user_input', ''), | |
| 'lang': mem.get('lang', ''), | |
| }) | |
| self._last_retrieve_results = results | |
| return results | |
| def last_recall_info(self): | |
| """Return simplified info about the last KNN recall for debug visualization. | |
| Returns list of {text, distance, mass} dicts, or empty list if no recall yet. | |
| """ | |
| results = getattr(self, '_last_retrieve_results', None) | |
| if not results: | |
| return [] | |
| return [ | |
| { | |
| 'text': r.get('user_input', r.get('monologue', ''))[:50], | |
| 'distance': r['distance'], | |
| 'mass': r.get('mass_eff', 1.0), | |
| } | |
| for r in results | |
| ] | |
| def crystallize(self, context, monologue, reply, user_input=""): | |
| """ | |
| Memory crystallization (time-aware). | |
| Nearby contexts → gravitational thickening + refresh timestamp. | |
| New contexts → create new memory with initial mass=2.0. | |
| """ | |
| new_vec = [round(v, 4) for v in _context_to_vec(context)] | |
| now = self._now | |
| # Check if we can merge | |
| best_idx = -1 | |
| best_dist = 999.0 | |
| for i, mem in enumerate(self._pool): | |
| d = _l2_distance(new_vec, mem['vector']) | |
| if d < best_dist: | |
| best_dist = d | |
| best_idx = i | |
| if best_dist < 0.25 and best_idx >= 0: | |
| # Gravitational thickening: increase mass + refresh timestamp | |
| # but KEEP original content (don't overwrite distinctive memories) | |
| # NOTE: this may mutate genesis entries in _pool (mass drift). | |
| # Genesis mass resets on restart (reloaded from DB). Known behavior. | |
| self._pool[best_idx]['mass'] = self._pool[best_idx].get('mass', 1.0) + 1.0 | |
| self._pool[best_idx]['last_used_at'] = now | |
| # Only overwrite if new content is longer (richer) | |
| if len(reply) > len(self._pool[best_idx].get('reply', '')): | |
| self._pool[best_idx]['monologue'] = monologue | |
| self._pool[best_idx]['reply'] = reply | |
| self._pool[best_idx]['user_input'] = user_input | |
| else: | |
| # New memory | |
| new_mem = { | |
| "vector": new_vec, | |
| "monologue": monologue, | |
| "reply": reply, | |
| "user_input": user_input, | |
| "mass": 2.0, | |
| "created_at": now, | |
| "last_used_at": now, | |
| } | |
| self._pool.append(new_mem) | |
| # Save personal memories to SQLite | |
| personal_mems = [m for m in self._pool if m.get('mass', 1.0) > 1.0] | |
| self._personal_count = len(personal_mems) | |
| conn = sqlite3.connect(self._state_db_path) | |
| conn.execute(""" | |
| INSERT INTO style_memory (persona_id, user_id, memories, updated_at) | |
| VALUES (?, ?, ?, ?) | |
| ON CONFLICT(persona_id, user_id) DO UPDATE SET | |
| memories = excluded.memories, | |
| updated_at = excluded.updated_at | |
| """, ( | |
| self._persona_id, | |
| self._user_id, | |
| json.dumps(personal_mems, ensure_ascii=False), | |
| self._now, | |
| )) | |
| conn.commit() | |
| conn.close() | |
| return self._personal_count | |
| def build_few_shot_prompt(self, context, top_k=3, monologue_only=False, lang='zh'): | |
| """Build few-shot prompt from retrieval results (with mass tags). | |
| Args: | |
| context: Critic context dict for KNN retrieval. | |
| monologue_only: If True, only include monologue (no reply). | |
| Legacy parameter, currently unused (single-pass mode). | |
| lang: Label language ('zh' or 'en'). | |
| """ | |
| memories = self.retrieve(context, top_k=top_k, lang_preference=lang) | |
| is_en = lang == 'en' | |
| if not memories: | |
| if monologue_only: | |
| return "(System: no inner feeling fragments available)" if is_en else "(系统:无可用的内心感受片段)" | |
| return "(System: no subconscious slices available)" if is_en else "(系统:无可用的潜意识切片)" | |
| parts = [] | |
| for i, mem in enumerate(memories): | |
| mass_eff = mem.get('mass_eff', 1.0) | |
| mass_raw = mem.get('mass_raw', 1.0) | |
| if mass_raw > 1.0: | |
| mass_tag = f"mass={mass_eff:.1f}/{mass_raw:.0f}" if is_en else f"质量={mass_eff:.1f}/{mass_raw:.0f}" | |
| else: | |
| mass_tag = "genesis" if is_en else "基因" | |
| if monologue_only: | |
| frag_label = "Inner thought fragment" if is_en else "内心念头片段" | |
| parts.append( | |
| f"--- {frag_label} {i+1} [{mass_tag}] ---\n" | |
| f"{mem['monologue']}" | |
| ) | |
| else: | |
| slice_label = "Subconscious slice" if is_en else "潜意识切片" | |
| mono_lbl = "[Inner Monologue]" if is_en else "【内心独白】" | |
| reply_lbl = "[Final Reply]" if is_en else "【最终回复】" | |
| parts.append( | |
| f"--- {slice_label} {i+1} [{mass_tag}] ---\n" | |
| f"{mono_lbl}{mem['monologue']}\n" | |
| f"{reply_lbl}{mem['reply']}" | |
| ) | |
| return "\n\n".join(parts) | |
| def stats(self): | |
| """Return memory statistics (with Hawking radiation-decayed mass).""" | |
| now = self._now | |
| masses_raw = [m.get('mass', 1.0) for m in self._pool] | |
| masses_eff = [ | |
| _hawking_mass(m.get('mass', 1.0), m.get('last_used_at', 0.0), now, gamma=self.hawking_gamma) | |
| for m in self._pool | |
| ] | |
| return { | |
| 'genesis_count': self._genesis_count, | |
| 'personal_count': self._personal_count, | |
| 'total': self.total_memories, | |
| 'total_mass_raw': sum(masses_raw), | |
| 'total_mass_eff': round(sum(masses_eff), 1), | |
| } | |
| def save_genesis_to_db(persona_id: str, seeds: list, db_path: str): | |
| """Save genesis seeds to DB (used by calibrate and migration scripts). | |
| Cleans action markers from monologue/reply before storing. | |
| Upserts: existing data for the same persona_id will be replaced. | |
| Warning: mutates seeds in-place (monologue/reply fields are cleaned). | |
| """ | |
| for seed in seeds: | |
| if 'monologue' in seed: | |
| seed['monologue'] = clean_action_markers(seed['monologue']) | |
| if 'reply' in seed: | |
| seed['reply'] = clean_action_markers(seed['reply']) | |
| conn = sqlite3.connect(db_path) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS genesis_seed ( | |
| persona_id TEXT PRIMARY KEY, | |
| seeds TEXT NOT NULL, | |
| created_at REAL NOT NULL | |
| ) | |
| """) | |
| conn.execute(""" | |
| INSERT INTO genesis_seed (persona_id, seeds, created_at) | |
| VALUES (?, ?, ?) | |
| ON CONFLICT(persona_id) DO UPDATE SET | |
| seeds = excluded.seeds, | |
| created_at = excluded.created_at | |
| """, (persona_id, json.dumps(seeds, ensure_ascii=False), time.time())) | |
| conn.commit() | |
| conn.close() | |