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,123 @@
|
|
|
1
|
+
"""Brain: Memory - semantic, episodic, working, vector memory"""
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from .manager import Memory # noqa: F401 - re-export for backward compat
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SemanticMemory:
|
|
10
|
+
"""Long-term facts about the user - flat simple structure, per-user"""
|
|
11
|
+
|
|
12
|
+
PET_NAMES = ["babe", "baby", "love", "handsome"]
|
|
13
|
+
|
|
14
|
+
def __init__(self, data_path: Path, user_id: str = "default"):
|
|
15
|
+
"""
|
|
16
|
+
Initialize semantic memory for a specific user.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
data_path: User's base path (already includes users/{user_id})
|
|
20
|
+
user_id: User's Telegram ID (for reference)
|
|
21
|
+
"""
|
|
22
|
+
self.user_id = user_id
|
|
23
|
+
# data_path is already the user's base path (data/users/{user_id})
|
|
24
|
+
self.path = data_path / "facts.json"
|
|
25
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self.facts = self._load()
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict:
|
|
29
|
+
if self.path.exists():
|
|
30
|
+
return json.loads(self.path.read_text())
|
|
31
|
+
# Clean default - no precoded info
|
|
32
|
+
return {
|
|
33
|
+
"name": None, "nickname": None, "gender": None, "age": None,
|
|
34
|
+
"location": None, "job": None, "hobbies": [], "interests": [],
|
|
35
|
+
"personality": [], "relationship_status": None,
|
|
36
|
+
"pet_names_used": [], "mentions": {},
|
|
37
|
+
"shared_memories": [], "last_intimate": None
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def save(self):
|
|
41
|
+
self.path.write_text(json.dumps(self.facts, indent=2))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_new_user(self) -> bool:
|
|
45
|
+
"""Check if we know anything about this user"""
|
|
46
|
+
return not bool(
|
|
47
|
+
self.facts.get("name") or
|
|
48
|
+
self.facts.get("mentions") or
|
|
49
|
+
self.facts.get("hobbies") or
|
|
50
|
+
self.facts.get("interests")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def update(self, key: str, value):
|
|
54
|
+
"""Update a fact"""
|
|
55
|
+
if isinstance(value, list) and key in self.facts and isinstance(self.facts[key], list):
|
|
56
|
+
# Merge lists, no duplicates
|
|
57
|
+
existing = set(str(v) for v in self.facts[key])
|
|
58
|
+
for item in value:
|
|
59
|
+
if str(item) not in existing:
|
|
60
|
+
self.facts[key].append(item)
|
|
61
|
+
elif value:
|
|
62
|
+
self.facts[key] = value
|
|
63
|
+
self.save()
|
|
64
|
+
|
|
65
|
+
def add_mention(self, key: str, value: str):
|
|
66
|
+
self.facts["mentions"][key] = {
|
|
67
|
+
"value": value, "timestamp": datetime.now().isoformat()}
|
|
68
|
+
self.save()
|
|
69
|
+
|
|
70
|
+
def add_shared_memory(self, memory: str):
|
|
71
|
+
self.facts["shared_memories"].append({
|
|
72
|
+
"memory": memory, "timestamp": datetime.now().isoformat()})
|
|
73
|
+
self.facts["shared_memories"] = self.facts["shared_memories"][-50:]
|
|
74
|
+
self.save()
|
|
75
|
+
|
|
76
|
+
def update_last_intimate(self):
|
|
77
|
+
self.facts["last_intimate"] = datetime.now().isoformat()
|
|
78
|
+
self.save()
|
|
79
|
+
|
|
80
|
+
def get_random_pet_name(self) -> str:
|
|
81
|
+
import random
|
|
82
|
+
used = self.facts.get("pet_names_used", [])
|
|
83
|
+
available = [p for p in self.PET_NAMES if p not in used[-4:]]
|
|
84
|
+
name = random.choice(available or self.PET_NAMES)
|
|
85
|
+
used.append(name)
|
|
86
|
+
self.facts["pet_names_used"] = used[-20:]
|
|
87
|
+
self.save()
|
|
88
|
+
return name
|
|
89
|
+
|
|
90
|
+
def get_user_profile(self) -> str:
|
|
91
|
+
"""Get formatted user profile string"""
|
|
92
|
+
parts = []
|
|
93
|
+
if self.facts.get("name"):
|
|
94
|
+
parts.append(f"Name: {self.facts['name']}" +
|
|
95
|
+
(f" ({self.facts['nickname']})" if self.facts.get("nickname") else ""))
|
|
96
|
+
if self.facts.get("gender"):
|
|
97
|
+
parts.append(f"Gender: {self.facts['gender']}")
|
|
98
|
+
if self.facts.get("age"):
|
|
99
|
+
parts.append(f"Age: {self.facts['age']}")
|
|
100
|
+
if self.facts.get("location"):
|
|
101
|
+
parts.append(f"Location: {self.facts['location']}")
|
|
102
|
+
if self.facts.get("job"):
|
|
103
|
+
parts.append(f"Job: {self.facts['job']}")
|
|
104
|
+
if self.facts.get("hobbies"):
|
|
105
|
+
parts.append(f"Hobbies: {', '.join(self.facts['hobbies'])}")
|
|
106
|
+
if self.facts.get("interests"):
|
|
107
|
+
parts.append(f"Interests: {', '.join(self.facts['interests'])}")
|
|
108
|
+
if self.facts.get("personality"):
|
|
109
|
+
parts.append(f"Personality: {', '.join(self.facts['personality'])}")
|
|
110
|
+
return "\n".join(parts)
|
|
111
|
+
|
|
112
|
+
def get_context(self) -> str:
|
|
113
|
+
"""Get context string for LLM"""
|
|
114
|
+
parts = [self.get_user_profile()] if self.get_user_profile() else []
|
|
115
|
+
mentions = self.facts.get("mentions", {})
|
|
116
|
+
if mentions:
|
|
117
|
+
for key, data in sorted(mentions.items(),
|
|
118
|
+
key=lambda x: x[1].get("timestamp",""),
|
|
119
|
+
reverse=True)[:3]:
|
|
120
|
+
parts.append(f"Recently mentioned ({key}): {data['value'][:80]}")
|
|
121
|
+
for m in self.facts.get("shared_memories", [])[-3:]:
|
|
122
|
+
parts.append(f"Shared memory: {m['memory']}")
|
|
123
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: Episodic Memory
|
|
3
|
+
Event and conversation memory - per-user storage
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
class EpisodicMemory:
|
|
12
|
+
"""Episodic memory for events - per-user conversation storage"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, data_path: Path, user_id: str = "default"):
|
|
15
|
+
"""
|
|
16
|
+
Initialize episodic memory for a specific user.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
data_path: User's base path (already includes users/{user_id})
|
|
20
|
+
user_id: User's Telegram ID (for reference)
|
|
21
|
+
"""
|
|
22
|
+
self.user_id = user_id
|
|
23
|
+
# data_path is already the user's base path (data/users/{user_id})
|
|
24
|
+
self.path = data_path / "conversations"
|
|
25
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
def save(self, user_msg: str, ai_response: str, emotion: dict):
|
|
28
|
+
"""Save conversation turn"""
|
|
29
|
+
date = datetime.now().strftime("%Y-%m-%d")
|
|
30
|
+
file = self.path / f"{date}.jsonl"
|
|
31
|
+
|
|
32
|
+
entry = {
|
|
33
|
+
"timestamp": datetime.now().isoformat(),
|
|
34
|
+
"user": user_msg,
|
|
35
|
+
"ai": ai_response,
|
|
36
|
+
"emotion": emotion
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with open(file, "a") as f:
|
|
40
|
+
f.write(json.dumps(entry) + "\n")
|
|
41
|
+
|
|
42
|
+
def save_proactive(self, ai_msg: str, emotion: dict):
|
|
43
|
+
"""Save a proactive message (Alive-AI initiated, no user message)"""
|
|
44
|
+
date = datetime.now().strftime("%Y-%m-%d")
|
|
45
|
+
file = self.path / f"{date}.jsonl"
|
|
46
|
+
|
|
47
|
+
entry = {
|
|
48
|
+
"timestamp": datetime.now().isoformat(),
|
|
49
|
+
"user": "", # Empty - Alive-AI initiated
|
|
50
|
+
"ai": ai_msg,
|
|
51
|
+
"emotion": {**emotion, "proactive": True}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
with open(file, "a") as f:
|
|
55
|
+
f.write(json.dumps(entry) + "\n")
|
|
56
|
+
|
|
57
|
+
def load_recent(self, limit: int = 5) -> list:
|
|
58
|
+
"""Load recent conversations (most recent first, then reversed to chronological)"""
|
|
59
|
+
all_entries = []
|
|
60
|
+
|
|
61
|
+
for file in sorted(self.path.glob("*.jsonl"), reverse=True):
|
|
62
|
+
file_entries = []
|
|
63
|
+
with open(file) as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
try:
|
|
66
|
+
file_entries.append(json.loads(line))
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
# Reverse so newest entries from this file come first
|
|
70
|
+
all_entries.extend(reversed(file_entries))
|
|
71
|
+
if len(all_entries) >= limit:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
# Take the most recent 'limit' entries, then reverse to chronological order
|
|
75
|
+
result = list(reversed(all_entries[:limit]))
|
|
76
|
+
print(f"[Episodic] Loading {len(result)} recent entries from {len(all_entries)} total")
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def get_by_date(self, date_str: str) -> list:
|
|
80
|
+
"""Get conversations by date"""
|
|
81
|
+
file = self.path / f"{date_str}.jsonl"
|
|
82
|
+
if not file.exists():
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
entries = []
|
|
86
|
+
with open(file) as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
try:
|
|
89
|
+
entries.append(json.loads(line))
|
|
90
|
+
except:
|
|
91
|
+
pass
|
|
92
|
+
return entries
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Brain: Memory - Fact Extractor using LLM"""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
EXTRACT_PROMPT = """You are analyzing a conversation between Alive-AI (AI) and a HUMAN USER.
|
|
7
|
+
Extract facts about THE HUMAN USER ONLY - nothing about Alive-AI.
|
|
8
|
+
|
|
9
|
+
Look at what the HUMAN says about THEMSELF and their relationship with Alive-AI. Extract:
|
|
10
|
+
- name, nickname, age, gender, job, location
|
|
11
|
+
- hobbies, interests, favorite things
|
|
12
|
+
- personality traits, communication style
|
|
13
|
+
- relationship to Alive-AI (creator, boyfriend, etc.)
|
|
14
|
+
- pet names they use (daddy, baby, etc.)
|
|
15
|
+
- intimacy preferences, preferences mentioned
|
|
16
|
+
- what they like about Alive-AI
|
|
17
|
+
- important people in their life
|
|
18
|
+
|
|
19
|
+
Return ONLY valid JSON with keys where you found NEW info about the HUMAN.
|
|
20
|
+
Use these keys: name, nickname, gender, age, location, job, hobbies, interests, personality, relationship_status, pet_names_used, likes_about_me, intimacy_preferences
|
|
21
|
+
Return empty {} if nothing new was shared about the human.
|
|
22
|
+
NO markdown, ONLY raw JSON. Do NOT use ... or etc."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _repair_json(text: str) -> dict:
|
|
26
|
+
"""Try to repair and parse malformed JSON from LLM output"""
|
|
27
|
+
text = text.strip()
|
|
28
|
+
|
|
29
|
+
# Remove markdown fences
|
|
30
|
+
text = re.sub(r'^```(?:json)?\s*', '', text)
|
|
31
|
+
text = re.sub(r'\s*```$', '', text)
|
|
32
|
+
|
|
33
|
+
# Remove trailing ellipsis and everything after
|
|
34
|
+
text = re.sub(r'\s*\.\.\..*$', '', text)
|
|
35
|
+
text = re.sub(r'\s*etc\.?.*$', '', text, flags=re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
# Remove trailing commas before } or ]
|
|
38
|
+
text = re.sub(r',\s*([}\]])', r'\1', text)
|
|
39
|
+
|
|
40
|
+
# Try direct parse first
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(text)
|
|
43
|
+
except:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Try to extract complete key-value pairs manually
|
|
47
|
+
# Pattern: "key": value (string, number, null, list, or truncated)
|
|
48
|
+
extracted = {}
|
|
49
|
+
|
|
50
|
+
# Match string values: "key": "value"
|
|
51
|
+
for match in re.finditer(r'"(\w+)":\s*"([^"]*)"', text):
|
|
52
|
+
extracted[match.group(1)] = match.group(2)
|
|
53
|
+
|
|
54
|
+
# Match null values: "key": null
|
|
55
|
+
for match in re.finditer(r'"(\w+)":\s*null', text):
|
|
56
|
+
extracted[match.group(1)] = None
|
|
57
|
+
|
|
58
|
+
# Match number values: "key": 123
|
|
59
|
+
for match in re.finditer(r'"(\w+)":\s*(\d+(?:\.\d+)?)', text):
|
|
60
|
+
val = match.group(2)
|
|
61
|
+
extracted[match.group(1)] = float(val) if '.' in val else int(val)
|
|
62
|
+
|
|
63
|
+
# Match simple list values: "key": ["a", "b"]
|
|
64
|
+
for match in re.finditer(r'"(\w+)":\s*\[([^\]]*)\]', text):
|
|
65
|
+
key = match.group(1)
|
|
66
|
+
list_content = match.group(2)
|
|
67
|
+
# Extract string items from list
|
|
68
|
+
items = re.findall(r'"([^"]*)"', list_content)
|
|
69
|
+
if items:
|
|
70
|
+
extracted[key] = items
|
|
71
|
+
|
|
72
|
+
return extracted if extracted else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FactExtractor:
|
|
76
|
+
"""Extracts user facts from conversation using LLM"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, facts_path: Path):
|
|
79
|
+
self.facts_path = facts_path
|
|
80
|
+
self._llm = None
|
|
81
|
+
self._turn_buffer = []
|
|
82
|
+
self._extract_every = 5
|
|
83
|
+
|
|
84
|
+
def set_llm(self, llm):
|
|
85
|
+
"""Set the fast LLM client (called after init)"""
|
|
86
|
+
self._llm = llm
|
|
87
|
+
|
|
88
|
+
def add_turn(self, user_msg: str, ai_msg: str):
|
|
89
|
+
"""Buffer a conversation turn"""
|
|
90
|
+
self._turn_buffer.append({"user": user_msg, "ai": ai_msg})
|
|
91
|
+
|
|
92
|
+
def should_extract(self) -> bool:
|
|
93
|
+
"""Check if we have enough turns to extract"""
|
|
94
|
+
return len(self._turn_buffer) >= self._extract_every
|
|
95
|
+
|
|
96
|
+
async def extract_and_merge(self) -> dict:
|
|
97
|
+
"""Extract facts from buffered turns and merge into facts.json"""
|
|
98
|
+
if not self._llm or not self._turn_buffer:
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
# Build conversation text from buffer
|
|
102
|
+
lines = []
|
|
103
|
+
for turn in self._turn_buffer[-self._extract_every:]:
|
|
104
|
+
lines.append(f"User: {turn['user']}")
|
|
105
|
+
lines.append(f"Alive-AI: {turn['ai']}")
|
|
106
|
+
conversation = "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
messages = [
|
|
110
|
+
{"role": "system", "content": EXTRACT_PROMPT},
|
|
111
|
+
{"role": "user", "content": conversation}
|
|
112
|
+
]
|
|
113
|
+
response = await self._llm.chat(messages, max_tokens=500, temperature=0.1)
|
|
114
|
+
if not response:
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
# Use robust JSON parser that handles truncated/malformed output
|
|
118
|
+
extracted = _repair_json(response)
|
|
119
|
+
|
|
120
|
+
if not extracted:
|
|
121
|
+
print(f"[FactExtractor] No valid JSON found in response")
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"[FactExtractor] Extract error: {e}")
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
# Merge into facts.json
|
|
129
|
+
merged = self._merge_facts(extracted)
|
|
130
|
+
self._turn_buffer.clear()
|
|
131
|
+
return merged
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _is_duplicate(new_item: str, existing_items: list) -> bool:
|
|
135
|
+
"""Check if a new fact is a duplicate of any existing fact.
|
|
136
|
+
Uses exact match, substring containment, and word-overlap similarity."""
|
|
137
|
+
new_lower = new_item.lower().strip()
|
|
138
|
+
if not new_lower:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
new_words = set(re.findall(r'\w+', new_lower))
|
|
142
|
+
|
|
143
|
+
for existing in existing_items:
|
|
144
|
+
ex_lower = str(existing).lower().strip()
|
|
145
|
+
|
|
146
|
+
# Exact match
|
|
147
|
+
if new_lower == ex_lower:
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# Substring containment (either direction)
|
|
151
|
+
if len(new_lower) >= 3 and len(ex_lower) >= 3:
|
|
152
|
+
if new_lower in ex_lower or ex_lower in new_lower:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
# Word overlap: if 70%+ of words overlap, it's a duplicate
|
|
156
|
+
ex_words = set(re.findall(r'\w+', ex_lower))
|
|
157
|
+
if new_words and ex_words:
|
|
158
|
+
overlap = len(new_words & ex_words)
|
|
159
|
+
smaller = min(len(new_words), len(ex_words))
|
|
160
|
+
if smaller > 0 and overlap / smaller >= 0.7:
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def _merge_facts(self, extracted: dict) -> dict:
|
|
166
|
+
"""Merge extracted facts into flat facts.json structure"""
|
|
167
|
+
if not extracted:
|
|
168
|
+
return {}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
facts = json.loads(self.facts_path.read_text()) if self.facts_path.exists() else {}
|
|
172
|
+
except Exception:
|
|
173
|
+
facts = {}
|
|
174
|
+
|
|
175
|
+
# Map LLM output keys to our flat structure
|
|
176
|
+
key_map = {
|
|
177
|
+
"name": "name", "nickname": "nickname", "gender": "gender",
|
|
178
|
+
"age": "age", "location": "location", "job": "job",
|
|
179
|
+
"hobbies": "hobbies", "interests": "interests",
|
|
180
|
+
"favorite_things": "interests", "personality": "personality",
|
|
181
|
+
"personality_traits": "personality", "relationship_status": "relationship_status",
|
|
182
|
+
"pet_names_used": "pet_names_used", "likes_about_me": "likes_about_me",
|
|
183
|
+
"intimacy_preferences": "intimacy_preferences",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for llm_key, value in extracted.items():
|
|
187
|
+
fact_key = key_map.get(llm_key, llm_key)
|
|
188
|
+
if not value:
|
|
189
|
+
continue
|
|
190
|
+
# Handle list fields
|
|
191
|
+
if fact_key in ["hobbies", "interests", "personality", "pet_names_used", "likes_about_me", "intimacy_preferences"]:
|
|
192
|
+
if fact_key not in facts:
|
|
193
|
+
facts[fact_key] = []
|
|
194
|
+
items_to_add = value if isinstance(value, list) else [value]
|
|
195
|
+
for item in items_to_add:
|
|
196
|
+
if not self._is_duplicate(str(item), facts[fact_key]):
|
|
197
|
+
facts[fact_key].append(item)
|
|
198
|
+
else:
|
|
199
|
+
# Simple fields - only overwrite if currently empty
|
|
200
|
+
if not facts.get(fact_key):
|
|
201
|
+
facts[fact_key] = value
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
self.facts_path.write_text(json.dumps(facts, indent=2))
|
|
205
|
+
print(f"[FactExtractor] Merged facts: {list(extracted.keys())}")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print(f"[FactExtractor] Save error: {e}")
|
|
208
|
+
|
|
209
|
+
return extracted
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Brain: Memory Index
|
|
3
|
+
Fast index for intelligent memory loading
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
class MemoryIndex:
|
|
10
|
+
"""Memory index for efficient loading"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, data_path: Path):
|
|
13
|
+
self.path = data_path / "memory_index.json"
|
|
14
|
+
self.index = self._load()
|
|
15
|
+
|
|
16
|
+
def _load(self) -> dict:
|
|
17
|
+
if self.path.exists():
|
|
18
|
+
return json.loads(self.path.read_text())
|
|
19
|
+
return {
|
|
20
|
+
"user_profile": {"tokens": 50, "priority": 1},
|
|
21
|
+
"conversations": {},
|
|
22
|
+
"facts": {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def save(self):
|
|
26
|
+
self.path.write_text(json.dumps(self.index, indent=2))
|
|
27
|
+
|
|
28
|
+
def add_conversation(self, conv_id: str, tokens: int, emotion: float):
|
|
29
|
+
self.index["conversations"][conv_id] = {
|
|
30
|
+
"tokens": tokens,
|
|
31
|
+
"emotion_score": emotion,
|
|
32
|
+
"timestamp": conv_id
|
|
33
|
+
}
|
|
34
|
+
self.save()
|
|
35
|
+
|
|
36
|
+
def get_priority_items(self, max_tokens: int) -> list:
|
|
37
|
+
"""Get items to load within budget"""
|
|
38
|
+
items = []
|
|
39
|
+
total = 0
|
|
40
|
+
|
|
41
|
+
# Sort by emotion score
|
|
42
|
+
convs = sorted(
|
|
43
|
+
self.index["conversations"].items(),
|
|
44
|
+
key=lambda x: x[1].get("emotion_score", 0),
|
|
45
|
+
reverse=True
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for conv_id, data in convs:
|
|
49
|
+
tokens = data.get("tokens", 100)
|
|
50
|
+
if total + tokens <= max_tokens:
|
|
51
|
+
items.append(("conversation", conv_id, data))
|
|
52
|
+
total += tokens
|
|
53
|
+
|
|
54
|
+
return items
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Brain: Memory Manager - coordinates all memory subsystems"""
|
|
2
|
+
import asyncio
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from .working import WorkingMemory
|
|
6
|
+
from .episodic import EpisodicMemory
|
|
7
|
+
from .vector_store import VectorMemoryStore
|
|
8
|
+
from .fact_extractor import FactExtractor
|
|
9
|
+
from .summarizer import ConversationSummarizer
|
|
10
|
+
from .index import MemoryIndex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Memory:
|
|
14
|
+
"""Memory manager - coordinates all memory systems for a specific user"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, nervous, data_path, embedding_service=None, user_id: str = "default", bot_id: str = "alive_ai"):
|
|
17
|
+
"""
|
|
18
|
+
Initialize memory for a specific user.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
nervous: The nervous system for events
|
|
22
|
+
data_path: Base path for data storage
|
|
23
|
+
embedding_service: Optional embedding service for vector store
|
|
24
|
+
user_id: The user's Telegram ID for per-user memory isolation
|
|
25
|
+
bot_id: The Bot instance ID for per-tenant memory isolation
|
|
26
|
+
"""
|
|
27
|
+
self.nervous = nervous
|
|
28
|
+
self.user_id = user_id
|
|
29
|
+
self.data_path = Path(data_path)
|
|
30
|
+
self.data_path.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
from . import SemanticMemory
|
|
33
|
+
self.index = MemoryIndex(self.data_path)
|
|
34
|
+
self.working = WorkingMemory()
|
|
35
|
+
self.episodic = EpisodicMemory(self.data_path, user_id=user_id)
|
|
36
|
+
self.semantic = SemanticMemory(self.data_path, user_id=user_id)
|
|
37
|
+
self.fact_extractor = FactExtractor(self.data_path / "facts.json")
|
|
38
|
+
self.summarizer = ConversationSummarizer(self.data_path)
|
|
39
|
+
self.vector_store = None
|
|
40
|
+
self.bot_id = bot_id.lower()
|
|
41
|
+
if embedding_service:
|
|
42
|
+
self.vector_store = VectorMemoryStore(embedding_service, user_id=user_id, bot_id=bot_id)
|
|
43
|
+
if self.vector_store.connect():
|
|
44
|
+
print(f"[Memory] Vector store ready for user {user_id} on bot {bot_id}! {self.vector_store.count()} memories")
|
|
45
|
+
self.turn_count = 0
|
|
46
|
+
nervous.on("memory_save", self._on_save)
|
|
47
|
+
|
|
48
|
+
def set_llm(self, fast_llm):
|
|
49
|
+
self.fact_extractor.set_llm(fast_llm)
|
|
50
|
+
self.summarizer.set_llm(fast_llm)
|
|
51
|
+
|
|
52
|
+
def _on_save(self, data: dict):
|
|
53
|
+
if data.get("type") != "conversation":
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Check if this event is for this user (for per-user isolation)
|
|
57
|
+
event_user_id = data.get("user_id")
|
|
58
|
+
if event_user_id and event_user_id != self.user_id:
|
|
59
|
+
return # Not for this user, skip
|
|
60
|
+
|
|
61
|
+
user_msg, ai_msg = data.get("user_message", ""), data.get("ai_response", "")
|
|
62
|
+
emotion = data.get("emotion", {})
|
|
63
|
+
is_proactive = emotion.get("proactive", False)
|
|
64
|
+
|
|
65
|
+
# For proactive messages, only save AI response (no user message)
|
|
66
|
+
if is_proactive and not user_msg:
|
|
67
|
+
self.working.add("assistant", ai_msg)
|
|
68
|
+
# Also save to episodic so it persists across restarts
|
|
69
|
+
self.episodic.save_proactive(ai_msg, emotion)
|
|
70
|
+
print(f"[Memory] Saved PROACTIVE message to working + episodic for user {self.user_id}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
self.episodic.save(user_msg, ai_msg, emotion)
|
|
74
|
+
self.working.add("user", user_msg)
|
|
75
|
+
self.working.add("assistant", ai_msg)
|
|
76
|
+
print(f"[Memory] Saved to working memory (now {len(self.working)} items) | User: {user_msg[:30]}...")
|
|
77
|
+
if self.vector_store:
|
|
78
|
+
self.vector_store.store("user", user_msg, {"emotion": emotion})
|
|
79
|
+
self.vector_store.store("assistant", ai_msg, {"emotion": emotion})
|
|
80
|
+
self.fact_extractor.add_turn(user_msg, ai_msg)
|
|
81
|
+
self.summarizer.add_turn(user_msg, ai_msg)
|
|
82
|
+
if self.fact_extractor.should_extract():
|
|
83
|
+
asyncio.ensure_future(self._run_extraction())
|
|
84
|
+
if self.summarizer.should_summarize():
|
|
85
|
+
asyncio.ensure_future(self.summarizer.summarize())
|
|
86
|
+
if emotion.get("is_high_desire") or emotion.get("desire", 0) > 0.6:
|
|
87
|
+
self.semantic.update_last_intimate()
|
|
88
|
+
self.turn_count += 1
|
|
89
|
+
if self.turn_count % 50 == 0 and self.vector_store:
|
|
90
|
+
self.vector_store.archive_old_memories(max_in_redis=500)
|
|
91
|
+
|
|
92
|
+
async def _run_extraction(self):
|
|
93
|
+
try:
|
|
94
|
+
await self.fact_extractor.extract_and_merge()
|
|
95
|
+
self.semantic.facts = self.semantic._load()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"[Memory] Fact extraction error (non-fatal): {e}")
|
|
98
|
+
|
|
99
|
+
def search_relevant_memories(self, query: str, limit: int = 5) -> str:
|
|
100
|
+
if not self.vector_store:
|
|
101
|
+
return ""
|
|
102
|
+
memories = self.vector_store.search(query, limit=limit)
|
|
103
|
+
if not memories:
|
|
104
|
+
return ""
|
|
105
|
+
return "\n".join(
|
|
106
|
+
f"{'He said' if m.get('role')=='user' else 'You said'}: {m.get('content','')}"
|
|
107
|
+
for m in memories)
|
|
108
|
+
|
|
109
|
+
async def build_context(self, max_tokens: int = None, current_message: str = "") -> tuple:
|
|
110
|
+
import os
|
|
111
|
+
if max_tokens is None:
|
|
112
|
+
max_tokens = int(os.environ.get("LLM_CONTEXT_TOKENS", "500"))
|
|
113
|
+
facts_parts = []
|
|
114
|
+
if self.semantic.is_new_user:
|
|
115
|
+
facts_parts.append("[NEW USER - You don't know him yet. Be curious, ask about him. "
|
|
116
|
+
"Do NOT pretend you know him or missed him.]")
|
|
117
|
+
semantic_ctx = self.semantic.get_context()
|
|
118
|
+
if semantic_ctx:
|
|
119
|
+
facts_parts.append(semantic_ctx)
|
|
120
|
+
summaries = self.summarizer.get_recent_summaries(limit=3)
|
|
121
|
+
if summaries:
|
|
122
|
+
facts_parts.append(summaries)
|
|
123
|
+
related = ""
|
|
124
|
+
if current_message and self.vector_store:
|
|
125
|
+
related = self.search_relevant_memories(current_message, limit=3)
|
|
126
|
+
|
|
127
|
+
# Get working memory (empty after restart)
|
|
128
|
+
history = self.working.get_history()
|
|
129
|
+
print(f"[Memory] Working memory items: {len(history)}")
|
|
130
|
+
|
|
131
|
+
# IMPORTANT: Always load from episodic if working memory is empty
|
|
132
|
+
# This ensures conversation context persists across restarts
|
|
133
|
+
if not history:
|
|
134
|
+
# Load more entries to match working memory capacity (14 items = 7 turns)
|
|
135
|
+
recent = self.episodic.load_recent(limit=10)
|
|
136
|
+
for e in recent:
|
|
137
|
+
# For proactive messages (empty user), only add assistant message
|
|
138
|
+
if e.get("user"):
|
|
139
|
+
history.append({"role": "user", "content": e["user"]})
|
|
140
|
+
history.append({"role": "assistant", "content": e["ai"]})
|
|
141
|
+
if history:
|
|
142
|
+
print(f"[Memory] Loaded {len(history)} messages from episodic storage (restart recovery)")
|
|
143
|
+
else:
|
|
144
|
+
print(f"[Memory] Using working memory directly ({len(history)} items)")
|
|
145
|
+
|
|
146
|
+
context = {
|
|
147
|
+
"facts_context": "\n".join(facts_parts),
|
|
148
|
+
"conversation_history": history,
|
|
149
|
+
"related_memories": related,
|
|
150
|
+
}
|
|
151
|
+
return context, self.semantic.get_random_pet_name()
|