alive-ai 0.1.13 → 0.1.14

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
@@ -277,7 +277,11 @@ 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 script is intentionally shipped as a single static file because the npm package has to run locally without a frontend build step. `npm run smoke` compiles the Python modules and checks the CLI; release validation also parses the embedded dashboard script so tab navigation, chat, settings, and thought rendering cannot be broken by a syntax error.
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.
281
+
282
+ 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
+
284
+ The WebUI script is intentionally shipped as a single static file because the npm package has to run locally without a frontend build step. `npm run smoke` compiles the Python modules and checks the CLI; release validation also parses the embedded dashboard script and verifies required dashboard hooks so tab navigation, chat, settings, and thought rendering cannot be broken by a syntax error.
281
285
 
282
286
  GitHub Pages cannot run the Python/FastAPI backend, so the public page includes a static export of the actual WebUI with mocked state:
283
287
 
@@ -251,7 +251,8 @@ class DefaultModeProcessor:
251
251
  if data_path:
252
252
  self.data_path = data_path
253
253
  else:
254
- self.data_path = Path(__file__).parent.parent / "data"
254
+ from core.paths import data_dir
255
+ self.data_path = data_dir()
255
256
 
256
257
  self.data_path.mkdir(parents=True, exist_ok=True)
257
258
 
@@ -26,4 +26,20 @@ if (count === 0) {
26
26
  }
27
27
 
28
28
  JSON.parse(fs.readFileSync(manifestPath, "utf8"));
29
+ for (const page of ["home", "chat", "settings"]) {
30
+ if (!html.includes(`data-page="${page}"`)) {
31
+ console.error(`Missing bottom-nav data-page="${page}" hook.`);
32
+ process.exit(1);
33
+ }
34
+ if (!html.includes(`id="page-${page}"`)) {
35
+ console.error(`Missing page container #page-${page}.`);
36
+ process.exit(1);
37
+ }
38
+ }
39
+ for (const required of ["/static/manifest.json", "/static/icon.svg"]) {
40
+ if (!html.includes(required)) {
41
+ console.error(`Missing static asset reference: ${required}`);
42
+ process.exit(1);
43
+ }
44
+ }
29
45
  console.log(`WebUI static check passed (${count} inline script parsed).`);
@@ -264,11 +264,11 @@ def _get_or_create_user_memory(self, user_id: str):
264
264
  if cache_key in _user_memories:
265
265
  return _user_memories[cache_key]
266
266
 
267
- # Create new memory instance for this user using INSTANCE-SPECIFIC data path
267
+ # Create new memory instance for this user using the canonical per-user path.
268
268
  from brain.memory import Memory
269
+ from core.user_manager import UserManager
269
270
 
270
- # Use instance's data path (self.base / "data") for proper isolation
271
- instance_data_path = self.base / "data"
271
+ instance_data_path = UserManager().get_user_paths(user_id)["base"]
272
272
 
273
273
  memory = Memory(
274
274
  nervous=self.nervous,
@@ -462,6 +462,7 @@ async def _process_single_message(self, data: dict):
462
462
  if self._subconscious: self._subconscious.register_interaction()
463
463
  if chat_id: self._default_chat_id = chat_id
464
464
  text = data.get("text", "")
465
+ message_id = data.get("message_id")
465
466
 
466
467
  circadian_interaction = {}
467
468
  if CIRCADIAN_AVAILABLE:
@@ -786,7 +787,7 @@ async def _process_single_message(self, data: dict):
786
787
  # Track if we asked a question (for follow-ups)
787
788
  _follow_up.record_message_sent(response)
788
789
 
789
- await _send_response(self, response, emotion, chat_id, text, user_id)
790
+ await _send_response(self, response, emotion, chat_id, text, user_id, message_id=message_id)
790
791
  if self._subconscious: _feed_learning(self._subconscious, text)
791
792
 
792
793
  # Actually send the media (we already decided what to send)
@@ -1094,7 +1095,7 @@ IMPORTANT: You are sending this media ALONG with your message. Reference it natu
1094
1095
  return fallback_response(emotion, msg)
1095
1096
 
1096
1097
 
1097
- async def _send_response(self, response, emotion, chat_id, text, user_id="default"):
1098
+ async def _send_response(self, response, emotion, chat_id, text, user_id="default", message_id=None):
1098
1099
  mood = emotion.get("mood", "neutral")
1099
1100
 
1100
1101
  # Process any action tags in the response (pass instance config path)
@@ -1123,9 +1124,19 @@ async def _send_response(self, response, emotion, chat_id, text, user_id="defaul
1123
1124
  "chat_id": chat_id,
1124
1125
  "fallback_text": response,
1125
1126
  "mood": mood,
1127
+ "user_id": user_id,
1128
+ "reply_to_message_id": message_id,
1129
+ "source": "runtime",
1126
1130
  })
1127
1131
  return
1128
- await self.nervous.emit("send_text", {"text": response, "mood": mood, "chat_id": chat_id})
1132
+ await self.nervous.emit("send_text", {
1133
+ "text": response,
1134
+ "mood": mood,
1135
+ "chat_id": chat_id,
1136
+ "user_id": user_id,
1137
+ "reply_to_message_id": message_id,
1138
+ "source": "runtime",
1139
+ })
1129
1140
 
1130
1141
 
1131
1142
  def _process_self_authorship_actions(response: str, user_id: str = "default", self_path: Path = None) -> tuple:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alive-ai",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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.13"
3
+ version = "0.1.14"
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
@@ -13,6 +13,12 @@ from fastapi import FastAPI, Request, BackgroundTasks
13
13
  from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse, JSONResponse
14
14
  from fastapi.staticfiles import StaticFiles
15
15
  from core.paths import data_dir, media_dir
16
+ from .persistence import (
17
+ append_chat_message,
18
+ load_chat_messages,
19
+ new_message_id,
20
+ resolve_active_user_id,
21
+ )
16
22
 
