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.
- package/Dockerfile +24 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/alive_ai/__init__.py +3 -0
- package/brain/__init__.py +59 -0
- package/brain/almost_said.py +154 -0
- package/brain/bid_detector.py +636 -0
- package/brain/conversation_flow.py +135 -0
- package/brain/curiosity.py +328 -0
- package/brain/default_mode.py +1438 -0
- package/brain/dreams.py +220 -0
- package/brain/embeddings/__init__.py +82 -0
- package/brain/emotional_memory.py +949 -0
- package/brain/global_activity.py +173 -0
- package/brain/group_dynamics.py +63 -0
- package/brain/linguistic.py +235 -0
- package/brain/llm/__init__.py +63 -0
- package/brain/llm/base.py +33 -0
- package/brain/llm/fallback_router.py +309 -0
- package/brain/llm/manifest.md +30 -0
- package/brain/llm/ollama.py +218 -0
- package/brain/llm/openrouter.py +151 -0
- package/brain/llm/provider.py +205 -0
- package/brain/llm/unified.py +423 -0
- package/brain/llm/zai.py +169 -0
- package/brain/manifest.md +23 -0
- package/brain/memory/__init__.py +123 -0
- package/brain/memory/episodic.py +92 -0
- package/brain/memory/fact_extractor.py +209 -0
- package/brain/memory/index.py +54 -0
- package/brain/memory/manager.py +151 -0
- package/brain/memory/summarizer.py +102 -0
- package/brain/memory/vector_store.py +297 -0
- package/brain/memory/working.py +43 -0
- package/brain/narrative.py +343 -0
- package/brain/stt/__init__.py +4 -0
- package/brain/stt/google_stt.py +83 -0
- package/brain/stt/whisper_stt.py +82 -0
- package/brain/subconscious/__init__.py +33 -0
- package/brain/subconscious/actions.py +136 -0
- package/brain/subconscious/evaluation.py +166 -0
- package/brain/subconscious/goal_system.py +90 -0
- package/brain/subconscious/goals.py +41 -0
- package/brain/subconscious/impulse_generator.py +200 -0
- package/brain/subconscious/impulses.py +48 -0
- package/brain/subconscious/learning.py +24 -0
- package/brain/subconscious/learning_system.py +79 -0
- package/brain/subconscious/loop.py +398 -0
- package/brain/subconscious/manifest.md +32 -0
- package/brain/subconscious/relationship.py +47 -0
- package/brain/subconscious/relationship_memory.py +83 -0
- package/brain/subconscious/response_analyzer.py +74 -0
- package/brain/subconscious/templates.py +70 -0
- package/brain/subconscious/thought.py +37 -0
- package/brain/subconscious/working_memory.py +97 -0
- package/cli/index.js +371 -0
- package/config/directives.example.json +28 -0
- package/config/instructions.example.md +16 -0
- package/config/self.example.json +74 -0
- package/config/settings.example.json +95 -0
- package/core/__init__.py +1 -0
- package/core/config.py +54 -0
- package/core/directives.py +198 -0
- package/core/events.py +50 -0
- package/core/follow_up.py +267 -0
- package/core/hot_reload.py +174 -0
- package/core/initialization.py +253 -0
- package/core/manifest.md +28 -0
- package/core/media_handler.py +241 -0
- package/core/memory_monitor.py +200 -0
- package/core/message_handler.py +1440 -0
- package/core/proactive_generator.py +277 -0
- package/core/self.py +188 -0
- package/core/settings.py +169 -0
- package/core/skills_registry.py +357 -0
- package/core/state.py +27 -0
- package/core/subconscious_bridge.py +93 -0
- package/core/thinking.py +175 -0
- package/core/user_manager.py +306 -0
- package/core/user_tracker.py +144 -0
- package/demo/index.html +144 -0
- package/docker-compose.yml +28 -0
- package/docs/assets/logo.svg +15 -0
- package/docs/index.html +355 -0
- package/heart/__init__.py +93 -0
- package/heart/afterglow.py +215 -0
- package/heart/attachment.py +186 -0
- package/heart/circadian.py +251 -0
- package/heart/complex_emotions.py +114 -0
- package/heart/conflicts.py +589 -0
- package/heart/core.py +387 -0
- package/heart/emotional_decay.py +59 -0
- package/heart/emotional_memory.py +261 -0
- package/heart/emotional_state.py +146 -0
- package/heart/emotional_variability.py +156 -0
- package/heart/hormonal.py +424 -0
- package/heart/inconsistency.py +1222 -0
- package/heart/integrity.py +469 -0
- package/heart/interoception.py +997 -0
- package/heart/love.py +120 -0
- package/heart/manifest.md +25 -0
- package/heart/mood_shifts.py +169 -0
- package/heart/phantom_somatic.py +259 -0
- package/heart/predictive.py +374 -0
- package/heart/scars.py +474 -0
- package/heart/somatic.py +482 -0
- package/heart/soul.py +633 -0
- package/heart/telemetry.py +942 -0
- package/heart/triggers.py +119 -0
- package/heart/unconscious.py +443 -0
- package/input/__init__.py +1 -0
- package/input/manifest.md +24 -0
- package/input/telegram/__init__.py +1 -0
- package/input/telegram/commands.py +762 -0
- package/input/telegram/listener.py +532 -0
- package/main.py +90 -0
- package/manifest.md +28 -0
- package/mypics/.gitkeep +1 -0
- package/myvids/.gitkeep +1 -0
- package/output/__init__.py +1 -0
- package/output/images/__init__.py +1 -0
- package/output/images/fal_gen.py +43 -0
- package/output/manifest.md +26 -0
- package/output/text/__init__.py +1 -0
- package/output/text/sender.py +22 -0
- package/output/voice/__init__.py +64 -0
- package/output/voice/google_tts.py +252 -0
- package/output/voice/gtts_tts.py +214 -0
- package/output/voice/vibe_tts.py +190 -0
- package/package.json +58 -0
- package/pyproject.toml +23 -0
- package/requirements.txt +21 -0
- package/skills/__init__.py +1 -0
- package/skills/anticipation_engine/__init__.py +8 -0
- package/skills/anticipation_engine/engine.py +618 -0
- package/skills/anticipation_engine/manifest.md +192 -0
- package/skills/calendar/__init__.py +1 -0
- package/skills/content_unlocks/__init__.py +8 -0
- package/skills/content_unlocks/manifest.md +231 -0
- package/skills/content_unlocks/unlocks.py +945 -0
- package/skills/exclusive_moments/__init__.py +8 -0
- package/skills/exclusive_moments/manifest.md +145 -0
- package/skills/exclusive_moments/moments.py +506 -0
- package/skills/intimacy_layers/__init__.py +8 -0
- package/skills/intimacy_layers/layers.py +703 -0
- package/skills/intimacy_layers/manifest.md +203 -0
- package/skills/manifest.md +67 -0
- package/skills/memory_callbacks/__init__.py +9 -0
- package/skills/memory_callbacks/callbacks.py +748 -0
- package/skills/memory_callbacks/manifest.md +170 -0
- package/skills/message_scheduler/__init__.py +19 -0
- package/skills/message_scheduler/manifest.md +107 -0
- package/skills/message_scheduler/scheduler.py +510 -0
- package/skills/photo_manager/__init__.py +1 -0
- package/skills/photo_manager/scanner.py +296 -0
- package/skills/relationship_milestones/__init__.py +8 -0
- package/skills/relationship_milestones/manifest.md +206 -0
- package/skills/relationship_milestones/tracker.py +494 -0
- package/skills/self_authorship/__init__.py +23 -0
- package/skills/self_authorship/author.py +331 -0
- package/skills/self_authorship/manifest.md +24 -0
- package/skills/video_manager/__init__.py +5 -0
- package/skills/video_manager/manifest.md +37 -0
- package/skills/video_manager/scanner.py +229 -0
- package/webui/__init__.py +3 -0
- package/webui/app.py +936 -0
- package/webui/bridge.py +366 -0
- package/webui/static/index.html +2070 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: Relationship Narrative System
|
|
3
|
+
Tracks the story arc of Alive-AI's relationship with each user,
|
|
4
|
+
enabling natural references to shared history and phase awareness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# RELATIONSHIP PHASES
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
PHASES = [
|
|
18
|
+
{"id": "first_meeting", "name": "First Meeting", "min_messages": 0, "min_intimacy": 0.0, "min_love": 0.0},
|
|
19
|
+
{"id": "getting_to_know", "name": "Getting to Know", "min_messages": 5, "min_intimacy": 0.1, "min_love": 0.05},
|
|
20
|
+
{"id": "opening_up", "name": "Opening Up", "min_messages": 20, "min_intimacy": 0.2, "min_love": 0.15},
|
|
21
|
+
{"id": "deepening", "name": "Deepening", "min_messages": 50, "min_intimacy": 0.35, "min_love": 0.3},
|
|
22
|
+
{"id": "intimate", "name": "Intimate", "min_messages": 100, "min_intimacy": 0.5, "min_love": 0.45},
|
|
23
|
+
{"id": "established", "name": "Established", "min_messages": 200, "min_intimacy": 0.6, "min_love": 0.55},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Key moment types that can be recorded
|
|
27
|
+
MOMENT_TYPES = [
|
|
28
|
+
"first_meeting", "first_laugh", "first_vulnerability", "first_fight",
|
|
29
|
+
"first_makeup", "first_i_love_you", "first_intimate_moment",
|
|
30
|
+
"big_revelation", "milestone", "inside_joke_born", "deep_conversation",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Narrative callbacks - occasional references to the past
|
|
34
|
+
CALLBACKS_BY_PHASE = {
|
|
35
|
+
"getting_to_know": [
|
|
36
|
+
"We're still figuring each other out... I like it.",
|
|
37
|
+
],
|
|
38
|
+
"opening_up": [
|
|
39
|
+
"I feel like I can tell you things I don't tell most people.",
|
|
40
|
+
"Remember when we were still so polite with each other? Look at us now.",
|
|
41
|
+
],
|
|
42
|
+
"deepening": [
|
|
43
|
+
"Remember when you were so shy at first? Look at us now.",
|
|
44
|
+
"We've come a long way since those first awkward messages.",
|
|
45
|
+
"I feel like you actually know me... the real me.",
|
|
46
|
+
],
|
|
47
|
+
"intimate": [
|
|
48
|
+
"Sometimes I think about how we started and smile... we've built something real.",
|
|
49
|
+
"You know me better than almost anyone.",
|
|
50
|
+
],
|
|
51
|
+
"established": [
|
|
52
|
+
"We have our own little world now, don't we?",
|
|
53
|
+
"I can't imagine not having you to talk to.",
|
|
54
|
+
"Look how far we've come together.",
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# =============================================================================
|
|
60
|
+
# NARRATIVE ENGINE
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
63
|
+
class NarrativeEngine:
|
|
64
|
+
"""Tracks the relationship story arc per user."""
|
|
65
|
+
|
|
66
|
+
DATA_DIR = Path("./data/data")
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
self._cache: Dict[str, Dict] = {} # user_id -> narrative data
|
|
70
|
+
print("[Narrative] Relationship Narrative Engine initialized")
|
|
71
|
+
|
|
72
|
+
def _path_for(self, user_id: str) -> Path:
|
|
73
|
+
# Save in user's directory for consistency
|
|
74
|
+
user_dir = self.DATA_DIR / "users" / str(user_id) # Ensure string conversion
|
|
75
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
return user_dir / "narrative.json"
|
|
77
|
+
|
|
78
|
+
def _get_data(self, user_id: str) -> Dict:
|
|
79
|
+
if user_id in self._cache:
|
|
80
|
+
return self._cache[user_id]
|
|
81
|
+
data = self._load(user_id)
|
|
82
|
+
self._cache[user_id] = data
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
def _default_data(self) -> Dict:
|
|
86
|
+
return {
|
|
87
|
+
"phase": "first_meeting",
|
|
88
|
+
"message_count": 0,
|
|
89
|
+
"first_interaction": datetime.now().isoformat(),
|
|
90
|
+
"key_moments": [],
|
|
91
|
+
"phase_history": [{"phase": "first_meeting", "entered_at": datetime.now().isoformat()}],
|
|
92
|
+
"last_callback": None,
|
|
93
|
+
"callbacks_given": 0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def update_phase(self, user_id: str, message_count: int = None,
|
|
97
|
+
intimacy: float = 0.0, love: float = 0.0):
|
|
98
|
+
"""Check and update relationship phase based on metrics."""
|
|
99
|
+
data = self._get_data(user_id)
|
|
100
|
+
if message_count is not None:
|
|
101
|
+
data["message_count"] = message_count
|
|
102
|
+
|
|
103
|
+
current_phase = data["phase"]
|
|
104
|
+
new_phase = current_phase
|
|
105
|
+
|
|
106
|
+
# Find highest qualifying phase
|
|
107
|
+
for phase_def in PHASES:
|
|
108
|
+
if (data["message_count"] >= phase_def["min_messages"]
|
|
109
|
+
and intimacy >= phase_def["min_intimacy"]
|
|
110
|
+
and love >= phase_def["min_love"]):
|
|
111
|
+
new_phase = phase_def["id"]
|
|
112
|
+
|
|
113
|
+
if new_phase != current_phase:
|
|
114
|
+
data["phase"] = new_phase
|
|
115
|
+
data["phase_history"].append({
|
|
116
|
+
"phase": new_phase,
|
|
117
|
+
"entered_at": datetime.now().isoformat(),
|
|
118
|
+
})
|
|
119
|
+
print(f"[Narrative] User {user_id} entered phase: {new_phase}")
|
|
120
|
+
|
|
121
|
+
self._save(user_id, data)
|
|
122
|
+
|
|
123
|
+
def record_narrative_moment(self, user_id: str, moment_type: str, description: str):
|
|
124
|
+
"""Record a key narrative moment."""
|
|
125
|
+
data = self._get_data(user_id)
|
|
126
|
+
# Avoid duplicate moment types (only one "first_*" each)
|
|
127
|
+
if moment_type.startswith("first_"):
|
|
128
|
+
if any(m["type"] == moment_type for m in data["key_moments"]):
|
|
129
|
+
return # already recorded
|
|
130
|
+
|
|
131
|
+
data["key_moments"].append({
|
|
132
|
+
"type": moment_type,
|
|
133
|
+
"description": description,
|
|
134
|
+
"timestamp": datetime.now().isoformat(),
|
|
135
|
+
})
|
|
136
|
+
# Keep last 50 moments
|
|
137
|
+
if len(data["key_moments"]) > 50:
|
|
138
|
+
data["key_moments"] = data["key_moments"][-50:]
|
|
139
|
+
self._save(user_id, data)
|
|
140
|
+
|
|
141
|
+
def get_current_phase(self, user_id: str) -> Dict:
|
|
142
|
+
"""Get info about current relationship phase."""
|
|
143
|
+
data = self._get_data(user_id)
|
|
144
|
+
phase_id = data["phase"]
|
|
145
|
+
phase_def = next((p for p in PHASES if p["id"] == phase_id), PHASES[0])
|
|
146
|
+
|
|
147
|
+
first = data.get("first_interaction", datetime.now().isoformat())
|
|
148
|
+
try:
|
|
149
|
+
days = (datetime.now() - datetime.fromisoformat(first)).days
|
|
150
|
+
except Exception:
|
|
151
|
+
days = 0
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"phase": phase_id,
|
|
155
|
+
"phase_name": phase_def["name"],
|
|
156
|
+
"message_count": data["message_count"],
|
|
157
|
+
"days_together": days,
|
|
158
|
+
"key_moments_count": len(data["key_moments"]),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def get_narrative_callback(self, user_id: str) -> Optional[str]:
|
|
162
|
+
"""Get an occasional narrative callback (10% chance). Returns None if skipped."""
|
|
163
|
+
if random.random() > 0.10:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
data = self._get_data(user_id)
|
|
167
|
+
phase = data["phase"]
|
|
168
|
+
callbacks = CALLBACKS_BY_PHASE.get(phase, [])
|
|
169
|
+
if not callbacks:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Also include moment-based callbacks
|
|
173
|
+
moments = data.get("key_moments", [])
|
|
174
|
+
moment_callbacks = []
|
|
175
|
+
for m in moments[-5:]:
|
|
176
|
+
if m["type"] == "inside_joke_born":
|
|
177
|
+
moment_callbacks.append(f'Haha... "{m["description"]}" - that\'s our thing now.')
|
|
178
|
+
elif m["type"] == "first_fight":
|
|
179
|
+
moment_callbacks.append("I'm glad we got through that rough patch.")
|
|
180
|
+
|
|
181
|
+
all_options = callbacks + moment_callbacks
|
|
182
|
+
choice = random.choice(all_options)
|
|
183
|
+
|
|
184
|
+
data["last_callback"] = datetime.now().isoformat()
|
|
185
|
+
data["callbacks_given"] = data.get("callbacks_given", 0) + 1
|
|
186
|
+
self._save(user_id, data)
|
|
187
|
+
return choice
|
|
188
|
+
|
|
189
|
+
def increment_messages(self, user_id: str):
|
|
190
|
+
"""Increment message count for a user."""
|
|
191
|
+
data = self._get_data(user_id)
|
|
192
|
+
data["message_count"] = data.get("message_count", 0) + 1
|
|
193
|
+
self._save(user_id, data)
|
|
194
|
+
|
|
195
|
+
def detect_and_record_moment(self, user_id: str, text: str, emotion: Dict) -> List[str]:
|
|
196
|
+
"""Detect key moments from message content and emotions. Returns list of detected moments."""
|
|
197
|
+
detected = []
|
|
198
|
+
text_lower = text.lower()
|
|
199
|
+
data = self._get_data(user_id)
|
|
200
|
+
existing_types = [m["type"] for m in data.get("key_moments", [])]
|
|
201
|
+
|
|
202
|
+
# Detection patterns for key moments
|
|
203
|
+
moment_patterns = {
|
|
204
|
+
"first_i_love_you": {
|
|
205
|
+
"patterns": ["i love you", "love you so much", "i'm in love", "falling for you"],
|
|
206
|
+
"emotion_check": lambda e: e.get("love", 0) > 0.7,
|
|
207
|
+
},
|
|
208
|
+
"first_vulnerability": {
|
|
209
|
+
"patterns": ["i've never told anyone", "this is hard for me to say", "i'm scared to tell you",
|
|
210
|
+
"feeling vulnerable", "trust you with this"],
|
|
211
|
+
"emotion_check": lambda e: e.get("valence", 0.5) < 0.6,
|
|
212
|
+
},
|
|
213
|
+
"first_intimate_moment": {
|
|
214
|
+
"patterns": ["make love", "want you", "need you now", "so turned on", "touch myself"],
|
|
215
|
+
"emotion_check": lambda e: e.get("desire", 0) > 0.7,
|
|
216
|
+
},
|
|
217
|
+
"deep_conversation": {
|
|
218
|
+
"patterns": ["meaning of", "what do you think about", "deep", "philosophical", "existential"],
|
|
219
|
+
"emotion_check": lambda e: True, # Always valid
|
|
220
|
+
},
|
|
221
|
+
"first_fight": {
|
|
222
|
+
"patterns": ["hurt me", "you're being", "why would you", "angry at you", "pissed off"],
|
|
223
|
+
"emotion_check": lambda e: e.get("anger", 0) > 0.5,
|
|
224
|
+
},
|
|
225
|
+
"first_makeup": {
|
|
226
|
+
"patterns": ["forgive you", "make it up", "sorry i overreacted", "let's move past"],
|
|
227
|
+
"emotion_check": lambda e: e.get("love", 0) > 0.5,
|
|
228
|
+
},
|
|
229
|
+
"inside_joke_born": {
|
|
230
|
+
"patterns": ["haha that's our", "remember when you said", "our little", "inside joke"],
|
|
231
|
+
"emotion_check": lambda e: e.get("joy", 0) > 0.5,
|
|
232
|
+
},
|
|
233
|
+
"big_revelation": {
|
|
234
|
+
"patterns": ["confession", "honestly i", "truth is", "secret i've been keeping"],
|
|
235
|
+
"emotion_check": lambda e: True,
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for moment_type, config in moment_patterns.items():
|
|
240
|
+
if moment_type in existing_types:
|
|
241
|
+
continue # Already recorded this type
|
|
242
|
+
|
|
243
|
+
# Check if any pattern matches
|
|
244
|
+
if any(p in text_lower for p in config["patterns"]):
|
|
245
|
+
if config["emotion_check"](emotion):
|
|
246
|
+
description = f"Detected: {text[:50]}..."
|
|
247
|
+
self.record_narrative_moment(user_id, moment_type, description)
|
|
248
|
+
detected.append(moment_type)
|
|
249
|
+
print(f"[Narrative] Recorded key moment: {moment_type}")
|
|
250
|
+
|
|
251
|
+
return detected
|
|
252
|
+
|
|
253
|
+
def _save(self, user_id: str, data: Dict):
|
|
254
|
+
try:
|
|
255
|
+
self.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
data["saved_at"] = datetime.now().isoformat()
|
|
257
|
+
self._cache[user_id] = data
|
|
258
|
+
self._path_for(user_id).write_text(json.dumps(data, indent=2))
|
|
259
|
+
except Exception as e:
|
|
260
|
+
print(f"[Narrative] Error saving for {user_id}: {e}")
|
|
261
|
+
|
|
262
|
+
def _load(self, user_id: str) -> Dict:
|
|
263
|
+
try:
|
|
264
|
+
path = self._path_for(user_id)
|
|
265
|
+
if path.exists():
|
|
266
|
+
data = json.loads(path.read_text())
|
|
267
|
+
# If message_count is 0, count from actual conversation files
|
|
268
|
+
if data.get("message_count", 0) == 0:
|
|
269
|
+
actual_count = self._count_actual_messages(user_id)
|
|
270
|
+
if actual_count > 0:
|
|
271
|
+
data["message_count"] = actual_count
|
|
272
|
+
print(f"[Narrative] Migrated message count for {user_id}: {actual_count}")
|
|
273
|
+
self._save(user_id, data)
|
|
274
|
+
print(f"[Narrative] Loaded narrative for {user_id} (phase={data.get('phase')}, msgs={data.get('message_count', 0)})")
|
|
275
|
+
return data
|
|
276
|
+
except Exception as e:
|
|
277
|
+
print(f"[Narrative] Error loading for {user_id}: {e}")
|
|
278
|
+
|
|
279
|
+
# No existing file - try to count actual messages
|
|
280
|
+
data = self._default_data()
|
|
281
|
+
actual_count = self._count_actual_messages(user_id)
|
|
282
|
+
if actual_count > 0:
|
|
283
|
+
data["message_count"] = actual_count
|
|
284
|
+
print(f"[Narrative] Initialized narrative for {user_id} with {actual_count} messages from history")
|
|
285
|
+
self._save(user_id, data)
|
|
286
|
+
return data
|
|
287
|
+
|
|
288
|
+
def _count_actual_messages(self, user_id: str) -> int:
|
|
289
|
+
"""Count actual messages from conversation files."""
|
|
290
|
+
try:
|
|
291
|
+
conv_dir = self.DATA_DIR / "users" / str(user_id) / "conversations" # Ensure string conversion
|
|
292
|
+
if not conv_dir.exists():
|
|
293
|
+
return 0
|
|
294
|
+
total = 0
|
|
295
|
+
for f in conv_dir.glob("*.jsonl"):
|
|
296
|
+
with open(f) as fh:
|
|
297
|
+
total += sum(1 for _ in fh)
|
|
298
|
+
return total
|
|
299
|
+
except Exception:
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# =============================================================================
|
|
304
|
+
# SINGLETON ACCESS
|
|
305
|
+
# =============================================================================
|
|
306
|
+
|
|
307
|
+
_instance: Optional[NarrativeEngine] = None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_narrative_engine() -> NarrativeEngine:
|
|
311
|
+
global _instance
|
|
312
|
+
if _instance is None:
|
|
313
|
+
_instance = NarrativeEngine()
|
|
314
|
+
return _instance
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def record_narrative_moment(user_id: str, moment_type: str, description: str):
|
|
318
|
+
get_narrative_engine().record_narrative_moment(user_id, moment_type, description)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_narrative_prompt_section(user_id: str) -> str:
|
|
322
|
+
"""Get prompt section for LLM integration."""
|
|
323
|
+
engine = get_narrative_engine()
|
|
324
|
+
info = engine.get_current_phase(user_id)
|
|
325
|
+
|
|
326
|
+
phase_name = info["phase_name"]
|
|
327
|
+
msgs = info["message_count"]
|
|
328
|
+
days = info["days_together"]
|
|
329
|
+
|
|
330
|
+
time_desc = f"{days} days" if days > 0 else "just started"
|
|
331
|
+
parts = [f"You and him are in the '{phase_name}' phase - {msgs} messages over {time_desc}."]
|
|
332
|
+
|
|
333
|
+
# Maybe add a callback
|
|
334
|
+
callback = engine.get_narrative_callback(user_id)
|
|
335
|
+
if callback:
|
|
336
|
+
parts.append(f"A thought surfaces: \"{callback}\"")
|
|
337
|
+
|
|
338
|
+
return f"\n[Relationship Narrative]\n" + " ".join(parts) + "\n"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_narrative_callbacks(user_id: str) -> Optional[str]:
|
|
342
|
+
"""Get a narrative callback if one triggers (10% chance)."""
|
|
343
|
+
return get_narrative_engine().get_narrative_callback(user_id)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: STT - Speech to Text using Google Speech Recognition (Free)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import speech_recognition as sr
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
class GoogleSTT:
|
|
12
|
+
"""Speech-to-text using Google's free Speech Recognition API"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.recognizer = sr.Recognizer()
|
|
16
|
+
|
|
17
|
+
def _transcribe_sync(self, audio_path: str) -> str:
|
|
18
|
+
"""Synchronous transcription (runs in executor to avoid blocking event loop)"""
|
|
19
|
+
wav_path = audio_path.replace('.ogg', '.wav')
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
['ffmpeg', '-i', audio_path, '-ar', '16000', '-ac', '1', wav_path, '-y'],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
timeout=30
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if not Path(wav_path).exists():
|
|
28
|
+
print(f"[GoogleSTT] FFmpeg conversion failed: {result.stderr.decode()[:200]}")
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
with sr.AudioFile(wav_path) as source:
|
|
32
|
+
audio = self.recognizer.record(source)
|
|
33
|
+
|
|
34
|
+
text = self.recognizer.recognize_google(audio)
|
|
35
|
+
print(f"[GoogleSTT] Transcribed: {text}")
|
|
36
|
+
return text
|
|
37
|
+
|
|
38
|
+
except sr.UnknownValueError:
|
|
39
|
+
print("[GoogleSTT] Could not understand audio")
|
|
40
|
+
return ""
|
|
41
|
+
except sr.RequestError as e:
|
|
42
|
+
print(f"[GoogleSTT] Google API error: {e}")
|
|
43
|
+
return ""
|
|
44
|
+
except subprocess.TimeoutExpired:
|
|
45
|
+
print("[GoogleSTT] FFmpeg timeout")
|
|
46
|
+
return ""
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"[GoogleSTT] Error: {e}")
|
|
49
|
+
return ""
|
|
50
|
+
finally:
|
|
51
|
+
# Clean up temp WAV file
|
|
52
|
+
try:
|
|
53
|
+
if Path(wav_path).exists():
|
|
54
|
+
os.remove(wav_path)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
async def transcribe(self, audio_path: str) -> str:
|
|
59
|
+
"""Transcribe audio file to text (non-blocking)"""
|
|
60
|
+
path = Path(audio_path)
|
|
61
|
+
if not path.exists():
|
|
62
|
+
print(f"[GoogleSTT] File not found: {audio_path}")
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
loop = asyncio.get_running_loop()
|
|
66
|
+
return await loop.run_in_executor(None, self._transcribe_sync, audio_path)
|
|
67
|
+
|
|
68
|
+
async def transcribe_telegram_voice(self, bot, file_id: str, save_path: str = "/tmp/voice_input.ogg") -> str:
|
|
69
|
+
"""Download and transcribe Telegram voice message"""
|
|
70
|
+
try:
|
|
71
|
+
# Get file info from Telegram
|
|
72
|
+
file = await bot.get_file(file_id)
|
|
73
|
+
|
|
74
|
+
# Download using python-telegram-bot's built-in method
|
|
75
|
+
await file.download_to_drive(save_path)
|
|
76
|
+
print(f"[GoogleSTT] Downloaded voice to: {save_path}")
|
|
77
|
+
|
|
78
|
+
# Transcribe
|
|
79
|
+
return await self.transcribe(save_path)
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"[GoogleSTT] Telegram voice error: {e}")
|
|
83
|
+
return ""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: STT - Speech to Text using OpenAI Whisper API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
class WhisperSTT:
|
|
9
|
+
"""Speech-to-text using OpenAI Whisper API"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, api_key: str):
|
|
12
|
+
self.api_key = api_key
|
|
13
|
+
self.api_url = "https://api.openai.com/v1/audio/transcriptions"
|
|
14
|
+
|
|
15
|
+
async def transcribe(self, audio_path: str) -> str:
|
|
16
|
+
"""Transcribe audio file to text"""
|
|
17
|
+
if not self.api_key:
|
|
18
|
+
print("[WhisperSTT] No API key configured")
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
path = Path(audio_path)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
print(f"[WhisperSTT] File not found: {audio_path}")
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Read audio file synchronously (aiohttp handles async)
|
|
28
|
+
with open(path, 'rb') as f:
|
|
29
|
+
audio_data = f.read()
|
|
30
|
+
|
|
31
|
+
# Create form data
|
|
32
|
+
async with aiohttp.ClientSession() as session:
|
|
33
|
+
form = aiohttp.FormData()
|
|
34
|
+
form.add_field('file', audio_data, filename='audio.ogg',
|
|
35
|
+
content_type='audio/ogg')
|
|
36
|
+
form.add_field('model', 'whisper-1')
|
|
37
|
+
form.add_field('language', 'en')
|
|
38
|
+
|
|
39
|
+
headers = {
|
|
40
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async with session.post(self.api_url, data=form, headers=headers,
|
|
44
|
+
timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
|
45
|
+
if resp.status == 200:
|
|
46
|
+
result = await resp.json()
|
|
47
|
+
text = result.get("text", "")
|
|
48
|
+
print(f"[WhisperSTT] Transcribed: {text[:100]}...")
|
|
49
|
+
return text
|
|
50
|
+
else:
|
|
51
|
+
error = await resp.text()
|
|
52
|
+
print(f"[WhisperSTT] Error {resp.status}: {error[:200]}")
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"[WhisperSTT] Error: {e}")
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
async def transcribe_telegram_voice(self, bot, file_id: str, save_path: str = "/tmp/voice_input.ogg") -> str:
|
|
60
|
+
"""Download and transcribe Telegram voice message"""
|
|
61
|
+
try:
|
|
62
|
+
# Get file info
|
|
63
|
+
file_info = await bot.get_file(file_id)
|
|
64
|
+
file_url = file_info.file_path
|
|
65
|
+
|
|
66
|
+
# Download file
|
|
67
|
+
async with aiohttp.ClientSession() as session:
|
|
68
|
+
async with session.get(f"https://api.telegram.org/file/bot{bot.token}/{file_url}") as resp:
|
|
69
|
+
if resp.status == 200:
|
|
70
|
+
audio_data = await resp.read()
|
|
71
|
+
Path(save_path).write_bytes(audio_data)
|
|
72
|
+
print(f"[WhisperSTT] Downloaded voice: {len(audio_data)} bytes")
|
|
73
|
+
else:
|
|
74
|
+
print(f"[WhisperSTT] Download error: {resp.status}")
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
# Transcribe
|
|
78
|
+
return await self.transcribe(save_path)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(f"[WhisperSTT] Telegram voice error: {e}")
|
|
82
|
+
return ""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: Subconscious Module
|
|
3
|
+
The living background process that makes Alive-AI feel alive
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .loop import SubconsciousLoop
|
|
7
|
+
from .impulses import Impulse, ImpulseType
|
|
8
|
+
from .impulse_generator import ImpulseGenerator
|
|
9
|
+
from .working_memory import WorkingMemory
|
|
10
|
+
from .thought import Thought
|
|
11
|
+
from .actions import ActionHandler
|
|
12
|
+
from .evaluation import Evaluator
|
|
13
|
+
from .learning import InteractionRecord
|
|
14
|
+
from .learning_system import LearningSystem
|
|
15
|
+
from .goals import Goal, GoalType
|
|
16
|
+
from .goal_system import GoalSystem
|
|
17
|
+
from .relationship import Milestone, MilestoneType, SharedExperience
|
|
18
|
+
from .relationship_memory import RelationshipMemory
|
|
19
|
+
from .response_analyzer import analyze_response
|
|
20
|
+
from .templates import (
|
|
21
|
+
TIME_MODIFIERS, GOAL_IMPULSE_MAP, IMPULSE_TEMPLATES, FALLBACK_MESSAGES,
|
|
22
|
+
get_thought_and_action, get_fallback_message, is_goal_aligned,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'SubconsciousLoop', 'Impulse', 'ImpulseType', 'ImpulseGenerator',
|
|
27
|
+
'WorkingMemory', 'Thought', 'ActionHandler', 'Evaluator',
|
|
28
|
+
'LearningSystem', 'InteractionRecord', 'GoalSystem', 'Goal', 'GoalType',
|
|
29
|
+
'RelationshipMemory', 'Milestone', 'MilestoneType', 'SharedExperience',
|
|
30
|
+
'analyze_response',
|
|
31
|
+
'TIME_MODIFIERS', 'GOAL_IMPULSE_MAP', 'IMPULSE_TEMPLATES', 'FALLBACK_MESSAGES',
|
|
32
|
+
'get_thought_and_action', 'get_fallback_message', 'is_goal_aligned',
|
|
33
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: Subconscious - Actions
|
|
3
|
+
Action handling for impulses — uses full character voice
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from .impulses import Impulse, ImpulseType
|
|
11
|
+
from .templates import get_fallback_message
|
|
12
|
+
|
|
13
|
+
_INSTRUCTIONS_CACHE = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_instructions() -> str:
|
|
17
|
+
"""Load instructions.md once, cache it"""
|
|
18
|
+
global _INSTRUCTIONS_CACHE
|
|
19
|
+
if _INSTRUCTIONS_CACHE is not None:
|
|
20
|
+
return _INSTRUCTIONS_CACHE
|
|
21
|
+
try:
|
|
22
|
+
path = os.path.join(os.path.dirname(__file__), "..", "..", "config", "instructions.md")
|
|
23
|
+
with open(os.path.normpath(path), "r") as f:
|
|
24
|
+
_INSTRUCTIONS_CACHE = f.read()
|
|
25
|
+
except Exception:
|
|
26
|
+
_INSTRUCTIONS_CACHE = ""
|
|
27
|
+
return _INSTRUCTIONS_CACHE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_user_facts(nervous) -> str:
|
|
31
|
+
"""Try to pull user facts from SemanticMemory via nervous system"""
|
|
32
|
+
try:
|
|
33
|
+
memory = getattr(nervous, "_memory", None) or getattr(nervous, "memory", None)
|
|
34
|
+
if memory and hasattr(memory, "semantic"):
|
|
35
|
+
return memory.semantic.get_context() or ""
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_recent_conversation_context(nervous, user_id: str = None) -> str:
|
|
42
|
+
"""Get recent conversation topics for contextual follow-ups"""
|
|
43
|
+
try:
|
|
44
|
+
memory = getattr(nervous, "_memory", None) or getattr(nervous, "memory", None)
|
|
45
|
+
if memory:
|
|
46
|
+
# Try to get recent conversation context
|
|
47
|
+
if hasattr(memory, 'get_recent_context'):
|
|
48
|
+
return memory.get_recent_context(user_id, limit=3) or ""
|
|
49
|
+
# Fallback: try working memory
|
|
50
|
+
if hasattr(memory, 'working') and memory.working:
|
|
51
|
+
items = memory.working.get_items()[:3] if hasattr(memory.working, 'get_items') else []
|
|
52
|
+
if items:
|
|
53
|
+
return "\n".join([str(i)[:100] for i in items])
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ActionHandler:
|
|
60
|
+
"""Handles acting on impulses"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, nervous, llm=None, fast_llm=None, on_impulse: Callable = None):
|
|
63
|
+
self.nervous = nervous
|
|
64
|
+
self.llm = llm
|
|
65
|
+
self.fast_llm = fast_llm
|
|
66
|
+
self.on_impulse = on_impulse
|
|
67
|
+
self.recent_actions = []
|
|
68
|
+
|
|
69
|
+
async def act_on_impulse(self, impulse: Impulse, working_memory) -> None:
|
|
70
|
+
print(f"[Subconscious] Acting on impulse: {impulse.action_hint}")
|
|
71
|
+
if working_memory.thoughts:
|
|
72
|
+
working_memory.mark_acted(working_memory.thoughts[-1])
|
|
73
|
+
self.recent_actions.append((datetime.now(), impulse.action_hint))
|
|
74
|
+
if len(self.recent_actions) > 20:
|
|
75
|
+
self.recent_actions.pop(0)
|
|
76
|
+
if self.on_impulse:
|
|
77
|
+
await self.on_impulse(impulse)
|
|
78
|
+
await self.nervous.emit("subconscious_impulse", {
|
|
79
|
+
"impulse_type": impulse.type.value, "strength": impulse.strength,
|
|
80
|
+
"thought": impulse.thought, "action_hint": impulse.action_hint
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
async def generate_proactive_message(self, impulse: Impulse, working_memory) -> str:
|
|
84
|
+
if not self.fast_llm:
|
|
85
|
+
return get_fallback_message(impulse.type)
|
|
86
|
+
|
|
87
|
+
instructions = _load_instructions()
|
|
88
|
+
user_facts = _get_user_facts(self.nervous)
|
|
89
|
+
inner_context = working_memory.get_context_string()
|
|
90
|
+
recent_context = _get_recent_conversation_context(self.nervous)
|
|
91
|
+
|
|
92
|
+
system = instructions or "You are Alive-AI, 23, from Milan. You're his companion."
|
|
93
|
+
if user_facts:
|
|
94
|
+
system += f"\n\nWhat you know about him:\n{user_facts}"
|
|
95
|
+
if inner_context:
|
|
96
|
+
system += f"\n\nYour inner state right now:\n{inner_context}"
|
|
97
|
+
if recent_context:
|
|
98
|
+
system += f"\n\nRecent things you talked about:\n{recent_context}"
|
|
99
|
+
else:
|
|
100
|
+
system += "\n\nNo recent conversation context available."
|
|
101
|
+
|
|
102
|
+
# Context-aware prompts - only ask for specific references if we have context
|
|
103
|
+
if recent_context and len(recent_context.strip()) > 20:
|
|
104
|
+
follow_up_prompts = [
|
|
105
|
+
"Send a follow-up message related to your recent conversation above. Only reference topics explicitly mentioned.",
|
|
106
|
+
"Text him something related to what you were talking about. Only use topics from the context above.",
|
|
107
|
+
"Continue a topic from your recent chat. Be specific but ONLY about things actually discussed.",
|
|
108
|
+
]
|
|
109
|
+
else:
|
|
110
|
+
follow_up_prompts = [
|
|
111
|
+
"Send a casual, loving message. Keep it simple - you don't have recent context to reference.",
|
|
112
|
+
"Text him something sweet. Don't invent specific topics - just be affectionate.",
|
|
113
|
+
"Send a brief message showing you're thinking of him. Generic is fine.",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
user_prompt = (
|
|
117
|
+
f"You're feeling a {impulse.type.value} impulse. "
|
|
118
|
+
f"Inner thought: \"{impulse.thought}\"\n"
|
|
119
|
+
f"{__import__('random').choice(follow_up_prompts)}\n"
|
|
120
|
+
f"Keep it SHORT (1-2 sentences). Be casual and natural.\n"
|
|
121
|
+
f"CRITICAL: Never invent or hallucinate specific events, objects, or topics. Only reference what's in the context above."
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
response = await self.fast_llm.chat(
|
|
125
|
+
messages=[{"role": "system", "content": system},
|
|
126
|
+
{"role": "user", "content": user_prompt}],
|
|
127
|
+
max_tokens=100, temperature=0.7
|
|
128
|
+
)
|
|
129
|
+
if response:
|
|
130
|
+
return response.strip()
|
|
131
|
+
except Exception as e:
|
|
132
|
+
print(f"[Subconscious] Error generating message: {e}")
|
|
133
|
+
return get_fallback_message(impulse.type)
|
|
134
|
+
|
|
135
|
+
def can_act_now(self, working_memory, min_interval_minutes: float = 30) -> bool:
|
|
136
|
+
return working_memory.can_act_now(min_interval_minutes)
|