alive-ai 0.1.14 → 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.
package/README.md CHANGED
@@ -31,7 +31,7 @@ The emotional layer now has real runtime consequences:
31
31
  | Complex emotions | Guilt, pride, jealousy, embarrassment, and anticipation. | They do not just label the dashboard. They push fear, sadness, anger, dominance, trust, arousal, joy, and future-facing behavior in different directions. |
32
32
  | Hormones | Oxytocin, dopamine, serotonin, cortisol, melatonin, plus residual metabolites. | Hormones modulate perception, soul valence/arousal, emotional deltas, somatic body state, interoception, impulse probability, and prompt guidance. Stress makes her more vigilant; bonding increases trust; dopamine increases pursuit; serotonin stabilizes; melatonin slows her down. |
33
33
  | Internal body state | Energy, arousal, certainty, social satiety, cognitive load, connection craving, body sensations, and somatic memories. | The body state is persisted and feeds prompt tone, sleep/rest behavior, and whether she feels steady, overloaded, touchy, open, or withdrawn. |
34
- | Circadian rhythm | Phase, sleep pressure, sleep debt, forced-awake windows, sleep cycle ID, wake time, and sleepiness. | She becomes sleepy, slows down, falls asleep, stops outward proactive behavior while asleep, can be woken by a message, recovers sleep debt, and wakes with lower or higher energy depending on rest. |
34
+ | Circadian rhythm | Phase, sleep pressure, sleep debt in hours, forced-awake windows, sleep cycle ID, wake time, and sleepiness. | She becomes sleepy, slows down, falls asleep, stops outward proactive behavior while asleep, can be woken by a message, recovers sleep debt, and wakes with lower or higher energy depending on rest. After 2am, high sleep pressure shortens user wake-up windows so she can drift back to sleep instead of staying pinned awake. |
35
35
  | Dreams | One normalized dream per sleep cycle, generated from memory fragments and emotion tags. | Dreams are saved, can appear in morning context, and are exposed in the dashboard and static Pages demo. |
36
36
  | Narrative | Relationship phase (first_meeting → bonded) tracked per user. Key moments are detected from message content. | Phase and moment count are injected into the LLM prompt each turn. The dashboard shows the current phase and total key moments. |
37
37
  | Curiosity | Per-user knowledge map across topics detected in messages. Topics range from 0 (unknown) to 1 (well-understood). | When knowledge on a topic is below 0.3 she asks a direct question. At 40% probability otherwise she surfaces curiosity as a prompt hint. Dashboard shows topics sorted by curiosity level. |
@@ -277,7 +277,9 @@ The real WebUI streams local runtime state over Server-Sent Events and shows:
277
277
  - attachment, circadian rhythm, sleepiness, body memory, dreams, curiosity, and conflicts,
278
278
  - runtime health through local endpoints.
279
279
 
280
- The WebUI hydrates from durable runtime stores instead of only the current browser session. Chat rows are journaled per active user under `data/users/<user>/webui_chat.jsonl`, with fallback to episodic conversation history. `/state` and the SSE stream now use the same composed snapshot: visible chat, runtime state, soul state, aliveness state, current thoughts, memory counters, and the active dashboard user.
280
+ The WebUI hydrates from durable runtime stores instead of only the current browser session. It resolves the active dashboard user from explicit WebUI input, live Telegram activity, configured owner ID, runtime state, and finally the most active user folder on disk. Chat rows are journaled per active user under `data/users/<user>/webui_chat.jsonl` and merged with episodic Telegram conversation history, including legacy flat `data/conversations` history after upgrades. `/state` and the SSE stream now use the same composed snapshot: visible chat, runtime state, soul state, aliveness state, current thoughts, memory counters, and the active dashboard user.
281
+
282
+ Sleep debt is stored and shown as hours on a 0-8h pressure scale. The UI no longer reports it as a misleading capped percentage, so a persisted `5.6h` debt displays as `5.6h` with the matching pressure bar.
281
283
 
282
284
  Settings edits validate JSON before saving and write atomically, so a bad edit cannot corrupt the existing config file. The Settings tab also protects unsaved edits while switching tabs.
283
285
 
@@ -268,7 +268,9 @@ def _get_or_create_user_memory(self, user_id: str):
268
268
  from brain.memory import Memory
269
269
  from core.user_manager import UserManager