17
23
 
18
24
  app = FastAPI(title="Alive-AI Dashboard")
@@ -245,6 +251,75 @@ aliveness_state = {
245
251
  }
246
252
 
247
253
 
254
+ def _active_user_id(explicit=None) -> str:
255
+ return resolve_active_user_id(explicit, self_ref=_self_ref, dashboard_state=alive_ai_state)
256
+
257
+
258
+ def _runtime_state_dict() -> dict:
259
+ runtime_state = getattr(_self_ref, "state", None)
260
+ if runtime_state and hasattr(runtime_state, "to_dict"):
261
+ try:
262
+ return runtime_state.to_dict()
263
+ except Exception:
264
+ return {}
265
+ return {}
266
+
267
+
268
+ def _runtime_chat_ready() -> bool:
269
+ nervous = getattr(_self_ref, "nervous", None)
270
+ listeners = getattr(nervous, "listeners", {}) if nervous else {}
271
+ # Bridge registers one listener; the runtime handler is attached during Self.start().
272
+ return len(listeners.get("message_received", [])) > 1
273
+
274
+
275
+ def _subconscious_thoughts(limit: int = 10) -> list:
276
+ thoughts = []
277
+ sub = getattr(_self_ref, "_subconscious", None)
278
+ wm = getattr(sub, "working_memory", None)
279
+ if wm and hasattr(wm, "get_recent_thoughts"):
280
+ try:
281
+ for thought in wm.get_recent_thoughts(limit):
282
+ thoughts.append({
283
+ "thought": getattr(thought, "content", ""),
284
+ "type": getattr(thought, "type", "reflection"),
285
+ "emotion": getattr(thought, "emotion", {}) or {},
286
+ "time": _format_time(getattr(thought, "created_at", None)),
287
+ })
288
+ except Exception:
289
+ thoughts = []
290
+ if thoughts:
291
+ return thoughts[-limit:]
292
+ return alive_ai_state.get("recent_thoughts", [])[-limit:]
293
+
294
+
295
+ def _format_time(value) -> str:
296
+ if not value:
297
+ return datetime.now().strftime("%H:%M:%S")
298
+ try:
299
+ if isinstance(value, datetime):
300
+ return value.strftime("%H:%M:%S")
301
+ return datetime.fromisoformat(str(value)).strftime("%H:%M:%S")
302
+ except Exception:
303
+ text = str(value)
304
+ return text[11:19] if len(text) >= 19 else text
305
+
306
+
307
+ def build_snapshot(user_id: str = None) -> dict:
308
+ """Compose the dashboard state from live and durable runtime stores."""
309
+ active_user = _active_user_id(user_id)
310
+ snapshot = dict(alive_ai_state)
311
+ snapshot["active_user"] = active_user
312
+ snapshot["runtime"] = _runtime_state_dict()
313
+ snapshot["soul"] = soul_state
314
+ snapshot["aliveness"] = aliveness_state
315
+ snapshot["conversation"] = load_chat_messages(active_user)
316
+ thoughts = _subconscious_thoughts()
317
+ snapshot["recent_thoughts"] = thoughts
318
+ snapshot["current_thought"] = thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought")
319
+ snapshot["updated_at"] = datetime.now().isoformat()
320
+ return snapshot
321
+
322
+
248
323
  def update_state(data: dict):
249
324
  """Called by nervous system to update state"""
250
325
  global alive_ai_state
@@ -255,15 +330,24 @@ def update_state(data: dict):
255
330
  client.set()
256
331
 
257
332
 
258
- def add_conversation(role: str, content: str):
333
+ def add_conversation(role: str, content: str, message_id: str = None,
334
+ status: str = "sent", user_id: str = None,
335
+ source: str = "runtime"):
259
336
  """Add a message to conversation history"""
337
+ if message_id and any(m.get("message_id") == message_id for m in alive_ai_state["conversation"]):
338
+ return
260
339
  alive_ai_state["conversation"].append({
340
+ "message_id": message_id or new_message_id(role),
261
341
  "role": role,
262
342
  "content": content,
263
- "time": datetime.now().strftime("%H:%M:%S")
343
+ "time": datetime.now().strftime("%H:%M:%S"),
344
+ "status": status,
345
+ "source": source,
264
346
  })
265
347
  # Keep last 20 messages
266
348
  alive_ai_state["conversation"] = alive_ai_state["conversation"][-20:]
349
+ if user_id:
350
+ alive_ai_state["active_user"] = user_id
267
351
  if role == "user":
268
352
  alive_ai_state["last_user_message"] = content
269
353
  else:
@@ -316,7 +400,7 @@ async def event_generator(request: Request):
316
400
 
317
401
  try:
318
402
  # Send initial state
319
- yield f"event: state\ndata: {json.dumps(alive_ai_state)}\n\n"
403
+ yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
320
404
 
321
405
  while True:
322
406
  if await request.is_disconnected():
@@ -331,7 +415,7 @@ async def event_generator(request: Request):
331
415
  continue
332
416
 
333
417
  # Send updated state
334
- yield f"event: state\ndata: {json.dumps(alive_ai_state)}\n\n"
418
+ yield f"event: state\ndata: {json.dumps(build_snapshot())}\n\n"
335
419
  except asyncio.CancelledError:
336
420
  pass # Client disconnected normally
337
421
  except Exception as e:
@@ -372,7 +456,7 @@ async def sse_events(request: Request):
372
456
  @app.get("/state")
373
457
  async def get_state():
374
458
  """Get current state (for polling fallback)"""
375
- return alive_ai_state
459
+ return build_snapshot()
376
460
 
377
461
 
378
462
  @app.get("/avatar")
@@ -456,9 +540,10 @@ async def get_memory_status():
456
540
  @app.get("/thoughts")
