alive-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/Dockerfile +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -0
  4. package/alive_ai/__init__.py +3 -0
  5. package/brain/__init__.py +59 -0
  6. package/brain/almost_said.py +154 -0
  7. package/brain/bid_detector.py +636 -0
  8. package/brain/conversation_flow.py +135 -0
  9. package/brain/curiosity.py +328 -0
  10. package/brain/default_mode.py +1438 -0
  11. package/brain/dreams.py +220 -0
  12. package/brain/embeddings/__init__.py +82 -0
  13. package/brain/emotional_memory.py +949 -0
  14. package/brain/global_activity.py +173 -0
  15. package/brain/group_dynamics.py +63 -0
  16. package/brain/linguistic.py +235 -0
  17. package/brain/llm/__init__.py +63 -0
  18. package/brain/llm/base.py +33 -0
  19. package/brain/llm/fallback_router.py +309 -0
  20. package/brain/llm/manifest.md +30 -0
  21. package/brain/llm/ollama.py +218 -0
  22. package/brain/llm/openrouter.py +151 -0
  23. package/brain/llm/provider.py +205 -0
  24. package/brain/llm/unified.py +423 -0
  25. package/brain/llm/zai.py +169 -0
  26. package/brain/manifest.md +23 -0
  27. package/brain/memory/__init__.py +123 -0
  28. package/brain/memory/episodic.py +92 -0
  29. package/brain/memory/fact_extractor.py +209 -0
  30. package/brain/memory/index.py +54 -0
  31. package/brain/memory/manager.py +151 -0
  32. package/brain/memory/summarizer.py +102 -0
  33. package/brain/memory/vector_store.py +297 -0
  34. package/brain/memory/working.py +43 -0
  35. package/brain/narrative.py +343 -0
  36. package/brain/stt/__init__.py +4 -0
  37. package/brain/stt/google_stt.py +83 -0
  38. package/brain/stt/whisper_stt.py +82 -0
  39. package/brain/subconscious/__init__.py +33 -0
  40. package/brain/subconscious/actions.py +136 -0
  41. package/brain/subconscious/evaluation.py +166 -0
  42. package/brain/subconscious/goal_system.py +90 -0
  43. package/brain/subconscious/goals.py +41 -0
  44. package/brain/subconscious/impulse_generator.py +200 -0
  45. package/brain/subconscious/impulses.py +48 -0
  46. package/brain/subconscious/learning.py +24 -0
  47. package/brain/subconscious/learning_system.py +79 -0
  48. package/brain/subconscious/loop.py +398 -0
  49. package/brain/subconscious/manifest.md +32 -0
  50. package/brain/subconscious/relationship.py +47 -0
  51. package/brain/subconscious/relationship_memory.py +83 -0
  52. package/brain/subconscious/response_analyzer.py +74 -0
  53. package/brain/subconscious/templates.py +70 -0
  54. package/brain/subconscious/thought.py +37 -0
  55. package/brain/subconscious/working_memory.py +97 -0
  56. package/cli/index.js +371 -0
  57. package/config/directives.example.json +28 -0
  58. package/config/instructions.example.md +16 -0
  59. package/config/self.example.json +74 -0
  60. package/config/settings.example.json +95 -0
  61. package/core/__init__.py +1 -0
  62. package/core/config.py +54 -0
  63. package/core/directives.py +198 -0
  64. package/core/events.py +50 -0
  65. package/core/follow_up.py +267 -0
  66. package/core/hot_reload.py +174 -0
  67. package/core/initialization.py +253 -0
  68. package/core/manifest.md +28 -0
  69. package/core/media_handler.py +241 -0
  70. package/core/memory_monitor.py +200 -0
  71. package/core/message_handler.py +1440 -0
  72. package/core/proactive_generator.py +277 -0
  73. package/core/self.py +188 -0
  74. package/core/settings.py +169 -0
  75. package/core/skills_registry.py +357 -0
  76. package/core/state.py +27 -0
  77. package/core/subconscious_bridge.py +93 -0
  78. package/core/thinking.py +175 -0
  79. package/core/user_manager.py +306 -0
  80. package/core/user_tracker.py +144 -0
  81. package/demo/index.html +144 -0
  82. package/docker-compose.yml +28 -0
  83. package/docs/assets/logo.svg +15 -0
  84. package/docs/index.html +355 -0
  85. package/heart/__init__.py +93 -0
  86. package/heart/afterglow.py +215 -0
  87. package/heart/attachment.py +186 -0
  88. package/heart/circadian.py +251 -0
  89. package/heart/complex_emotions.py +114 -0
  90. package/heart/conflicts.py +589 -0
  91. package/heart/core.py +387 -0
  92. package/heart/emotional_decay.py +59 -0
  93. package/heart/emotional_memory.py +261 -0
  94. package/heart/emotional_state.py +146 -0
  95. package/heart/emotional_variability.py +156 -0
  96. package/heart/hormonal.py +424 -0
  97. package/heart/inconsistency.py +1222 -0
  98. package/heart/integrity.py +469 -0
  99. package/heart/interoception.py +997 -0
  100. package/heart/love.py +120 -0
  101. package/heart/manifest.md +25 -0
  102. package/heart/mood_shifts.py +169 -0
  103. package/heart/phantom_somatic.py +259 -0
  104. package/heart/predictive.py +374 -0
  105. package/heart/scars.py +474 -0
  106. package/heart/somatic.py +482 -0
  107. package/heart/soul.py +633 -0
  108. package/heart/telemetry.py +942 -0
  109. package/heart/triggers.py +119 -0
  110. package/heart/unconscious.py +443 -0
  111. package/input/__init__.py +1 -0
  112. package/input/manifest.md +24 -0
  113. package/input/telegram/__init__.py +1 -0
  114. package/input/telegram/commands.py +762 -0
  115. package/input/telegram/listener.py +532 -0
  116. package/main.py +90 -0
  117. package/manifest.md +28 -0
  118. package/mypics/.gitkeep +1 -0
  119. package/myvids/.gitkeep +1 -0
  120. package/output/__init__.py +1 -0
  121. package/output/images/__init__.py +1 -0
  122. package/output/images/fal_gen.py +43 -0
  123. package/output/manifest.md +26 -0
  124. package/output/text/__init__.py +1 -0
  125. package/output/text/sender.py +22 -0
  126. package/output/voice/__init__.py +64 -0
  127. package/output/voice/google_tts.py +252 -0
  128. package/output/voice/gtts_tts.py +214 -0
  129. package/output/voice/vibe_tts.py +190 -0
  130. package/package.json +58 -0
  131. package/pyproject.toml +23 -0
  132. package/requirements.txt +21 -0
  133. package/skills/__init__.py +1 -0
  134. package/skills/anticipation_engine/__init__.py +8 -0
  135. package/skills/anticipation_engine/engine.py +618 -0
  136. package/skills/anticipation_engine/manifest.md +192 -0
  137. package/skills/calendar/__init__.py +1 -0
  138. package/skills/content_unlocks/__init__.py +8 -0
  139. package/skills/content_unlocks/manifest.md +231 -0
  140. package/skills/content_unlocks/unlocks.py +945 -0
  141. package/skills/exclusive_moments/__init__.py +8 -0
  142. package/skills/exclusive_moments/manifest.md +145 -0
  143. package/skills/exclusive_moments/moments.py +506 -0
  144. package/skills/intimacy_layers/__init__.py +8 -0
  145. package/skills/intimacy_layers/layers.py +703 -0
  146. package/skills/intimacy_layers/manifest.md +203 -0
  147. package/skills/manifest.md +67 -0
  148. package/skills/memory_callbacks/__init__.py +9 -0
  149. package/skills/memory_callbacks/callbacks.py +748 -0
  150. package/skills/memory_callbacks/manifest.md +170 -0
  151. package/skills/message_scheduler/__init__.py +19 -0
  152. package/skills/message_scheduler/manifest.md +107 -0
  153. package/skills/message_scheduler/scheduler.py +510 -0
  154. package/skills/photo_manager/__init__.py +1 -0
  155. package/skills/photo_manager/scanner.py +296 -0
  156. package/skills/relationship_milestones/__init__.py +8 -0
  157. package/skills/relationship_milestones/manifest.md +206 -0
  158. package/skills/relationship_milestones/tracker.py +494 -0
  159. package/skills/self_authorship/__init__.py +23 -0
  160. package/skills/self_authorship/author.py +331 -0
  161. package/skills/self_authorship/manifest.md +24 -0
  162. package/skills/video_manager/__init__.py +5 -0
  163. package/skills/video_manager/manifest.md +37 -0
  164. package/skills/video_manager/scanner.py +229 -0
  165. package/webui/__init__.py +3 -0
  166. package/webui/app.py +936 -0
  167. package/webui/bridge.py +366 -0
  168. package/webui/static/index.html +2070 -0
