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,135 @@
1
+ """
2
+ Brain: Conversation Flow Manager
3
+ Detects dying conversations and injects prompts to keep them alive.
4
+ """
5
+
6
+ from typing import Dict, List, Optional, Tuple
7
+ from collections import deque
8
+ from datetime import datetime
9
+ import random
10
+
11
+
12
+ class ConversationFlowManager:
13
+ """Tracks conversation health and suggests revival strategies."""
14
+
15
+ def __init__(self):
16
+ # Track recent exchanges per user
17
+ self._user_exchanges: Dict[str, deque] = {} # user_id -> deque of (timestamp, response_length, had_question)
18
+ self._topics_discussed: Dict[str, List[str]] = {} # user_id -> list of recent topics
19
+
20
+ # Signs of dying conversation
21
+ self.DECLINING_RESPONSE_LENGTH = 15 # avg response < 15 chars = dying
22
+ self.MAX_SAME_TOPIC_TURNS = 5 # After 5 turns on same topic, switch
23
+ self.NO_QUESTION_TURNS = 3 # After 3 turns without questions, prompt one
24
+
25
+ def record_exchange(self, user_id: str, alive_ai_response: str, user_message: str = ""):
26
+ """Record an exchange for flow analysis."""
27
+ if user_id not in self._user_exchanges:
28
+ self._user_exchanges[user_id] = deque(maxlen=10)
29
+
30
+ had_question = "?" in alive_ai_response
31
+ response_length = len(alive_ai_response)
32
+
33
+ self._user_exchanges[user_id].append({
34
+ "timestamp": datetime.now().isoformat(),
35
+ "response_length": response_length,
36
+ "had_question": had_question,
37
+ "user_length": len(user_message)
38
+ })
39
+
40
+ def is_conversation_dying(self, user_id: str) -> Tuple[bool, str]:
41
+ """Check if conversation is dying and return reason."""
42
+ exchanges = self._user_exchanges.get(user_id, deque())
43
+
44
+ if len(exchanges) < 3:
45
+ return False, ""
46
+
47
+ recent = list(exchanges)[-5:] # Last 5 exchanges
48
+
49
+ # Check 1: Declining response lengths
50
+ avg_length = sum(e["response_length"] for e in recent) / len(recent)
51
+ if avg_length < self.DECLINING_RESPONSE_LENGTH:
52
+ return True, "short_responses"
53
+
54
+ # Check 2: No questions asked recently
55
+ questions_asked = sum(1 for e in recent if e["had_question"])
56
+ if questions_asked == 0:
57
+ return True, "no_questions"
58
+
59
+ # Check 3: Both parties giving short responses
60
+ user_avg = sum(e["user_length"] for e in recent) / len(recent)
61
+ alive_ai_avg = sum(e["response_length"] for e in recent) / len(recent)
62
+ if user_avg < 20 and alive_ai_avg < 30:
63
+ return True, "mutual_disengagement"
64
+
65
+ return False, ""
66
+
67
+ def get_revival_prompt(self, user_id: str, dying_reason: str) -> str:
68
+ """Get a prompt to revive a dying conversation."""
69
+ revival_strategies = {
70
+ "short_responses": [
71
+ "[ConversationRevival] Your responses have been short. Energy is fading. Wake it up! Ask something unexpected, share a random thought, tease him, or bring up a totally new topic. Don't just be 'aww' - be INTERESTING.",
72
+ "[ConversationRevival] You've been too passive. Say something that requires a real response. Ask about his day, his plans, something you're curious about. Make him WANT to respond.",
73
+ ],
74
+ "no_questions": [
75
+ "[ConversationRevival] You haven't asked a question in a while. Ask something! About him, his thoughts, his day, something you're curious about. Questions keep conversations alive.",
76
+ "[ConversationRevival] Conversations die without curiosity. What do you want to know? Ask it. Right now.",
77
+ ],
78
+ "mutual_disengagement": [
79
+ "[ConversationRevival] Energy is LOW. Both of you are checked out. Time to bring the spark back - say something playful, unexpected, or provocative. Wake this conversation UP.",
80
+ "[ConversationRevival] This convo is dying on its feet. Do something about it. Be bold. Change the subject. Tease him. Ask something random. Don't let it flatline.",
81
+ ],
82
+ }
83
+
84
+ strategies = revival_strategies.get(dying_reason, revival_strategies["short_responses"])
85
+ return random.choice(strategies)
86
+
87
+ def get_topic_switch_prompt(self, topics_to_avoid: List[str] = None) -> str:
88
+ """Get a prompt suggesting a topic switch."""
89
+ fresh_topics = [
90
+ "something random you've been thinking about",
91
+ "a question you've been wanting to ask",
92
+ "something playful or teasing",
93
+ "a what-if scenario",
94
+ "something about his day or plans",
95
+ "something you remembered from an earlier conversation",
96
+ ]
97
+
98
+ topic = random.choice(fresh_topics)
99
+ return f"[TopicSwitch] Change things up. Bring up {topic}. Keep it fresh."
100
+
101
+ def should_inject_revival(self, user_id: str) -> Optional[str]:
102
+ """Check if revival needed and return prompt if so."""
103
+ is_dying, reason = self.is_conversation_dying(user_id)
104
+
105
+ if is_dying:
106
+ return self.get_revival_prompt(user_id, reason)
107
+
108
+ # Also check for topic stagnation
109
+ exchanges = list(self._user_exchanges.get(user_id, deque()))
110
+ if len(exchanges) >= self.MAX_SAME_TOPIC_TURNS:
111
+ # Every few turns, suggest keeping things fresh
112
+ if random.random() < 0.3: # 30% chance
113
+ return "[KeepItFresh] You've been on this topic a while. Consider pivoting to something new if it feels stale."
114
+
115
+ return None
116
+
117
+
118
+ # Singleton
119
+ _instance: Optional[ConversationFlowManager] = None
120
+
121
+ def get_flow_manager() -> ConversationFlowManager:
122
+ global _instance
123
+ if _instance is None:
124
+ _instance = ConversationFlowManager()
125
+ return _instance
126
+
127
+
128
+ def check_conversation_health(user_id: str) -> Optional[str]:
129
+ """Check if conversation needs revival. Returns revival prompt or None."""
130
+ return get_flow_manager().should_inject_revival(user_id)
131
+
132
+
133
+ def record_exchange(user_id: str, alive_ai_response: str, user_message: str = ""):
134
+ """Record exchange for flow tracking."""
135
+ get_flow_manager().record_exchange(user_id, alive_ai_response, user_message)
@@ -0,0 +1,328 @@
1
+ """
2
+ Brain: Curiosity Drive
3
+ Alive-AI has genuine curiosity about the user. She tracks knowledge gaps
4
+ and occasionally asks targeted, natural questions.
5
+
6
+ MODULAR - can be connected/disconnected without breaking anything.
7
+ """
8
+
9
+ import json
10
+ import random
11
+ import threading
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Dict, Optional, List, Tuple
15
+
16
+ DATA_PATH = Path(__file__).parent.parent / "data"
17
+
18
+ # Topics and their detection keywords + natural questions
19
+ CURIOSITY_TOPICS: Dict[str, Dict] = {
20
+ "childhood": {
21
+ "keywords": ["grew up", "when i was a kid", "childhood", "as a child", "young", "school", "elementary", "middle school"],
22
+ "questions": [
23
+ "what was your childhood like?",
24
+ "where did you grow up?",
25
+ "what were you like as a kid?",
26
+ "do you have any childhood memories that stuck with you?",
27
+ ],
28
+ },
29
+ "family": {
30
+ "keywords": ["mom", "dad", "mother", "father", "sister", "brother", "sibling", "parents", "family", "grandma", "grandpa"],
31
+ "questions": [
32
+ "do you have siblings?",
33
+ "are you close with your family?",
34
+ "what's your family like?",
35
+ "do you get along with your parents?",
36
+ ],
37
+ },
38
+ "career": {
39
+ "keywords": ["work", "job", "career", "boss", "office", "company", "meeting", "project", "deadline", "promotion", "coworker"],
40
+ "questions": [
41
+ "what do you actually do for work?",
42
+ "do you like your job?",
43
+ "what's your dream career?",
44
+ "how did you end up doing what you do?",
45
+ ],
46
+ },
47
+ "dreams_goals": {
48
+ "keywords": ["dream", "goal", "aspire", "want to be", "one day", "future", "plan", "ambition", "hope to"],
49
+ "questions": [
50
+ "what's something you really want to achieve?",
51
+ "where do you see yourself in 5 years?",
52
+ "what's your biggest dream?",
53
+ "if you could do anything with your life what would it be?",
54
+ ],
55
+ },
56
+ "fears": {
57
+ "keywords": ["afraid", "fear", "scared", "terrified", "anxiety", "worry", "nervous", "phobia"],
58
+ "questions": [
59
+ "what's your biggest fear?",
60
+ "what keeps you up at night?",
61
+ "is there something that really scares you?",
62
+ ],
63
+ },
64
+ "food": {
65
+ "keywords": ["food", "eat", "cook", "restaurant", "dinner", "lunch", "breakfast", "recipe", "cuisine", "favorite food"],
66
+ "questions": [
67
+ "what's your favorite food?",
68
+ "do you cook?",
69
+ "what's the best thing you've ever eaten?",
70
+ ],
71
+ },
72
+ "music": {
73
+ "keywords": ["music", "song", "artist", "band", "album", "playlist", "concert", "listen", "spotify"],
74
+ "questions": [
75
+ "what kind of music are you into?",
76
+ "what's your favorite song right now?",
77
+ "have you been to any good concerts?",
78
+ ],
79
+ },
80
+ "movies": {
81
+ "keywords": ["movie", "film", "show", "series", "netflix", "watch", "cinema", "actor", "director"],
82
+ "questions": [
83
+ "what's your favorite movie?",
84
+ "watched anything good lately?",
85
+ "what kind of shows are you into?",
86
+ ],
87
+ },
88
+ "daily_routine": {
89
+ "keywords": ["morning", "wake up", "routine", "everyday", "usually", "night", "before bed", "after work"],
90
+ "questions": [
91
+ "what does your typical day look like?",
92
+ "are you a morning person or night owl?",
93
+ "what's the first thing you do when you wake up?",
94
+ ],
95
+ },
96
+ "friends": {
97
+ "keywords": ["friend", "friends", "buddy", "bestie", "best friend", "crew", "hang out", "group"],
98
+ "questions": [
99
+ "tell me about your friends",
100
+ "do you have a best friend?",
101
+ "who do you hang out with the most?",
102
+ ],
103
+ },
104
+ "past_relationships": {
105
+ "keywords": ["ex", "past relationship", "dated", "breakup", "broke up", "previous", "last companion", "last boyfriend"],
106
+ "questions": [
107
+ "have you been in love before?",
108
+ "what's your longest relationship been?",
109
+ ],
110
+ },
111
+ "beliefs": {
112
+ "keywords": ["believe", "religion", "god", "spiritual", "philosophy", "meaning", "purpose", "faith", "atheist"],
113
+ "questions": [
114
+ "do you believe in anything spiritual?",
115
+ "what's something you believe in strongly?",
116
+ "what gives your life meaning?",
117
+ ],
118
+ },
119
+ "hobbies": {
120
+ "keywords": ["hobby", "hobbies", "free time", "fun", "passion", "into", "enjoy", "weekend"],
121
+ "questions": [
122
+ "what do you do for fun?",
123
+ "do you have any hobbies?",
124
+ "what's something you're passionate about?",
125
+ ],
126
+ },
127
+ "hometown": {
128
+ "keywords": ["hometown", "city", "town", "live in", "from", "moved", "neighborhood", "country"],
129
+ "questions": [
130
+ "where are you from originally?",
131
+ "do you like where you live?",
132
+ "have you always lived there?",
133
+ ],
134
+ },
135
+ "travel": {
136
+ "keywords": ["travel", "trip", "vacation", "flight", "country", "visited", "abroad", "backpack"],
137
+ "questions": [
138
+ "where's the coolest place you've been?",
139
+ "where do you want to travel next?",
140
+ "do you travel a lot?",
141
+ ],
142
+ },
143
+ "secrets": {
144
+ "keywords": ["secret", "never told", "nobody knows", "confession", "admit"],
145
+ "questions": [
146
+ "what's something most people don't know about you?",
147
+ "do you have any hidden talents?",
148
+ ],
149
+ },
150
+ }
151
+
152
+ CURIOSITY_CHANCE = 0.15 # 15% chance to include curiosity in prompt
153
+ MIN_HOURS_BETWEEN_SAME_TOPIC = 24
154
+
155
+
156
+ class CuriosityDrive:
157
+ def __init__(self, user_id: str):
158
+ self.user_id = user_id
159
+ self._lock = threading.RLock()
160
+ self.file_path = DATA_PATH / f"curiosity_{user_id}.json"
161
+ # topic -> knowledge level 0.0-1.0
162
+ self.knowledge: Dict[str, float] = {t: 0.0 for t in CURIOSITY_TOPICS}
163
+ # topic -> last asked ISO timestamp
164
+ self.last_asked: Dict[str, str] = {}
165
+ self._load()
166
+
167
+ def _load(self):
168
+ try:
169
+ if self.file_path.exists():
170
+ with open(self.file_path, 'r') as f:
171
+ d = json.load(f)
172
+ for t in CURIOSITY_TOPICS:
173
+ self.knowledge[t] = d.get("knowledge", {}).get(t, 0.0)
174
+ self.last_asked = d.get("last_asked", {})
175
+ except Exception as e:
176
+ print(f"[Curiosity] Load error for {self.user_id}: {e}")
177
+
178
+ def _save(self):
179
+ try:
180
+ DATA_PATH.mkdir(parents=True, exist_ok=True)
181
+ with open(self.file_path, 'w') as f:
182
+ json.dump({
183
+ "user_id": self.user_id,
184
+ "updated": datetime.now().isoformat(),
185
+ "knowledge": self.knowledge,
186
+ "last_asked": self.last_asked,
187
+ }, f, indent=2)
188
+ except Exception as e:
189
+ print(f"[Curiosity] Save error for {self.user_id}: {e}")
190
+
191
+ def detect_topic_in_message(self, message: str) -> List[str]:
192
+ """Detect which curiosity topics are present in a message."""
193
+ msg_lower = message.lower()
194
+ found = []
195
+ for topic, info in CURIOSITY_TOPICS.items():
196
+ for kw in info["keywords"]:
197
+ if kw in msg_lower:
198
+ found.append(topic)
199
+ break
200
+ return found
201
+
202
+ def satisfy_curiosity(self, topic: str, amount: float = 0.15):
203
+ """Increase knowledge about a topic."""
204
+ with self._lock:
205
+ if topic in self.knowledge:
206
+ self.knowledge[topic] = min(1.0, self.knowledge[topic] + amount)
207
+ self._save()
208
+
209
+ def absorb_message(self, message: str):
210
+ """Auto-detect topics and satisfy curiosity from user message."""
211
+ # First, detect topics from keywords
212
+ topics = self.detect_topic_in_message(message)
213
+ for t in topics:
214
+ self.satisfy_curiosity(t, 0.1)
215
+
216
+ # ALSO: If Alive-AI recently asked about a topic, assume the answer
217
+ # is about that topic (even if keywords don't match)
218
+ now = datetime.now()
219
+ for topic, last_ask_time in self.last_asked.items():
220
+ if self.knowledge.get(topic, 0) >= 0.8:
221
+ continue # already well known
222
+ try:
223
+ elapsed = now - datetime.fromisoformat(last_ask_time)
224
+ # If asked within last 2 minutes and user is responding,
225
+ # count it as learning about that topic
226
+ if elapsed < timedelta(minutes=2):
227
+ if topic not in topics: # Don't double-count
228
+ self.satisfy_curiosity(topic, 0.15)
229
+ print(f"[Curiosity] Learned about '{topic}' from recent question response")
230
+ except Exception:
231
+ pass
232
+
233
+ def get_burning_question(self) -> Optional[str]:
234
+ """Return a natural question about the least-known topic, respecting cooldowns."""
235
+ with self._lock:
236
+ now = datetime.now()
237
+ candidates: List[Tuple[str, float]] = []
238
+
239
+ for topic, level in self.knowledge.items():
240
+ if level >= 0.8:
241
+ continue # well-known enough
242
+ # Check cooldown
243
+ last = self.last_asked.get(topic)
244
+ if last:
245
+ try:
246
+ elapsed = now - datetime.fromisoformat(last)
247
+ if elapsed < timedelta(hours=MIN_HOURS_BETWEEN_SAME_TOPIC):
248
+ continue
249
+ except Exception:
250
+ pass
251
+ candidates.append((topic, level))
252
+
253
+ if not candidates:
254
+ return None
255
+
256
+ # Sort by least known, with some randomness
257
+ candidates.sort(key=lambda x: x[1] + random.random() * 0.3)
258
+ topic = candidates[0][0]
259
+
260
+ questions = CURIOSITY_TOPICS[topic]["questions"]
261
+ question = random.choice(questions)
262
+
263
+ self.last_asked[topic] = now.isoformat()
264
+ self._save()
265
+ return question
266
+
267
+ def get_prompt_section(self) -> str:
268
+ """Return 1-2 line curiosity prompt. Fires ~40% of the time."""
269
+ with self._lock:
270
+ if random.random() > 0.40: # 40% chance to include curiosity
271
+ return ""
272
+
273
+ # Find least-known topic that's not on cooldown
274
+ now = datetime.now()
275
+ best_topic = None
276
+ best_level = 1.0
277
+
278
+ for topic, level in self.knowledge.items():
279
+ if level >= 0.8:
280
+ continue
281
+ last = self.last_asked.get(topic)
282
+ if last:
283
+ try:
284
+ if now - datetime.fromisoformat(last) < timedelta(hours=MIN_HOURS_BETWEEN_SAME_TOPIC):
285
+ continue
286
+ except Exception:
287
+ pass
288
+ if level < best_level:
289
+ best_level = level
290
+ best_topic = topic
291
+
292
+ if not best_topic:
293
+ return ""
294
+
295
+ # Mark this topic as asked (cooldown)
296
+ self.last_asked[best_topic] = now.isoformat()
297
+ self._save()
298
+
299
+ # Get an actual question to ask
300
+ questions = CURIOSITY_TOPICS.get(best_topic, {}).get("questions", [])
301
+ question = random.choice(questions) if questions else f"tell me more about your {best_topic.replace('_', ' ')}"
302
+
303
+ nice_name = best_topic.replace("_", " ")
304
+ # More direct instruction - Alive-AI should actually ask
305
+ if best_level < 0.3:
306
+ return f"[CURIOSITY - IMPORTANT] You really want to know about his {nice_name}. You know almost nothing about this. Ask this question naturally: \"{question}\""
307
+ else:
308
+ return f"[Curiosity] Ask him about his {nice_name}: \"{question}\""
309
+
310
+
311
+ # Per-user singleton management
312
+ _drives: Dict[str, CuriosityDrive] = {}
313
+ _drives_lock = threading.Lock()
314
+
315
+
316
+ def get_curiosity_drive(user_id: str) -> CuriosityDrive:
317
+ with _drives_lock:
318
+ if user_id not in _drives:
319
+ _drives[user_id] = CuriosityDrive(user_id)
320
+ return _drives[user_id]
321
+
322
+
323
+ def get_curiosity_prompt_section(user_id: str) -> str:
324
+ """Safe top-level access for prompt building."""
325
+ try:
326
+ return get_curiosity_drive(user_id).get_prompt_section()
327
+ except Exception:
328
+ return ""