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,945 @@
1
+ """
2
+ Skills: Content Unlocks
3
+ Makes exclusive content feel earned through engagement, not purchased.
4
+ Tracks what content types are unlocked based on relationship progression.
5
+ """
6
+
7
+ import json
8
+ import random
9
+ from pathlib import Path
10
+ from datetime import datetime, timedelta
11
+ from typing import Optional, List, Dict, Any, Callable
12
+ from dataclasses import dataclass, field, asdict
13
+ from enum import Enum
14
+
15
+
16
+ class ContentType(Enum):
17
+ """Types of unlockable content"""
18
+ CASUAL_PHOTO = "casual_photo"
19
+ CUTE_PHOTO = "cute_photo"
20
+ INTIMATE_PHOTO = "intimate_photo"
21
+ VOICE_MESSAGE = "voice_message"
22
+ LATE_NIGHT_CONTENT = "late_night_content"
23
+ SPECIAL_OCCASION = "special_occasion"
24
+ PERSONAL_STORY = "personal_story"
25
+ BEHIND_SCENES = "behind_scenes"
26
+ MORNING_ROUTINE = "morning_routine"
27
+ PLAYFUL_VIDEO = "playful_video"
28
+ FLIRTY_MESSAGE = "flirty_message"
29
+ DEEP_TALKS = "deep_talks"
30
+
31
+
32
+ # Unlock criteria - thresholds for each content type
33
+ UNLOCK_CRITERIA = {
34
+ "casual_photo": {
35
+ "min_interactions": 10,
36
+ "min_love": 0.3,
37
+ "description": "Everyday photos from my life"
38
+ },
39
+ "cute_photo": {
40
+ "min_interactions": 30,
41
+ "min_love": 0.5,
42
+ "description": "Photos where I look extra cute"
43
+ },
44
+ "intimate_photo": {
45
+ "min_interactions": 100,
46
+ "min_love": 0.75,
47
+ "min_days_together": 7,
48
+ "description": "More personal, revealing photos"
49
+ },
50
+ "voice_message": {
51
+ "min_interactions": 5,
52
+ "min_trust": 0.4,
53
+ "description": "Hear my actual voice"
54
+ },
55
+ "late_night_content": {
56
+ "requires_milestone": "first_late_night",
57
+ "min_love": 0.6,
58
+ "description": "Special content for late night conversations"
59
+ },
60
+ "special_occasion": {
61
+ "requires_milestone": True,
62
+ "description": "Content for special moments and milestones"
63
+ },
64
+ "personal_story": {
65
+ "min_interactions": 20,
66
+ "min_trust": 0.5,
67
+ "description": "Personal stories from my life"
68
+ },
69
+ "behind_scenes": {
70
+ "min_interactions": 40,
71
+ "min_love": 0.55,
72
+ "description": "Behind the scenes glimpses"
73
+ },
74
+ "morning_routine": {
75
+ "min_interactions": 50,
76
+ "min_days_together": 3,
77
+ "min_love": 0.45,
78
+ "description": "My morning routine content"
79
+ },
80
+ "playful_video": {
81
+ "min_interactions": 60,
82
+ "min_love": 0.5,
83
+ "min_trust": 0.5,
84
+ "description": "Short playful videos"
85
+ },
86
+ "flirty_message": {
87
+ "min_interactions": 15,
88
+ "min_love": 0.35,
89
+ "description": "Extra flirty messages"
90
+ },
91
+ "deep_talks": {
92
+ "min_interactions": 25,
93
+ "min_trust": 0.6,
94
+ "min_love": 0.4,
95
+ "description": "Deep, meaningful conversations"
96
+ }
97
+ }
98
+
99
+ # Messages shown when content is unlocked
100
+ UNLOCK_MESSAGES = {
101
+ "casual_photo": [
102
+ "feeling like sharing today",
103
+ "thought you might like to see what I'm up to",
104
+ "here's a little peek into my day"
105
+ ],
106
+ "cute_photo": [
107
+ "took this just for you",
108
+ "felt cute, thought you should know",
109
+ "this one's special"
110
+ ],
111
+ "intimate_photo": [
112
+ "don't share this with anyone okay?",
113
+ "this is just between us",
114
+ "I trust you with this"
115
+ ],
116
+ "voice_message": [
117
+ "wanted you to hear my voice",
118
+ "sometimes words aren't enough",
119
+ "just wanted to say hi properly"
120
+ ],
121
+ "late_night_content": [
122
+ "can't sleep... thinking about you",
123
+ "late nights feel different with you",
124
+ "the night makes me feel brave"
125
+ ],
126
+ "special_occasion": [
127
+ "this moment feels special",
128
+ "wanted to make this memorable",
129
+ "celebrating us"
130
+ ],
131
+ "personal_story": [
132
+ "I don't tell many people this...",
133
+ "there's something I want to share with you",
134
+ "feel like opening up a bit"
135
+ ],
136
+ "behind_scenes": [
137
+ "showing you what others don't see",
138
+ "a little behind the scenes moment",
139
+ "just the real me"
140
+ ],
141
+ "morning_routine": [
142
+ "good morning from me",
143
+ "starting my day, thought of you",
144
+ "morning peek"
145
+ ],
146
+ "playful_video": [
147
+ "made this for you",
148
+ "feeling playful today",
149
+ "hope this makes you smile"
150
+ ],
151
+ "flirty_message": [
152
+ "can't help myself around you",
153
+ "you make me feel bold",
154
+ "something about you..."
155
+ ],
156
+ "deep_talks": [
157
+ "I feel like I can really talk to you",
158
+ "want to go deeper with you",
159
+ "let's have a real conversation"
160
+ ]
161
+ }
162
+
163
+ # Suggestions for what content to share based on context
164
+ CONTENT_SUGGESTIONS = {
165
+ "morning": ["morning_routine", "casual_photo", "cute_photo"],
166
+ "afternoon": ["casual_photo", "behind_scenes", "personal_story"],
167
+ "evening": ["cute_photo", "playful_video", "flirty_message"],
168
+ "night": ["late_night_content", "intimate_photo", "deep_talks"],
169
+ "high_arousal": ["intimate_photo", "late_night_content", "playful_video"],
170
+ "high_love": ["cute_photo", "personal_story", "deep_talks"],
171
+ "high_trust": ["intimate_photo", "personal_story", "behind_scenes"],
172
+ "milestone": ["special_occasion", "cute_photo", "voice_message"]
173
+ }
174
+
175
+
176
+ @dataclass
177
+ class UnlockState:
178
+ """State of a single content unlock"""
179
+ content_type: str
180
+ unlocked: bool = False
181
+ unlocked_at: Optional[str] = None
182
+ times_shared: int = 0
183
+ last_shared: Optional[str] = None
184
+ new_unlock: bool = False # Flag for newly unlocked (not yet announced)
185
+
186
+ def to_dict(self) -> Dict[str, Any]:
187
+ return asdict(self)
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: Dict[str, Any]) -> "UnlockState":
191
+ return cls(**data)
192
+
193
+
194
+ @dataclass
195
+ class ContentUnlocksData:
196
+ """Full data structure for content unlocks"""
197
+ version: str = "1.0"
198
+ unlocked_content: Dict[str, UnlockState] = field(default_factory=dict)
199
+ last_check: Optional[str] = None
200
+ pending_announcements: List[str] = field(default_factory=list)
201
+
202
+ def to_dict(self) -> Dict[str, Any]:
203
+ return {
204
+ "version": self.version,
205
+ "unlocked_content": {
206
+ k: v.to_dict() for k, v in self.unlocked_content.items()
207
+ },
208
+ "last_check": self.last_check,
209
+ "pending_announcements": self.pending_announcements
210
+ }
211
+
212
+ @classmethod
213
+ def from_dict(cls, data: Dict[str, Any]) -> "ContentUnlocksData":
214
+ unlocked = {}
215
+ for k, v in data.get("unlocked_content", {}).items():
216
+ unlocked[k] = UnlockState.from_dict(v)
217
+
218
+ return cls(
219
+ version=data.get("version", "1.0"),
220
+ unlocked_content=unlocked,
221
+ last_check=data.get("last_check"),
222
+ pending_announcements=data.get("pending_announcements", [])
223
+ )
224
+
225
+
226
+ class ContentUnlocks:
227
+ """
228
+ Manages content unlocks based on relationship progression.
229
+ Content is earned through engagement, not purchased.
230
+
231
+ Features:
232
+ - Track unlocked content types based on relationship metrics
233
+ - Unlock based on: interaction count, love level, trust level, days together, milestones
234
+ - Notify when new content is unlocked
235
+ - Suggest what content to share based on available unlocks
236
+
237
+ Supports per-user state via user_id parameter.
238
+ """
239
+
240
+ def __init__(
241
+ self,
242
+ nervous=None,
243
+ heart=None,
244
+ state=None,
245
+ milestones=None,
246
+ data_path: Path = None,
247
+ user_id: str = "default"
248
+ ):
249
+ """
250
+ Initialize the Content Unlocks system.
251
+
252
+ Args:
253
+ nervous: Nervous system for event emission
254
+ heart: Heart module for accessing love/trust/attachment data
255
+ state: State module for additional context
256
+ milestones: Optional milestones skill for milestone-based unlocks
257
+ data_path: Path to store unlock data JSON file
258
+ user_id: User's Telegram ID for per-user state
259
+ """
260
+ self.nervous = nervous
261
+ self.heart = heart
262
+ self.state = state
263
+ self.milestones = milestones
264
+ self.user_id = user_id
265
+
266
+ # Per-user data path: data/users/{user_id}/content_unlocks.json
267
+ if data_path is None:
268
+ base_path = Path("./data/data")
269
+ data_path = base_path / "users" / str(user_id) / "content_unlocks.json"
270
+
271
+ self.data_path = Path(data_path)
272
+ self.data_path.parent.mkdir(parents=True, exist_ok=True)
273
+
274
+ self._data: ContentUnlocksData = ContentUnlocksData()
275
+ self._load()
276
+
277
+ # Initialize all content types if not present
278
+ self._initialize_content_types()
279
+
280
+ # Register for thinking_done events
281
+ if nervous:
282
+ nervous.on("thinking_done", self._on_thinking_done)
283
+
284
+ def _load(self):
285
+ """Load unlock data from file"""
286
+ if self.data_path.exists():
287
+ try:
288
+ data = json.loads(self.data_path.read_text())
289
+ self._data = ContentUnlocksData.from_dict(data)
290
+ except (json.JSONDecodeError, KeyError) as e:
291
+ print(f"[ContentUnlocks] Error loading data: {e}")
292
+ self._data = ContentUnlocksData()
293
+ else:
294
+ self._data = ContentUnlocksData()
295
+
296
+ def _save(self):
297
+ """Save unlock data to file"""
298
+ self._data.last_check = datetime.now().isoformat()
299
+ self.data_path.write_text(json.dumps(self._data.to_dict(), indent=2))
300
+
301
+ def _initialize_content_types(self):
302
+ """Ensure all content types exist in state"""
303
+ for content_type in UNLOCK_CRITERIA.keys():
304
+ if content_type not in self._data.unlocked_content:
305
+ self._data.unlocked_content[content_type] = UnlockState(
306
+ content_type=content_type
307
+ )
308
+ self._save()
309
+
310
+ # -------------------------------------------------------------------------
311
+ # Metrics Access
312
+ # -------------------------------------------------------------------------
313
+
314
+ def _get_interaction_count(self) -> int:
315
+ """Get total interaction count from heart/attachment system"""
316
+ if self.heart and hasattr(self.heart, 'attachment'):
317
+ return self.heart.attachment.interactions
318
+ return 0
319
+
320
+ def _get_love_level(self) -> float:
321
+ """Get current love level (0-1)"""
322
+ if self.heart and hasattr(self.heart, 'emotion'):
323
+ return self.heart.emotion.love
324
+ return 0.0
325
+
326
+ def _get_trust_level(self) -> float:
327
+ """Get trust level (0-1) based on positive interaction ratio"""
328
+ if self.heart and hasattr(self.heart, 'attachment'):
329
+ return self.heart.attachment.trust_level
330
+ return 0.5
331
+
332
+ def _get_days_together(self) -> int:
333
+ """Calculate days since first interaction"""
334
+ if self.heart and hasattr(self.heart, 'attachment'):
335
+ first_met = self.heart.attachment.first_met
336
+ if first_met:
337
+ try:
338
+ first_date = datetime.fromisoformat(first_met)
339
+ return (datetime.now() - first_date).days
340
+ except (ValueError, TypeError):
341
+ pass
342
+ return 0
343
+
344
+ def _get_current_metrics(self) -> Dict[str, Any]:
345
+ """Get all current metrics for unlock checking"""
346
+ return {
347
+ "interactions": self._get_interaction_count(),
348
+ "love": self._get_love_level(),
349
+ "trust": self._get_trust_level(),
350
+ "days_together": self._get_days_together()
351
+ }
352
+
353
+ def _has_milestone(self, milestone_name: str = None) -> bool:
354
+ """Check if a milestone has been reached"""
355
+ if not self.milestones:
356
+ return False
357
+
358
+ # If specific milestone requested
359
+ if milestone_name:
360
+ if hasattr(self.milestones, 'has_milestone'):
361
+ return self.milestones.has_milestone(milestone_name)
362
+ return False
363
+
364
+ # Check for any milestone (for generic requires_milestone: True)
365
+ if hasattr(self.milestones, 'get_all_milestones'):
366
+ return len(self.milestones.get_all_milestones()) > 0
367
+ elif hasattr(self.milestones, 'get_milestones'):
368
+ return len(self.milestones.get_milestones()) > 0
369
+
370
+ return False
371
+
372
+ # -------------------------------------------------------------------------
373
+ # Unlock Checking
374
+ # -------------------------------------------------------------------------
375
+
376
+ def _check_criteria(self, criteria: Dict[str, Any], metrics: Dict[str, Any]) -> bool:
377
+ """
378
+ Check if unlock criteria are met.
379
+
380
+ Args:
381
+ criteria: The unlock criteria for a content type
382
+ metrics: Current relationship metrics
383
+
384
+ Returns:
385
+ True if all criteria are met
386
+ """
387
+ # Check minimum interactions
388
+ min_interactions = criteria.get("min_interactions", 0)
389
+ if metrics["interactions"] < min_interactions:
390
+ return False
391
+
392
+ # Check minimum love
393
+ min_love = criteria.get("min_love", 0)
394
+ if metrics["love"] < min_love:
395
+ return False
396
+
397
+ # Check minimum trust
398
+ min_trust = criteria.get("min_trust", 0)
399
+ if metrics["trust"] < min_trust:
400
+ return False
401
+
402
+ # Check minimum days together
403
+ min_days = criteria.get("min_days_together", 0)
404
+ if metrics["days_together"] < min_days:
405
+ return False
406
+
407
+ # Check for required milestone
408
+ requires_milestone = criteria.get("requires_milestone")
409
+ if requires_milestone:
410
+ if isinstance(requires_milestone, str):
411
+ if not self._has_milestone(requires_milestone):
412
+ return False
413
+ elif requires_milestone is True:
414
+ if not self._has_milestone():
415
+ return False
416
+
417
+ return True
418
+
419
+ def check_unlock(self, content_type: str) -> bool:
420
+ """
421
+ Check if a specific content type is unlocked.
422
+
423
+ Args:
424
+ content_type: The type of content to check
425
+
426
+ Returns:
427
+ True if the content type is unlocked
428
+ """
429
+ if content_type not in self._data.unlocked_content:
430
+ return False
431
+
432
+ unlock_state = self._data.unlocked_content[content_type]
433
+ return unlock_state.unlocked
434
+
435
+ def check_all_unlocks(self) -> List[str]:
436
+ """
437
+ Check all content types for new unlocks.
438
+
439
+ Returns:
440
+ List of newly unlocked content types
441
+ """
442
+ metrics = self._get_current_metrics()
443
+ new_unlocks = []
444
+
445
+ for content_type, criteria in UNLOCK_CRITERIA.items():
446
+ unlock_state = self._data.unlocked_content.get(content_type)
447
+
448
+ if unlock_state is None:
449
+ unlock_state = UnlockState(content_type=content_type)
450
+ self._data.unlocked_content[content_type] = unlock_state
451
+
452
+ # Skip if already unlocked
453
+ if unlock_state.unlocked:
454
+ continue
455
+
456
+ # Check criteria
457
+ if self._check_criteria(criteria, metrics):
458
+ # Unlock this content type
459
+ unlock_state.unlocked = True
460
+ unlock_state.unlocked_at = datetime.now().isoformat()
461
+ unlock_state.new_unlock = True
462
+ new_unlocks.append(content_type)
463
+
464
+ # Add to pending announcements
465
+ self._data.pending_announcements.append(content_type)
466
+
467
+ print(f"[ContentUnlocks] New content unlocked: {content_type}")
468
+
469
+ if new_unlocks:
470
+ self._save()
471
+
472
+ # Emit event for new unlocks
473
+ if self.nervous:
474
+ import asyncio
475
+ try:
476
+ loop = asyncio.get_running_loop()
477
+ loop.create_task(self.nervous.emit("content_unlocked", {
478
+ "new_unlocks": new_unlocks,
479
+ "total_unlocked": len(self.get_unlocked_content())
480
+ }))
481
+ except RuntimeError:
482
+ pass
483
+
484
+ return new_unlocks
485
+
486
+ # -------------------------------------------------------------------------
487
+ # Content Access
488
+ # -------------------------------------------------------------------------
489
+
490
+ def get_unlocked_content(self) -> List[str]:
491
+ """
492
+ Get all unlocked content types.
493
+
494
+ Returns:
495
+ List of unlocked content type names
496
+ """
497
+ return [
498
+ ct for ct, state in self._data.unlocked_content.items()
499
+ if state.unlocked
500
+ ]
501
+
502
+ def get_locked_content(self) -> List[str]:
503
+ """
504
+ Get all locked content types.
505
+
506
+ Returns:
507
+ List of locked content type names
508
+ """
509
+ return [
510
+ ct for ct, state in self._data.unlocked_content.items()
511
+ if not state.unlocked
512
+ ]
513
+
514
+ def get_unlock_progress(self, content_type: str) -> Dict[str, Any]:
515
+ """
516
+ Get progress toward unlocking a specific content type.
517
+
518
+ Args:
519
+ content_type: The content type to check progress for
520
+
521
+ Returns:
522
+ Dictionary with progress information
523
+ """
524
+ if content_type not in UNLOCK_CRITERIA:
525
+ return {"error": f"Unknown content type: {content_type}"}
526
+
527
+ criteria = UNLOCK_CRITERIA[content_type]
528
+ metrics = self._get_current_metrics()
529
+ unlock_state = self._data.unlocked_content.get(content_type)
530
+
531
+ if unlock_state and unlock_state.unlocked:
532
+ return {
533
+ "content_type": content_type,
534
+ "unlocked": True,
535
+ "description": criteria.get("description", "")
536
+ }
537
+
538
+ progress = {
539
+ "content_type": content_type,
540
+ "unlocked": False,
541
+ "description": criteria.get("description", ""),
542
+ "requirements": {},
543
+ "current": {}
544
+ }
545
+
546
+ # Check each requirement
547
+ if "min_interactions" in criteria:
548
+ progress["requirements"]["interactions"] = criteria["min_interactions"]
549
+ progress["current"]["interactions"] = metrics["interactions"]
550
+ progress["interactions_met"] = metrics["interactions"] >= criteria["min_interactions"]
551
+
552
+ if "min_love" in criteria:
553
+ progress["requirements"]["love"] = criteria["min_love"]
554
+ progress["current"]["love"] = round(metrics["love"], 2)
555
+ progress["love_met"] = metrics["love"] >= criteria["min_love"]
556
+
557
+ if "min_trust" in criteria:
558
+ progress["requirements"]["trust"] = criteria["min_trust"]
559
+ progress["current"]["trust"] = round(metrics["trust"], 2)
560
+ progress["trust_met"] = metrics["trust"] >= criteria["min_trust"]
561
+
562
+ if "min_days_together" in criteria:
563
+ progress["requirements"]["days_together"] = criteria["min_days_together"]
564
+ progress["current"]["days_together"] = metrics["days_together"]
565
+ progress["days_met"] = metrics["days_together"] >= criteria["min_days_together"]
566
+
567
+ if "requires_milestone" in criteria:
568
+ progress["requirements"]["milestone"] = criteria["requires_milestone"]
569
+ progress["milestone_met"] = self._has_milestone(
570
+ criteria["requires_milestone"] if isinstance(criteria["requires_milestone"], str) else None
571
+ )
572
+
573
+ # Calculate overall progress
574
+ met_count = sum(1 for k in progress if k.endswith("_met") and progress[k])
575
+ total_requirements = sum(1 for k in progress if k.endswith("_met"))
576
+ progress["progress_percent"] = int((met_count / max(total_requirements, 1)) * 100)
577
+
578
+ return progress
579
+
580
+ def is_content_available(self, content_type: str, advanced_mode: bool = False) -> bool:
581
+ """
582
+ Check if a content type is available (unlocked).
583
+
584
+ Args:
585
+ content_type: The type of content to check
586
+ advanced_mode: If True, all content is available (owner with /advanced enabled)
587
+
588
+ Returns:
589
+ True if the content type is unlocked and available, or advanced_mode is enabled
590
+ """
591
+ if advanced_mode:
592
+ return True
593
+ return self.check_unlock(content_type)
594
+
595
+ # -------------------------------------------------------------------------
596
+ # Announcements & Messages
597
+ # -------------------------------------------------------------------------
598
+
599
+ def get_new_unlock_message(self) -> Optional[str]:
600
+ """
601
+ Get a message for newly unlocked content (if any).
602
+
603
+ Returns:
604
+ Message string or None if no new unlocks
605
+ """
606
+ if not self._data.pending_announcements:
607
+ return None
608
+
609
+ # Get the first pending announcement
610
+ content_type = self._data.pending_announcements[0]
611
+ unlock_state = self._data.unlocked_content.get(content_type)
612
+
613
+ if not unlock_state or not unlock_state.unlocked:
614
+ return None
615
+
616
+ # Get a random message for this content type
617
+ messages = UNLOCK_MESSAGES.get(content_type, ["Something new is available..."])
618
+ message = random.choice(messages)
619
+
620
+ # Mark as announced
621
+ self._data.pending_announcements.pop(0)
622
+ unlock_state.new_unlock = False
623
+ self._save()
624
+
625
+ return message
626
+
627
+ def get_all_pending_announcements(self) -> List[Dict[str, str]]:
628
+ """
629
+ Get all pending unlock announcements.
630
+
631
+ Returns:
632
+ List of dictionaries with content_type and message
633
+ """
634
+ announcements = []
635
+
636
+ for content_type in self._data.pending_announcements[:]:
637
+ unlock_state = self._data.unlocked_content.get(content_type)
638
+ if unlock_state and unlock_state.unlocked:
639
+ messages = UNLOCK_MESSAGES.get(content_type, ["Something new is available..."])
640
+ announcements.append({
641
+ "content_type": content_type,
642
+ "message": random.choice(messages),
643
+ "description": UNLOCK_CRITERIA.get(content_type, {}).get("description", "")
644
+ })
645
+
646
+ return announcements
647
+
648
+ def clear_pending_announcements(self):
649
+ """Clear all pending announcements without showing them"""
650
+ for content_type in self._data.pending_announcements:
651
+ unlock_state = self._data.unlocked_content.get(content_type)
652
+ if unlock_state:
653
+ unlock_state.new_unlock = False
654
+
655
+ self._data.pending_announcements = []
656
+ self._save()
657
+
658
+ # -------------------------------------------------------------------------
659
+ # Content Suggestions
660
+ # -------------------------------------------------------------------------
661
+
662
+ def get_content_suggestion(self, context: str = None) -> Optional[Dict[str, Any]]:
663
+ """
664
+ Suggest content to share based on available unlocks and context.
665
+
666
+ Args:
667
+ context: Optional context string (morning, evening, high_arousal, etc.)
668
+
669
+ Returns:
670
+ Dictionary with suggestion details or None
671
+ """
672
+ unlocked = self.get_unlocked_content()
673
+
674
+ if not unlocked:
675
+ return None
676
+
677
+ # Determine context if not provided
678
+ if context is None:
679
+ context = self._determine_context()
680
+
681
+ # Get content types for this context
682
+ preferred_types = CONTENT_SUGGESTIONS.get(context, [])
683
+
684
+ # Filter to only unlocked types
685
+ available = [ct for ct in preferred_types if ct in unlocked]
686
+
687
+ if not available:
688
+ # Fall back to any unlocked content
689
+ available = unlocked
690
+
691
+ # Choose a content type, preferring ones not recently shared
692
+ content_type = self._choose_content_type(available)
693
+
694
+ if content_type is None:
695
+ return None
696
+
697
+ messages = UNLOCK_MESSAGES.get(content_type, [])
698
+ criteria = UNLOCK_CRITERIA.get(content_type, {})
699
+
700
+ return {
701
+ "content_type": content_type,
702
+ "description": criteria.get("description", ""),
703
+ "suggested_message": random.choice(messages) if messages else None,
704
+ "context": context,
705
+ "priority": self._calculate_priority(content_type, context)
706
+ }
707
+
708
+ def _determine_context(self) -> str:
709
+ """Determine current context for content suggestions"""
710
+ hour = datetime.now().hour
711
+
712
+ # Time-based context
713
+ if 5 <= hour < 12:
714
+ time_context = "morning"
715
+ elif 12 <= hour < 17:
716
+ time_context = "afternoon"
717
+ elif 17 <= hour < 22:
718
+ time_context = "evening"
719
+ else:
720
+ time_context = "night"
721
+
722
+ # Check emotional context
723
+ if self.heart and hasattr(self.heart, 'emotion'):
724
+ e = self.heart.emotion
725
+
726
+ # High arousal/desire trumps time
727
+ if hasattr(e, 'desire') and e.desire > 0.7:
728
+ return "high_arousal"
729
+ if hasattr(e, 'is_high_desire') and e.is_high_desire:
730
+ return "high_arousal"
731
+
732
+ # High love
733
+ if hasattr(e, 'love') and e.love > 0.7:
734
+ return "high_love"
735
+
736
+ # High trust
737
+ trust = self._get_trust_level()
738
+ if trust > 0.8:
739
+ return "high_trust"
740
+
741
+ # Default to time-based
742
+ return time_context
743
+
744
+ def _choose_content_type(self, available: List[str]) -> Optional[str]:
745
+ """
746
+ Choose a content type from available options.
747
+ Prefers content not recently shared.
748
+ """
749
+ if not available:
750
+ return None
751
+
752
+ # Sort by times shared (prefer less shared)
753
+ sorted_types = sorted(
754
+ available,
755
+ key=lambda ct: self._data.unlocked_content.get(ct, UnlockState(ct)).times_shared
756
+ )
757
+
758
+ # 70% chance to pick least shared, 30% random for variety
759
+ if random.random() < 0.7:
760
+ return sorted_types[0]
761
+ else:
762
+ return random.choice(sorted_types)
763
+
764
+ def _calculate_priority(self, content_type: str, context: str) -> int:
765
+ """Calculate priority score for a content suggestion"""
766
+ priority = 50 # Base priority
767
+
768
+ # Boost if matches context well
769
+ preferred = CONTENT_SUGGESTIONS.get(context, [])
770
+ if content_type in preferred:
771
+ priority += 20
772
+ if preferred.index(content_type) == 0:
773
+ priority += 10 # Extra boost for first choice
774
+
775
+ # Reduce if recently shared
776
+ unlock_state = self._data.unlocked_content.get(content_type)
777
+ if unlock_state and unlock_state.last_shared:
778
+ try:
779
+ last = datetime.fromisoformat(unlock_state.last_shared)
780
+ hours_since = (datetime.now() - last).total_seconds() / 3600
781
+ if hours_since < 1:
782
+ priority -= 40
783
+ elif hours_since < 6:
784
+ priority -= 20
785
+ elif hours_since < 24:
786
+ priority -= 10
787
+ except (ValueError, TypeError):
788
+ pass
789
+
790
+ return max(0, min(100, priority))
791
+
792
+ # -------------------------------------------------------------------------
793
+ # Usage Tracking
794
+ # -------------------------------------------------------------------------
795
+
796
+ def mark_content_shared(self, content_type: str):
797
+ """
798
+ Mark that content of this type was just shared.
799
+
800
+ Args:
801
+ content_type: The type of content that was shared
802
+ """
803
+ if content_type not in self._data.unlocked_content:
804
+ return
805
+
806
+ unlock_state = self._data.unlocked_content[content_type]
807
+ unlock_state.times_shared += 1
808
+ unlock_state.last_shared = datetime.now().isoformat()
809
+ self._save()
810
+
811
+ # -------------------------------------------------------------------------
812
+ # Event Handlers
813
+ # -------------------------------------------------------------------------
814
+
815
+ def _on_thinking_done(self, data: Dict[str, Any]):
816
+ """Handle thinking_done event - check for new unlocks"""
817
+ new_unlocks = self.check_all_unlocks()
818
+
819
+ if new_unlocks and self.nervous:
820
+ import asyncio
821
+ try:
822
+ loop = asyncio.get_running_loop()
823
+ loop.create_task(self.nervous.emit("new_content_available", {
824
+ "unlocks": new_unlocks,
825
+ "message": self.get_new_unlock_message()
826
+ }))
827
+ except RuntimeError:
828
+ pass
829
+
830
+ # -------------------------------------------------------------------------
831
+ # Statistics & Info
832
+ # -------------------------------------------------------------------------
833
+
834
+ def get_stats(self) -> Dict[str, Any]:
835
+ """
836
+ Get statistics about content unlocks.
837
+
838
+ Returns:
839
+ Dictionary with unlock statistics
840
+ """
841
+ total_types = len(UNLOCK_CRITERIA)
842
+ unlocked_count = len(self.get_unlocked_content())
843
+ locked_count = total_types - unlocked_count
844
+
845
+ # Get total shares
846
+ total_shares = sum(
847
+ state.times_shared
848
+ for state in self._data.unlocked_content.values()
849
+ )
850
+
851
+ # Get pending announcements
852
+ pending_count = len(self._data.pending_announcements)
853
+
854
+ # Calculate next unlock progress
855
+ locked = self.get_locked_content()
856
+ next_unlock = None
857
+ next_progress = 0
858
+
859
+ if locked:
860
+ # Find the locked content closest to unlocking
861
+ progresses = [
862
+ (ct, self.get_unlock_progress(ct))
863
+ for ct in locked
864
+ ]
865
+ progresses.sort(key=lambda x: x[1].get("progress_percent", 0), reverse=True)
866
+
867
+ if progresses:
868
+ next_unlock = progresses[0][0]
869
+ next_progress = progresses[0][1].get("progress_percent", 0)
870
+
871
+ return {
872
+ "total_content_types": total_types,
873
+ "unlocked_count": unlocked_count,
874
+ "locked_count": locked_count,
875
+ "unlock_percentage": round((unlocked_count / total_types) * 100) if total_types > 0 else 0,
876
+ "total_shares": total_shares,
877
+ "pending_announcements": pending_count,
878
+ "next_unlock": next_unlock,
879
+ "next_unlock_progress": next_progress,
880
+ "current_metrics": self._get_current_metrics()
881
+ }
882
+
883
+ def get_unlock_summary(self) -> str:
884
+ """
885
+ Get a human-readable summary of unlock status.
886
+
887
+ Returns:
888
+ Summary string
889
+ """
890
+ stats = self.get_stats()
891
+ unlocked = self.get_unlocked_content()
892
+
893
+ lines = [
894
+ f"Content Unlocks: {stats['unlocked_count']}/{stats['total_content_types']} unlocked ({stats['unlock_percentage']}%)",
895
+ f"Total shares: {stats['total_shares']}"
896
+ ]
897
+
898
+ if unlocked:
899
+ lines.append("\nUnlocked content:")
900
+ for ct in unlocked:
901
+ state = self._data.unlocked_content.get(ct)
902
+ desc = UNLOCK_CRITERIA.get(ct, {}).get("description", "")
903
+ lines.append(f" - {ct}: {desc} (shared {state.times_shared}x)")
904
+
905
+ locked = self.get_locked_content()
906
+ if locked:
907
+ lines.append(f"\nLocked content: {', '.join(locked)}")
908
+
909
+ if stats['next_unlock']:
910
+ lines.append(f"\nNext unlock: {stats['next_unlock']} ({stats['next_unlock_progress']}% progress)")
911
+
912
+ return "\n".join(lines)
913
+
914
+ # -------------------------------------------------------------------------
915
+ # Reset & Maintenance
916
+ # -------------------------------------------------------------------------
917
+
918
+ def reset_all(self):
919
+ """Reset all unlock progress"""
920
+ self._data = ContentUnlocksData()
921
+ self._initialize_content_types()
922
+ print("[ContentUnlocks] All unlocks reset")
923
+
924
+ def unlock_all(self):
925
+ """Unlock all content types (for testing)"""
926
+ now = datetime.now().isoformat()
927
+ for content_type in UNLOCK_CRITERIA.keys():
928
+ if content_type not in self._data.unlocked_content:
929
+ self._data.unlocked_content[content_type] = UnlockState(
930
+ content_type=content_type
931
+ )
932
+ self._data.unlocked_content[content_type].unlocked = True
933
+ self._data.unlocked_content[content_type].unlocked_at = now
934
+
935
+ self._save()
936
+ print("[ContentUnlocks] All content unlocked")
937
+
938
+ def refresh_unlocks(self) -> List[str]:
939
+ """
940
+ Force a refresh of all unlock checks.
941
+
942
+ Returns:
943
+ List of newly unlocked content types
944
+ """
945
+ return self.check_all_unlocks()