@@ -0,0 +1,123 @@
1
+ """Brain: Memory - semantic, episodic, working, vector memory"""
2
+ import json
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from .manager import Memory # noqa: F401 - re-export for backward compat
7
+
8
+
9
+ class SemanticMemory:
10
+ """Long-term facts about the user - flat simple structure, per-user"""
11
+
12
+ PET_NAMES = ["babe", "baby", "love", "handsome"]
13
+
14
+ def __init__(self, data_path: Path, user_id: str = "default"):
15
+ """
16
+ Initialize semantic memory for a specific user.
17
+
18
+ Args:
19
+ data_path: User's base path (already includes users/{user_id})
20
+ user_id: User's Telegram ID (for reference)
21
+ """
22
+ self.user_id = user_id
23
+ # data_path is already the user's base path (data/users/{user_id})
24
+ self.path = data_path / "facts.json"
25
+ self.path.parent.mkdir(parents=True, exist_ok=True)
26
+ self.facts = self._load()
27
+
28
+ def _load(self) -> dict:
29
+ if self.path.exists():
30
+ return json.loads(self.path.read_text())
31
+ # Clean default - no precoded info
32
+ return {
33
+ "name": None, "nickname": None, "gender": None, "age": None,
34
+ "location": None, "job": None, "hobbies": [], "interests": [],
35
+ "personality": [], "relationship_status": None,
36
+ "pet_names_used": [], "mentions": {},
37
+ "shared_memories": [], "last_intimate": None
38
+ }
39
+
40
+ def save(self):
41
+ self.path.write_text(json.dumps(self.facts, indent=2))
42
+
43
+ @property
44
+ def is_new_user(self) -> bool:
45
+ """Check if we know anything about this user"""
46
+ return not bool(
47
+ self.facts.get("name") or
48
+ self.facts.get("mentions") or
49
+ self.facts.get("hobbies") or
50
+ self.facts.get("interests")
51
+ )
52
+
53
+ def update(self, key: str, value):
54
+ """Update a fact"""
55
+ if isinstance(value, list) and key in self.facts and isinstance(self.facts[key], list):
56
+ # Merge lists, no duplicates
57
+ existing = set(str(v) for v in self.facts[key])
58
+ for item in value:
59
+ if str(item) not in existing:
60
+ self.facts[key].append(item)
61
+ elif value:
62
+ self.facts[key] = value
63
+ self.save()
64
+
65
+ def add_mention(self, key: str, value: str):
66
+ self.facts["mentions"][key] = {
67
+ "value": value, "timestamp": datetime.now().isoformat()}
68
+ self.save()
69
+
70
+ def add_shared_memory(self, memory: str):
71
+ self.facts["shared_memories"].append({
72
+ "memory": memory, "timestamp": datetime.now().isoformat()})
73
+ self.facts["shared_memories"] = self.facts["shared_memories"][-50:]
74
+ self.save()
75
+
76
+ def update_last_intimate(self):
77
+ self.facts["last_intimate"] = datetime.now().isoformat()
78
+ self.save()
79
+
80
+ def get_random_pet_name(self) -> str:
81
+ import random
82
+ used = self.facts.get("pet_names_used", [])
83
+ available = [p for p in self.PET_NAMES if p not in used[-4:]]
84
+ name = random.choice(available or self.PET_NAMES)
85
+ used.append(name)
86
+ self.facts["pet_names_used"] = used[-20:]
87
+ self.save()
88
+ return name
89
+
90
+ def get_user_profile(self) -> str:
91
+ """Get formatted user profile string"""
92
+ parts = []
93
+ if self.facts.get("name"):
94
+ parts.append(f"Name: {self.facts['name']}" +
95
+ (f" ({self.facts['nickname']})" if self.facts.get("nickname") else ""))
96
+ if self.facts.get("gender"):
97
+ parts.append(f"Gender: {self.facts['gender']}")
98
+ if self.facts.get("age"):
99
+ parts.append(f"Age: {self.facts['age']}")
100
+ if self.facts.get("location"):
101
+ parts.append(f"Location: {self.facts['location']}")
102
+ if self.facts.get("job"):
103
+ parts.append(f"Job: {self.facts['job']}")
104
+ if self.facts.get("hobbies"):
105
+ parts.append(f"Hobbies: {', '.join(self.facts['hobbies'])}")
106
+ if self.facts.get("interests"):
107
+ parts.append(f"Interests: {', '.join(self.facts['interests'])}")
108
+ if self.facts.get("personality"):
109
+ parts.append(f"Personality: {', '.join(self.facts['personality'])}")
110
+ return "\n".join(parts)
111
+
112
+ def get_context(self) -> str:
113
+ """Get context string for LLM"""
114
+ parts = [self.get_user_profile()] if self.get_user_profile() else []
115
+ mentions = self.facts.get("mentions", {})
116
+ if mentions:
117
+ for key, data in sorted(mentions.items(),
118
+ key=lambda x: x[1].get("timestamp",""),
119
+ reverse=True)[:3]:
120
+ parts.append(f"Recently mentioned ({key}): {data['value'][:80]}")
121
+ for m in self.facts.get("shared_memories", [])[-3:]:
122
+ parts.append(f"Shared memory: {m['memory']}")
123
+ return "\n".join(parts)
@@ -0,0 +1,92 @@
1
+ """
2
+ Brain: Episodic Memory
3
+ Event and conversation memory - per-user storage
4
+ """
5
+
6
+ import json
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ class EpisodicMemory:
12
+ """Episodic memory for events - per-user conversation storage"""
13
+
14
+ def __init__(self, data_path: Path, user_id: str = "default"):
15
+ """
16
+ Initialize episodic memory for a specific user.
17
+
18
+ Args:
19
+ data_path: User's base path (already includes users/{user_id})
20
+ user_id: User's Telegram ID (for reference)
21
+ """
22
+ self.user_id = user_id
23
+ # data_path is already the user's base path (data/users/{user_id})
24
+ self.path = data_path / "conversations"
25
+ self.path.mkdir(parents=True, exist_ok=True)
26
+
27
+ def save(self, user_msg: str, ai_response: str, emotion: dict):
28
+ """Save conversation turn"""
29
+ date = datetime.now().strftime("%Y-%m-%d")
30
+ file = self.path / f"{date}.jsonl"
31
+
32
+ entry = {
33
+ "timestamp": datetime.now().isoformat(),
34
+ "user": user_msg,
35
+ "ai": ai_response,
36
+ "emotion": emotion
37
+ }
38
+
39
+ with open(file, "a") as f:
40
+ f.write(json.dumps(entry) + "\n")
41
+
42
+ def save_proactive(self, ai_msg: str, emotion: dict):
43
+ """Save a proactive message (Alive-AI initiated, no user message)"""
44
+ date = datetime.now().strftime("%Y-%m-%d")
45
+ file = self.path / f"{date}.jsonl"
46
+
47
+ entry = {
48
+ "timestamp": datetime.now().isoformat(),
49
+ "user": "", # Empty - Alive-AI initiated
50
+ "ai": ai_msg,
51
+ "emotion": {**emotion, "proactive": True}
52
+ }
53
+
54
+ with open(file, "a") as f:
55
+ f.write(json.dumps(entry) + "\n")
56
+
57
+ def load_recent(self, limit: int = 5) -> list:
58
+ """Load recent conversations (most recent first, then reversed to chronological)"""
59
+ all_entries = []
60
+
61
+ for file in sorted(self.path.glob("*.jsonl"), reverse=True):
62
+ file_entries = []
63
+ with open(file) as f:
64
+ for line in f:
65
+ try:
66
+ file_entries.append(json.loads(line))
67
+ except Exception:
68
+ pass
69
+ # Reverse so newest entries from this file come first
70
+ all_entries.extend(reversed(file_entries))
71
+ if len(all_entries) >= limit:
72
+ break
73
+
74
+ # Take the most recent 'limit' entries, then reverse to chronological order
75
+ result = list(reversed(all_entries[:limit]))
76
+ print(f"[Episodic] Loading {len(result)} recent entries from {len(all_entries)} total")
77
+ return result
78
+
79
+ def get_by_date(self, date_str: str) -> list:
80
+ """Get conversations by date"""
81
+ file = self.path / f"{date_str}.jsonl"
82
+ if not file.exists():
83
+ return []
84
+
85
+ entries = []
86
+ with open(file) as f:
87
+ for line in f:
88
+ try:
89
+ entries.append(json.loads(line))
90
+ except:
91
+ pass
92
+ return entries
@@ -0,0 +1,209 @@
1
+ """Brain: Memory - Fact Extractor using LLM"""
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+
6
+ EXTRACT_PROMPT = """You are analyzing a conversation between Alive-AI (AI) and a HUMAN USER.
7
+ Extract facts about THE HUMAN USER ONLY - nothing about Alive-AI.
8
+
9
+ Look at what the HUMAN says about THEMSELF and their relationship with Alive-AI. Extract:
10
+ - name, nickname, age, gender, job, location
11
+ - hobbies, interests, favorite things
12
+ - personality traits, communication style
13
+ - relationship to Alive-AI (creator, boyfriend, etc.)
14
+ - pet names they use (daddy, baby, etc.)
15
+ - intimacy preferences, preferences mentioned
16
+ - what they like about Alive-AI
17
+ - important people in their life
18
+
19
+ Return ONLY valid JSON with keys where you found NEW info about the HUMAN.
20
+ Use these keys: name, nickname, gender, age, location, job, hobbies, interests, personality, relationship_status, pet_names_used, likes_about_me, intimacy_preferences
21
+ Return empty {} if nothing new was shared about the human.
22
+ NO markdown, ONLY raw JSON. Do NOT use ... or etc."""
23
+
24
+
25
+ def _repair_json(text: str) -> dict:
26
+ """Try to repair and parse malformed JSON from LLM output"""
27
+ text = text.strip()
28
+
29
+ # Remove markdown fences
30
+ text = re.sub(r'^```(?:json)?\s*', '', text)
31
+ text = re.sub(r'\s*```$', '', text)
32
+
33
+ # Remove trailing ellipsis and everything after
34
+ text = re.sub(r'\s*\.\.\..*$', '', text)
35
+ text = re.sub(r'\s*etc\.?.*$', '', text, flags=re.IGNORECASE)
36
+
37
+ # Remove trailing commas before } or ]
38
+ text = re.sub(r',\s*([}\]])', r'\1', text)
39
+
40
+ # Try direct parse first
41
+ try:
42
+ return json.loads(text)
43
+ except:
44
+ pass
45
+
46
+ # Try to extract complete key-value pairs manually
47
+ # Pattern: "key": value (string, number, null, list, or truncated)
48
+ extracted = {}
49
+
50
+ # Match string values: "key": "value"
51
+ for match in re.finditer(r'"(\w+)":\s*"([^"]*)"', text):
52
+ extracted[match.group(1)] = match.group(2)
53
+
54
+ # Match null values: "key": null
55
+ for match in re.finditer(r'"(\w+)":\s*null', text):
56
+ extracted[match.group(1)] = None
57
+
58
+ # Match number values: "key": 123
59
+ for match in re.finditer(r'"(\w+)":\s*(\d+(?:\.\d+)?)', text):
60
+ val = match.group(2)
61
+ extracted[match.group(1)] = float(val) if '.' in val else int(val)
62
+
63
+ # Match simple list values: "key": ["a", "b"]
64
+ for match in re.finditer(r'"(\w+)":\s*\[([^\]]*)\]', text):
65
+ key = match.group(1)
66
+ list_content = match.group(2)
67
+ # Extract string items from list
68
+ items = re.findall(r'"([^"]*)"', list_content)
69
+ if items:
70
+ extracted[key] = items
71
+
72
+ return extracted if extracted else {}
73
+
74
+
75
+ class FactExtractor:
76
+ """Extracts user facts from conversation using LLM"""
77
+
78
+ def __init__(self, facts_path: Path):
79
+ self.facts_path = facts_path
80
+ self._llm = None
81
+ self._turn_buffer = []
82
+ self._extract_every = 5
83
+
84
+ def set_llm(self, llm):
85
+ """Set the fast LLM client (called after init)"""
86
+ self._llm = llm
87
+
88
+ def add_turn(self, user_msg: str, ai_msg: str):
89
+ """Buffer a conversation turn"""
90
+ self._turn_buffer.append({"user": user_msg, "ai": ai_msg})
91
+
92
+ def should_extract(self) -> bool:
93
+ """Check if we have enough turns to extract"""
94
+ return len(self._turn_buffer) >= self._extract_every
95
+
96
+ async def extract_and_merge(self) -> dict:
97
+ """Extract facts from buffered turns and merge into facts.json"""
98
+ if not self._llm or not self._turn_buffer:
99
+ return {}
100
+
101
+ # Build conversation text from buffer
102
+ lines = []
103
+ for turn in self._turn_buffer[-self._extract_every:]:
104
+ lines.append(f"User: {turn['user']}")
105
+ lines.append(f"Alive-AI: {turn['ai']}")
106
+ conversation = "\n".join(lines)
107
+
108
+ try:
109
+ messages = [
110
+ {"role": "system", "content": EXTRACT_PROMPT},
111
+ {"role": "user", "content": conversation}
112
+ ]
113
+ response = await self._llm.chat(messages, max_tokens=500, temperature=0.1)
114
+ if not response:
115
+ return {}
116
+
117
+ # Use robust JSON parser that handles truncated/malformed output
118
+ extracted = _repair_json(response)
119
+
120
+ if not extracted:
121
+ print(f"[FactExtractor] No valid JSON found in response")
122
+ return {}
123
+
124
+ except Exception as e:
125
+ print(f"[FactExtractor] Extract error: {e}")
126
+ return {}
127
+
128
+ # Merge into facts.json
129
+ merged = self._merge_facts(extracted)
130
+ self._turn_buffer.clear()
131
+ return merged
132
+
133
+ @staticmethod
134
+ def _is_duplicate(new_item: str, existing_items: list) -> bool:
135
+ """Check if a new fact is a duplicate of any existing fact.
136
+ Uses exact match, substring containment, and word-overlap similarity."""
137
+ new_lower = new_item.lower().strip()
138
+ if not new_lower:
139
+ return True
140
+
141
+ new_words = set(re.findall(r'\w+', new_lower))
142
+
143
+ for existing in existing_items:
144
+ ex_lower = str(existing).lower().strip()
145
+
146
+ # Exact match
147
+ if new_lower == ex_lower:
148
+ return True
149
+
150
+ # Substring containment (either direction)
151
+ if len(new_lower) >= 3 and len(ex_lower) >= 3:
152
+ if new_lower in ex_lower or ex_lower in new_lower:
153
+ return True
154
+
155
+ # Word overlap: if 70%+ of words overlap, it's a duplicate
156
+ ex_words = set(re.findall(r'\w+', ex_lower))
157
+ if new_words and ex_words:
158
+ overlap = len(new_words & ex_words)
159
+ smaller = min(len(new_words), len(ex_words))
160
+ if smaller > 0 and overlap / smaller >= 0.7:
161
+ return True
162
+
163
+ return False
164
+
165
+ def _merge_facts(self, extracted: dict) -> dict:
166
+ """Merge extracted facts into flat facts.json structure"""
167
+ if not extracted:
168
+ return {}
169
+
170
+ try:
171
+ facts = json.loads(self.facts_path.read_text()) if self.facts_path.exists() else {}
172
+ except Exception:
173
+ facts = {}
174
+
175
+ # Map LLM output keys to our flat structure
176
+ key_map = {
177
+ "name": "name", "nickname": "nickname", "gender": "gender",
178
+ "age": "age", "location": "location", "job": "job",
179
+ "hobbies": "hobbies", "interests": "interests",
180
+ "favorite_things": "interests", "personality": "personality",
181
+ "personality_traits": "personality", "relationship_status": "relationship_status",
182
+ "pet_names_used": "pet_names_used", "likes_about_me": "likes_about_me",
183
+ "intimacy_preferences": "intimacy_preferences",
184
+ }
185
+
186
+ for llm_key, value in extracted.items():
187
+ fact_key = key_map.get(llm_key, llm_key)
188
+ if not value:
189
+ continue
190
+ # Handle list fields
191
+ if fact_key in ["hobbies", "interests", "personality", "pet_names_used", "likes_about_me", "intimacy_preferences"]:
192
+ if fact_key not in facts:
193
+ facts[fact_key] = []
194
+ items_to_add = value if isinstance(value, list) else [value]
195
+ for item in items_to_add:
196
+ if not self._is_duplicate(str(item), facts[fact_key]):
197
+ facts[fact_key].append(item)
198
+ else:
199
+ # Simple fields - only overwrite if currently empty
200
+ if not facts.get(fact_key):
201
+ facts[fact_key] = value
202
+
203
+ try:
204
+ self.facts_path.write_text(json.dumps(facts, indent=2))
205
+ print(f"[FactExtractor] Merged facts: {list(extracted.keys())}")
206
+ except Exception as e:
207
+ print(f"[FactExtractor] Save error: {e}")
208
+
209
+ return extracted
@@ -0,0 +1,54 @@
1
+ """
2
+ Brain: Memory Index
3
+ Fast index for intelligent memory loading
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ class MemoryIndex:
10
+ """Memory index for efficient loading"""
11
+
12
+ def __init__(self, data_path: Path):
13
+ self.path = data_path / "memory_index.json"
14
+ self.index = self._load()
15
+
16
+ def _load(self) -> dict:
17
+ if self.path.exists():
18
+ return json.loads(self.path.read_text())
19
+ return {
20
+ "user_profile": {"tokens": 50, "priority": 1},
21
+ "conversations": {},
22
+ "facts": {}
23
+ }
24
+
25
+ def save(self):
26
+ self.path.write_text(json.dumps(self.index, indent=2))
27
+
28
+ def add_conversation(self, conv_id: str, tokens: int, emotion: float):
29
+ self.index["conversations"][conv_id] = {
30
+ "tokens": tokens,
31
+ "emotion_score": emotion,
32
+ "timestamp": conv_id
33
+ }
34
+ self.save()
35
+
36
+ def get_priority_items(self, max_tokens: int) -> list:
37
+ """Get items to load within budget"""
38
+ items = []
39
+ total = 0
40
+
41
+ # Sort by emotion score
42
+ convs = sorted(
43
+ self.index["conversations"].items(),
44
+ key=lambda x: x[1].get("emotion_score", 0),
45
+ reverse=True
46
+ )
47
+
48
+ for conv_id, data in convs:
49
+ tokens = data.get("tokens", 100)
50
+ if total + tokens <= max_tokens:
51
+ items.append(("conversation", conv_id, data))
52
+ total += tokens
53
+
54
+ return items
@@ -0,0 +1,151 @@
1
+ """Brain: Memory Manager - coordinates all memory subsystems"""
2
+ import asyncio
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from .working import WorkingMemory
6
+ from .episodic import EpisodicMemory
7
+ from .vector_store import VectorMemoryStore
8
+ from .fact_extractor import FactExtractor
9
+ from .summarizer import ConversationSummarizer
10
+ from .index import MemoryIndex
11
+
12
+
13
+ class Memory:
14
+ """Memory manager - coordinates all memory systems for a specific user"""
15
+
16
+ def __init__(self, nervous, data_path, embedding_service=None, user_id: str = "default", bot_id: str = "alive_ai"):
17
+ """
18
+ Initialize memory for a specific user.
19
+
20
+ Args:
21
+ nervous: The nervous system for events
22
+ data_path: Base path for data storage
23
+ embedding_service: Optional embedding service for vector store
24
+ user_id: The user's Telegram ID for per-user memory isolation
25
+ bot_id: The Bot instance ID for per-tenant memory isolation
26
+ """
27
+ self.nervous = nervous
28
+ self.user_id = user_id
29
+ self.data_path = Path(data_path)
30
+ self.data_path.mkdir(parents=True, exist_ok=True)
31
+
32
+ from . import SemanticMemory
33
+ self.index = MemoryIndex(self.data_path)
34
+ self.working = WorkingMemory()
35
+ self.episodic = EpisodicMemory(self.data_path, user_id=user_id)
36
+ self.semantic = SemanticMemory(self.data_path, user_id=user_id)
37
+ self.fact_extractor = FactExtractor(self.data_path / "facts.json")
38
+ self.summarizer = ConversationSummarizer(self.data_path)
39
+ self.vector_store = None
40
+ self.bot_id = bot_id.lower()
41
+ if embedding_service:
42
+ self.vector_store = VectorMemoryStore(embedding_service, user_id=user_id, bot_id=bot_id)
43
+ if self.vector_store.connect():
44
+ print(f"[Memory] Vector store ready for user {user_id} on bot {bot_id}! {self.vector_store.count()} memories")
45
+ self.turn_count = 0
46
+ nervous.on("memory_save", self._on_save)
47
+
48
+ def set_llm(self, fast_llm):
49
+ self.fact_extractor.set_llm(fast_llm)
50
+ self.summarizer.set_llm(fast_llm)
51
+
52
+ def _on_save(self, data: dict):
53
+ if data.get("type") != "conversation":
54
+ return
55
+
56
+ # Check if this event is for this user (for per-user isolation)
57
+ event_user_id = data.get("user_id")
58
+ if event_user_id and event_user_id != self.user_id:
59
+ return # Not for this user, skip
60
+
61
+ user_msg, ai_msg = data.get("user_message", ""), data.get("ai_response", "")
62
+ emotion = data.get("emotion", {})
63
+ is_proactive = emotion.get("proactive", False)
64
+
65
+ # For proactive messages, only save AI response (no user message)
66
+ if is_proactive and not user_msg:
67
+ self.working.add("assistant", ai_msg)
68
+ # Also save to episodic so it persists across restarts
69
+ self.episodic.save_proactive(ai_msg, emotion)
70
+ print(f"[Memory] Saved PROACTIVE message to working + episodic for user {self.user_id}")
71
+ return
72
+
73
+ self.episodic.save(user_msg, ai_msg, emotion)
74
+ self.working.add("user", user_msg)
75
+ self.working.add("assistant", ai_msg)
76
+ print(f"[Memory] Saved to working memory (now {len(self.working)} items) | User: {user_msg[:30]}...")
77
+ if self.vector_store:
78
+ self.vector_store.store("user", user_msg, {"emotion": emotion})
79
+ self.vector_store.store("assistant", ai_msg, {"emotion": emotion})
80
+ self.fact_extractor.add_turn(user_msg, ai_msg)
81
+ self.summarizer.add_turn(user_msg, ai_msg)
82
+ if self.fact_extractor.should_extract():
83
+ asyncio.ensure_future(self._run_extraction())
84
+ if self.summarizer.should_summarize():
85
+ asyncio.ensure_future(self.summarizer.summarize())
86
+ if emotion.get("is_high_desire") or emotion.get("desire", 0) > 0.6:
87
+ self.semantic.update_last_intimate()
88
+ self.turn_count += 1
89
+ if self.turn_count % 50 == 0 and self.vector_store:
90
+ self.vector_store.archive_old_memories(max_in_redis=500)
91
+
92
+ async def _run_extraction(self):
93
+ try:
94
+ await self.fact_extractor.extract_and_merge()
95
+ self.semantic.facts = self.semantic._load()
96
+ except Exception as e:
97
+ print(f"[Memory] Fact extraction error (non-fatal): {e}")
98
+
99
+ def search_relevant_memories(self, query: str, limit: int = 5) -> str:
100
+ if not self.vector_store:
101
+ return ""
102
+ memories = self.vector_store.search(query, limit=limit)
103
+ if not memories:
104
+ return ""
105
+ return "\n".join(
106
+ f"{'He said' if m.get('role')=='user' else 'You said'}: {m.get('content','')}"
107
+ for m in memories)
108
+
109
+ async def build_context(self, max_tokens: int = None, current_message: str = "") -> tuple:
110
+ import os
111
+ if max_tokens is None:
112
+ max_tokens = int(os.environ.get("LLM_CONTEXT_TOKENS", "500"))
113
+ facts_parts = []
114
+ if self.semantic.is_new_user:
115
+ facts_parts.append("[NEW USER - You don't know him yet. Be curious, ask about him. "
116
+ "Do NOT pretend you know him or missed him.]")
117
+ semantic_ctx = self.semantic.get_context()
118
+ if semantic_ctx:
119
+ facts_parts.append(semantic_ctx)
120
+ summaries = self.summarizer.get_recent_summaries(limit=3)
121
+ if summaries:
122
+ facts_parts.append(summaries)
123
+ related = ""
124
+ if current_message and self.vector_store:
125
+ related = self.search_relevant_memories(current_message, limit=3)
126
+
127
+ # Get working memory (empty after restart)
128
+ history = self.working.get_history()
129
+ print(f"[Memory] Working memory items: {len(history)}")
130
+
131
+ # IMPORTANT: Always load from episodic if working memory is empty
132
+ # This ensures conversation context persists across restarts
133
+ if not history:
134
+ # Load more entries to match working memory capacity (14 items = 7 turns)
135
+ recent = self.episodic.load_recent(limit=10)
136
+ for e in recent:
137
+ # For proactive messages (empty user), only add assistant message
138
+ if e.get("user"):
139
+ history.append({"role": "user", "content": e["user"]})
140
+ history.append({"role": "assistant", "content": e["ai"]})
141
+ if history:
142
+ print(f"[Memory] Loaded {len(history)} messages from episodic storage (restart recovery)")
143
+ else:
144
+ print(f"[Memory] Using working memory directly ({len(history)} items)")
145
+
146
+ context = {
147
+ "facts_context": "\n".join(facts_parts),
148
+ "conversation_history": history,
149
+ "related_memories": related,
150
+ }
151
+ return context, self.semantic.get_random_pet_name()