270
270
 
271
- instance_data_path = UserManager().get_user_paths(user_id)["base"]
271
+ user_manager = UserManager()
272
+ user_manager.migrate_legacy_data(user_id)
273
+ instance_data_path = user_manager.get_user_paths(user_id)["base"]
272
274
 
273
275
  memory = Memory(
274
276
  nervous=self.nervous,
@@ -140,9 +140,21 @@ class CircadianEngine:
140
140
  return round(self._clamp(sleepiness), 2)
141
141
 
142
142
  def _should_auto_sleep(self, now: datetime) -> bool:
143
- if self._is_forced_awake(now):
143
+ sleepiness = self.get_sleepiness()
144
+ if not (2 <= now.hour < 6 and sleepiness >= 0.85):
144
145
  return False
145
- return 2 <= now.hour < 6 and self.get_sleepiness() >= 0.85
146
+ if not self._is_forced_awake(now):
147
+ return True
148
+ # After 2am, sleep pressure can override a user wake-up. Messages can
149
+ # briefly rouse her, but they should not pin her awake for another hour.
150
+ if sleepiness >= 0.95:
151
+ return True
152
+ try:
153
+ until = datetime.fromisoformat(self.forced_awake_until) if self.forced_awake_until else now
154
+ forced_started = until - timedelta(minutes=self._forced_awake_duration_minutes(now))
155
+ return (now - forced_started).total_seconds() >= 10 * 60
156
+ except Exception:
157
+ return sleepiness >= 0.9
146
158
 
147
159
  def _should_auto_wake(self, now: datetime) -> bool:
148
160
  slept = self._hours_asleep(now)
@@ -231,9 +243,21 @@ class CircadianEngine:
231
243
  self._save()
232
244
  return True
233
245
 
234
- def stay_up_for_user(self, duration_minutes: int = 45):
246
+ def _forced_awake_duration_minutes(self, now: datetime = None) -> int:
247
+ now = now or self._now()
248
+ sleepiness = self.get_sleepiness()
249
+ if 2 <= now.hour < 6 and sleepiness >= 0.9:
250
+ return 10
251
+ if 0 <= now.hour < 6 and sleepiness >= 0.85:
252
+ return 20
253
+ if now.hour >= 23 and sleepiness >= 0.75:
254
+ return 30
255
+ return 45
256
+
257
+ def stay_up_for_user(self, duration_minutes: int = None):
235
258
  """User is keeping her awake past bedtime."""
236
259
  now = self._now()
260
+ duration_minutes = duration_minutes or self._forced_awake_duration_minutes(now)
237
261
  self.forced_awake = True
238
262
  self.forced_awake_until = (now + timedelta(minutes=duration_minutes)).isoformat()
239
263
  self.last_transition_reason = "staying_up_for_user"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alive-ai",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Local-first emotional AI runtime with memory, impulses, and a live dashboard.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vindepemarte.github.io/alive-ai/",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alive-ai-runtime"
3
- version = "0.1.14"
3
+ version = "0.1.15"
4
4
  description = "Local-first emotional AI runtime with memory, impulses, and a live dashboard."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
package/webui/app.py CHANGED
@@ -15,6 +15,7 @@ from fastapi.staticfiles import StaticFiles
15
15
  from core.paths import data_dir, media_dir
16
16
  from .persistence import (
17
17
  append_chat_message,
18
+ count_visible_messages,
18
19
  load_chat_messages,
19
20
  new_message_id,
20
21
  resolve_active_user_id,
@@ -27,26 +28,45 @@ app = FastAPI(title="Alive-AI Dashboard")
27
28
  _start_time = datetime.now()
28
29
 
29
30
 
30
- def load_persistent_stats() -> dict:
31
+ def load_persistent_stats(active_user: str = None) -> dict:
31
32
  """Load stats from actual data sources on startup"""
32
33
  stats = {"messages": 0, "memories": 0, "evaluations": 0}
33
34
 
34
35
  # Try different base paths
35
36
  base_paths = [data_dir()]
36
37
 
37
- # Count messages from conversation summaries (in users/*/summaries/)
38
+ if active_user:
39
+ try:
40
+ stats["messages"] = count_visible_messages(active_user)
41
+ except Exception:
42
+ pass
43
+
44
+ # Count actual per-user conversation rows and WebUI journal rows.
38
45
  for base_path in base_paths:
39
46
  try:
40
- # Look for summaries in users/*/summaries/
41
47
  users_path = base_path / "users"
42
48
  if users_path.exists():
43
49
  count = 0
44
50
  for user_dir in users_path.iterdir():
45
- summaries_path = user_dir / "summaries"
46
- if summaries_path.exists():
47
- count += len(list(summaries_path.glob("*.json")))
51
+ conv_path = user_dir / "conversations"
52
+ if conv_path.exists():
53
+ for conv_file in conv_path.glob("*.jsonl"):
54
+ with conv_file.open() as fh:
55
+ for line in fh:
56
+ try:
57
+ row = json.loads(line)
58
+ if row.get("user"):
59
+ count += 1
60
+ if row.get("ai"):
61
+ count += 1
62
+ except Exception:
63
+ continue
64
+ journal_path = user_dir / "webui_chat.jsonl"
65
+ if journal_path.exists():
66
+ with journal_path.open() as fh:
67
+ count += sum(1 for _ in fh)
48
68
  if count > 0:
49
- stats["messages"] = count
69
+ stats["messages"] = max(stats["messages"], count)
50
70
  break
51
71
  except Exception:
52
72
  pass
@@ -313,6 +333,10 @@ def build_snapshot(user_id: str = None) -> dict:
313
333
  snapshot["soul"] = soul_state
314
334
  snapshot["aliveness"] = aliveness_state
315
335
  snapshot["conversation"] = load_chat_messages(active_user)
336
+ snapshot["stats"] = {
337
+ **snapshot.get("stats", {}),
338
+ **load_persistent_stats(active_user),
339
+ }
316
340
  thoughts = _subconscious_thoughts()
317
341
  snapshot["recent_thoughts"] = thoughts
318
342
  snapshot["current_thought"] = thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought")
@@ -491,7 +515,7 @@ async def health():
491
515
  @app.get("/api/stats")
492
516
  async def get_persistent_stats():
493
517
  """Get stats refreshed from actual data sources"""
494
- stats = load_persistent_stats()
518
+ stats = load_persistent_stats(_active_user_id())
495
519
 
496
520
  # Update global state with fresh stats
497
521
  alive_ai_state["stats"] = stats
@@ -936,8 +960,7 @@ async def get_new_aliveness():
936
960
  try:
937
961
  from brain.narrative import get_narrative_engine
938
962
  ne = get_narrative_engine()
939
- from core.settings import get as settings_get
940
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
963
+ owner_id = _active_user_id()
941
964
 
942
965
  # Fallback: when owner_id is empty (terminal mode), find the most active user
943
966
  if not owner_id:
@@ -972,7 +995,7 @@ async def get_new_aliveness():
972
995
 
973
996
  result["narrative"] = {
974
997
  "phase": data.get("phase", "first_meeting"),
975
- "message_count": msg_count,
998
+ "message_count": max(msg_count, count_visible_messages(owner_id)),
976
999
  "moments": len(data.get("key_moments", []))
977
1000
  }
978
1001
  else:
@@ -991,8 +1014,7 @@ async def get_new_aliveness():
991
1014
  # Linguistic
992
1015
  try:
993
1016
  from brain.linguistic import get_linguistic_profile
994
- from core.settings import get as settings_get
995
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
1017
+ owner_id = _active_user_id()
996
1018
  if owner_id:
997
1019
  lp = get_linguistic_profile(owner_id)
998
1020
  patterns = lp.get_absorbed_patterns() if hasattr(lp, 'get_absorbed_patterns') else {}
@@ -1010,8 +1032,7 @@ async def get_new_aliveness():
1010
1032
  # Curiosity
1011
1033
  try:
1012
1034
  from brain.curiosity import get_curiosity_drive
1013
- from core.settings import get as settings_get
1014
- owner_id = str(settings_get("TELEGRAM_OWNER_ID", ""))
1035
+ owner_id = _active_user_id()
1015
1036
 
1016
1037
  # Fallback: when owner_id is empty (terminal mode), find the most active user
1017
1038
  if not owner_id:
@@ -10,7 +10,7 @@ import re
10
10
  import uuid
11
11
  from datetime import datetime
12
12
  from pathlib import Path
13
- from typing import Any, Dict, List, Optional
13
+ from typing import Any, Dict, List, Optional, Tuple
14
14
 
15
15
  from core.paths import data_dir
16
16
 
@@ -26,23 +26,93 @@ def normalize_user_id(user_id: Any) -> str:
26
26
  return safe or "webui"
27
27
 
28
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
+
29
83
  def resolve_active_user_id(explicit: Any = None, self_ref: Any = None,
30
84
  dashboard_state: Optional[Dict[str, Any]] = None) -> str:
31
85
  if explicit:
32
86
  return normalize_user_id(explicit)
33
87
 
34
88
  dashboard_state = dashboard_state or {}
35
- if dashboard_state.get("active_user"):
89
+ active = dashboard_state.get("active_user")
90
+ if active and normalize_user_id(active) not in {"default", "webui"}:
36
91
  return normalize_user_id(dashboard_state["active_user"])
37
92
 
38
- runtime_state = getattr(self_ref, "state", None)
39
- if runtime_state and getattr(runtime_state, "user_id", None):
40
- return normalize_user_id(runtime_state.user_id)
93
+ tracked = _tracked_active_user_id()
94
+ if tracked:
95
+ return normalize_user_id(tracked)
41
96
 
42
- owner = os.environ.get("TELEGRAM_OWNER_ID", "")
97
+ owner = _configured_owner_id()
43
98
  if owner:
44
99
  return normalize_user_id(owner)
45
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
+
46
116
  return "webui"
47
117
 
48
118
 
@@ -134,19 +204,26 @@ def _load_journal(user_id: str) -> List[Dict[str, Any]]:
134
204
  def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, Any]]:
135
205
  base = user_base(user_id) / "conversations"
136
206
  legacy = data_dir() / "conversations"
137
- conv_dir = base if list(base.glob("*.jsonl")) else legacy
138
- if not conv_dir.exists():
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:
139
216
  return []
140
217
 
141
218
  turns: List[Dict[str, Any]] = []
142
- for file in sorted(conv_dir.glob("*.jsonl"), reverse=True):
143
- file_rows = _read_jsonl(file)
144
- turns.extend(reversed(file_rows))
145
- if len(turns) >= limit_turns:
146
- break
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]
147
224
 
148
225
  messages: List[Dict[str, Any]] = []
149
- for row in reversed(turns[:limit_turns]):
226
+ for row in reversed(turns):
150
227
  ts = row.get("timestamp", "")
151
228
  if row.get("user"):
152
229
  messages.append(_format_entry({
@@ -168,7 +245,24 @@ def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, An
168
245
 
169
246
 
170
247
  def load_chat_messages(user_id: str, limit: int = 60) -> List[Dict[str, Any]]:
171
- messages = _load_journal(user_id)
172
- if not messages:
173
- messages = _load_episodic_fallback(user_id, max(1, limit // 2))
174
- return messages[-limit:]
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))
@@ -40,10 +40,10 @@
40
40
  html, body {
41
41
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
42
42
  background: var(--bg-primary);
43
- min-height: 100vh;
44
- min-height: -webkit-fill-available;
43
+ height: 100%;
44
+ min-height: 100%;
45
45
  color: var(--text-primary);
46
- overflow-x: hidden;
46
+ overflow: hidden;
47
47
  -webkit-font-smoothing: antialiased;
48
48
  }
49
49
 
@@ -70,12 +70,14 @@
70
70
 
71
71
  /* Mobile App Shell */
72
72
  .app {
73
- min-height: 100vh;
73
+ height: 100vh;
74
+ height: 100dvh;
74
75
  min-height: -webkit-fill-available;
75
76
  display: flex;
76
77
  flex-direction: column;
77
78
  padding-top: var(--safe-top);
78
79
  padding-bottom: var(--safe-bottom);
80
+ overflow: hidden;
79
81
  }
80
82
 
81
83
  /* Header */
@@ -170,6 +172,7 @@
170
172
  /* Main Content */
171
173
  .main {
172
174
  flex: 1;
175
+ min-height: 0;
173
176
  overflow-y: auto;
174
177
  padding: 0 16px 100px;
175
178
  max-width: 600px;
@@ -683,6 +686,8 @@
683
686
  border-radius: 24px;
684
687
  margin-top: 20px;
685
688
  margin-bottom: 20px;
689
+ height: calc(100dvh - 40px);
690
+ min-height: 0;
686
691
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
687
692
  overflow: hidden;
688
693
  background: var(--bg-secondary);
@@ -1078,7 +1083,7 @@
1078
1083
  padding-bottom: 0;
1079
1084
  display: flex;
1080
1085
  flex-direction: column;
1081
- height: calc(100dvh - 75px - var(--safe-top) - var(--safe-bottom) - 75px);
1086
+ height: auto;
1082
1087
  min-height: 0;
1083
1088
  }
1084
1089
 
@@ -1330,6 +1335,8 @@
1330
1335
  bottom: 80px;
1331
1336
  left: 50%;
1332
1337
  transform: translateX(-50%) translateY(100px);
1338
+ opacity: 0;
1339
+ visibility: hidden;
1333
1340
  background: rgba(26, 26, 46, 0.95);
1334
1341
  border: 1px solid var(--accent-pink);
1335
1342
  box-shadow: 0 4px 20px var(--glow-pink);
@@ -1339,11 +1346,13 @@
1339
1346
  font-size: 0.9rem;
1340
1347
  font-weight: 600;
1341
1348
  z-index: 2000;
1342
- transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
1349
+ transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.2s ease, visibility 0.2s ease;
1343
1350
  pointer-events: none;
1344
1351
  }
1345
1352
  .toast-container.show {
1346
1353
  transform: translateX(-50%) translateY(0);
1354
+ opacity: 1;
1355
+ visibility: visible;
1347
1356
  }
1348
1357
  </style>
1349
1358
  </head>
@@ -1742,7 +1751,7 @@
1742
1751
  <div class="circadian-debt">
1743
1752
  <div class="circadian-debt-label">
1744
1753
  <span>Sleep Debt</span>
1745
- <span id="circadian-debt-val">0%</span>
1754
+ <span id="circadian-debt-val">0.0h</span>
1746
1755
  </div>
1747
1756
  <div class="circadian-debt-bar">
1748
1757
  <div class="circadian-debt-fill" id="circadian-debt-bar" style="width:0%"></div>
@@ -1873,7 +1882,7 @@
1873
1882
  </div>
1874
1883
  </div>
1875
1884
  </div>
1876
- <div class="chat-input-container" style="display:flex; gap:10px; padding:10px 0; background:var(--bg-primary); border-top:1px solid rgba(255,255,255,0.05); align-items: flex-end;">
1885
+ <div class="chat-input-container" style="display:flex; gap:10px; padding:10px 0; background:var(--bg-secondary); border-top:1px solid rgba(255,255,255,0.05); align-items: flex-end;">
1877
1886
  <textarea id="chat-input" rows="1" placeholder="Type a message..." style="flex:1; background:var(--bg-card); border:1px solid rgba(255,255,255,0.1); border-radius:20px; padding:10px 16px; color:#fff; font-size:0.9rem; resize:none; font-family:inherit; outline:none; transition:border-color 0.2s; max-height:120px; overflow-y:auto;"></textarea>
1878
1887
  <button id="chat-send" class="btn-send" style="background:linear-gradient(135deg,var(--accent-pink),#a55eea); border:none; border-radius:50%; width:42px; height:42px; color:#fff; font-size:1.1rem; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:transform 0.2s, opacity 0.2s; outline:none;">↑</button>
1879
1888
  </div>
@@ -2577,9 +2586,10 @@
2577
2586
  badge.className = 'circadian-sleep-badge ' + (sleeping ? 'sleeping' : 'awake');
2578
2587
  document.getElementById('circadian-sleep-text').textContent = sleeping ? 'Sleeping' : 'Awake';
2579
2588
 
2580
- const debt = toPct(c.sleep_debt, 0, 2);
2581
- document.getElementById('circadian-debt-val').textContent = debt + '%';
2582
- document.getElementById('circadian-debt-bar').style.width = debt + '%';
2589
+ const debtHours = clampNumber(c.sleep_debt, 0, 8);
2590
+ const debtFill = toPct(debtHours, 0, 8);
2591
+ document.getElementById('circadian-debt-val').textContent = debtHours.toFixed(1) + 'h';
2592
+ document.getElementById('circadian-debt-bar').style.width = debtFill + '%';
2583
2593
 
2584
2594
  const mods = c.modifiers || {};
2585
2595
  ['energy', 'inhibition', 'warmth', 'verbosity'].forEach(m => {