457
541
  async def get_thoughts():
458
542
  """Get recent thoughts from subconscious"""
543
+ thoughts = _subconscious_thoughts()
459
544
  return {
460
- "current_thought": alive_ai_state.get("current_thought"),
461
- "recent_thoughts": alive_ai_state.get("recent_thoughts", [])
545
+ "current_thought": thoughts[-1]["thought"] if thoughts else alive_ai_state.get("current_thought"),
546
+ "recent_thoughts": thoughts
462
547
  }
463
548
 
464
549
 
@@ -726,7 +811,7 @@ async def get_memory_state():
726
811
  # Try to get fresh data from emotional memory system
727
812
  try:
728
813
  from brain.emotional_memory import get_emotional_memory_system
729
- system = get_emotional_memory_system()
814
+ system = get_emotional_memory_system(_active_user_id())
730
815
  stats = system.get_stats()
731
816
  recent_high = system.get_recent_high_emotion(hours=24, limit=1)
732
817
 
@@ -966,22 +1051,35 @@ async def get_new_aliveness():
966
1051
  async def chat_endpoint(request: Request, background_tasks: BackgroundTasks):
967
1052
  data = await request.json()
968
1053
  text = data.get("text", "").strip()
969
- if not text or not _self_ref:
1054
+ if not text or not _self_ref or not _runtime_chat_ready():
970
1055
  return JSONResponse({"status": "error", "message": "No text or AI not ready"}, 400)
971
- # Add user message immediately to conversation
972
- add_conversation("user", text)
1056
+ user_id = _active_user_id(data.get("user_id"))
1057
+ message_id = data.get("message_id") or new_message_id("webui_user")
1058
+ append_chat_message(user_id, "user", text, message_id=message_id, status="pending", source="webui")
1059
+ add_conversation("user", text, message_id=message_id, status="pending", user_id=user_id, source="webui")
973
1060
  update_state({})
974
- # Fire message handler in background
1061
+
975
1062
  async def _send():
976
- from core.message_handler import handle_message
977
- await handle_message(_self_ref, {
978
- "user_id": "webui",
979
- "text": text,
980
- "chat_id": "webui",
981
- "source": "webui"
982
- })
1063
+ try:
1064
+ await _self_ref.nervous.emit("message_received", {
1065
+ "message_id": message_id,
1066
+ "user_id": user_id,
1067
+ "webui_user_id": user_id,
1068
+ "text": text,
1069
+ "chat_id": "webui",
1070
+ "source": "webui"
1071
+ })
1072
+ except Exception as e:
1073
+ append_chat_message(
1074
+ user_id,
1075
+ "alive_ai",
1076
+ f"Something went wrong while processing that message: {e}",
1077
+ status="error",
1078
+ source="webui",
1079
+ )
1080
+ update_state({"thinking": False})
983
1081
  background_tasks.add_task(_send)
984
- return JSONResponse({"status": "sent"})
1082
+ return JSONResponse({"status": "sent", "message_id": message_id, "user_id": user_id})
985
1083
 
986
1084
 
987
1085
  @app.get("/api/settings")
@@ -1013,13 +1111,18 @@ async def save_settings(request: Request):
1013
1111
  if fname not in allowed:
1014
1112
  return JSONResponse({"status": "error", "message": "Invalid file"}, 400)
1015
1113
  config_dir = Path(os.environ.get("ALIVE_AI_ROOT", ".")) / "config"
1114
+ config_dir.mkdir(parents=True, exist_ok=True)
1016
1115
  p = config_dir / fname
1017
1116
  content = data.get("content")
1018
1117
  try:
1019
1118
  if fname.endswith(".json"):
1020
- p.write_text(json.dumps(content, indent=2, ensure_ascii=False))
1119
+ text = json.dumps(content, indent=2, ensure_ascii=False) + "\n"
1120
+ json.loads(text)
1021
1121
  else:
1022
- p.write_text(content)
1122
+ text = str(content or "")
1123
+ tmp = p.with_suffix(p.suffix + ".tmp")
1124
+ tmp.write_text(text)
1125
+ tmp.replace(p)
1023
1126
  return {"status": "saved"}
1024
1127
  except Exception as e:
1025
1128
  return JSONResponse({"status": "error", "message": str(e)}, 500)
package/webui/bridge.py CHANGED
@@ -10,6 +10,7 @@ from .app import (
10
10
  update_interoceptive_state, update_idle_state, update_bids_state,
11
11
  update_memory_state, update_inconsistency_state
12
12
  )
13
+ from .persistence import append_chat_message, new_message_id, resolve_active_user_id
13
14
 
14
15
  _webui_server = None
15
16
 
@@ -46,16 +47,25 @@ def init_bridge(nervous, ai=None):
46
47
 
47
48
  async def on_message_sent(data):
48
49
  """Track outgoing messages"""
49
- text = data.get("text", "")
50
- add_conversation("alive_ai", text)
50
+ text = data.get("text") or data.get("fallback_text", "")
51
+ user_id = resolve_active_user_id(data.get("user_id"), dashboard_state=alive_ai_state)
52
+ message_id = data.get("message_id") or new_message_id("alive_ai")
53
+ append_chat_message(user_id, "alive_ai", text, message_id=message_id,
54
+ status="sent", source=data.get("source", "runtime"))
55
+ add_conversation("alive_ai", text, message_id=message_id, user_id=user_id,
56
+ source=data.get("source", "runtime"))
51
57
  alive_ai_state["stats"]["messages"] = alive_ai_state["stats"].get("messages", 0) + 1
52
58
  update_state({})
53
59
 
54
60
  async def on_message_received(data):
55
61
  """Track incoming messages"""
56
62
  text = data.get("text", "")
