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,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core: User Manager
|
|
3
|
+
Manages per-user memory instances and settings.
|
|
4
|
+
Each user gets their own memory, intimacy state, and content unlocks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Optional, Any
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserManager:
|
|
14
|
+
"""
|
|
15
|
+
Manages per-user state and memory paths.
|
|
16
|
+
Provides user isolation for memory, intimacy layers, and content unlocks.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_path: Path = None):
|
|
20
|
+
"""
|
|
21
|
+
Initialize the User Manager.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
base_path: Base path for data storage (defaults to /app/data or local data/)
|
|
25
|
+
"""
|
|
26
|
+
if base_path:
|
|
27
|
+
self.base_path = base_path
|
|
28
|
+
else:
|
|
29
|
+
# Try Docker path first, then local development path
|
|
30
|
+
docker_path = Path("/app/data")
|
|
31
|
+
local_path = Path(__file__).parent.parent / "data"
|
|
32
|
+
self.base_path = docker_path if docker_path.exists() else local_path
|
|
33
|
+
|
|
34
|
+
self.users_path = self.base_path / "users"
|
|
35
|
+
self.users_path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
# Cache for user data paths
|
|
38
|
+
self._user_paths: Dict[str, Dict[str, Path]] = {}
|
|
39
|
+
|
|
40
|
+
# Owner settings cache
|
|
41
|
+
self._owner_settings: Optional[dict] = None
|
|
42
|
+
|
|
43
|
+
def get_user_path(self, user_id: str) -> Path:
|
|
44
|
+
"""
|
|
45
|
+
Get the base path for a user's data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
user_id: The user's Telegram ID
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path to the user's data directory
|
|
52
|
+
"""
|
|
53
|
+
user_path = self.users_path / str(user_id)
|
|
54
|
+
user_path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return user_path
|
|
56
|
+
|
|
57
|
+
def get_user_paths(self, user_id: str) -> Dict[str, Path]:
|
|
58
|
+
"""
|
|
59
|
+
Get all paths for a user's data files.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
user_id: The user's Telegram ID
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dictionary with paths for all user data files
|
|
66
|
+
"""
|
|
67
|
+
if user_id in self._user_paths:
|
|
68
|
+
return self._user_paths[user_id]
|
|
69
|
+
|
|
70
|
+
base = self.get_user_path(user_id)
|
|
71
|
+
paths = {
|
|
72
|
+
"base": base,
|
|
73
|
+
"conversations": base / "conversations",
|
|
74
|
+
"facts": base / "facts.json",
|
|
75
|
+
"intimacy_layers": base / "intimacy_layers.json",
|
|
76
|
+
"content_unlocks": base / "content_unlocks.json",
|
|
77
|
+
"summaries": base / "summaries",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Ensure directories exist
|
|
81
|
+
paths["conversations"].mkdir(parents=True, exist_ok=True)
|
|
82
|
+
paths["summaries"].mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
self._user_paths[user_id] = paths
|
|
85
|
+
return paths
|
|
86
|
+
|
|
87
|
+
# -------------------------------------------------------------------------
|
|
88
|
+
# Owner Settings
|
|
89
|
+
# -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _load_owner_settings(self) -> dict:
|
|
92
|
+
"""Load owner settings from file"""
|
|
93
|
+
if self._owner_settings is not None:
|
|
94
|
+
return self._owner_settings
|
|
95
|
+
|
|
96
|
+
settings_path = self.base_path / "owner_settings.json"
|
|
97
|
+
if settings_path.exists():
|
|
98
|
+
try:
|
|
99
|
+
self._owner_settings = json.loads(settings_path.read_text())
|
|
100
|
+
except (json.JSONDecodeError, Exception):
|
|
101
|
+
self._owner_settings = {}
|
|
102
|
+
else:
|
|
103
|
+
self._owner_settings = {}
|
|
104
|
+
|
|
105
|
+
return self._owner_settings
|
|
106
|
+
|
|
107
|
+
def _save_owner_settings(self, settings: dict):
|
|
108
|
+
"""Save owner settings to file"""
|
|
109
|
+
settings_path = self.base_path / "owner_settings.json"
|
|
110
|
+
settings["updated_at"] = datetime.now().isoformat()
|
|
111
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
112
|
+
self._owner_settings = settings
|
|
113
|
+
|
|
114
|
+
def is_advanced_enabled(self) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Check if owner has /advanced mode enabled (advanced access).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if advanced mode is enabled
|
|
120
|
+
"""
|
|
121
|
+
settings = self._load_owner_settings()
|
|
122
|
+
return settings.get("advanced_enabled", False)
|
|
123
|
+
|
|
124
|
+
def set_advanced_enabled(self, enabled: bool) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Set the advanced mode status.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
enabled: Whether to enable or disable advanced mode
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The new enabled state
|
|
133
|
+
"""
|
|
134
|
+
settings = self._load_owner_settings()
|
|
135
|
+
settings["advanced_enabled"] = enabled
|
|
136
|
+
self._save_owner_settings(settings)
|
|
137
|
+
return enabled
|
|
138
|
+
|
|
139
|
+
def toggle_advanced(self) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Toggle the advanced mode status.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The new enabled state
|
|
145
|
+
"""
|
|
146
|
+
current = self.is_advanced_enabled()
|
|
147
|
+
return self.set_advanced_enabled(not current)
|
|
148
|
+
|
|
149
|
+
def get_owner_settings(self) -> dict:
|
|
150
|
+
"""
|
|
151
|
+
Get all owner settings.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Dictionary with all owner settings
|
|
155
|
+
"""
|
|
156
|
+
return self._load_owner_settings().copy()
|
|
157
|
+
|
|
158
|
+
# -------------------------------------------------------------------------
|
|
159
|
+
# User Existence & Migration
|
|
160
|
+
# -------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def user_exists(self, user_id: str) -> bool:
|
|
163
|
+
"""
|
|
164
|
+
Check if a user has existing data.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
user_id: The user's Telegram ID
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if user has any data
|
|
171
|
+
"""
|
|
172
|
+
user_path = self.get_user_path(user_id)
|
|
173
|
+
facts_path = user_path / "facts.json"
|
|
174
|
+
conv_path = user_path / "conversations"
|
|
175
|
+
|
|
176
|
+
return facts_path.exists() or (conv_path.exists() and any(conv_path.glob("*.jsonl")))
|
|
177
|
+
|
|
178
|
+
def get_all_users(self) -> list:
|
|
179
|
+
"""
|
|
180
|
+
Get list of all user IDs that have data.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of user ID strings
|
|
184
|
+
"""
|
|
185
|
+
users = []
|
|
186
|
+
if self.users_path.exists():
|
|
187
|
+
for user_dir in self.users_path.iterdir():
|
|
188
|
+
if user_dir.is_dir() and user_dir.name.isdigit():
|
|
189
|
+
users.append(user_dir.name)
|
|
190
|
+
return users
|
|
191
|
+
|
|
192
|
+
def migrate_legacy_data(self, owner_id: str):
|
|
193
|
+
"""
|
|
194
|
+
Migrate legacy data from the old flat structure to per-user structure.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
owner_id: The owner's Telegram ID to migrate data to
|
|
198
|
+
"""
|
|
199
|
+
import shutil
|
|
200
|
+
|
|
201
|
+
legacy_paths = {
|
|
202
|
+
"conversations": self.base_path / "conversations",
|
|
203
|
+
"facts": self.base_path / "facts.json",
|
|
204
|
+
"intimacy_layers": self.base_path / "intimacy_layers.json",
|
|
205
|
+
"content_unlocks": self.base_path / "content_unlocks.json",
|
|
206
|
+
"summaries": self.base_path / "summaries",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
user_paths = self.get_user_paths(owner_id)
|
|
210
|
+
|
|
211
|
+
# Migrate conversations
|
|
212
|
+
if legacy_paths["conversations"].exists():
|
|
213
|
+
for conv_file in legacy_paths["conversations"].glob("*.jsonl"):
|
|
214
|
+
dest = user_paths["conversations"] / conv_file.name
|
|
215
|
+
if not dest.exists():
|
|
216
|
+
shutil.copy2(conv_file, dest)
|
|
217
|
+
print(f"[UserManager] Migrated conversation: {conv_file.name}")
|
|
218
|
+
|
|
219
|
+
# Migrate facts.json
|
|
220
|
+
if legacy_paths["facts"].exists() and not user_paths["facts"].exists():
|
|
221
|
+
shutil.copy2(legacy_paths["facts"], user_paths["facts"])
|
|
222
|
+
print(f"[UserManager] Migrated facts.json")
|
|
223
|
+
|
|
224
|
+
# Migrate intimacy_layers.json
|
|
225
|
+
if legacy_paths["intimacy_layers"].exists() and not user_paths["intimacy_layers"].exists():
|
|
226
|
+
shutil.copy2(legacy_paths["intimacy_layers"], user_paths["intimacy_layers"])
|
|
227
|
+
print(f"[UserManager] Migrated intimacy_layers.json")
|
|
228
|
+
|
|
229
|
+
# Migrate content_unlocks.json
|
|
230
|
+
if legacy_paths["content_unlocks"].exists() and not user_paths["content_unlocks"].exists():
|
|
231
|
+
shutil.copy2(legacy_paths["content_unlocks"], user_paths["content_unlocks"])
|
|
232
|
+
print(f"[UserManager] Migrated content_unlocks.json")
|
|
233
|
+
|
|
234
|
+
# Migrate summaries
|
|
235
|
+
if legacy_paths["summaries"].exists():
|
|
236
|
+
for summary_file in legacy_paths["summaries"].glob("*.json"):
|
|
237
|
+
dest = user_paths["summaries"] / summary_file.name
|
|
238
|
+
if not dest.exists():
|
|
239
|
+
shutil.copy2(summary_file, dest)
|
|
240
|
+
print(f"[UserManager] Migrated summary: {summary_file.name}")
|
|
241
|
+
|
|
242
|
+
print(f"[UserManager] Migration complete for user {owner_id}")
|
|
243
|
+
|
|
244
|
+
# -------------------------------------------------------------------------
|
|
245
|
+
# User Stats
|
|
246
|
+
# -------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def get_user_stats(self, user_id: str) -> Dict[str, Any]:
|
|
249
|
+
"""
|
|
250
|
+
Get statistics for a user.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
user_id: The user's Telegram ID
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Dictionary with user statistics
|
|
257
|
+
"""
|
|
258
|
+
paths = self.get_user_paths(user_id)
|
|
259
|
+
|
|
260
|
+
stats = {
|
|
261
|
+
"user_id": user_id,
|
|
262
|
+
"exists": self.user_exists(user_id),
|
|
263
|
+
"has_facts": paths["facts"].exists(),
|
|
264
|
+
"has_intimacy": paths["intimacy_layers"].exists(),
|
|
265
|
+
"has_unlocks": paths["content_unlocks"].exists(),
|
|
266
|
+
"conversation_files": 0,
|
|
267
|
+
"summary_files": 0,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if paths["conversations"].exists():
|
|
271
|
+
stats["conversation_files"] = len(list(paths["conversations"].glob("*.jsonl")))
|
|
272
|
+
|
|
273
|
+
if paths["summaries"].exists():
|
|
274
|
+
stats["summary_files"] = len(list(paths["summaries"].glob("*.json")))
|
|
275
|
+
|
|
276
|
+
return stats
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Global singleton instance
|
|
280
|
+
_user_manager: Optional[UserManager] = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_user_manager(base_path: Path = None) -> UserManager:
|
|
284
|
+
"""
|
|
285
|
+
Get the global UserManager instance.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
base_path: Base path for data storage (only used on first call)
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The UserManager singleton
|
|
292
|
+
"""
|
|
293
|
+
global _user_manager
|
|
294
|
+
if _user_manager is None:
|
|
295
|
+
_user_manager = UserManager(base_path)
|
|
296
|
+
return _user_manager
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def is_advanced_enabled() -> bool:
|
|
300
|
+
"""
|
|
301
|
+
Convenience function to check if advanced mode is enabled.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if advanced mode is enabled
|
|
305
|
+
"""
|
|
306
|
+
return get_user_manager().is_advanced_enabled()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core: User Tracker
|
|
3
|
+
Track active users for proactive messaging and multi-user support
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, Optional, List
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ActiveUser:
|
|
13
|
+
"""Represents an active user conversation"""
|
|
14
|
+
user_id: str
|
|
15
|
+
chat_id: int
|
|
16
|
+
last_interaction: float = field(default_factory=time.time)
|
|
17
|
+
message_count: int = 0
|
|
18
|
+
pet_name: str = "babe"
|
|
19
|
+
|
|
20
|
+
def touch(self):
|
|
21
|
+
"""Update last interaction time"""
|
|
22
|
+
self.last_interaction = time.time()
|
|
23
|
+
self.message_count += 1
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def silence_minutes(self) -> float:
|
|
27
|
+
"""How long since last interaction"""
|
|
28
|
+
return (time.time() - self.last_interaction) / 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserTracker:
|
|
32
|
+
"""
|
|
33
|
+
Tracks active users for proactive messaging.
|
|
34
|
+
Stores user_id, chat_id, and conversation metadata.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Users inactive for this long are considered "gone"
|
|
38
|
+
INACTIVE_AFTER_MINUTES = 120
|
|
39
|
+
|
|
40
|
+
# Users inactive for this long are removed from tracking
|
|
41
|
+
FORGET_AFTER_HOURS = 48
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self._users: Dict[str, ActiveUser] = {}
|
|
45
|
+
self._chat_to_user: Dict[int, str] = {} # chat_id -> user_id mapping
|
|
46
|
+
|
|
47
|
+
def register_message(self, user_id: str, chat_id: int, pet_name: str = "babe"):
|
|
48
|
+
"""Register that a message was received from this user"""
|
|
49
|
+
if not user_id:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
user_id = str(user_id)
|
|
53
|
+
|
|
54
|
+
if user_id in self._users:
|
|
55
|
+
self._users[user_id].touch()
|
|
56
|
+
self._users[user_id].chat_id = chat_id
|
|
57
|
+
self._users[user_id].pet_name = pet_name
|
|
58
|
+
else:
|
|
59
|
+
self._users[user_id] = ActiveUser(
|
|
60
|
+
user_id=user_id,
|
|
61
|
+
chat_id=chat_id,
|
|
62
|
+
pet_name=pet_name
|
|
63
|
+
)
|
|
64
|
+
print(f"[UserTracker] New user registered: {user_id}")
|
|
65
|
+
|
|
66
|
+
# Update chat_id mapping
|
|
67
|
+
self._chat_to_user[chat_id] = user_id
|
|
68
|
+
|
|
69
|
+
def get_user(self, user_id: str) -> Optional[ActiveUser]:
|
|
70
|
+
"""Get user by user_id"""
|
|
71
|
+
return self._users.get(str(user_id))
|
|
72
|
+
|
|
73
|
+
def get_user_by_chat(self, chat_id: int) -> Optional[ActiveUser]:
|
|
74
|
+
"""Get user by chat_id"""
|
|
75
|
+
user_id = self._chat_to_user.get(chat_id)
|
|
76
|
+
if user_id:
|
|
77
|
+
return self._users.get(user_id)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def get_active_users(self, within_minutes: float = None) -> List[ActiveUser]:
|
|
81
|
+
"""
|
|
82
|
+
Get list of users who are still considered active.
|
|
83
|
+
within_minutes: only users who messaged within this time (default: INACTIVE_AFTER_MINUTES)
|
|
84
|
+
"""
|
|
85
|
+
threshold = (within_minutes or self.INACTIVE_AFTER_MINUTES) * 60
|
|
86
|
+
now = time.time()
|
|
87
|
+
return [u for u in self._users.values() if (now - u.last_interaction) < threshold]
|
|
88
|
+
|
|
89
|
+
def get_users_for_follow_up(self, min_silence_minutes: float = 30, max_silence_minutes: float = 180) -> List[ActiveUser]:
|
|
90
|
+
"""
|
|
91
|
+
Get users who might need a follow-up message.
|
|
92
|
+
- Have been silent for at least min_silence_minutes
|
|
93
|
+
- Haven't been silent for more than max_silence_minutes (they're probably gone)
|
|
94
|
+
"""
|
|
95
|
+
result = []
|
|
96
|
+
for user in self._users.values():
|
|
97
|
+
silence = user.silence_minutes
|
|
98
|
+
if min_silence_minutes <= silence <= max_silence_minutes:
|
|
99
|
+
result.append(user)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
def cleanup_stale(self):
|
|
103
|
+
"""Remove users who have been inactive too long"""
|
|
104
|
+
threshold = self.FORGET_AFTER_HOURS * 3600
|
|
105
|
+
now = time.time()
|
|
106
|
+
stale = [uid for uid, u in self._users.items() if (now - u.last_interaction) > threshold]
|
|
107
|
+
for uid in stale:
|
|
108
|
+
chat_id = self._users[uid].chat_id
|
|
109
|
+
del self._users[uid]
|
|
110
|
+
if chat_id in self._chat_to_user:
|
|
111
|
+
del self._chat_to_user[chat_id]
|
|
112
|
+
print(f"[UserTracker] Removed stale user: {uid}")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def total_users(self) -> int:
|
|
116
|
+
return len(self._users)
|
|
117
|
+
|
|
118
|
+
def get_status(self) -> dict:
|
|
119
|
+
"""Get status summary for debugging"""
|
|
120
|
+
return {
|
|
121
|
+
"total_users": self.total_users,
|
|
122
|
+
"active_users": len(self.get_active_users()),
|
|
123
|
+
"users_needing_follow_up": len(self.get_users_for_follow_up()),
|
|
124
|
+
"users": [
|
|
125
|
+
{
|
|
126
|
+
"user_id": u.user_id,
|
|
127
|
+
"silence_minutes": round(u.silence_minutes, 1),
|
|
128
|
+
"message_count": u.message_count
|
|
129
|
+
}
|
|
130
|
+
for u in self._users.values()
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Global instance
|
|
136
|
+
_tracker: Optional[UserTracker] = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_user_tracker() -> UserTracker:
|
|
140
|
+
"""Get the global user tracker instance"""
|
|
141
|
+
global _tracker
|
|
142
|
+
if _tracker is None:
|
|
143
|
+
_tracker = UserTracker()
|
|
144
|
+
return _tracker
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Alive-AI Demo Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--bg: #080b0f;
|
|
11
|
+
--panel: #111821;
|
|
12
|
+
--line: #263445;
|
|
13
|
+
--text: #f5f7fb;
|
|
14
|
+
--muted: #9aa8b7;
|
|
15
|
+
--green: #41f0a1;
|
|
16
|
+
--pink: #ff5c8a;
|
|
17
|
+
--yellow: #ffcf5a;
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
min-height: 100vh;
|
|
23
|
+
background: var(--bg);
|
|
24
|
+
color: var(--text);
|
|
25
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
letter-spacing: 0;
|
|
27
|
+
}
|
|
28
|
+
.app {
|
|
29
|
+
width: min(980px, calc(100vw - 32px));
|
|
30
|
+
margin: 0 auto;
|
|
31
|
+
padding: 28px 0;
|
|
32
|
+
}
|
|
33
|
+
header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
margin-bottom: 18px;
|
|
38
|
+
}
|
|
39
|
+
.brand { display: flex; align-items: center; gap: 12px; }
|
|
40
|
+
.brand img { width: 42px; height: 42px; border-radius: 8px; }
|
|
41
|
+
h1 { margin: 0; font-size: 22px; }
|
|
42
|
+
.status {
|
|
43
|
+
border: 1px solid rgba(65,240,161,0.3);
|
|
44
|
+
color: var(--green);
|
|
45
|
+
padding: 8px 10px;
|
|
46
|
+
border-radius: 999px;
|
|
47
|
+
font-size: 13px;
|
|
48
|
+
font-weight: 750;
|
|
49
|
+
}
|
|
50
|
+
.grid {
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template-columns: 1.1fr 0.9fr;
|
|
53
|
+
gap: 16px;
|
|
54
|
+
}
|
|
55
|
+
.panel {
|
|
56
|
+
background: var(--panel);
|
|
57
|
+
border: 1px solid var(--line);
|
|
58
|
+
border-radius: 8px;
|
|
59
|
+
padding: 16px;
|
|
60
|
+
}
|
|
61
|
+
.panel h2 {
|
|
62
|
+
margin: 0 0 14px;
|
|
63
|
+
font-size: 15px;
|
|
64
|
+
color: var(--muted);
|
|
65
|
+
text-transform: uppercase;
|
|
66
|
+
}
|
|
67
|
+
.metric { margin-bottom: 14px; }
|
|
68
|
+
.metric label { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; margin-bottom: 8px; }
|
|
69
|
+
.bar { height: 11px; border-radius: 999px; overflow: hidden; background: #25313d; }
|
|
70
|
+
.fill { height: 100%; width: 50%; border-radius: inherit; background: var(--green); transition: width 500ms ease; }
|
|
71
|
+
.fill.pink { background: var(--pink); }
|
|
72
|
+
.fill.yellow { background: var(--yellow); }
|
|
73
|
+
.thought {
|
|
74
|
+
min-height: 132px;
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
font-size: 22px;
|
|
78
|
+
line-height: 1.35;
|
|
79
|
+
}
|
|
80
|
+
.counters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
81
|
+
.counter { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #0c131a; }
|
|
82
|
+
.counter strong { display: block; font-size: 28px; color: var(--green); }
|
|
83
|
+
.counter span { color: var(--muted); font-size: 13px; }
|
|
84
|
+
@media (max-width: 720px) {
|
|
85
|
+
.grid { grid-template-columns: 1fr; }
|
|
86
|
+
.counters { grid-template-columns: 1fr; }
|
|
87
|
+
header { align-items: flex-start; gap: 12px; flex-direction: column; }
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<main class="app">
|
|
93
|
+
<header>
|
|
94
|
+
<div class="brand"><img src="/assets/logo.svg" alt=""><div><h1>Alive-AI Demo</h1><div id="mood">curious</div></div></div>
|
|
95
|
+
<div class="status">local dashboard preview</div>
|
|
96
|
+
</header>
|
|
97
|
+
<section class="grid">
|
|
98
|
+
<div class="panel">
|
|
99
|
+
<h2>Emotional State</h2>
|
|
100
|
+
<div class="metric"><label>Joy <span id="joyVal">64%</span></label><div class="bar"><div class="fill" id="joy"></div></div></div>
|
|
101
|
+
<div class="metric"><label>Trust <span id="trustVal">58%</span></label><div class="bar"><div class="fill pink" id="trust"></div></div></div>
|
|
102
|
+
<div class="metric"><label>Anticipation <span id="anticipationVal">72%</span></label><div class="bar"><div class="fill yellow" id="anticipation"></div></div></div>
|
|
103
|
+
<div class="metric"><label>Vulnerability <span id="vulnerabilityVal">34%</span></label><div class="bar"><div class="fill" id="vulnerability"></div></div></div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="panel">
|
|
106
|
+
<h2>Current Thought</h2>
|
|
107
|
+
<div class="thought" id="thought">I keep a little emotional residue from every interaction.</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="panel">
|
|
110
|
+
<h2>Runtime Counters</h2>
|
|
111
|
+
<div class="counters">
|
|
112
|
+
<div class="counter"><strong id="memories">128</strong><span>memories</span></div>
|
|
113
|
+
<div class="counter"><strong id="impulses">42</strong><span>impulses</span></div>
|
|
114
|
+
<div class="counter"><strong>SSE</strong><span>live state</span></div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="panel">
|
|
118
|
+
<h2>Install</h2>
|
|
119
|
+
<pre><code>npx . setup
|
|
120
|
+
npx . start</code></pre>
|
|
121
|
+
</div>
|
|
122
|
+
</section>
|
|
123
|
+
</main>
|
|
124
|
+
<script>
|
|
125
|
+
async function tick() {
|
|
126
|
+
const res = await fetch("/state");
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
document.getElementById("mood").textContent = data.mood;
|
|
129
|
+
document.getElementById("thought").textContent = data.thought;
|
|
130
|
+
for (const key of Object.keys(data.emotions)) {
|
|
131
|
+
const value = data.emotions[key];
|
|
132
|
+
const bar = document.getElementById(key);
|
|
133
|
+
const label = document.getElementById(key + "Val");
|
|
134
|
+
if (bar) bar.style.width = value + "%";
|
|
135
|
+
if (label) label.textContent = value + "%";
|
|
136
|
+
}
|
|
137
|
+
document.getElementById("memories").textContent = data.counters.memories;
|
|
138
|
+
document.getElementById("impulses").textContent = data.counters.impulses;
|
|
139
|
+
}
|
|
140
|
+
tick();
|
|
141
|
+
setInterval(tick, 1000);
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
services:
|
|
2
|
+
alive-ai:
|
|
3
|
+
build: .
|
|
4
|
+
container_name: alive-ai
|
|
5
|
+
command: python main.py
|
|
6
|
+
env_file:
|
|
7
|
+
- .env
|
|
8
|
+
volumes:
|
|
9
|
+
- .:/app
|
|
10
|
+
- alive_ai_cache:/app/.cache
|
|
11
|
+
ports:
|
|
12
|
+
- "8080:8080"
|
|
13
|
+
depends_on:
|
|
14
|
+
- redis
|
|
15
|
+
restart: unless-stopped
|
|
16
|
+
|
|
17
|
+
redis:
|
|
18
|
+
image: redis/redis-stack-server:latest
|
|
19
|
+
container_name: alive-ai-redis
|
|
20
|
+
ports:
|
|
21
|
+
- "6379:6379"
|
|
22
|
+
volumes:
|
|
23
|
+
- alive_ai_redis:/data
|
|
24
|
+
restart: unless-stopped
|
|
25
|
+
|
|
26
|
+
volumes:
|
|
27
|
+
alive_ai_cache:
|
|
28
|
+
alive_ai_redis:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">Alive-AI logo</title>
|
|
3
|
+
<desc id="desc">A neural core wrapped by a heartbeat pulse.</desc>
|
|
4
|
+
<rect width="512" height="512" rx="112" fill="#080b0f"/>
|
|
5
|
+
<circle cx="256" cy="256" r="154" fill="none" stroke="#f5f7fb" stroke-width="12" opacity="0.18"/>
|
|
6
|
+
<path d="M86 274h72l30-70 47 148 50-201 40 123h101" fill="none" stroke="#41f0a1" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
|
|
7
|
+
<path d="M151 335c35 48 91 77 153 69 83-11 145-84 138-168-7-91-89-160-180-150-51 6-96 36-121 80" fill="none" stroke="#ff5c8a" stroke-width="14" stroke-linecap="round"/>
|
|
8
|
+
<circle cx="256" cy="256" r="57" fill="#101820" stroke="#f5f7fb" stroke-width="10"/>
|
|
9
|
+
<circle cx="256" cy="256" r="15" fill="#41f0a1"/>
|
|
10
|
+
<circle cx="216" cy="224" r="10" fill="#ffcf5a"/>
|
|
11
|
+
<circle cx="301" cy="223" r="10" fill="#ff5c8a"/>
|
|
12
|
+
<circle cx="221" cy="295" r="10" fill="#f5f7fb"/>
|
|
13
|
+
<circle cx="300" cy="294" r="10" fill="#41f0a1"/>
|
|
14
|
+
<path d="M226 229l30 27 45-31M256 256l-35 39M256 256l44 38" fill="none" stroke="#f5f7fb" stroke-width="7" stroke-linecap="round"/>
|
|
15
|
+
</svg>
|