alive-ai 0.1.13 → 0.1.15

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.
@@ -0,0 +1,268 @@
1
+ """Durable WebUI projection helpers.
2
+
3
+ The runtime has several durable stores. This module gives the dashboard one
4
+ small journal for visible chat rows and a safe fallback into episodic memory.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import uuid
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from core.paths import data_dir
16
+
17
+
18
+ _SAFE_ID = re.compile(r"[^A-Za-z0-9_.@-]+")
19
+
20
+
21
+ def normalize_user_id(user_id: Any) -> str:
22
+ raw = str(user_id or "").strip()
23
+ if not raw:
24
+ raw = "webui"
25
+ safe = _SAFE_ID.sub("_", raw).strip("._-")
26
+ return safe or "webui"
27
+
28
+
29
+ def _configured_owner_id() -> str:
30
+ owner = os.environ.get("TELEGRAM_OWNER_ID", "")
31
+ if owner:
32
+ return owner
33
+ try:
34
+ from core.settings import get as settings_get
35
+ return str(settings_get("TELEGRAM_OWNER_ID", "") or "")
36
+ except Exception:
37
+ return ""
38
+
39
+
40
+ def _tracked_active_user_id() -> str:
41
+ try:
42
+ from core.user_tracker import get_user_tracker
43
+ active = get_user_tracker().get_active_users(within_minutes=24 * 60)
44
+ if active:
45
+ active = sorted(active, key=lambda u: u.last_interaction, reverse=True)
46
+ return active[0].user_id
47
+ except Exception:
48
+ pass
49
+ return ""
50
+
51
+
52
+ def _path_activity_score(path: Path) -> Tuple[float, int]:
53
+ latest = path.stat().st_mtime if path.exists() else 0.0
54
+ count = 0
55
+ for pattern in ("conversations/*.jsonl", "webui_chat.jsonl", "narrative.json",
56
+ "facts.json", "emotional_memories.json"):
57
+ for item in path.glob(pattern):
58
+ try:
59
+ latest = max(latest, item.stat().st_mtime)
60
+ if item.is_file():
61
+ count += 1
62
+ except Exception:
63
+ continue
64
+ return latest, count
65
+
66
+
67
+ def _most_active_disk_user_id() -> str:
68
+ users = data_dir() / "users"
69
+ if not users.exists():
70
+ return ""
71
+ candidates = []
72
+ for child in users.iterdir():
73
+ if not child.is_dir() or child.name in {"default", "webui"}:
74
+ continue
75
+ latest, count = _path_activity_score(child)
76
+ if count:
77
+ candidates.append((latest, child.name))
78
+ if not candidates:
79
+ return ""
80
+ return max(candidates)[1]
81
+
82
+
83
+ def resolve_active_user_id(explicit: Any = None, self_ref: Any = None,
84
+ dashboard_state: Optional[Dict[str, Any]] = None) -> str:
85
+ if explicit:
86
+ return normalize_user_id(explicit)
87
+
88
+ dashboard_state = dashboard_state or {}
89
+ active = dashboard_state.get("active_user")
90
+ if active and normalize_user_id(active) not in {"default", "webui"}:
91
+ return normalize_user_id(dashboard_state["active_user"])
92
+
93
+ tracked = _tracked_active_user_id()
94
+ if tracked:
95
+ return normalize_user_id(tracked)
96
+
97
+ owner = _configured_owner_id()
98
+ if owner:
99
+ return normalize_user_id(owner)
100
+
101
+ runtime_state = getattr(self_ref, "state", None)
102
+ runtime_user = getattr(runtime_state, "user_id", None) if runtime_state else None
103
+ if runtime_user and normalize_user_id(runtime_user) not in {"default", "webui"}:
104
+ return normalize_user_id(runtime_state.user_id)
105
+
106
+ disk_user = _most_active_disk_user_id()
107
+ if disk_user:
108
+ return normalize_user_id(disk_user)
109
+
110
+ if active:
111
+ return normalize_user_id(active)
112
+
113
+ if runtime_user:
114
+ return normalize_user_id(runtime_user)
115
+
116
+ return "webui"
117
+
118
+
119
+ def user_base(user_id: str) -> Path:
120
+ path = data_dir() / "users" / normalize_user_id(user_id)
121
+ path.mkdir(parents=True, exist_ok=True)
122
+ return path
123
+
124
+
125
+ def chat_journal_path(user_id: str) -> Path:
126
+ return user_base(user_id) / "webui_chat.jsonl"
127
+
128
+
129
+ def new_message_id(prefix: str = "msg") -> str:
130
+ return f"{prefix}_{uuid.uuid4().hex[:16]}"
131
+
132
+
133
+ def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
134
+ rows: List[Dict[str, Any]] = []
135
+ if not path.exists():
136
+ return rows
137
+ with path.open() as fh:
138
+ for line in fh:
139
+ try:
140
+ row = json.loads(line)
141
+ if isinstance(row, dict):
142
+ rows.append(row)
143
+ except Exception:
144
+ continue
145
+ return rows
146
+
147
+
148
+ def append_chat_message(user_id: str, role: str, content: str,
149
+ message_id: Optional[str] = None,
150
+ status: str = "sent", source: str = "runtime",
151
+ metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
152
+ message_id = message_id or new_message_id(role)
153
+ entry = {
154
+ "message_id": message_id,
155
+ "role": role,
156
+ "content": str(content or ""),
157
+ "status": status,
158
+ "source": source,
159
+ "timestamp": datetime.now().isoformat(),
160
+ }
161
+ if metadata:
162
+ entry["metadata"] = metadata
163
+
164
+ path = chat_journal_path(user_id)
165
+ existing = _read_jsonl(path)
166
+ for idx, row in enumerate(existing):
167
+ if row.get("message_id") == message_id:
168
+ merged = {**row, **{k: v for k, v in entry.items() if v not in (None, "")}}
169
+ existing[idx] = merged
170
+ tmp = path.with_suffix(path.suffix + ".tmp")
171
+ with tmp.open("w") as fh:
172
+ for item in existing:
173
+ fh.write(json.dumps(item, ensure_ascii=False) + "\n")
174
+ tmp.replace(path)
175
+ return entry
176
+ with path.open("a") as fh:
177
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
178
+ return entry
179
+
180
+
181
+ def _format_entry(row: Dict[str, Any]) -> Dict[str, Any]:
182
+ timestamp = row.get("timestamp") or row.get("created_at") or ""
183
+ time_label = ""
184
+ if timestamp:
185
+ try:
186
+ time_label = datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
187
+ except Exception:
188
+ time_label = str(timestamp)[11:19] if len(str(timestamp)) >= 19 else ""
189
+ return {
190
+ "message_id": row.get("message_id") or new_message_id("legacy"),
191
+ "role": row.get("role", "assistant"),
192
+ "content": row.get("content", ""),
193
+ "time": time_label,
194
+ "timestamp": timestamp,
195
+ "status": row.get("status", "sent"),
196
+ "source": row.get("source", "runtime"),
197
+ }
198
+
199
+
200
+ def _load_journal(user_id: str) -> List[Dict[str, Any]]:
201
+ return [_format_entry(row) for row in _read_jsonl(chat_journal_path(user_id))]
202
+
203
+
204
+ def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, Any]]:
205
+ base = user_base(user_id) / "conversations"
206
+ legacy = data_dir() / "conversations"
207
+ conv_dirs = [base]
208
+ if legacy != base:
209
+ conv_dirs.append(legacy)
210
+ bot_prefixed = [p for p in (data_dir() / "users").glob(f"*_{normalize_user_id(user_id)}")
211
+ if (p / "conversations").exists()]
212
+ conv_dirs.extend(p / "conversations" for p in bot_prefixed)
213
+
214
+ existing_dirs = [p for p in conv_dirs if p.exists() and list(p.glob("*.jsonl"))]
215
+ if not existing_dirs:
216
+ return []
217
+
218
+ turns: List[Dict[str, Any]] = []
219
+ for conv_dir in existing_dirs:
220
+ for file in sorted(conv_dir.glob("*.jsonl"), reverse=True):
221
+ file_rows = _read_jsonl(file)
222
+ turns.extend(reversed(file_rows))
223
+ turns = sorted(turns, key=lambda row: row.get("timestamp", ""), reverse=True)[:limit_turns]
224
+
225
+ messages: List[Dict[str, Any]] = []
226
+ for row in reversed(turns):
227
+ ts = row.get("timestamp", "")
228
+ if row.get("user"):
229
+ messages.append(_format_entry({
230
+ "message_id": f"legacy_user_{len(messages)}_{ts}",
231
+ "role": "user",
232
+ "content": row.get("user", ""),
233
+ "timestamp": ts,
234
+ "source": "episodic",
235
+ }))
236
+ if row.get("ai"):
237
+ messages.append(_format_entry({
238
+ "message_id": f"legacy_ai_{len(messages)}_{ts}",
239
+ "role": "alive_ai",
240
+ "content": row.get("ai", ""),
241
+ "timestamp": ts,
242
+ "source": "episodic",
243
+ }))
244
+ return messages
245
+
246
+
247
+ def load_chat_messages(user_id: str, limit: int = 60) -> List[Dict[str, Any]]:
248
+ if limit and limit > 0:
249
+ episodic_limit = max(1, limit // 2)
250
+ else:
251
+ episodic_limit = 1_000_000
252
+ messages = _load_episodic_fallback(user_id, episodic_limit)
253
+ messages.extend(_load_journal(user_id))
254
+
255
+ deduped: Dict[str, Dict[str, Any]] = {}
256
+ for msg in messages:
257
+ key = msg.get("message_id") or f"{msg.get('role')}:{msg.get('timestamp')}:{msg.get('content')}"
258
+ deduped[key] = msg
259
+
260
+ ordered = sorted(
261
+ deduped.values(),
262
+ key=lambda m: m.get("timestamp") or ""
263
+ )
264
+ return ordered[-limit:] if limit and limit > 0 else ordered
265
+
266
+
267
+ def count_visible_messages(user_id: str) -> int:
268
+ return len(load_chat_messages(user_id, limit=0))