57
- user_id = data.get("user_id", "unknown")
58
- add_conversation("user", text)
63
+ user_id = resolve_active_user_id(data.get("webui_user_id") or data.get("user_id"), dashboard_state=alive_ai_state)
64
+ message_id = data.get("message_id") or new_message_id("user")
65
+ append_chat_message(user_id, "user", text, message_id=message_id,
66
+ status="sent", source=data.get("source", "runtime"))
67
+ add_conversation("user", text, message_id=message_id, user_id=user_id,
68
+ source=data.get("source", "runtime"))
59
69
  # Track active user
60
70
  alive_ai_state["active_user"] = user_id
61
71
 
@@ -0,0 +1,174 @@
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
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 resolve_active_user_id(explicit: Any = None, self_ref: Any = None,
30
+ dashboard_state: Optional[Dict[str, Any]] = None) -> str:
31
+ if explicit:
32
+ return normalize_user_id(explicit)
33
+
34
+ dashboard_state = dashboard_state or {}
35
+ if dashboard_state.get("active_user"):
36
+ return normalize_user_id(dashboard_state["active_user"])
37
+
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)
41
+
42
+ owner = os.environ.get("TELEGRAM_OWNER_ID", "")
43
+ if owner:
44
+ return normalize_user_id(owner)
45
+
46
+ return "webui"
47
+
48
+
49
+ def user_base(user_id: str) -> Path:
50
+ path = data_dir() / "users" / normalize_user_id(user_id)
51
+ path.mkdir(parents=True, exist_ok=True)
52
+ return path
53
+
54
+
55
+ def chat_journal_path(user_id: str) -> Path:
56
+ return user_base(user_id) / "webui_chat.jsonl"
57
+
58
+
59
+ def new_message_id(prefix: str = "msg") -> str:
60
+ return f"{prefix}_{uuid.uuid4().hex[:16]}"
61
+
62
+
63
+ def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
64
+ rows: List[Dict[str, Any]] = []
65
+ if not path.exists():
66
+ return rows
67
+ with path.open() as fh:
68
+ for line in fh:
69
+ try:
70
+ row = json.loads(line)
71
+ if isinstance(row, dict):
72
+ rows.append(row)
73
+ except Exception:
74
+ continue
75
+ return rows
76
+
77
+
78
+ def append_chat_message(user_id: str, role: str, content: str,
79
+ message_id: Optional[str] = None,
80
+ status: str = "sent", source: str = "runtime",
81
+ metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
82
+ message_id = message_id or new_message_id(role)
83
+ entry = {
84
+ "message_id": message_id,
85
+ "role": role,
86
+ "content": str(content or ""),
87
+ "status": status,
88
+ "source": source,
89
+ "timestamp": datetime.now().isoformat(),
90
+ }
91
+ if metadata:
92
+ entry["metadata"] = metadata
93
+
94
+ path = chat_journal_path(user_id)
95
+ existing = _read_jsonl(path)
96
+ for idx, row in enumerate(existing):
97
+ if row.get("message_id") == message_id:
98
+ merged = {**row, **{k: v for k, v in entry.items() if v not in (None, "")}}
99
+ existing[idx] = merged
100
+ tmp = path.with_suffix(path.suffix + ".tmp")
101
+ with tmp.open("w") as fh:
102
+ for item in existing:
103
+ fh.write(json.dumps(item, ensure_ascii=False) + "\n")
104
+ tmp.replace(path)
105
+ return entry
106
+ with path.open("a") as fh:
107
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
108
+ return entry
109
+
110
+
111
+ def _format_entry(row: Dict[str, Any]) -> Dict[str, Any]:
112
+ timestamp = row.get("timestamp") or row.get("created_at") or ""
113
+ time_label = ""
114
+ if timestamp:
115
+ try:
116
+ time_label = datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
117
+ except Exception:
118
+ time_label = str(timestamp)[11:19] if len(str(timestamp)) >= 19 else ""
119
+ return {
120
+ "message_id": row.get("message_id") or new_message_id("legacy"),
121
+ "role": row.get("role", "assistant"),
122
+ "content": row.get("content", ""),
123
+ "time": time_label,
124
+ "timestamp": timestamp,
125
+ "status": row.get("status", "sent"),
126
+ "source": row.get("source", "runtime"),
127
+ }
128
+
129
+
130
+ def _load_journal(user_id: str) -> List[Dict[str, Any]]:
131
+ return [_format_entry(row) for row in _read_jsonl(chat_journal_path(user_id))]
132
+
133
+
134
+ def _load_episodic_fallback(user_id: str, limit_turns: int) -> List[Dict[str, Any]]:
135
+ base = user_base(user_id) / "conversations"
136
+ legacy = data_dir() / "conversations"
137
+ conv_dir = base if list(base.glob("*.jsonl")) else legacy
138
+ if not conv_dir.exists():
139
+ return []
140
+
141
+ 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
147
+
148
+ messages: List[Dict[str, Any]] = []
149
+ for row in reversed(turns[:limit_turns]):
150
+ ts = row.get("timestamp", "")
151
+ if row.get("user"):
152
+ messages.append(_format_entry({
153
+ "message_id": f"legacy_user_{len(messages)}_{ts}",
154
+ "role": "user",
155
+ "content": row.get("user", ""),
156
+ "timestamp": ts,
157
+ "source": "episodic",
158
+ }))
159
+ if row.get("ai"):
160
+ messages.append(_format_entry({
161
+ "message_id": f"legacy_ai_{len(messages)}_{ts}",
162
+ "role": "alive_ai",
163
+ "content": row.get("ai", ""),
164
+ "timestamp": ts,
165
+ "source": "episodic",
166
+ }))
167
+ return messages
168
+
169
+
170
+ 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:]
@@ -630,11 +630,11 @@
630
630
  right: 0;
631
631
  background: var(--bg-secondary);
632
632
  border-top: 1px solid rgba(255, 255, 255, 0.05);
