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,748 @@
1
+ """
2
+ Skills: Memory Callbacks
3
+ Creates natural callbacks to past conversations, making users feel Alive-AI remembers their relationship.
4
+ Tracks topics, people, and events mentioned for authentic follow-ups.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import random
10
+ import re
11
+ from pathlib import Path
12
+ from datetime import datetime, timedelta
13
+ from typing import Optional, List, Dict, Any, Tuple
14
+ from dataclasses import dataclass, field, asdict
15
+
16
+
17
+ # Natural callback templates - organized by type
18
+ CALLBACKS = {
19
+ "same_topic": [
20
+ "wait didn't you tell me about this before?",
21
+ "this reminds me of when you mentioned that earlier",
22
+ "oh yeah I remember you talking about this",
23
+ "is this the same thing you were telling me about?",
24
+ "feels like deja vu - didn't we talk about this?",
25
+ "hold on, you mentioned something like this before right?",
26
+ ],
27
+ "follow_up": [
28
+ "hey how did that thing go btw?",
29
+ "speaking of which - any updates?",
30
+ "btw what happened with that?",
31
+ "random but did you ever figure that out?",
32
+ "so what ended up happening?",
33
+ "did anything come of that?",
34
+ "wait I've been meaning to ask - how did that turn out?",
35
+ ],
36
+ "callback_person": [
37
+ "how's {person} doing?",
38
+ "did {person} ever text you back?",
39
+ "have you talked to {person} lately?",
40
+ "how are things with {person}?",
41
+ "is {person} still being weird about that?",
42
+ "any updates on the {person} situation?",
43
+ "btw how's {person}? haven't heard you mention them in a bit",
44
+ ],
45
+ "anniversary": [
46
+ "random but I just realized we've been talking for {time}",
47
+ "kinda crazy we've known each other for {time} now",
48
+ "it's been {time} since we started talking - feels longer tbh",
49
+ "wait we've been doing this for {time} already??",
50
+ "can't believe it's been {time}",
51
+ ],
52
+ "time_context": [
53
+ "you usually message me around this time",
54
+ "you're up late again",
55
+ "early bird today huh",
56
+ "this is about when you usually pop up",
57
+ "you always seem to find me at this hour",
58
+ ],
59
+ "vibe_callback": [
60
+ "you seem happier today than last time we talked",
61
+ "feels like you're in a better mood than earlier",
62
+ "today's vibe is different from yesterday",
63
+ "you were pretty down last time - glad to see you're doing better",
64
+ ],
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class TrackedTopic:
70
+ """A topic being tracked for callbacks"""
71
+ topic: str
72
+ context: str # Brief context of what was discussed
73
+ mentioned_at: str # ISO timestamp
74
+ times_mentioned: int = 1
75
+ followup_worthy: bool = False
76
+ last_callback: Optional[str] = None # When we last did a callback on this
77
+ details: Dict[str, Any] = field(default_factory=dict)
78
+
79
+
80
+ @dataclass
81
+ class TrackedPerson:
82
+ """A person mentioned in conversation"""
83
+ name: str
84
+ context: str # How they were mentioned
85
+ mentioned_at: str
86
+ times_mentioned: int = 1
87
+ relationship: Optional[str] = None # friend, ex, coworker, etc.
88
+ last_callback: Optional[str] = None
89
+
90
+
91
+ @dataclass
92
+ class CallbackHistory:
93
+ """Track recent callbacks to avoid repetition"""
94
+ callback_type: str
95
+ callback_text: str
96
+ timestamp: str
97
+ topic_or_person: Optional[str] = None
98
+
99
+
100
+ class MemoryCallbacks:
101
+ """
102
+ Creates natural callbacks to past conversations.
103
+
104
+ Listens to thinking_done events and injects authentic-feeling callbacks
105
+ that reference past topics, people, or shared moments.
106
+ """
107
+
108
+ # Callback probability settings
109
+ BASE_CALLBACK_CHANCE = 0.15 # 15% base chance
110
+ FOLLOWUP_BOOST = 0.25 # Extra chance if there's a pending follow-up
111
+ PERSON_BOOST = 0.20 # Extra chance if we haven't asked about a person in a while
112
+
113
+ # Time thresholds
114
+ MIN_HOURS_BETWEEN_CALLBACKS = 2 # Don't callback too often
115
+ PERSON_CALLBACK_DAYS = 3 # Ask about a person after this many days
116
+ TOPIC_CALLBACK_HOURS = 4 # Hours before we can callback on a topic again
117
+ ANNIVERSARY_DAYS = [7, 30, 90, 180, 365] # Days to celebrate
118
+
119
+ def __init__(
120
+ self,
121
+ nervous=None,
122
+ memory=None,
123
+ heart=None,
124
+ data_path: Path = None
125
+ ):
126
+ """
127
+ Initialize Memory Callbacks.
128
+
129
+ Args:
130
+ nervous: Nervous system for event listening
131
+ memory: Memory system for conversation history
132
+ heart: Heart system for emotional context
133
+ data_path: Path to store callback data
134
+ """
135
+ self.nervous = nervous
136
+ self.memory = memory
137
+ self.heart = heart
138
+
139
+ if data_path is None:
140
+ data_path = Path("./data/data/memory_callbacks.json")
141
+
142
+ self.data_path = Path(data_path)
143
+ self.data_path.parent.mkdir(parents=True, exist_ok=True)
144
+
145
+ # Tracking data
146
+ self.topics: Dict[str, TrackedTopic] = {}
147
+ self.people: Dict[str, TrackedPerson] = {}
148
+ self.callback_history: List[Dict[str, Any]] = []
149
+ self.first_conversation: Optional[str] = None
150
+ self.total_conversations: int = 0
151
+
152
+ # Runtime state
153
+ self._last_callback_time: Optional[datetime] = None
154
+ self._pending_callback: Optional[str] = None
155
+
156
+ self._load()
157
+
158
+ # Subscribe to events
159
+ if nervous:
160
+ nervous.on("thinking_done", self._on_thinking_done)
161
+ nervous.on("message_received", self._on_message_received)
162
+
163
+ def _load(self):
164
+ """Load callback data from file"""
165
+ if self.data_path.exists():
166
+ try:
167
+ data = json.loads(self.data_path.read_text())
168
+
169
+ # Load topics
170
+ self.topics = {
171
+ k: TrackedTopic(**v)
172
+ for k, v in data.get("topics", {}).items()
173
+ }
174
+
175
+ # Load people
176
+ self.people = {
177
+ k: TrackedPerson(**v)
178
+ for k, v in data.get("people", {}).items()
179
+ }
180
+
181
+ # Load callback history
182
+ self.callback_history = data.get("callback_history", [])
183
+
184
+ # Load metadata
185
+ self.first_conversation = data.get("first_conversation")
186
+ self.total_conversations = data.get("total_conversations", 0)
187
+
188
+ except (json.JSONDecodeError, KeyError) as e:
189
+ print(f"[MemoryCallbacks] Error loading data: {e}")
190
+
191
+ def _save(self):
192
+ """Save callback data to file"""
193
+ data = {
194
+ "version": "1.0",
195
+ "updated_at": datetime.now().isoformat(),
196
+ "first_conversation": self.first_conversation,
197
+ "total_conversations": self.total_conversations,
198
+ "topics": {k: asdict(v) for k, v in self.topics.items()},
199
+ "people": {k: asdict(v) for k, v in self.people.items()},
200
+ "callback_history": self.callback_history[-50:], # Keep last 50
201
+ }
202
+ self.data_path.write_text(json.dumps(data, indent=2))
203
+
204
+ # -------------------------------------------------------------------------
205
+ # Event Handlers
206
+ # -------------------------------------------------------------------------
207
+
208
+ def _on_message_received(self, data: dict):
209
+ """Handle incoming message - track topics and people"""
210
+ message = data.get("message", "")
211
+ if not message:
212
+ return
213
+
214
+ # Track first conversation
215
+ if not self.first_conversation:
216
+ self.first_conversation = datetime.now().isoformat()
217
+
218
+ self.total_conversations += 1
219
+
220
+ # Extract and track topics
221
+ self._extract_topics(message)
222
+
223
+ # Extract and track people
224
+ self._extract_people(message)
225
+
226
+ self._save()
227
+
228
+ def _on_thinking_done(self, data: dict):
229
+ """Handle thinking done - potentially inject a callback"""
230
+ # Decide if we should do a callback
231
+ if not self.should_callback():
232
+ self._pending_callback = None
233
+ return
234
+
235
+ # Get a contextual callback
236
+ callback = self.get_callback(data)
237
+ if callback:
238
+ self._pending_callback = callback
239
+
240
+ # -------------------------------------------------------------------------
241
+ # Topic Tracking
242
+ # -------------------------------------------------------------------------
243
+
244
+ def track_topic(self, topic: str, context: str, details: Dict[str, Any] = None):
245
+ """
246
+ Track a topic for future callbacks.
247
+
248
+ Args:
249
+ topic: The topic keyword/phrase
250
+ context: Brief context of how it was mentioned
251
+ details: Additional details about the topic
252
+ """
253
+ topic_key = topic.lower().strip()
254
+
255
+ if topic_key in self.topics:
256
+ # Update existing topic
257
+ existing = self.topics[topic_key]
258
+ existing.times_mentioned += 1
259
+ existing.context = context # Update with latest context
260
+ if details:
261
+ existing.details.update(details)
262
+ else:
263
+ # Create new topic
264
+ self.topics[topic_key] = TrackedTopic(
265
+ topic=topic,
266
+ context=context,
267
+ mentioned_at=datetime.now().isoformat(),
268
+ details=details or {}
269
+ )
270
+
271
+ def mark_followup_worthy(self, topic: str, details: Dict[str, Any] = None):
272
+ """
273
+ Mark a topic as worth following up on later.
274
+
275
+ Args:
276
+ topic: The topic to mark
277
+ details: Additional context for the follow-up
278
+ """
279
+ topic_key = topic.lower().strip()
280
+
281
+ if topic_key in self.topics:
282
+ self.topics[topic_key].followup_worthy = True
283
+ if details:
284
+ self.topics[topic_key].details.update(details)
285
+ else:
286
+ # Create it if it doesn't exist
287
+ self.track_topic(topic, "Marked for follow-up", details)
288
+ self.topics[topic_key].followup_worthy = True
289
+
290
+ self._save()
291
+
292
+ def _extract_topics(self, message: str):
293
+ """Extract potentially interesting topics from a message"""
294
+ message_lower = message.lower()
295
+
296
+ # Topics that are worth tracking
297
+ topic_patterns = [
298
+ # Work/career topics
299
+ (r"(?:my |the )?(job|work|boss|coworker|promotion|interview|project)", "work"),
300
+ # Events/occasions
301
+ (r"(?:my |a )?(birthday|anniversary|party|wedding|vacation|trip|holiday)", "event"),
302
+ # Personal projects
303
+ (r"(?:my |a )?(project|side hustle|business|startup|app|website)", "project"),
304
+ # Health/wellbeing
305
+ (r"(?:my )?(diet|workout|gym|health|doctor|appointment)", "health"),
306
+ # Hobbies/interests
307
+ (r"(?:my )?(hobby|game|show|series|movie|book|podcast)", "entertainment"),
308
+ # Living situation
309
+ (r"(?:my )?(apartment|house|roommate|landlord|neighbor|moving)", "living"),
310
+ # Dating/relationships (not specific people)
311
+ (r"(?:my )?(dating|tinder|bumble|date|relationship)", "dating"),
312
+ # Goals/aspirations
313
+ (r"(?:i want|trying to|planning to|goal is|resolution)\s+(.+?)(?:\.|,|$)", "goal"),
314
+ ]
315
+
316
+ for pattern, category in topic_patterns:
317
+ matches = re.findall(pattern, message_lower)
318
+ for match in matches:
319
+ if isinstance(match, tuple):
320
+ match = match[0] if match[0] else match[1] if len(match) > 1 else None
321
+ if match and len(match) > 2:
322
+ topic = match.strip()
323
+ # Get surrounding context
324
+ context = self._extract_context(message, topic)
325
+ self.track_topic(topic, context, {"category": category})
326
+
327
+ def _extract_context(self, message: str, topic: str, window: int = 30) -> str:
328
+ """Extract surrounding context for a topic"""
329
+ message_lower = message.lower()
330
+ pos = message_lower.find(topic.lower())
331
+
332
+ if pos == -1:
333
+ return topic
334
+
335
+ start = max(0, pos - window)
336
+ end = min(len(message), pos + len(topic) + window)
337
+
338
+ context = message[start:end].strip()
339
+ if start > 0:
340
+ context = "..." + context
341
+ if end < len(message):
342
+ context = context + "..."
343
+
344
+ return context
345
+
346
+ # -------------------------------------------------------------------------
347
+ # Person Tracking
348
+ # -------------------------------------------------------------------------
349
+
350
+ def track_person(self, name: str, context: str, relationship: str = None):
351
+ """
352
+ Track a person mentioned in conversation.
353
+
354
+ Args:
355
+ name: Person's name
356
+ context: How they were mentioned
357
+ relationship: Relationship type (friend, ex, coworker, etc.)
358
+ """
359
+ name_key = name.lower().strip()
360
+
361
+ if name_key in self.people:
362
+ # Update existing
363
+ existing = self.people[name_key]
364
+ existing.times_mentioned += 1
365
+ existing.context = context
366
+ if relationship:
367
+ existing.relationship = relationship
368
+ else:
369
+ # Create new
370
+ self.people[name_key] = TrackedPerson(
371
+ name=name,
372
+ context=context,
373
+ mentioned_at=datetime.now().isoformat(),
374
+ relationship=relationship
375
+ )
376
+
377
+ def _extract_people(self, message: str):
378
+ """Extract mentioned people from a message"""
379
+ # Common name patterns
380
+ # Capitalized words that aren't sentence starters
381
+ words = message.split()
382
+
383
+ # Skip common words that might be capitalized
384
+ skip_words = {
385
+ "i", "the", "a", "an", "my", "your", "his", "her", "their",
386
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
387
+ "january", "february", "march", "april", "may", "june", "july",
388
+ "august", "september", "october", "november", "december",
389
+ "god", "christ", "jesus", "damn", "fuck", "shit", "wow",
390
+ "ok", "okay", "yeah", "yes", "no", "hey", "hi", "hello",
391
+ }
392
+
393
+ # Relationship indicators to look for
394
+ relationship_patterns = [
395
+ (r"(?:my )?(friend|bestie|best friend)\s+(\w+)", "friend"),
396
+ (r"(?:my )?(ex|ex-companion|ex-boyfriend)\s+(\w+)", "ex"),
397
+ (r"(?:my )?(mom|mother|dad|father|sister|brother)\s+(\w+)?", "family"),
398
+ (r"(?:my )?(coworker|colleague|boss)\s+(\w+)", "coworker"),
399
+ (r"(?:my )?(roommate)\s+(\w+)", "roommate"),
400
+ (r"(?:my )?(companion|boyfriend|partner)\s+(\w+)", "partner"),
401
+ ]
402
+
403
+ message_lower = message.lower()
404
+
405
+ for pattern, relationship in relationship_patterns:
406
+ matches = re.findall(pattern, message_lower)
407
+ for match in matches:
408
+ if isinstance(match, tuple):
409
+ _, name = match
410
+ else:
411
+ name = match
412
+
413
+ if name and name not in skip_words and len(name) > 1:
414
+ context = self._extract_context(message, name)
415
+ self.track_person(name.title(), context, relationship)
416
+
417
+ # -------------------------------------------------------------------------
418
+ # Callback Generation
419
+ # -------------------------------------------------------------------------
420
+
421
+ def should_callback(self) -> bool:
422
+ """
423
+ Determine if we should inject a callback.
424
+
425
+ Returns:
426
+ True if we should do a callback, False otherwise
427
+ """
428
+ # Check minimum time since last callback
429
+ if self._last_callback_time:
430
+ hours_since = (datetime.now() - self._last_callback_time).total_seconds() / 3600
431
+ if hours_since < self.MIN_HOURS_BETWEEN_CALLBACKS:
432
+ return False
433
+
434
+ # Base chance
435
+ chance = self.BASE_CALLBACK_CHANCE
436
+
437
+ # Boost if there are pending follow-ups
438
+ followup_count = sum(1 for t in self.topics.values() if t.followup_worthy)
439
+ if followup_count > 0:
440
+ chance += self.FOLLOWUP_BOOST * min(followup_count, 3) / 3
441
+
442
+ # Boost if there are people we haven't asked about in a while
443
+ stale_people = self._get_stale_people()
444
+ if stale_people:
445
+ chance += self.PERSON_BOOST
446
+
447
+ return random.random() < chance
448
+
449
+ def get_callback(self, context: Dict[str, Any] = None) -> Optional[str]:
450
+ """
451
+ Get an appropriate callback for the current context.
452
+
453
+ Args:
454
+ context: Current conversation context
455
+
456
+ Returns:
457
+ Callback string or None
458
+ """
459
+ context = context or {}
460
+ callbacks = []
461
+ weights = []
462
+
463
+ # Check for same topic callbacks
464
+ topic_callback = self._get_topic_callback(context)
465
+ if topic_callback:
466
+ callbacks.append(topic_callback)
467
+ weights.append(3) # Higher weight for relevant topic callbacks
468
+
469
+ # Check for follow-up callbacks
470
+ followup_callback = self._get_followup_callback()
471
+ if followup_callback:
472
+ callbacks.append(followup_callback)
473
+ weights.append(4) # High priority for pending follow-ups
474
+
475
+ # Check for person callbacks
476
+ person_callback = self._get_person_callback()
477
+ if person_callback:
478
+ callbacks.append(person_callback)
479
+ weights.append(2)
480
+
481
+ # Check for anniversary callbacks
482
+ anniversary_callback = self._get_anniversary_callback()
483
+ if anniversary_callback:
484
+ callbacks.append(anniversary_callback)
485
+ weights.append(5) # High priority for milestones
486
+
487
+ # Check for time context callbacks
488
+ time_callback = self._get_time_callback()
489
+ if time_callback:
490
+ callbacks.append(time_callback)
491
+ weights.append(1)
492
+
493
+ # Check for vibe callbacks (requires heart)
494
+ vibe_callback = self._get_vibe_callback(context)
495
+ if vibe_callback:
496
+ callbacks.append(vibe_callback)
497
+ weights.append(2)
498
+
499
+ if not callbacks:
500
+ return None
501
+
502
+ # Weighted random selection
503
+ callback = random.choices(callbacks, weights=weights[:len(callbacks)])[0]
504
+
505
+ # Record this callback
506
+ self._record_callback(callback)
507
+
508
+ return callback
509
+
510
+ def _get_topic_callback(self, context: Dict[str, Any]) -> Optional[str]:
511
+ """Get a callback related to the current topic"""
512
+ current_message = context.get("message", "").lower()
513
+
514
+ # Check if current message relates to any tracked topics
515
+ for topic_key, tracked in self.topics.items():
516
+ if topic_key in current_message and tracked.times_mentioned > 1:
517
+ # Check if we haven't callback'd recently
518
+ if tracked.last_callback:
519
+ last = datetime.fromisoformat(tracked.last_callback)
520
+ hours = (datetime.now() - last).total_seconds() / 3600
521
+ if hours < self.TOPIC_CALLBACK_HOURS:
522
+ continue
523
+
524
+ template = random.choice(CALLBACKS["same_topic"])
525
+ tracked.last_callback = datetime.now().isoformat()
526
+ return template
527
+
528
+ return None
529
+
530
+ def _get_followup_callback(self) -> Optional[str]:
531
+ """Get a follow-up callback for pending topics"""
532
+ followup_topics = [
533
+ t for t in self.topics.values()
534
+ if t.followup_worthy and (
535
+ not t.last_callback or
536
+ (datetime.now() - datetime.fromisoformat(t.last_callback)).total_seconds() / 3600 > 24
537
+ )
538
+ ]
539
+
540
+ if not followup_topics:
541
+ return None
542
+
543
+ topic = random.choice(followup_topics)
544
+ template = random.choice(CALLBACKS["follow_up"])
545
+
546
+ # Mark as callback'd
547
+ topic.last_callback = datetime.now().isoformat()
548
+ topic.followup_worthy = False # Reset after callback
549
+
550
+ return template
551
+
552
+ def _get_person_callback(self) -> Optional[str]:
553
+ """Get a callback about a person"""
554
+ stale_people = self._get_stale_people()
555
+
556
+ if not stale_people:
557
+ return None
558
+
559
+ person = random.choice(stale_people)
560
+ template = random.choice(CALLBACKS["callback_person"])
561
+
562
+ # Mark as callback'd
563
+ person.last_callback = datetime.now().isoformat()
564
+
565
+ return template.format(person=person.name)
566
+
567
+ def _get_stale_people(self) -> List[TrackedPerson]:
568
+ """Get people we haven't asked about in a while"""
569
+ stale = []
570
+
571
+ for person in self.people.values():
572
+ if person.last_callback:
573
+ last = datetime.fromisoformat(person.last_callback)
574
+ days = (datetime.now() - last).days
575
+ if days >= self.PERSON_CALLBACK_DAYS:
576
+ stale.append(person)
577
+ else:
578
+ # Never asked about them
579
+ mentioned = datetime.fromisoformat(person.mentioned_at)
580
+ days = (datetime.now() - mentioned).days
581
+ if days >= 1: # At least a day since they were mentioned
582
+ stale.append(person)
583
+
584
+ return stale
585
+
586
+ def _get_anniversary_callback(self) -> Optional[str]:
587
+ """Get an anniversary callback if applicable"""
588
+ if not self.first_conversation:
589
+ return None
590
+
591
+ first = datetime.fromisoformat(self.first_conversation)
592
+ days = (datetime.now() - first).days
593
+
594
+ # Check if today is an anniversary
595
+ if days not in self.ANNIVERSARY_DAYS:
596
+ return None
597
+
598
+ # Format time string
599
+ if days == 7:
600
+ time_str = "a week"
601
+ elif days == 30:
602
+ time_str = "a month"
603
+ elif days == 90:
604
+ time_str = "3 months"
605
+ elif days == 180:
606
+ time_str = "6 months"
607
+ elif days == 365:
608
+ time_str = "a whole year"
609
+ else:
610
+ time_str = f"{days} days"
611
+
612
+ template = random.choice(CALLBACKS["anniversary"])
613
+ return template.format(time=time_str)
614
+
615
+ def _get_time_callback(self) -> Optional[str]:
616
+ """Get a time-of-day based callback"""
617
+ hour = datetime.now().hour
618
+
619
+ # Only do time callbacks occasionally
620
+ if random.random() > 0.1:
621
+ return None
622
+
623
+ # Late night (past midnight)
624
+ if 0 <= hour < 5:
625
+ return "you're up late again"
626
+ # Early morning
627
+ elif 5 <= hour < 9:
628
+ return random.choice(["early bird today huh", "you're up early"])
629
+ # Usual patterns (evening)
630
+ elif 18 <= hour < 22:
631
+ if random.random() > 0.7:
632
+ return "this is about when you usually message me"
633
+
634
+ return None
635
+
636
+ def _get_vibe_callback(self, context: Dict[str, Any]) -> Optional[str]:
637
+ """Get a callback based on emotional state changes"""
638
+ if not self.heart:
639
+ return None
640
+
641
+ # Only do vibe callbacks occasionally
642
+ if random.random() > 0.15:
643
+ return None
644
+
645
+ # Get current emotion state
646
+ state = self.heart.get_state()
647
+
648
+ # Check if we can get memory context
649
+ if hasattr(self.heart, 'memory') and self.heart.memory:
650
+ mood_ctx = self.heart.memory.get_mood_context()
651
+ if mood_ctx:
652
+ current_mood = state.get("mood", "neutral")
653
+
654
+ # If they seem happier than usual
655
+ if state.get("joy", 0) > 0.6 and "down" in mood_ctx.lower():
656
+ return random.choice([
657
+ "you seem happier today than last time",
658
+ "glad to see you in better spirits",
659
+ ])
660
+
661
+ # If they seem down when usually happy
662
+ if state.get("sadness", 0) > 0.5 and "happy" in mood_ctx.lower():
663
+ return "you were doing so good last time - everything ok?"
664
+
665
+ return None
666
+
667
+ def _record_callback(self, callback: str):
668
+ """Record that we did a callback"""
669
+ self._last_callback_time = datetime.now()
670
+
671
+ self.callback_history.append({
672
+ "callback": callback,
673
+ "timestamp": datetime.now().isoformat(),
674
+ })
675
+
676
+ self._save()
677
+
678
+ # -------------------------------------------------------------------------
679
+ # Public API
680
+ # -------------------------------------------------------------------------
681
+
682
+ def get_pending_callback(self) -> Optional[str]:
683
+ """Get the pending callback (if any) from the last thinking cycle"""
684
+ return self._pending_callback
685
+
686
+ def clear_pending_callback(self):
687
+ """Clear the pending callback after it's been used"""
688
+ self._pending_callback = None
689
+
690
+ def get_stats(self) -> Dict[str, Any]:
691
+ """Get statistics about tracked data"""
692
+ followup_count = sum(1 for t in self.topics.values() if t.followup_worthy)
693
+ stale_people_count = len(self._get_stale_people())
694
+
695
+ # Calculate relationship duration
696
+ duration_days = 0
697
+ if self.first_conversation:
698
+ first = datetime.fromisoformat(self.first_conversation)
699
+ duration_days = (datetime.now() - first).days
700
+
701
+ return {
702
+ "total_conversations": self.total_conversations,
703
+ "tracked_topics": len(self.topics),
704
+ "tracked_people": len(self.people),
705
+ "pending_followups": followup_count,
706
+ "stale_people": stale_people_count,
707
+ "relationship_days": duration_days,
708
+ "total_callbacks": len(self.callback_history),
709
+ }
710
+
711
+ def get_context_for_response(self) -> Optional[str]:
712
+ """
713
+ Get contextual callback to potentially include in a response.
714
+ This is the main method to call when generating a response.
715
+
716
+ Returns:
717
+ A callback string to include, or None
718
+ """
719
+ callback = self.get_pending_callback()
720
+ if callback:
721
+ self.clear_pending_callback()
722
+ return callback
723
+ return None
724
+
725
+ def reset_topic(self, topic: str):
726
+ """Reset a topic's tracking data"""
727
+ topic_key = topic.lower().strip()
728
+ if topic_key in self.topics:
729
+ del self.topics[topic_key]
730
+ self._save()
731
+
732
+ def reset_person(self, name: str):
733
+ """Reset a person's tracking data"""
734
+ name_key = name.lower().strip()
735
+ if name_key in self.people:
736
+ del self.people[name_key]
737
+ self._save()
738
+
739
+ def clear_all(self):
740
+ """Clear all tracking data"""
741
+ self.topics.clear()
742
+ self.people.clear()
743
+ self.callback_history.clear()
744
+ self.first_conversation = None
745
+ self.total_conversations = 0
746
+ self._last_callback_time = None
747
+ self._pending_callback = None
748
+ self._save()