633
- padding: 8px 0;
633
+ padding: 8px 12px;
634
634
  padding-bottom: calc(8px + var(--safe-bottom));
635
- display: flex;
636
- justify-content: center;
637
- gap: 40px;
635
+ display: grid;
636
+ grid-template-columns: repeat(3, minmax(0, 1fr));
637
+ gap: 8px;
638
638
  z-index: 100;
639
639
  }
640
640
 
@@ -647,6 +647,11 @@
647
647
  border-radius: 12px;
648
648
  transition: all 0.2s;
649
649
  cursor: pointer;
650
+ border: 0;
651
+ background: transparent;
652
+ color: var(--text-secondary);
653
+ font: inherit;
654
+ min-width: 0;
650
655
  }
651
656
 
652
657
  .nav-item.active {
@@ -1073,7 +1078,8 @@
1073
1078
  padding-bottom: 0;
1074
1079
  display: flex;
1075
1080
  flex-direction: column;
1076
- height: calc(100vh - 75px - var(--safe-top) - var(--safe-bottom) - 75px);
1081
+ height: calc(100dvh - 75px - var(--safe-top) - var(--safe-bottom) - 75px);
1082
+ min-height: 0;
1077
1083
  }
1078
1084
 
1079
1085
  .page-section {
@@ -1086,11 +1092,13 @@
1086
1092
  display: flex;
1087
1093
  flex-direction: column;
1088
1094
  height: 100%;
1095
+ min-height: 0;
1089
1096
  position: relative;
1090
1097
  }
1091
1098
 
1092
1099
  .chat-messages {
1093
1100
  flex: 1;
1101
+ min-height: 0;
1094
1102
  overflow-y: auto;
1095
1103
  padding: 10px 0;
1096
1104
  display: flex;
@@ -1866,7 +1874,7 @@
1866
1874
  </div>
1867
1875
  </div>
1868
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;">
1869
- <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;" oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px';"></textarea>
1877
+ <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>
1870
1878
  <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>
1871
1879
  </div>
1872
1880
  </div>
@@ -1881,18 +1889,18 @@
1881
1889
  <div id="toast-notification" class="toast-container">Settings saved successfully</div>
1882
1890
 
1883
1891
  <nav class="bottom-nav">
1884
- <div class="nav-item active">
1892
+ <button type="button" class="nav-item active" data-page="home" aria-selected="true">
1885
1893
  <span class="nav-icon">🏠</span>
1886
1894
  <span class="nav-label">Home</span>
1887
- </div>
1888
- <div class="nav-item">
1895
+ </button>
1896
+ <button type="button" class="nav-item" data-page="chat" aria-selected="false">
1889
1897
  <span class="nav-icon">💬</span>
1890
1898
  <span class="nav-label">Chat</span>
1891
- </div>
1892
- <div class="nav-item">
1899
+ </button>
1900
+ <button type="button" class="nav-item" data-page="settings" aria-selected="false">
1893
1901
  <span class="nav-icon">⚙️</span>
1894
1902
  <span class="nav-label">Settings</span>
1895
- </div>
1903
+ </button>
1896
1904
  </nav>
1897
1905
  </div>
1898
1906
 
@@ -1912,16 +1920,39 @@
1912
1920
  let conversationCache = [];
1913
1921
  let isSettingsLoaded = false;
1914
1922
  let isSendingMessage = false;
1923
+ let settingsDirty = false;
1924
+
1925
+ function clampNumber(value, min = 0, max = 1) {
1926
+ const num = Number(value);
1927
+ if (!Number.isFinite(num)) return min;
1928
+ return Math.min(max, Math.max(min, num));
1929
+ }
1930
+
1931
+ function toPct(value, min = 0, max = 1) {
1932
+ return Math.round(((clampNumber(value, min, max) - min) / (max - min)) * 100);
1933
+ }
1934
+
1935
+ function resizeChatInput(inputEl) {
1936
+ if (!inputEl) return;
1937
+ inputEl.style.height = 'auto';
1938
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1939
+ }
1915
1940
 
1916
1941
  // Tab switching
1917
1942
  document.addEventListener('DOMContentLoaded', () => {
1918
1943
  const navItems = document.querySelectorAll('.nav-item');
1919
1944
  const mainEl = document.querySelector('.main');
1920
1945
 
1921
- navItems.forEach((el, index) => {
1946
+ navItems.forEach((el) => {
1922
1947
  el.addEventListener('click', () => {
1923
- navItems.forEach(item => item.classList.remove('active'));
1948
+ const requestedPage = el.dataset.page;
1949
+ if (!requestedPage || !pages[requestedPage]) return;
1950
+ navItems.forEach(item => {
1951
+ item.classList.remove('active');
1952
+ item.setAttribute('aria-selected', 'false');
1953
+ });
1924
1954
  el.classList.add('active');
1955
+ el.setAttribute('aria-selected', 'true');
1925
1956
 
1926
1957
  // Hide all pages
1927
1958
  document.getElementById('page-home').style.display = 'none';
@@ -1929,8 +1960,7 @@
1929
1960
  document.getElementById('page-settings').style.display = 'none';
1930
1961
 
1931
1962
  // Show current page
1932
- const pageKeys = Object.keys(pages);
1933
- activePage = pageKeys[index];
1963
+ activePage = requestedPage;
1934
1964
  const activePageId = pages[activePage];
1935
1965
  const activeEl = document.getElementById(activePageId);
1936
1966
 
@@ -1938,12 +1968,15 @@
1938
1968
  activeEl.style.display = 'flex';
1939
1969
  mainEl.classList.add('chat-active');
1940
1970
  scrollChatToBottom();
1971
+ } else if (activePage === 'settings') {
1972
+ activeEl.style.display = 'flex';
1973
+ mainEl.classList.remove('chat-active');
1941
1974
  } else {
1942
1975
  activeEl.style.display = 'block';
1943
1976
  mainEl.classList.remove('chat-active');
1944
1977
  }
1945
1978
 
1946
- if (activePage === 'settings') {
1979
+ if (activePage === 'settings' && (!isSettingsLoaded || !settingsDirty)) {
1947
1980
  loadSettings();
1948
1981
  }
1949
1982
  });
@@ -1956,6 +1989,7 @@
1956
1989
  sendBtn.addEventListener('click', sendMessage);
1957
1990
  }
1958
1991
  if (inputEl) {
1992
+ inputEl.addEventListener('input', () => resizeChatInput(inputEl));
1959
1993
  inputEl.addEventListener('keydown', (e) => {
1960
1994
  if (e.key === 'Enter' && !e.shiftKey) {
1961
1995
  e.preventDefault();
@@ -2044,7 +2078,7 @@
2044
2078
  const resData = await response.json();
2045
2079
  if (response.ok && resData.status === 'sent') {
2046
2080
  inputEl.value = '';
2047
- inputEl.style.height = 'auto'; // reset textarea height
2081
+ resizeChatInput(inputEl);
2048
2082
  } else {
2049
2083
  showToast('Failed to send: ' + (resData.message || 'Unknown error'));
2050
2084
  }
@@ -2063,6 +2097,7 @@
2063
2097
  async function loadSettings() {
2064
2098
  const container = document.getElementById('settings-content');
2065
2099
  if (!container) return;
2100
+ if (settingsDirty) return;
2066
2101
 
2067
2102
  try {
2068
2103
  container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">Loading settings...</div>';
@@ -2081,8 +2116,7 @@
2081
2116
  function renderSettings(settingsData) {
2082
2117
  const container = document.getElementById('settings-content');
2083
2118
  if (!container) return;
2084
-
2085
- let html = '';
2119
+ container.replaceChildren();
2086
2120
  Object.entries(settingsData).forEach(([filename, fileData]) => {
2087
2121
  let contentStr = '';
2088
2122
  if (fileData.type === 'json') {
@@ -2090,25 +2124,50 @@
2090
2124
  } else {
2091
2125
  contentStr = fileData.content;
2092
2126
  }
2093
-
2094
- html += `
2095
- <div class="section settings-card">
2096
- <div class="settings-header-btn-row">
2097
- <div class="settings-file-title">⚙️ ${filename}</div>
2098
- <button class="btn-save btn-action" data-file="${filename}" onclick="saveFileSettings('${filename}')">Save</button>
2099
- </div>
2100
- <div class="settings-grid">
2101
- <textarea class="settings-textarea" data-file="${filename}" oninput="validateJSONInput(this, '${filename}')" spellcheck="false">${escapeHtml(contentStr)}</textarea>
2102
- </div>
2103
- </div>
2104
- `;
2127
+
2128
+ const card = document.createElement('div');
2129
+ card.className = 'section settings-card';
2130
+
2131
+ const header = document.createElement('div');
2132
+ header.className = 'settings-header-btn-row';
2133
+
2134
+ const title = document.createElement('div');
2135
+ title.className = 'settings-file-title';
2136
+ title.textContent = `⚙️ ${filename}`;
2137
+
2138
+ const saveBtn = document.createElement('button');
2139
+ saveBtn.className = 'btn-save btn-action';
2140
+ saveBtn.type = 'button';
2141
+ saveBtn.dataset.file = filename;
2142
+ saveBtn.textContent = 'Save';
2143
+
2144
+ const grid = document.createElement('div');
2145
+ grid.className = 'settings-grid';
2146
+
2147
+ const textarea = document.createElement('textarea');
2148
+ textarea.className = 'settings-textarea';
2149
+ textarea.dataset.file = filename;
2150
+ textarea.spellcheck = false;
2151
+ textarea.value = contentStr || '';
2152
+ textarea.addEventListener('input', () => {
2153
+ settingsDirty = true;
2154
+ validateJSONInput(textarea, filename);
2155
+ });
2156
+ saveBtn.addEventListener('click', () => saveFileSettings(filename));
2157
+
2158
+ header.append(title, saveBtn);
2159
+ grid.appendChild(textarea);
2160
+ card.append(header, grid);
2161
+ container.appendChild(card);
2162
+ validateJSONInput(textarea, filename);
2105
2163
  });
2106
- container.innerHTML = html;
2164
+ settingsDirty = false;
2107
2165
  }
2108
2166
 
2109
2167
  // Real-time JSON validation
2110
2168
  function validateJSONInput(textarea, filename) {
2111
- const btn = document.querySelector(`.btn-save[data-file="${filename}"]`);
2169
+ const btn = Array.from(document.querySelectorAll('.btn-save'))
2170
+ .find(el => el.dataset.file === filename);
2112
2171
  let errorEl = textarea.parentNode.querySelector('.json-error-msg');
2113
2172
 
2114
2173
  if (!errorEl) {
@@ -2141,7 +2200,8 @@
2141
2200
 
2142
2201
  // Settings saving
2143
2202
  async function saveFileSettings(filename) {
2144
- const textarea = document.querySelector(`.settings-textarea[data-file="${filename}"]`);
2203
+ const textarea = Array.from(document.querySelectorAll('.settings-textarea'))
2204
+ .find(el => el.dataset.file === filename);
2145
2205
  if (!textarea) return;
2146
2206
 
2147
2207
  let parsedContent;
@@ -2156,7 +2216,8 @@
2156
2216
  parsedContent = textarea.value;
2157
2217
  }
2158
2218
 
2159
- const btn = document.querySelector(`.btn-save[data-file="${filename}"]`);
2219
+ const btn = Array.from(document.querySelectorAll('.btn-save'))
2220
+ .find(el => el.dataset.file === filename);
2160
2221
  if (btn) {
2161
2222
  btn.disabled = true;
2162
2223
  btn.textContent = 'Saving...';
@@ -2173,6 +2234,7 @@
2173
2234
  });
2174
2235
  const resData = await response.json();
2175
2236
  if (response.ok && resData.status === 'saved') {
2237
+ settingsDirty = false;
2176
2238
  showToast(`${filename} saved successfully!`);
2177
2239
  } else {
2178
2240
  showToast(`Failed to save: ${resData.message || 'Unknown error'}`);
@@ -2223,7 +2285,7 @@
2223
2285
 
2224
2286
  // Emotions - all 17 emotions
2225
2287
  ['arousal', 'desire', 'love', 'joy', 'sadness', 'trust', 'fear', 'anger', 'boredom', 'guilt', 'pride', 'jealousy', 'embarrassment', 'anticipation', 'hope', 'dread'].forEach(em => {
2226
- const val = Math.round((data[em] || 0) * 100);
2288
+ const val = toPct(data[em]);
2227
2289
  const valEl = document.getElementById(`val-${em}`);
2228
2290
  const barEl = document.getElementById(`bar-${em}`);
2229
2291
  if (valEl) valEl.textContent = `${val}%`;
@@ -2254,22 +2316,26 @@
2254
2316
  }
2255
2317
 
2256
2318
  // Recent thoughts (subconscious impulses)
2257
- if (data.recent_thoughts && data.recent_thoughts.length > 0) {
2258
- const historyEl = document.getElementById('thought-history');
2259
- if (historyEl) {
2319
+ const historyEl = document.getElementById('thought-history');
2320
+ if (historyEl) {
2321
+ if (data.recent_thoughts && data.recent_thoughts.length > 0) {
2260
2322
  historyEl.innerHTML = data.recent_thoughts.slice(-5).reverse().map(t =>
2261
- `<div class="thought-entry"><span class="thought-time">${t.time || ''}</span> <span class="thought-type">${t.type || ''}</span> ${escapeHtml(t.thought || '')}</div>`
2323
+ `<div class="thought-entry"><span class="thought-time">${escapeHtml(t.time || '')}</span> <span class="thought-type">${escapeHtml(t.type || t.thought_type || '')}</span> ${escapeHtml(t.thought || t.content || '')}</div>`
2262
2324
  ).join('');
2325
+ } else {
2326
+ historyEl.innerHTML = '<div class="thought-entry empty">No recent thoughts yet</div>';
2263
2327
  }
2264
2328
  }
2265
2329
 
2266
2330
  // Status flags
2331
+ const flagEl = document.getElementById('status-flags');
2267
2332
  if (data.is_high_desire || data.is_in_love) {
2268
2333
  let flags = [];
2269
2334
  if (data.is_in_love) flags.push('In Love');
2270
2335
  if (data.is_high_desire) flags.push('High desire');
2271
- const flagEl = document.getElementById('status-flags');
2272
2336
  if (flagEl) flagEl.textContent = flags.join(' + ');
2337
+ } else if (flagEl) {
2338
+ flagEl.textContent = '';
2273
2339
  }
2274
2340
  }
2275
2341
 
@@ -2311,10 +2377,10 @@
2311
2377
  function updateHormone(name, value) {
2312
2378
  const chip = document.getElementById(`hormone-${name}`);
2313
2379
  const valEl = document.getElementById(`val-${name}`);
2314
- const pct = Math.round(value * 100);
2380
+ const pct = toPct(value);
2315
2381
  if (valEl) valEl.textContent = `${pct}%`;
2316
2382
  if (chip) {
2317
- chip.classList.toggle('elevated', value > 0.6);
2383
+ chip.classList.toggle('elevated', clampNumber(value) > 0.6);
2318
2384
  }
2319
2385
  }
2320
2386
 
@@ -2335,19 +2401,18 @@
2335
2401
  const state = states[stateName];
2336
2402
  if (!state) continue;
2337
2403
 
2338
- const value = state.current_value;
2404
+ const value = clampNumber(state.current_value, config.min, config.max);
2339
2405
  const valEl = document.getElementById(`val-${stateName.replace(/_/g, '-')}`);
2340
2406
  const barEl = document.getElementById(`bar-${stateName.replace(/_/g, '-')}`);
2341
2407
 
2342
2408
  if (valEl && barEl) {
2343
2409
  if (config.min < 0) {
2344
- const displayVal = value >= 0 ? `+${Math.round(value * 100)}%` : `${Math.round(value * 100)}%`;
2345
- valEl.textContent = displayVal;
2346
- const barWidth = ((value - config.min) / (config.max - config.min)) * 100;
2347
- barEl.style.width = `${barWidth}%`;
2348
- } else {
2349
- valEl.textContent = `${Math.round(value * 100)}%`;
2350
- barEl.style.width = `${value * 100}%`;
2410
+ const displayVal = value >= 0 ? `+${Math.round(value * 100)}%` : `${Math.round(value * 100)}%`;
2411
+ valEl.textContent = displayVal;
2412
+ barEl.style.width = `${toPct(value, config.min, config.max)}%`;
2413
+ } else {
2414
+ valEl.textContent = `${toPct(value)}%`;
2415
+ barEl.style.width = `${toPct(value)}%`;
2351
2416
  }
2352
2417
  }
2353
2418
  }
@@ -2364,13 +2429,13 @@
2364
2429
  if (!data) return;
2365
2430
 
2366
2431
  const thoughtsEl = document.getElementById('idle-thoughts');
2367
- if (thoughtsEl && data.recent_thoughts && data.recent_thoughts.length > 0) {
2368
- thoughtsEl.innerHTML = data.recent_thoughts.slice(0, 5).map(t => `
2369
- <div class="idle-thought">
2370
- <div class="thought-type">${t.thought_type || 'thought'}</div>
2371
- ${escapeHtml(t.content || t.thought || '')}
2372
- </div>
2373
- `).join('');
2432
+ if (thoughtsEl && data.recent_thoughts && data.recent_thoughts.length > 0) {
2433
+ thoughtsEl.innerHTML = data.recent_thoughts.slice(0, 5).map(t => `
2434
+ <div class="idle-thought">
2435
+ <div class="thought-type">${escapeHtml(t.thought_type || t.type || 'thought')}</div>
2436
+ ${escapeHtml(t.content || t.thought || '')}
2437
+ </div>
2438
+ `).join('');
2374
2439
  } else if (thoughtsEl) {
2375
2440
  thoughtsEl.innerHTML = '<div class="idle-empty">No recent background thoughts...</div>';
2376
2441
  }
@@ -2387,9 +2452,16 @@
2387
2452
  const conflicts = data.active_conflicts || data.conflicts || [];
2388
2453
  const count = data.count || conflicts.length;
2389
2454
 
2390
- if (badgeEl) {
2391
- badgeEl.textContent = count;
2392
- }
2455
+ if (badgeEl) {
2456
+ badgeEl.textContent = count;
2457
+ }
2458
+
2459
+ const tendencyEl = document.getElementById('tendency-badge');
2460
+ if (tendencyEl) {
2461
+ const tendency = String(data.behavioral_tendency || 'neutral').toLowerCase();
2462
+ tendencyEl.textContent = tendency.replace(/_/g, ' ');
2463
+ tendencyEl.className = 'tendency-badge ' + tendency;
2464
+ }
2393
2465
 
2394
2466
  if (conflictsEl && conflicts.length > 0) {
2395
2467
  conflictsEl.innerHTML = conflicts.slice(0, 5).map(c => {
@@ -2399,7 +2471,7 @@
2399
2471
  const fear = c.fear || c.side_b || '';
2400
2472
  // Support both 'tension' (inconsistency API) and 'tension_level' (soul API)
2401
2473
  const rawTension = c.tension_level !== undefined ? c.tension_level : (c.tension !== undefined ? c.tension : c.intensity || 0);
2402
- const tension = Math.round(rawTension * 100);
2474
+ const tension = toPct(rawTension);
2403
2475
 
2404
2476
  return `
2405
2477
  <div class="conflict-item">
@@ -2505,15 +2577,15 @@
2505
2577
  badge.className = 'circadian-sleep-badge ' + (sleeping ? 'sleeping' : 'awake');
2506
2578
  document.getElementById('circadian-sleep-text').textContent = sleeping ? 'Sleeping' : 'Awake';
2507
2579
 
2508
- const debt = Math.round((c.sleep_debt || 0) * 100);
2580
+ const debt = toPct(c.sleep_debt, 0, 2);
2509
2581
  document.getElementById('circadian-debt-val').textContent = debt + '%';
2510
2582
  document.getElementById('circadian-debt-bar').style.width = debt + '%';
2511
2583
 
2512
- const mods = c.modifiers || {};
2513
- ['energy', 'inhibition', 'warmth', 'verbosity'].forEach(m => {
2514
- const el = document.getElementById('circadian-mod-' + m);
2515
- if (el) el.textContent = Math.round((mods[m] || 0) * 100) + '%';
2516
- });
2584
+ const mods = c.modifiers || {};
2585
+ ['energy', 'inhibition', 'warmth', 'verbosity'].forEach(m => {
2586
+ const el = document.getElementById('circadian-mod-' + m);
2587
+ if (el) el.textContent = toPct(mods[m]) + '%';
2588
+ });
2517
2589
  }
2518
2590
 
2519
2591
  function updateAttachmentUI(a) {
@@ -2527,7 +2599,7 @@
2527
2599
  badge.className = 'attachment-style-badge ' + style;
2528
2600
 
2529
2601
  // Fix: API returns 'security', not 'security_score'
2530
- const score = Math.round((a.security || a.security_score || 0) * 100);
2602
+ const score = toPct(a.security || a.security_score);
2531
2603
  document.getElementById('attachment-security-val').textContent = score + '%';
2532
2604
  document.getElementById('attachment-security-bar').style.width = score + '%';
2533
2605
 
@@ -2567,7 +2639,7 @@
2567
2639
  html += '<div class="body-memory-list">';
2568
2640
  afterglowList.forEach(a => {
2569
2641
  const icon = afterglowIcons[a.type] || '✨';
2570
- const intensity = Math.round((a.intensity || 0) * 100);
2642
+ const intensity = toPct(a.intensity);
2571
2643
  const ago = a.hours_ago ? a.hours_ago.toFixed(1) + 'h ago' : '';
2572
2644
  html += `<div class="body-memory-item">
2573
2645
  <span class="body-memory-icon">${icon}</span>
@@ -2584,7 +2656,7 @@
2584
2656
  if (hasPhantom) {
2585
2657
  html += '<div class="body-memory-sub-header">Phantom Sensations</div><div class="body-memory-list">';
2586
2658
  phantomList.forEach(p => {
2587
- const intensity = Math.round((p.intensity || 0) * 100);
2659
+ const intensity = toPct(p.intensity);
2588
2660
  html += `<div class="body-memory-item">
2589
2661
  <span class="body-memory-icon">👻</span>
2590
2662
  <div class="body-memory-content">
@@ -2643,7 +2715,7 @@
2643
2715
  topicsArray.sort((a, b) => a.level - b.level);
2644
2716
 
2645
2717
  grid.innerHTML = topicsArray.map(t => {
2646
- const knowledge = Math.round((t.level || 0) * 100);
2718
+ const knowledge = toPct(t.level);
2647
2719
  const curiosityLevel = 100 - knowledge; // Invert: less knowledge = more curious
2648
2720
  const emoji = curiosityLevel > 80 ? '🤔' : curiosityLevel > 50 ? '💭' : '✓';
2649
2721
  return `<div class="curiosity-chip">