This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. Dockerfile +11 -37
  2. README.md +0 -18
  3. backend/Odin.py +11 -85
  4. backend/Pathfinder_test.py +4 -8
  5. backend/data/environment/relationship_matrix.json +288 -2
  6. backend/data/personalities/amitabh/amitabh.json +22 -0
  7. backend/data/personalities/jarvis/jarvis.json +22 -0
  8. backend/pathfinder.py +20 -55
  9. backend/src/agents/Actions.py +18 -11
  10. backend/src/agents/Long_term.py +5 -10
  11. backend/src/agents/Short_term.py +8 -8
  12. backend/src/agents/Single_agent.py +21 -9
  13. backend/src/agents/autonomy.py +5 -8
  14. backend/src/agents/body.py +20 -10
  15. backend/src/agents/brain.py +8 -8
  16. backend/src/agents/conversation.py +28 -18
  17. backend/src/agents/daily_flavor.py +5 -9
  18. backend/src/agents/day_planner.py +82 -251
  19. backend/src/agents/memory_index.py +6 -8
  20. backend/src/agents/react.py +13 -11
  21. backend/src/agents/vector_memory.py +5 -10
  22. backend/src/auth/__init__.py +0 -9
  23. backend/src/auth/manager.py +5 -8
  24. backend/src/auth/routes.py +2 -8
  25. backend/src/config.py +8 -33
  26. backend/src/core/agent_registry.py +7 -8
  27. backend/src/core/budget.py +17 -11
  28. backend/src/core/checkpoint_manager.py +9 -8
  29. backend/src/core/log.py +24 -19
  30. backend/src/core/log_relay.py +0 -82
  31. backend/src/core/perceive.py +8 -7
  32. backend/src/core/runtime_health.py +1 -11
  33. backend/src/core/snapshot.py +29 -7
  34. backend/src/core/tick_graph.py +15 -8
  35. backend/src/core/world_engine.py +92 -120
  36. backend/src/core/world_events.py +6 -9
  37. backend/src/core/world_state.py +34 -8
  38. backend/src/llm/gemini_client.py +26 -40
  39. backend/test_gemini_client.py +0 -60
  40. backend/tools/sidecar_monitor.py +6 -8
  41. frontend/src/App.jsx +1 -19
  42. frontend/src/components/ActionDetail.jsx +0 -11
  43. frontend/src/components/AgentWindow.jsx +1 -14
  44. frontend/src/components/ChatBubble.jsx +0 -11
  45. frontend/src/components/ChatPanel.jsx +0 -13
  46. frontend/src/components/ConversationFeed.jsx +1 -13
  47. frontend/src/components/DebugPanel.jsx +0 -12
  48. frontend/src/components/EventsPanel.jsx +0 -12
  49. frontend/src/components/InfoBar.jsx +6 -28
  50. frontend/src/components/Legend.jsx +1 -13
Dockerfile CHANGED
@@ -1,48 +1,22 @@
1
- # Stage 1: Build the React frontend SPA
2
- FROM node:20-slim AS frontend-builder
3
- WORKDIR /app/frontend
4
- COPY frontend/package*.json ./
5
- RUN npm ci
6
- COPY frontend/ ./
7
- # Ensure public directory exists for Vite static asset bundling
8
- RUN mkdir -p public
9
- RUN npm run build
10
-
11
- # Stage 2: Python backend runtime environment
12
  FROM python:3.11-slim
13
 
14
- # System dependencies for Pillow/OpenCV + Git & Git LFS for binary assets
15
- RUN apt-get update && apt-get install -y --no-install-recommends \
16
- libjpeg62-turbo-dev libglib2.0-0 git git-lfs \
17
- && rm -rf /var/lib/apt/lists/* \
18
- && git lfs install
19
-
20
- # Hugging Face Spaces requires running under user UID 1000
21
- RUN useradd -m -u 1000 user
22
-
23
- ENV HOME=/home/user \
24
- PATH=/home/user/.local/bin:$PATH \
25
- PYTHONUNBUFFERED=1
26
 
27
- WORKDIR $HOME/app
 
 
 
28
 
29
- # Install Python dependencies
30
  COPY requirements.txt .
31
  RUN pip install --no-cache-dir -r requirements.txt
32
 
33
- # Copy repository files including backend and git assets
34
- COPY . .
35
-
36
- # Pull Git LFS binary files (e.g. path.png, map images) so pointers are converted to real images
37
- RUN git lfs pull || true
38
-
39
- # Copy built frontend assets from Stage 1 into frontend/dist
40
- COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
41
-
42
- # Ensure user 1000 owns the app directory for runtime state and checkpoint writing
43
- RUN chown -R user:user $HOME/app
44
 
45
- USER user
 
46
 
47
  EXPOSE 7860
48
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
+ WORKDIR /app
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ # System deps for Pillow/OpenCV if wheels need them
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libjpeg62-turbo-dev libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
 
10
+ # Python deps
11
  COPY requirements.txt .
12
  RUN pip install --no-cache-dir -r requirements.txt
13
 
14
+ # Backend code
15
+ COPY backend/ backend/
16
+ COPY pathfinder.py .
 
 
 
 
 
 
 
 
17
 
18
+ # Frontend build (pre-built via Vercel, embedded for same-origin fallback)
19
+ COPY frontend/dist/ frontend/dist/
20
 
21
  EXPOSE 7860
22
 
README.md CHANGED
@@ -1,13 +1,3 @@
1
- ---
2
- license: mit
3
- title: Valhalla
4
- sdk: docker
5
- app_port: 7860
6
- emoji: 🚀
7
- colorFrom: red
8
- colorTo: blue
9
- short_description: Valhalla, a multi agent simulation
10
- ---
11
  # Valhalla
12
 
13
  Valhalla is a multi-agent campus-life simulation set at IIT Ropar.
@@ -178,14 +168,6 @@ Set values in `.env` (copy from `.env.local` first).
178
  | `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation |
179
  | `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue |
180
  | `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates |
181
- | `MAP_IMAGE_URL` | `""` | Public HTTPS URL for the daytime map displayed in the browser |
182
- | `MAP_NIGHT_IMAGE_URL` | `""` | Public HTTPS URL for the night-map overlay displayed in the browser |
183
- | `PATH_IMAGE_URL` | `""` | Public HTTPS URL for the walkable-path PNG used by the backend |
184
-
185
- For a Hugging Face Space that keeps images in GitHub, add these as **Variables**
186
- (not Secrets) in **Settings → Variables and secrets**. Use GitHub raw-content
187
- URLs, for example `https://raw.githubusercontent.com/OWNER/REPO/main/assets/map.png`.
188
- Set all three URLs; `PATH_IMAGE_URL` is required for backend route calculation.
189
 
190
  ## Project Structure
191
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Valhalla
2
 
3
  Valhalla is a multi-agent campus-life simulation set at IIT Ropar.
 
168
  | `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation |
169
  | `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue |
170
  | `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates |
 
 
 
 
 
 
 
 
171
 
172
  ## Project Structure
173
 
backend/Odin.py CHANGED
@@ -1,13 +1,8 @@
1
- """Odin — the web server and process entry point for Valhalla.
2
-
3
- Serves the React dashboard, exposes REST + WebSocket endpoints (sim
4
- control, roster, auth, pathfinding), and hosts the WorldEngine as a
5
- background asyncio task with per-tick snapshot broadcasts.
6
-
7
- Architecture: the only entry point that runs the full system; depends on
8
- src.core.world_engine, src.auth, pathfinder, and the frontend build.
9
- Design: all sim-control endpoints are auth-gated; roster edits are only
10
- allowed while the simulation is stopped.
11
  """
12
 
13
  import os
@@ -19,7 +14,6 @@ import socket
19
  from contextlib import asynccontextmanager
20
  from datetime import datetime, timedelta
21
  from pathlib import Path
22
- from urllib.parse import urlparse
23
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, Header
24
  from fastapi.responses import FileResponse, JSONResponse
25
  from pydantic import BaseModel, Field
@@ -37,18 +31,6 @@ FRONTEND = os.path.join(ROOT, "frontend")
37
  DATA_DIR = os.path.join(ROOT, "backend", "data")
38
 
39
 
40
- def _public_asset_url(value: str) -> str:
41
- """Return a browser-loadable URL, correcting GitHub's HTML blob links."""
42
- value = value.strip()
43
- parsed = urlparse(value)
44
- parts = parsed.path.strip("/").split("/")
45
- if parsed.scheme == "https" and parsed.netloc in {"github.com", "www.github.com"} \
46
- and len(parts) >= 5 and parts[2] == "blob":
47
- owner, repository, _, revision, *path = parts
48
- return f"https://raw.githubusercontent.com/{owner}/{repository}/{revision}/{'/'.join(path)}"
49
- return value
50
-
51
-
52
  # --------------------------------------------------------------------------- #
53
  # Sim Manager — connection broadcast + background WorldEngine
54
  # --------------------------------------------------------------------------- #
@@ -235,30 +217,6 @@ async def require_admin(authorization: str = Header(None)):
235
  return user
236
 
237
 
238
- # ---------------------------------------------------------------------------
239
- # Admin log relay — live view of backend logs without the Space console.
240
- # The relay mirror lives in src/core/log_relay.py and is installed by
241
- # src/core/log.py setup_logging(); only these two endpoints expose it.
242
- # ---------------------------------------------------------------------------
243
-
244
- @app.get("/api/logs")
245
- async def get_log_lines(
246
- since: int = Query(default=0, ge=0),
247
- _user: dict = Depends(require_admin),
248
- ):
249
- """Return relayed log lines newer than `since`; `next` is the poll cursor."""
250
- from src.core.log_relay import relay_lines
251
- return relay_lines(since)
252
-
253
-
254
- @app.post("/api/logs/clear")
255
- async def clear_log_lines(_user: dict = Depends(require_admin)):
256
- """Clear the in-memory relay buffer. The live log file is left intact."""
257
- from src.core.log_relay import clear_relay
258
- clear_relay()
259
- return {"ok": True}
260
-
261
-
262
  def _print_agent_plans(engine):
263
  """Print each agent's full action plan to the CLI."""
264
  from src.core.agent_registry import AgentRuntimeState
@@ -717,8 +675,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)
717
  "current_time": f"{current_date} {current_hhmm}", "places": None,
718
  "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
719
  "current_location_id": generated.hostel, "upcoming_events": [],
720
- "energy_level": engine._energy_baseline(persona),
721
- "emotion_state": engine._emotion_baseline(persona),
722
  }))
723
  day_plan = plan_result.get("day_plan", [])
724
  if not day_plan:
@@ -732,7 +688,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)
732
  engine.registry.register(AgentRuntimeState(
733
  agent_id=agent_id, persona=persona, persona_name=generated.name,
734
  manager=manager, position=position, day_plan=day_plan,
735
- energy_level=engine._energy_baseline(persona),
736
  emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
737
  ))
738
  engine.world.register_agent(agent_id, position)
@@ -1007,20 +962,6 @@ def get_entrypoints():
1007
  return json.load(f)
1008
 
1009
 
1010
- @app.get("/api/assets")
1011
- def get_asset_urls():
1012
- """Return public map URLs configured on the running Space.
1013
-
1014
- Hugging Face Space variables are runtime environment variables, while the
1015
- Vite bundle is built earlier. The browser reads this endpoint so changing
1016
- a Space variable does not require committing image files to the Space.
1017
- """
1018
- return {
1019
- "map_image_url": _public_asset_url(os.environ.get("MAP_IMAGE_URL", "")),
1020
- "night_image_url": _public_asset_url(os.environ.get("MAP_NIGHT_IMAGE_URL", "")),
1021
- }
1022
-
1023
-
1024
  # Serve React production build (dist/) if it exists, else fallback to frontend/
1025
  import mimetypes
1026
  # On Windows, the registry often maps .js to text/plain, which makes browsers
@@ -1032,32 +973,17 @@ mimetypes.add_type("text/css", ".css")
1032
  mimetypes.add_type("image/svg+xml", ".svg")
1033
  mimetypes.add_type("image/png", ".png")
1034
 
1035
- @app.api_route("/{full_path:path}", methods=["GET", "HEAD"])
1036
  async def serve_spa(full_path: str):
1037
  dist = os.path.join(FRONTEND, "dist")
1038
- public = os.path.join(FRONTEND, "public")
1039
-
1040
- # 1. Try dist directory first
1041
- if os.path.isdir(dist) and full_path:
1042
- file_path = os.path.join(dist, full_path)
1043
  if os.path.isfile(file_path):
1044
  return FileResponse(file_path)
1045
-
1046
- # 2. Try frontend/public directory (source static assets)
1047
- if os.path.isdir(public) and full_path:
1048
- pub_path = os.path.join(public, full_path)
1049
- if os.path.isfile(pub_path):
1050
- return FileResponse(pub_path)
1051
-
1052
- # 3. Try frontend root directory
1053
- if full_path:
1054
- fe_path = os.path.join(FRONTEND, full_path)
1055
- if os.path.isfile(fe_path):
1056
- return FileResponse(fe_path)
1057
-
1058
- # 4. Fallback to index.html for SPA client-side routing
1059
- if os.path.isdir(dist):
1060
  return FileResponse(os.path.join(dist, "index.html"))
 
 
 
1061
  return FileResponse(os.path.join(FRONTEND, "index.html"))
1062
 
1063
 
 
1
+ """
2
+ FastAPI web server for the Valhalla agent map.
3
+ Serves the frontend (React SPA), exposes REST + WebSocket
4
+ endpoints for pathfinding (/api/path, /api/path/stream, /ws), and
5
+ streams simulation state via /ws/sim for live agent visualization.
 
 
 
 
 
6
  """
7
 
8
  import os
 
14
  from contextlib import asynccontextmanager
15
  from datetime import datetime, timedelta
16
  from pathlib import Path
 
17
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, Header
18
  from fastapi.responses import FileResponse, JSONResponse
19
  from pydantic import BaseModel, Field
 
31
  DATA_DIR = os.path.join(ROOT, "backend", "data")
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # --------------------------------------------------------------------------- #
35
  # Sim Manager — connection broadcast + background WorldEngine
36
  # --------------------------------------------------------------------------- #
 
217
  return user
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def _print_agent_plans(engine):
221
  """Print each agent's full action plan to the CLI."""
222
  from src.core.agent_registry import AgentRuntimeState
 
675
  "current_time": f"{current_date} {current_hhmm}", "places": None,
676
  "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
677
  "current_location_id": generated.hostel, "upcoming_events": [],
 
 
678
  }))
679
  day_plan = plan_result.get("day_plan", [])
680
  if not day_plan:
 
688
  engine.registry.register(AgentRuntimeState(
689
  agent_id=agent_id, persona=persona, persona_name=generated.name,
690
  manager=manager, position=position, day_plan=day_plan,
 
691
  emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
692
  ))
693
  engine.world.register_agent(agent_id, position)
 
962
  return json.load(f)
963
 
964
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  # Serve React production build (dist/) if it exists, else fallback to frontend/
966
  import mimetypes
967
  # On Windows, the registry often maps .js to text/plain, which makes browsers
 
973
  mimetypes.add_type("image/svg+xml", ".svg")
974
  mimetypes.add_type("image/png", ".png")
975
 
976
+ @app.get("/{full_path:path}")
977
  async def serve_spa(full_path: str):
978
  dist = os.path.join(FRONTEND, "dist")
979
+ if os.path.isdir(dist):
980
+ file_path = os.path.join(dist, full_path) if full_path else os.path.join(dist, "index.html")
 
 
 
981
  if os.path.isfile(file_path):
982
  return FileResponse(file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  return FileResponse(os.path.join(dist, "index.html"))
984
+ file_path = os.path.join(FRONTEND, full_path) if full_path else os.path.join(FRONTEND, "index.html")
985
+ if os.path.isfile(file_path):
986
+ return FileResponse(file_path)
987
  return FileResponse(os.path.join(FRONTEND, "index.html"))
988
 
989
 
backend/Pathfinder_test.py CHANGED
@@ -1,11 +1,7 @@
1
- """Pathfinder_test — interactive CLI tool for verifying pixel pathfinding.
2
-
3
- Loads the campus map, computes a BFS path between two pixel coordinates,
4
- and renders it in a matplotlib window.
5
-
6
- Architecture: a developer tool, not part of the simulation runtime; it
7
- exercises backend/pathfinder.py against the real walkability map.
8
- Design: keeps the visual debugging loop out of the server code.
9
  """
10
 
11
  import sys
 
1
+ """
2
+ CLI tool for pixel-level pathfinding on the Valhalla map.
3
+ Loads map.png, computes the shortest path between two pixel coordinates
4
+ via BFS, and displays the result with start/end markers in a matplotlib window.
 
 
 
 
5
  """
6
 
7
  import sys
backend/data/environment/relationship_matrix.json CHANGED
@@ -1,6 +1,94 @@
1
  {
2
  "schema_version": 2,
3
  "relationships": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  "ansh_batra->anubhav_prasad": {
5
  "score": 0.41,
6
  "tags": [
@@ -23,7 +111,14 @@
23
  "campus-acquaintance",
24
  "party-bros"
25
  ],
26
- "context": "Ansh and Gurnoor's parties always end in legendary stories including that one time they both woke up in the same bed after a dare and just laughed it off... mostly."
 
 
 
 
 
 
 
27
  },
28
  "ansh_batra->lavanya_sharma": {
29
  "score": 0.44,
@@ -65,6 +160,14 @@
65
  ],
66
  "context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute."
67
  },
 
 
 
 
 
 
 
 
68
  "anubhav_prasad->ansh_batra": {
69
  "score": 0.41,
70
  "tags": [
@@ -89,6 +192,13 @@
89
  ],
90
  "context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't."
91
  },
 
 
 
 
 
 
 
92
  "anubhav_prasad->lavanya_sharma": {
93
  "score": 0.73,
94
  "tags": [
@@ -129,6 +239,14 @@
129
  ],
130
  "context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding."
131
  },
 
 
 
 
 
 
 
 
132
  "ghanisht_kaushal->ansh_batra": {
133
  "score": 0.66,
134
  "tags": [
@@ -153,6 +271,13 @@
153
  ],
154
  "context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold."
155
  },
 
 
 
 
 
 
 
156
  "ghanisht_kaushal->lavanya_sharma": {
157
  "score": 0.36,
158
  "tags": [
@@ -193,6 +318,14 @@
193
  ],
194
  "context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder."
195
  },
 
 
 
 
 
 
 
 
196
  "gurnoor_singh->ansh_batra": {
197
  "score": 0.53,
198
  "tags": [
@@ -217,6 +350,13 @@
217
  ],
218
  "context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him."
219
  },
 
 
 
 
 
 
 
220
  "gurnoor_singh->lavanya_sharma": {
221
  "score": 0.5,
222
  "tags": [
@@ -257,6 +397,85 @@
257
  ],
258
  "context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush."
259
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  "lavanya_sharma->ansh_batra": {
261
  "score": 0.44,
262
  "tags": [
@@ -289,6 +508,13 @@
289
  ],
290
  "context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious."
291
  },
 
 
 
 
 
 
 
292
  "lavanya_sharma->parv_singla": {
293
  "score": 0.43,
294
  "tags": [
@@ -321,6 +547,14 @@
321
  ],
322
  "context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up."
323
  },
 
 
 
 
 
 
 
 
324
  "parv_singla->ansh_batra": {
325
  "score": 0.56,
326
  "tags": [
@@ -353,6 +587,13 @@
353
  ],
354
  "context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories."
355
  },
 
 
 
 
 
 
 
356
  "parv_singla->lavanya_sharma": {
357
  "score": 0.43,
358
  "tags": [
@@ -385,6 +626,14 @@
385
  ],
386
  "context": "Parv loves hyping Tanishq up and watching him gain confidence."
387
  },
 
 
 
 
 
 
 
 
388
  "riya_murarka->ansh_batra": {
389
  "score": 0.5,
390
  "tags": [
@@ -417,6 +666,13 @@
417
  ],
418
  "context": "Riya finds Gurnoor's energy entertaining in small doses."
419
  },
 
 
 
 
 
 
 
420
  "riya_murarka->lavanya_sharma": {
421
  "score": 0.57,
422
  "tags": [
@@ -449,6 +705,14 @@
449
  ],
450
  "context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now."
451
  },
 
 
 
 
 
 
 
 
452
  "saksham->ansh_batra": {
453
  "score": 0.34,
454
  "tags": [
@@ -481,6 +745,13 @@
481
  ],
482
  "context": "Saksham finds Gurnoor's energy exhausting but entertaining."
483
  },
 
 
 
 
 
 
 
484
  "saksham->lavanya_sharma": {
485
  "score": 0.47,
486
  "tags": [
@@ -513,6 +784,14 @@
513
  ],
514
  "context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor."
515
  },
 
 
 
 
 
 
 
 
516
  "tanishq->ansh_batra": {
517
  "score": 0.56,
518
  "tags": [
@@ -545,6 +824,13 @@
545
  ],
546
  "context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it."
547
  },
 
 
 
 
 
 
 
548
  "tanishq->lavanya_sharma": {
549
  "score": 0.63,
550
  "tags": [
@@ -578,4 +864,4 @@
578
  "context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way."
579
  }
580
  }
581
- }
 
1
  {
2
  "schema_version": 2,
3
  "relationships": {
4
+ "amitabh->ansh_batra": {
5
+ "score": 0.55,
6
+ "tags": [
7
+ "campus-acquaintance",
8
+ "low-pressure"
9
+ ],
10
+ "context": "Amitabh and Ansh Batra know each other through campus routines. Amitabh appreciates ansh_batra's enthusiastic plans, but the friendship is still finding its rhythm."
11
+ },
12
+ "amitabh->anubhav_prasad": {
13
+ "score": 0.49,
14
+ "tags": [
15
+ "campus-acquaintance",
16
+ "low-pressure"
17
+ ],
18
+ "context": "Amitabh and Anubhav Prasad know each other through campus routines. Amitabh appreciates anubhav_prasad's calm listening, but the friendship is still finding its rhythm."
19
+ },
20
+ "amitabh->ghanisht_kaushal": {
21
+ "score": 0.46,
22
+ "tags": [
23
+ "campus-acquaintance",
24
+ "low-pressure"
25
+ ],
26
+ "context": "Amitabh and Ghanisht Kaushal know each other through campus routines. Amitabh appreciates ghanisht_kaushal's reliable follow-through, but the friendship is still finding its rhythm."
27
+ },
28
+ "amitabh->gurnoor_singh": {
29
+ "score": 0.56,
30
+ "tags": [
31
+ "campus-acquaintance",
32
+ "low-pressure"
33
+ ],
34
+ "context": "Amitabh and Gurnoor Singh know each other through campus routines. Amitabh appreciates gurnoor_singh's big social energy, but the friendship is still finding its rhythm."
35
+ },
36
+ "amitabh->jarvis": {
37
+ "score": 0.63,
38
+ "tags": [
39
+ "campus-acquaintance",
40
+ "low-pressure"
41
+ ],
42
+ "context": "Amitabh and Jarvis know each other through campus routines. Amitabh appreciates jarvis's steady conversation, but the friendship is still finding its rhythm."
43
+ },
44
+ "amitabh->lavanya_sharma": {
45
+ "score": 0.5700000000000001,
46
+ "tags": [
47
+ "campus-acquaintance",
48
+ "low-pressure"
49
+ ],
50
+ "context": "Amitabh and Lavanya Sharma know each other through campus routines. Amitabh appreciates lavanya_sharma's direct feedback, but the friendship is still finding its rhythm."
51
+ },
52
+ "amitabh->parv_singla": {
53
+ "score": 0.54,
54
+ "tags": [
55
+ "campus-acquaintance",
56
+ "low-pressure"
57
+ ],
58
+ "context": "Amitabh and Parv Singla know each other through campus routines. Amitabh appreciates parv_singla's impulsive invitations, but the friendship is still finding its rhythm."
59
+ },
60
+ "amitabh->riya_murarka": {
61
+ "score": 0.3,
62
+ "tags": [
63
+ "campus-acquaintance",
64
+ "low-pressure"
65
+ ],
66
+ "context": "Amitabh and Riya Murarka know each other through campus routines. Amitabh appreciates riya_murarka's clear boundaries, but the friendship is still finding its rhythm."
67
+ },
68
+ "amitabh->saksham": {
69
+ "score": 0.53,
70
+ "tags": [
71
+ "campus-acquaintance",
72
+ "low-pressure"
73
+ ],
74
+ "context": "Amitabh and Saksham know each other through campus routines. Amitabh appreciates saksham's dry humour, but the friendship is still finding its rhythm."
75
+ },
76
+ "amitabh->tanishq": {
77
+ "score": 0.31,
78
+ "tags": [
79
+ "campus-acquaintance",
80
+ "low-pressure"
81
+ ],
82
+ "context": "Amitabh and Tanishq know each other through campus routines. Amitabh appreciates tanishq's quiet, improving confidence, but the friendship is still finding its rhythm."
83
+ },
84
+ "ansh_batra->amitabh": {
85
+ "score": 0.55,
86
+ "tags": [
87
+ "campus-acquaintance",
88
+ "low-pressure"
89
+ ],
90
+ "context": "Ansh Batra and Amitabh know each other through football and visual storytelling. Ansh Batra appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
91
+ },
92
  "ansh_batra->anubhav_prasad": {
93
  "score": 0.41,
94
  "tags": [
 
111
  "campus-acquaintance",
112
  "party-bros"
113
  ],
114
+ "context": "Ansh and Gurnoor's parties always end in legendary stories \u2014 including that one time they both woke up in the same bed after a dare and just laughed it off... mostly."
115
+ },
116
+ "ansh_batra->jarvis": {
117
+ "score": 0.32,
118
+ "tags": [
119
+ "new-acquaintance"
120
+ ],
121
+ "context": "Ansh Batra has only recently met Jarvis; the connection is open but untested."
122
  },
123
  "ansh_batra->lavanya_sharma": {
124
  "score": 0.44,
 
160
  ],
161
  "context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute."
162
  },
163
+ "anubhav_prasad->amitabh": {
164
+ "score": 0.49,
165
+ "tags": [
166
+ "campus-acquaintance",
167
+ "low-pressure"
168
+ ],
169
+ "context": "Anubhav Prasad and Amitabh know each other through co-op games and late-night chai. Anubhav Prasad appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
170
+ },
171
  "anubhav_prasad->ansh_batra": {
172
  "score": 0.41,
173
  "tags": [
 
192
  ],
193
  "context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't."
194
  },
195
+ "anubhav_prasad->jarvis": {
196
+ "score": 0.32,
197
+ "tags": [
198
+ "new-acquaintance"
199
+ ],
200
+ "context": "Anubhav Prasad has only recently met Jarvis; the connection is open but untested."
201
+ },
202
  "anubhav_prasad->lavanya_sharma": {
203
  "score": 0.73,
204
  "tags": [
 
239
  ],
240
  "context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding."
241
  },
242
+ "ghanisht_kaushal->amitabh": {
243
+ "score": 0.46,
244
+ "tags": [
245
+ "campus-acquaintance",
246
+ "low-pressure"
247
+ ],
248
+ "context": "Ghanisht Kaushal and Amitabh know each other through badminton and blunt movie opinions. Ghanisht Kaushal appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
249
+ },
250
  "ghanisht_kaushal->ansh_batra": {
251
  "score": 0.66,
252
  "tags": [
 
271
  ],
272
  "context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold."
273
  },
274
+ "ghanisht_kaushal->jarvis": {
275
+ "score": 0.32,
276
+ "tags": [
277
+ "new-acquaintance"
278
+ ],
279
+ "context": "Ghanisht Kaushal has only recently met Jarvis; the connection is open but untested."
280
+ },
281
  "ghanisht_kaushal->lavanya_sharma": {
282
  "score": 0.36,
283
  "tags": [
 
318
  ],
319
  "context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder."
320
  },
321
+ "gurnoor_singh->amitabh": {
322
+ "score": 0.56,
323
+ "tags": [
324
+ "campus-acquaintance",
325
+ "low-pressure"
326
+ ],
327
+ "context": "Gurnoor Singh and Amitabh know each other through photography and road-trip playlists. Gurnoor Singh appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
328
+ },
329
  "gurnoor_singh->ansh_batra": {
330
  "score": 0.53,
331
  "tags": [
 
350
  ],
351
  "context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him."
352
  },
353
+ "gurnoor_singh->jarvis": {
354
+ "score": 0.32,
355
+ "tags": [
356
+ "new-acquaintance"
357
+ ],
358
+ "context": "Gurnoor Singh has only recently met Jarvis; the connection is open but untested."
359
+ },
360
  "gurnoor_singh->lavanya_sharma": {
361
  "score": 0.5,
362
  "tags": [
 
397
  ],
398
  "context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush."
399
  },
400
+ "jarvis->amitabh": {
401
+ "score": 0.63,
402
+ "tags": [
403
+ "campus-acquaintance",
404
+ "low-pressure"
405
+ ],
406
+ "context": "Jarvis and Amitabh know each other through campus routines. Jarvis appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
407
+ },
408
+ "jarvis->ansh_batra": {
409
+ "score": 0.32,
410
+ "tags": [
411
+ "new-acquaintance"
412
+ ],
413
+ "context": "Jarvis is new to this circle and is still learning Ansh Batra's rhythm."
414
+ },
415
+ "jarvis->anubhav_prasad": {
416
+ "score": 0.32,
417
+ "tags": [
418
+ "new-acquaintance"
419
+ ],
420
+ "context": "Jarvis is new to this circle and is still learning Anubhav Prasad's rhythm."
421
+ },
422
+ "jarvis->ghanisht_kaushal": {
423
+ "score": 0.32,
424
+ "tags": [
425
+ "new-acquaintance"
426
+ ],
427
+ "context": "Jarvis is new to this circle and is still learning Ghanisht Kaushal's rhythm."
428
+ },
429
+ "jarvis->gurnoor_singh": {
430
+ "score": 0.32,
431
+ "tags": [
432
+ "new-acquaintance"
433
+ ],
434
+ "context": "Jarvis is new to this circle and is still learning Gurnoor Singh's rhythm."
435
+ },
436
+ "jarvis->lavanya_sharma": {
437
+ "score": 0.37,
438
+ "tags": [
439
+ "new-acquaintance"
440
+ ],
441
+ "context": "Jarvis is new to this circle and is still learning Lavanya Sharma's rhythm."
442
+ },
443
+ "jarvis->parv_singla": {
444
+ "score": 0.32,
445
+ "tags": [
446
+ "new-acquaintance"
447
+ ],
448
+ "context": "Jarvis is new to this circle and is still learning Parv Singla's rhythm."
449
+ },
450
+ "jarvis->riya_murarka": {
451
+ "score": 0.37,
452
+ "tags": [
453
+ "new-acquaintance"
454
+ ],
455
+ "context": "Jarvis is new to this circle and is still learning Riya Murarka's rhythm."
456
+ },
457
+ "jarvis->saksham": {
458
+ "score": 0.42,
459
+ "tags": [
460
+ "new-acquaintance"
461
+ ],
462
+ "context": "Jarvis is new to this circle and is still learning Saksham's rhythm."
463
+ },
464
+ "jarvis->tanishq": {
465
+ "score": 0.32,
466
+ "tags": [
467
+ "new-acquaintance"
468
+ ],
469
+ "context": "Jarvis is new to this circle and is still learning Tanishq's rhythm."
470
+ },
471
+ "lavanya_sharma->amitabh": {
472
+ "score": 0.5700000000000001,
473
+ "tags": [
474
+ "campus-acquaintance",
475
+ "low-pressure"
476
+ ],
477
+ "context": "Lavanya Sharma and Amitabh know each other through basketball and debate. Lavanya Sharma appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
478
+ },
479
  "lavanya_sharma->ansh_batra": {
480
  "score": 0.44,
481
  "tags": [
 
508
  ],
509
  "context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious."
510
  },
511
+ "lavanya_sharma->jarvis": {
512
+ "score": 0.37,
513
+ "tags": [
514
+ "new-acquaintance"
515
+ ],
516
+ "context": "Lavanya Sharma has only recently met Jarvis; the connection is open but untested."
517
+ },
518
  "lavanya_sharma->parv_singla": {
519
  "score": 0.43,
520
  "tags": [
 
547
  ],
548
  "context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up."
549
  },
550
+ "parv_singla->amitabh": {
551
+ "score": 0.54,
552
+ "tags": [
553
+ "campus-acquaintance",
554
+ "low-pressure"
555
+ ],
556
+ "context": "Parv Singla and Amitabh know each other through running and music. Parv Singla appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
557
+ },
558
  "parv_singla->ansh_batra": {
559
  "score": 0.56,
560
  "tags": [
 
587
  ],
588
  "context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories."
589
  },
590
+ "parv_singla->jarvis": {
591
+ "score": 0.32,
592
+ "tags": [
593
+ "new-acquaintance"
594
+ ],
595
+ "context": "Parv Singla has only recently met Jarvis; the connection is open but untested."
596
+ },
597
  "parv_singla->lavanya_sharma": {
598
  "score": 0.43,
599
  "tags": [
 
626
  ],
627
  "context": "Parv loves hyping Tanishq up and watching him gain confidence."
628
  },
629
+ "riya_murarka->amitabh": {
630
+ "score": 0.3,
631
+ "tags": [
632
+ "campus-acquaintance",
633
+ "low-pressure"
634
+ ],
635
+ "context": "Riya Murarka and Amitabh know each other through reading circles and long runs. Riya Murarka appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
636
+ },
637
  "riya_murarka->ansh_batra": {
638
  "score": 0.5,
639
  "tags": [
 
666
  ],
667
  "context": "Riya finds Gurnoor's energy entertaining in small doses."
668
  },
669
+ "riya_murarka->jarvis": {
670
+ "score": 0.37,
671
+ "tags": [
672
+ "new-acquaintance"
673
+ ],
674
+ "context": "Riya Murarka has only recently met Jarvis; the connection is open but untested."
675
+ },
676
  "riya_murarka->lavanya_sharma": {
677
  "score": 0.57,
678
  "tags": [
 
705
  ],
706
  "context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now."
707
  },
708
+ "saksham->amitabh": {
709
+ "score": 0.53,
710
+ "tags": [
711
+ "campus-acquaintance",
712
+ "low-pressure"
713
+ ],
714
+ "context": "Saksham and Amitabh know each other through badminton and strategy games. Saksham appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
715
+ },
716
  "saksham->ansh_batra": {
717
  "score": 0.34,
718
  "tags": [
 
745
  ],
746
  "context": "Saksham finds Gurnoor's energy exhausting but entertaining."
747
  },
748
+ "saksham->jarvis": {
749
+ "score": 0.42,
750
+ "tags": [
751
+ "new-acquaintance"
752
+ ],
753
+ "context": "Saksham has only recently met Jarvis; the connection is open but untested."
754
+ },
755
  "saksham->lavanya_sharma": {
756
  "score": 0.47,
757
  "tags": [
 
784
  ],
785
  "context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor."
786
  },
787
+ "tanishq->amitabh": {
788
+ "score": 0.31,
789
+ "tags": [
790
+ "campus-acquaintance",
791
+ "low-pressure"
792
+ ],
793
+ "context": "Tanishq and Amitabh know each other through strategy games and playlists. Tanishq appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
794
+ },
795
  "tanishq->ansh_batra": {
796
  "score": 0.56,
797
  "tags": [
 
824
  ],
825
  "context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it."
826
  },
827
+ "tanishq->jarvis": {
828
+ "score": 0.32,
829
+ "tags": [
830
+ "new-acquaintance"
831
+ ],
832
+ "context": "Tanishq has only recently met Jarvis; the connection is open but untested."
833
+ },
834
  "tanishq->lavanya_sharma": {
835
  "score": 0.63,
836
  "tags": [
 
864
  "context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way."
865
  }
866
  }
867
+ }
backend/data/personalities/amitabh/amitabh.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Amitabh",
3
+ "Age": "20",
4
+ "Gender": "Male",
5
+ "Branch": "Computer Science",
6
+ "Home City": "Delhi",
7
+ "Hostel": "Beas",
8
+ "daily_plan_req": "Gym in the morning, classes, evening coding + chai sessions, night gaming or deep talks",
9
+ "innate": "Calm, observant, secretly sarcastic, gets easily flustered by bold flirting but plays it cool",
10
+ "learned": "How to give good advice while hiding his own chaos, how to handle friends' impulsiveness",
11
+ "lifestyle": "Lowkey chill but down for spontaneous shit at 2am. Lowkey addicted to emotional tension and slow-burn crushes",
12
+ "hobbies": "Hardware tinkering, playlists, late-night chai, overthinking texts, secret meme saving",
13
+ "goals": "Graduate with good grades, figure out what he wants in relationships, maybe finally make a move on someone",
14
+ "interests": [
15
+ "Deep conversations",
16
+ "Tech",
17
+ "Flirty banter",
18
+ "Gym",
19
+ "Music",
20
+ "Quiet tension with girls/guys"
21
+ ]
22
+ }
backend/data/personalities/jarvis/jarvis.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Jarvis",
3
+ "Age": "18",
4
+ "Gender": "Non-binary",
5
+ "Branch": "Mechanical Engineering",
6
+ "Home City": "Indore",
7
+ "Hostel": "Chenab",
8
+ "daily_plan_req": "Wake up at 7 AM for a morning run or quick gym session. Attend core engineering lectures from 9 AM to 4 PM, with a lunch break at the mess. Dedicate 4 PM to 6 PM to library studies and assignment completion. The evenings are prioritized for socializing, communal dinner with friends, and engaging in light-hearted hostel activities. Late nights are reserved for deep dives into single-player gaming sessions and relaxing before lights out at midnight.",
9
+ "innate": "I possess a natural curiosity for how physical systems and machinery work, which pairs well with my optimistic and social nature. I am inherently empathetic and quick to make friends, always looking to find common ground with those around me to foster a welcoming social environment.",
10
+ "learned": "Through my first year, I have learned how to manage heavy academic workloads effectively without sacrificing my mental health. I have developed strong skills in CAD software, collaborative problem-solving, and the art of navigating complex social dynamics in a communal living setting.",
11
+ "lifestyle": "I lead a balanced life that centers around the high-energy environment of my hostel. I value my friendships deeply and make it a point to be an active presence in the student community. My routine allows for professional growth through academics while keeping enough space for creative decompression via gaming and hobbies.",
12
+ "hobbies": "My hobbies include immersive PC gaming, particularly narrative-driven RPGs, tinkering with basic electronics and hardware, playing casual chess in the common room, and curating indie music playlists to share with friends.",
13
+ "goals": "To successfully secure a prestigious internship in the robotics field by my third year, maintain a consistent academic record above 8.5 CGPA, and cultivate a supportive social circle that enriches my college experience.",
14
+ "interests": [
15
+ "PC Gaming",
16
+ "Robotics",
17
+ "Mechanical Design",
18
+ "Chess",
19
+ "Indie Music",
20
+ "Photography"
21
+ ]
22
+ }
backend/pathfinder.py CHANGED
@@ -1,20 +1,12 @@
1
- """pathfinder — pixel-space BFS pathfinding over the campus walkability map.
2
-
3
- Loads path.png (white pixels = walkable) and provides shortest_path(),
4
- stats(), and is_walkable() for the engine and the /api/path endpoints.
5
-
6
- Architecture: consumed by Odin.py and the agent action manager (Actions.py)
7
- to compute routes between buildings; anchors come from entrypoint.json.
8
- Design: 4-neighbor BFS with nearest-walkable endpoint snapping, because
9
- doors and interiors sit just off the walkable network.
10
  """
11
 
12
  import os
13
  import threading
14
- from io import BytesIO
15
  from collections import deque
16
- from urllib.parse import urlparse
17
- from urllib.request import Request, urlopen
18
  from PIL import Image
19
 
20
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -25,18 +17,6 @@ _W = _H = 0
25
  _load_lock = threading.Lock()
26
 
27
 
28
- def _public_asset_url(value):
29
- """Convert a GitHub blob page URL to its downloadable raw-file URL."""
30
- value = value.strip()
31
- parsed = urlparse(value)
32
- parts = parsed.path.strip("/").split("/")
33
- if parsed.scheme == "https" and parsed.netloc in {"github.com", "www.github.com"} \
34
- and len(parts) >= 5 and parts[2] == "blob":
35
- owner, repository, _, revision, *path = parts
36
- return f"https://raw.githubusercontent.com/{owner}/{repository}/{revision}/{'/'.join(path)}"
37
- return value
38
-
39
-
40
  def _load():
41
  global _path_img, _white_pixels, _W, _H
42
  if _white_pixels is not None:
@@ -52,37 +32,22 @@ def _load():
52
  os.path.join(ROOT, "frontend", "dist", "path.png"),
53
  ]
54
  path_file = next((p for p in candidates if os.path.exists(p)), None)
55
- path_url = _public_asset_url(os.environ.get("PATH_IMAGE_URL", ""))
56
- try:
57
- if path_file:
58
- image_source = path_file
59
- else:
60
- if not path_url:
61
- raise FileNotFoundError("path.png not found and PATH_IMAGE_URL is not configured")
62
- if urlparse(path_url).scheme != "https":
63
- raise ValueError("PATH_IMAGE_URL must be an https URL")
64
- request = Request(path_url, headers={"User-Agent": "Valhalla/1.0"})
65
- with urlopen(request, timeout=20) as response:
66
- image_bytes = response.read(20 * 1024 * 1024 + 1)
67
- if len(image_bytes) > 20 * 1024 * 1024:
68
- raise ValueError("PATH_IMAGE_URL exceeds the 20 MB download limit")
69
- image_source = BytesIO(image_bytes)
70
-
71
- with Image.open(image_source) as image:
72
- rgb = image.convert("RGB")
73
- _W, _H = rgb.size
74
- pix = rgb.load()
75
- _white_pixels = {
76
- (x, y)
77
- for y in range(_H)
78
- for x in range(_W)
79
- if pix[x, y] == (255, 255, 255)
80
- }
81
- except Exception as exc:
82
- import logging
83
- logging.getLogger(__name__).warning("Failed to load path.png (%s). Falling back to open map grid.", exc)
84
- _W, _H = 1276, 1233
85
- _white_pixels = {(x, y) for y in range(_H) for x in range(_W)}
86
 
87
 
88
  def _nearest_walkable(pt, max_r=60):
 
1
+ """
2
+ Core pathfinding module — imported by Odin.py and pixel_pathfinder.py.
3
+ Loads path.png into a set of walkable (white) pixels and provides
4
+ BFS shortest_path(), stats(), and is_walkable() helpers.
 
 
 
 
 
5
  """
6
 
7
  import os
8
  import threading
 
9
  from collections import deque
 
 
10
  from PIL import Image
11
 
12
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
17
  _load_lock = threading.Lock()
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def _load():
21
  global _path_img, _white_pixels, _W, _H
22
  if _white_pixels is not None:
 
32
  os.path.join(ROOT, "frontend", "dist", "path.png"),
33
  ]
34
  path_file = next((p for p in candidates if os.path.exists(p)), None)
35
+ if path_file is None:
36
+ raise FileNotFoundError(
37
+ "path.png not found in frontend/public, frontend, or frontend/dist"
38
+ )
39
+ # Copy decoded pixels while the image handle is open, then release the
40
+ # handle immediately so the source image is not locked on Windows.
41
+ with Image.open(path_file) as image:
42
+ rgb = image.convert("RGB")
43
+ _W, _H = rgb.size
44
+ pix = rgb.load()
45
+ _white_pixels = {
46
+ (x, y)
47
+ for y in range(_H)
48
+ for x in range(_W)
49
+ if pix[x, y] == (255, 255, 255)
50
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  def _nearest_walkable(pt, max_r=60):
backend/src/agents/Actions.py CHANGED
@@ -1,14 +1,23 @@
1
- """Actions — the agent's executor: turns a validated day plan into movement
2
- and activity on the map, tick by tick.
 
 
 
 
 
 
 
3
 
4
- Owns the AgentActionManager (last/current/next action state machine),
5
- location resolution (place id -> pixel position), route computation via
6
- pathfinder, and conversation freeze/resume.
7
 
8
- Architecture: the only module that advances agent position; called by the
9
- brain/body each tick and checkpointed whole by checkpoint_manager.
10
- Design: travel is schedule-aware (arrives by the plan's end time); the
11
- agent keeps its origin location_id while in transit.
 
 
12
  """
13
 
14
  from __future__ import annotations
@@ -73,7 +82,6 @@ class ActionState(BaseModel):
73
  path_index: int = 0 # current position along path
74
  energy_change: float = 0.0 # total change over entire action
75
  emotion_change: float = 0.0 # total change over entire action
76
- energy_target: Optional[float] = None # declared cumulative energy at action end (0-1); None = delta-based
77
  is_final_plan_action: bool = False
78
  event_id: Optional[str] = None # data-driven world event, when applicable
79
 
@@ -348,7 +356,6 @@ class AgentActionManager:
348
  position=position,
349
  energy_change=plan_action.get("energy_change", 0.0),
350
  emotion_change=plan_action.get("emotion_change", 0.0),
351
- energy_target=plan_action.get("energy_target"),
352
  is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]),
353
  event_id=plan_action.get("world_event_id"),
354
  )
 
1
+ """
2
+ Actions -- manages agent action execution: last, current, next.
3
+
4
+ Takes the raw day plan produced by day_planner.py and drives it forward
5
+ tick by tick. Handles three action types:
6
+
7
+ 1. MOVE -- agent walks from place A to place B (pathfinder.py)
8
+ 2. MISC -- static activity: studying, coding, eating, etc.
9
+ 3. CONVERSATION -- triggered when two agents are in proximity.
10
 
11
+ The module converts location_id strings (from day plans) into pixel
12
+ coordinates (from entrypoint.json) and uses the BFS pathfinder to
13
+ compute walkable paths between locations.
14
 
15
+ Usage:
16
+ from src.agents.Actions import AgentActionManager, LocationResolver
17
+
18
+ resolver = LocationResolver()
19
+ manager = AgentActionManager("parv_singla", day_plan, initial_position)
20
+ state = manager.tick(world_tick, snapshot)
21
  """
22
 
23
  from __future__ import annotations
 
82
  path_index: int = 0 # current position along path
83
  energy_change: float = 0.0 # total change over entire action
84
  emotion_change: float = 0.0 # total change over entire action
 
85
  is_final_plan_action: bool = False
86
  event_id: Optional[str] = None # data-driven world event, when applicable
87
 
 
356
  position=position,
357
  energy_change=plan_action.get("energy_change", 0.0),
358
  emotion_change=plan_action.get("emotion_change", 0.0),
 
359
  is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]),
360
  event_id=plan_action.get("world_event_id"),
361
  )
backend/src/agents/Long_term.py CHANGED
@@ -1,15 +1,10 @@
1
- """Long_term — Qdrant-backed long-term memory interface.
2
 
3
- Provides the process-wide MemoryRetriever singleton over vector_memory.py;
4
- long-term records live only in Qdrant, while per-day short-term files are
5
- summarized and indexed at day handoff.
6
-
7
- Architecture: consumed by brain.py and the engine for retrieval; paired
8
- with Short_term.py (operational memory) and vector_memory.py (storage).
9
- Design: deliberately no JSON archive reader — Qdrant is the single
10
- long-term source of truth.
11
  """
12
-
13
  from __future__ import annotations
14
 
15
  from typing import List, Optional, Protocol, runtime_checkable
 
1
+ """Qdrant-backed long-term memory interface.
2
 
3
+ Long-term agent memory is stored only in Qdrant. Short-term JSON files remain
4
+ the operational record for the active simulation day; they are summarized and
5
+ indexed at handoff, then removed. This module deliberately has no JSON
6
+ archive reader or keyword-search fallback.
 
 
 
 
7
  """
 
8
  from __future__ import annotations
9
 
10
  from typing import List, Optional, Protocol, runtime_checkable
backend/src/agents/Short_term.py CHANGED
@@ -1,13 +1,13 @@
1
- """Short_term — per-agent, per-day operational memory.
 
 
 
 
2
 
3
- Stores the full day (plan, events, conversations, world snapshots, LLM
4
- daily summary) as one JSON file per persona per date, with atomic writes,
5
- and archives the day to Qdrant long-term memory at handoff.
6
 
7
- Architecture: the memory layer between the engine and long-term storage;
8
- written by world_engine.py, read by brain.py and day_planner.py.
9
- Design: short-term files are the operational record of the active day
10
- and are removed after successful archival.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Short-term Memory -- per-agent, per-day detailed memory store.
3
+
4
+ Stores the full day's data (plan, events, conversations, world snapshots)
5
+ as a single JSON file per persona per simulation date.
6
 
7
+ File layout:
8
+ data/Short_term_db/<persona_name>/<YYYY-MM-DD>.json
 
9
 
10
+ Implements MemoryStreamProtocol (from tick_graph.py) for tick-graph integration.
 
 
 
11
  """
12
 
13
  from __future__ import annotations
backend/src/agents/Single_agent.py CHANGED
@@ -1,12 +1,24 @@
1
- """Single_agent — single-agent planning graph used for one-persona runs.
2
-
3
- A minimal LangGraph (retrieve memories -> generate day plan) that
4
- exercises the planner for a single persona in isolation.
5
-
6
- Architecture: a debugging/study tool parallel to the full engine; the
7
- docstring reserves future nodes (execute_tick, reflect, conversation).
8
- Design: kept intentionally small so single-agent experiments do not drag
9
- in the whole WorldEngine.
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
  from __future__ import annotations
 
1
+ """
2
+ Main brain / command centre of a single agent.
3
+
4
+ Makes decisions, calls and delegates tasks to sub-modules (day_planner,
5
+ memory, reflection, etc.), and runs the agent's action loop.
6
+
7
+ Exports:
8
+ create_agent_graph() -> CompiledGraph[AgentState]
9
+ A single-agent LangGraph. Currently one node: generate_day_plan.
10
+ Future: execute_tick, reflect, update_memory, conversation.
11
+
12
+ Usage as a library (for the multi-agent orchestrator):
13
+ graph = create_agent_graph()
14
+ result = graph.invoke({
15
+ "persona_name": "parv_singla",
16
+ "persona": {...},
17
+ "current_time": "2026-07-03 06:00",
18
+ })
19
+
20
+ Usage from CLI:
21
+ python Single_agent.py parv_singla
22
  """
23
 
24
  from __future__ import annotations
backend/src/agents/autonomy.py CHANGED
@@ -1,12 +1,9 @@
1
- """autonomy — schema for a future per-minute LLM "deviate from plan" decision.
2
-
3
- Defines AutonomyDecision (deviate / deviation_type / reason / duration)
4
- as the structured contract for a behavior switch the brain may request.
5
 
6
- Architecture: prepared for brain.py and the engine; currently no call
7
- site exists (the decision is gated off until enabled).
8
- Design: kept as a standalone schema so enabling autonomy later requires
9
- no changes to existing callers.
10
  """
11
 
12
  from __future__ import annotations
 
1
+ """
2
+ Autonomy — schema for the per-minute LLM decision to deviate from the plan.
 
 
3
 
4
+ The brain calls this once per agent per minute (when enabled). The LLM
5
+ sees the agent's persona, current plan, and nearby surroundings, then
6
+ decides whether to continue the plan or deviate temporarily.
 
7
  """
8
 
9
  from __future__ import annotations
backend/src/agents/body.py CHANGED
@@ -1,13 +1,23 @@
1
- """body — the agent's motor layer; the only way the brain moves the body.
2
-
3
- Wraps the AgentActionManager so the brain can advance, enter or resume
4
- conversations, and read position/current action without touching executor
5
- details.
6
-
7
- Architecture: sits between brain.py and Actions.py; used by the engine's
8
- act phase via brain.act().
9
- Design: the brain never manipulates the manager directly the body is
10
- the single command interface for movement.
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Body -- the agent's "limbs". The motor layer the brain commands.
3
+
4
+ In the human-like architecture the *brain* (brain.py) does the thinking:
5
+ it perceives, recalls, and decides. It never moves the agent directly.
6
+ Instead it issues motor commands to this Body, which is the only thing that
7
+ actually changes the agent's position and current activity.
8
+
9
+ The Body is a thin, behaviour-preserving adapter around the existing action
10
+ state machine (`AgentActionManager` in Actions.py) -- the proven executor
11
+ that walks paths and steps through the day plan. Wrapping it (rather than
12
+ replacing it) means the body/brain split is a clean architectural layer with
13
+ zero change to how movement and actions actually run.
14
+
15
+ Motor command surface (all 0-LLM):
16
+ - advance(tick) : take the next step of the current plan
17
+ - enter_conversation(name) : freeze into a conversation with someone
18
+ - resume(day_plan) : leave conversation / reload the plan
19
+ Read-only senses of the body's own state:
20
+ - position, current_action, is_last_action
21
  """
22
 
23
  from __future__ import annotations
backend/src/agents/brain.py CHANGED
@@ -1,13 +1,13 @@
1
- """brain — per-agent cognition: the LLM-backed decide step.
 
2
 
3
- Each tick, when the engine detects novel observations (and gates pass),
4
- the brain asks the LLM whether to continue the current plan or replan,
5
- and issues motor commands through the body.
 
6
 
7
- Architecture: called by WorldEngine._phase_llm_decide; consumes memories
8
- from Short_term/Long_term and produces TickDecision for the replan phase.
9
- Design: conservative by prompt (replan only for significant events) and
10
- by default (any LLM failure falls back to "continue").
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Brain -- the agent's cognition / command centre.
3
 
4
+ Each tick the brain may be called to decide (via LLM) whether the agent
5
+ should continue their current plan or replan, based on novel observations.
6
+ The LLM call is gated: it only fires when the perceive phase detects a change
7
+ in the set of (agent_id, action_description) within 50px.
8
 
9
+ When no novel observations exist, the brain returns "continue" without an LLM
10
+ call the agent follows its existing plan.
 
 
11
  """
12
 
13
  from __future__ import annotations
backend/src/agents/conversation.py CHANGED
@@ -1,13 +1,33 @@
1
- """conversation — generates agent-to-agent dialogue and its effects.
 
 
 
 
 
 
 
 
 
 
2
 
3
- One LLM call produces a full 4-12 message conversation with duration,
4
- sentiment, per-agent relationship, energy, and emotion effects; also owns
5
- the persistent RelationshipMatrix (directed scores, tags, context).
6
 
7
- Architecture: triggered by WorldEngine proximity detection; results are
8
- applied to Short_term memory, the relationship matrix, and wellbeing.
9
- Design: whole-conversation generation trades turn-by-turn fidelity for
10
- roughly an order of magnitude fewer LLM calls and structured effects.
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -84,13 +104,6 @@ class ConversationResult(BaseModel):
84
  duration_minutes: int = Field(ge=6, le=20)
85
  sentiment: Literal["positive", "neutral", "negative"]
86
  relationship_delta: float = Field(ge=-0.15, le=0.15)
87
- # LLM-decided net wellbeing effect of the chat for each participant.
88
- # Energy and mood each run from 0.0 to 1.0; the resulting value after the
89
- # chat must stay inside that range (never below 0% or above 100%).
90
- energy_delta_a: float = 0.0
91
- emotion_delta_a: float = 0.0
92
- energy_delta_b: float = 0.0
93
- emotion_delta_b: float = 0.0
94
  # Folded-in replan decision: avoids a separate 4-call day-plan regeneration
95
  # per agent after every conversation. True only when the conversation
96
  # genuinely changes an agent's immediate intentions.
@@ -545,9 +558,6 @@ Return a JSON object with:
545
  - "duration_minutes": integer from 6 to 20 that matches the amount of dialogue
546
  - "sentiment": "positive" | "neutral" | "negative"
547
  - "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship)
548
- - "energy_delta_a" / "energy_delta_b": each agent's net ENERGY change from this chat (positive = recharged, negative = drained)
549
- - "emotion_delta_a" / "emotion_delta_b": each agent's net MOOD change from this chat (positive = lifted, negative = dampened)
550
- - ENERGY AND MOOD each run from 0.0 to 1.0 (0% to 100%). Add each delta to the agent's current value shown above; the resulting value must stay between 0.0 and 1.0 — never above 100% or below 0%
551
  - "should_replan": boolean — true ONLY if this conversation genuinely changes what one of them intends to do next (e.g. they agree to meet, go somewhere together, or drop a task). Default false; most casual chats do NOT require replanning.
552
  - "plan_change": short string describing the change if should_replan is true, else null"""
553
 
 
1
+ """
2
+ Conversation -- generates dialogue between two agents via a single LLM call.
3
+
4
+ The WorldEngine calls `generate_conversation()` when two agents share a
5
+ location_id, are both in compatible actions (not sleeping), and neither is
6
+ already mid-conversation.
7
+
8
+ The single LLM call produces the full conversation (messages, summary,
9
+ duration, sentiment, relationship delta). Both agents get their current
10
+ action overwritten to "Chatting with X" for the duration, then naturally
11
+ replan via the tick graph when it expires.
12
 
13
+ Usage:
14
+ from src.agents.conversation import generate_conversation, RelationshipMatrix
 
15
 
16
+ matrix = RelationshipMatrix()
17
+ result = generate_conversation(
18
+ agent_a_id="parv_singla",
19
+ agent_b_id="tanishq",
20
+ persona_a=gray_wilder_persona,
21
+ persona_b=jules_persona,
22
+ plan_a=gray_wilder_plan,
23
+ plan_b=jules_plan,
24
+ action_a=gray_wilder_current_action,
25
+ action_b=jules_current_action,
26
+ rel_a_to_b=matrix.get("parv_singla", "tanishq"),
27
+ rel_b_to_a=matrix.get("tanishq", "parv_singla"),
28
+ location_id="mess",
29
+ current_hhmm="08:05",
30
+ )
31
  """
32
 
33
  from __future__ import annotations
 
104
  duration_minutes: int = Field(ge=6, le=20)
105
  sentiment: Literal["positive", "neutral", "negative"]
106
  relationship_delta: float = Field(ge=-0.15, le=0.15)
 
 
 
 
 
 
 
107
  # Folded-in replan decision: avoids a separate 4-call day-plan regeneration
108
  # per agent after every conversation. True only when the conversation
109
  # genuinely changes an agent's immediate intentions.
 
558
  - "duration_minutes": integer from 6 to 20 that matches the amount of dialogue
559
  - "sentiment": "positive" | "neutral" | "negative"
560
  - "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship)
 
 
 
561
  - "should_replan": boolean — true ONLY if this conversation genuinely changes what one of them intends to do next (e.g. they agree to meet, go somewhere together, or drop a task). Default false; most casual chats do NOT require replanning.
562
  - "plan_change": short string describing the change if should_replan is true, else null"""
563
 
backend/src/agents/daily_flavor.py CHANGED
@@ -1,13 +1,9 @@
1
- """daily_flavor — random daily theme and emotion for plan variety.
2
-
3
- Picks one of ten themes (e.g. Sports, Academics) and one of ten emotions
4
- (e.g. Excited, Melancholic) per agent per day, injected into planner
5
- prompts so days do not feel scripted.
6
 
7
- Architecture: consumed by day_planner.py at plan time; a single, tiny
8
- dependency-free module.
9
- Design: deliberate randomness is the point — variety is the seed of
10
- unpredictable-but-plausible schedules.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Daily flavor — random theme and emotion pickers for day_planner.
 
 
 
3
 
4
+ Each day an agent gets a random theme (what they focus on) and emotion
5
+ (their mood), injected into the planner prompts so the schedule doesn't
6
+ feel identical every day.
 
7
  """
8
 
9
  from __future__ import annotations
backend/src/agents/day_planner.py CHANGED
@@ -1,16 +1,33 @@
1
- """day_planner — the cognitive core: generates validated day plans.
2
-
3
- A LangGraph pipeline that decomposes a day coarse (5-8 blocks) -> hourly
4
- -> fine (5-15 min actions), validates every level (time coverage, tiling,
5
- locations, academic venue policy, content safety), retries up to
6
- MAX_PLAN_RETRIES, and force-accepts a deterministic fallback schedule if
7
- the model keeps failing.
8
-
9
- Architecture: the only producer of day_plan; called by the engine at
10
- startup, day handoff, and replan; consumes persona, places, memories,
11
- events, and current wellbeing.
12
- Design: validation is local and deterministic before any LLM semantic
13
- QA; energy targets are embedded in every action (see engine glide).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
@@ -90,7 +107,7 @@ def _academic_venue_policy(persona: Dict[str, Any]) -> str:
90
  return "No branch-specific policy is known; choose the listed location that explicitly fits."
91
  return (
92
  f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use "
93
- f"`{destination}` or LHC/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. "
94
 
95
  )
96
 
@@ -120,11 +137,10 @@ def _local_academic_venue_check(actions: List[Dict[str, Any]], persona: Dict[str
120
  continue
121
  if any(word in description for word in _SHARED_SESSION_WORDS):
122
  continue
123
- allowed_venues = {required, "SAB", "LHC"}
124
- if location not in allowed_venues:
125
  return (
126
  f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} "
127
- f"must use {required}, SAB, or LHC, not {location}"
128
  )
129
  return None
130
 
@@ -196,14 +212,6 @@ class CoarseBlock(BaseModel):
196
  )
197
  energy_change: float = 0.0
198
  emotion_change: float = 0.0
199
- energy_target: Optional[float] = Field(
200
- default=None,
201
- description=(
202
- "Optional: your declared cumulative energy level (0.0-1.0) the agent "
203
- "should have when this block ends. The runtime glides energy toward "
204
- "this target. Follow the TIME-OF-DAY rules in the guidance."
205
- ),
206
- )
207
 
208
 
209
  class CoarsePlanOutput(BaseModel):
@@ -236,10 +244,6 @@ class HourlyBlock(BaseModel):
236
  parent_activity: str = Field(description="The coarse block this refines")
237
  energy_change: float = 0.0
238
  emotion_change: float = 0.0
239
- energy_target: Optional[float] = Field(
240
- default=None,
241
- description="Optional cumulative energy level (0.0-1.0) at the end of this block.",
242
- )
243
 
244
 
245
  class HourlyPlanOutput(BaseModel):
@@ -255,14 +259,6 @@ class FineAction(BaseModel):
255
  sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable")
256
  energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring")
257
  emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining")
258
- energy_target: Optional[float] = Field(
259
- default=None,
260
- description=(
261
- "Optional cumulative energy level (0.0-1.0) the agent should have when "
262
- "this action ends. The runtime glides energy toward this declared target; "
263
- "follow the TIME-OF-DAY rules in the guidance."
264
- ),
265
- )
266
 
267
 
268
  class FinePlanOutput(BaseModel):
@@ -275,7 +271,6 @@ class AtomicLocationAssignment(BaseModel):
275
  sub_area: Optional[str] = None
276
  energy_change: float = 0.0
277
  emotion_change: float = 0.0
278
- energy_target: Optional[float] = None
279
 
280
  class AtomicLocationOutput(BaseModel):
281
  assignments: List[AtomicLocationAssignment]
@@ -386,21 +381,6 @@ def _flavor_block(state: DayPlannerState) -> str:
386
  return f"Today's vibe: {emotion}, leaning into {theme}."
387
 
388
 
389
- def _agent_name(state: DayPlannerState) -> str:
390
- persona = state.get("persona") or {}
391
- return str(persona.get("Name") or persona.get("name") or "unknown")
392
-
393
-
394
- def _conflict_feedback(state: DayPlannerState) -> str:
395
- reason = state.get("conflict_reason")
396
- if not reason:
397
- return ""
398
- return (
399
- f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n"
400
- f"{reason}"
401
- )
402
-
403
-
404
  def _planning_window(state: DayPlannerState) -> tuple[str, str]:
405
  """Return the exact time window owned by this planner invocation."""
406
  current_time = state.get("current_time", "")
@@ -463,38 +443,6 @@ def _within_source_windows(record: Dict[str, Any], sources: List[Dict[str, Any]]
463
  # Nodes
464
  # ---------------------------------------------------------------------------
465
 
466
- _WELLBEING_GUIDANCE = (
467
- "For EACH block/action, assign energy_change and emotion_change values. "
468
- "These are the NET changes to the agent's running energy and mood caused "
469
- "by that activity.\n"
470
- "- energy_change: positive = restores energy, negative = drains energy\n"
471
- "- emotion_change: positive = lifts mood, negative = dampens mood\n"
472
- "Energy and mood each run from 0.0 to 1.0 (0% to 100%). Track the day "
473
- "cumulatively: after EVERY activity the running total of energy and of "
474
- "mood must stay between 0.0 and 1.0 — never above 100% or below 0%.\n"
475
- "Be realistic for the persona: a full night's sleep restores a lot "
476
- "(about +0.2 to +0.5), meals and rest restore a little, hard exercise and "
477
- "all-nighters drain substantially, ordinary classes and chores sit in "
478
- "between.\n"
479
- "TIME-OF-DAY (circadian) RULES — energy must follow the clock, and YOU "
480
- "choose the exact numbers:\n"
481
- "- Energy is highest after waking (aim for roughly 0.65-0.85 at day "
482
- "start), dips mid-afternoon around 14:00, declines through the evening, "
483
- "and is LOWEST before bed (roughly 0.15-0.35 by 22:00-23:00).\n"
484
- "- A day that ends near where it started is unrealistic: plan the day to "
485
- "end at least 0.2-0.4 BELOW its morning energy.\n"
486
- "- After ~22:00 nothing restores energy except sleep — late study, "
487
- "screens, and socialising drain or stay neutral.\n"
488
- "- Only sleep, meals, and genuine rest restore; classes, labs, study, "
489
- "and exercise drain at least a little, sized to how long and demanding "
490
- "the activity is.\n"
491
- "Optionally declare energy_target for EACH block/action: your cumulative "
492
- "energy level (0.0-1.0) at the moment it ends. The runtime glides the "
493
- "agent's energy toward each declared target, so use it to encode the "
494
- "circadian curve above. When omitted, the runtime uses energy_change "
495
- "directly."
496
- )
497
-
498
  def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
499
  persona = state["persona"]
500
  mode = state.get("mode", "full_day")
@@ -528,18 +476,10 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
528
  "worth planning separately. Examples: sleeping, attending a class/lecture, "
529
  "sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n"
530
  "- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n"
531
- + _WELLBEING_GUIDANCE
532
  )
533
  if current_loc:
534
  loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location."
535
- wellbeing_line = (
536
- f"CURRENT WELLBEING: energy {state['current_energy']:.2f}/1.0, "
537
- f"emotion {state['current_emotion']:.2f}/1.0 — plan the remaining day "
538
- "from these values, keeping the cumulative energy and mood totals "
539
- "between 0.0 and 1.0.\n\n"
540
- if state.get("current_energy") is not None and state.get("current_emotion") is not None
541
- else ""
542
- )
543
  user_prompt = (
544
  f"PERSONA:\n{_persona_block(persona)}\n\n"
545
  f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n"
@@ -550,13 +490,15 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
550
  f"Plan mode: {mode}\n"
551
  f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n"
552
  f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n"
553
- f"{wellbeing_line}"
554
  f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n"
555
  "Generate the coarse plan now."
556
  )
557
 
558
  if state.get("conflict_reason"):
559
- user_prompt += _conflict_feedback(state)
 
 
 
560
 
561
  required_start = _planning_window(state)[0]
562
  result = call_gemini(
@@ -565,7 +507,7 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
565
  _coarse_output_schema(required_start),
566
  "default",
567
  )
568
- logger.info("[day_planner][%s] coarse plan generated: %d blocks", _agent_name(state), len(result.blocks))
569
 
570
  return {
571
  **state,
@@ -582,16 +524,14 @@ def validate_coarse_window(state: DayPlannerState) -> DayPlannerState:
582
  action_key="activity",
583
  )
584
  if issue:
585
- logger.info("[day_planner][%s] coarse-window validation failed: %s", _agent_name(state), issue)
586
  return {
587
  **state,
588
  "conflict_detected": True,
589
  "conflict_reason": issue,
590
  "retry_count": state.get("retry_count", 0) + 1,
591
  }
592
- # Keep conflict_reason across the retry cycle: intermediate stages must not
593
- # clear the last rejection before the failing stage regenerates.
594
- return {**state, "conflict_detected": False}
595
 
596
 
597
  def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
@@ -619,7 +559,6 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
619
  "granularity": "atomic",
620
  "energy_change": b.get("energy_change", 0.0),
621
  "emotion_change": b.get("emotion_change", 0.0),
622
- "energy_target": b.get("energy_target"),
623
  }
624
  for b in atomic_blocks
625
  ]
@@ -636,8 +575,12 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
636
  "(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). "
637
  "Only the blocks provided here need refining. Do not create walk, commute, or "
638
  "transit sub-blocks: refine only activities performed at the destination.\n\n"
639
- + _WELLBEING_GUIDANCE
640
- + f"{_window_constraint(state)}"
 
 
 
 
641
  )
642
  user_prompt = (
643
  f"PERSONA:\n{_persona_block(persona)}\n\n"
@@ -647,15 +590,13 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
647
  f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
648
  "Produce the hourly-resolution plan for the blocks listed under "
649
  "'BLOCKS TO REFINE' only."
650
- + _conflict_feedback(state)
651
  )
652
  result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default")
653
  raw_refined = [b.model_dump() for b in result.blocks]
654
  refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)]
655
  if len(refined) != len(raw_refined):
656
  logger.warning(
657
- "[day_planner][%s] discarded %d hourly refinement block(s) outside flexible source windows",
658
- _agent_name(state),
659
  len(raw_refined) - len(refined),
660
  )
661
  for b in refined:
@@ -664,8 +605,7 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
664
 
665
  hourly_blocks.sort(key=lambda b: b["start"])
666
  logger.info(
667
- "[day_planner][%s] hourly plan: %d atomic passthrough + %d refined",
668
- _agent_name(state),
669
  len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly),
670
  )
671
 
@@ -707,100 +647,14 @@ def validate_hourly_refinement(state: DayPlannerState) -> DayPlannerState:
707
  issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window"
708
  break
709
  if issue:
710
- logger.info("[day_planner][%s] hourly refinement validation failed: %s", _agent_name(state), issue)
711
  return {
712
  **state,
713
  "conflict_detected": True,
714
  "conflict_reason": issue,
715
  "retry_count": state.get("retry_count", 0) + 1,
716
  }
717
- # Keep conflict_reason across the retry cycle (see validate_coarse_window).
718
- return {**state, "conflict_detected": False}
719
-
720
- def _match_atomic_assignment(
721
- activity: str, loc_by_activity: Dict[str, deque]
722
- ) -> Optional[AtomicLocationAssignment]:
723
- """Pick a location assignment for an atomic block: exact activity match
724
- first, then a case-insensitive label match. The location model sometimes
725
- rewrites activity labels between stages, so an exact-only lookup leaves
726
- blocks with no assignment and the whole day falls back to force-accept."""
727
- candidates = loc_by_activity.get(activity)
728
- if candidates:
729
- return candidates.popleft()
730
- for key in list(loc_by_activity):
731
- if not key:
732
- continue
733
- if (
734
- key.lower() == activity.lower()
735
- or key.lower() in activity.lower()
736
- or activity.lower() in key.lower()
737
- ):
738
- return loc_by_activity[key].popleft()
739
- return None
740
-
741
-
742
- def _fallback_location_id(
743
- activity: str,
744
- places: List[Place],
745
- current_loc: str,
746
- persona: Dict[str, Any],
747
- ) -> Optional[str]:
748
- """Deterministic venue fallback for atomic blocks the location model did
749
- not assign. Returns a valid location_id whenever any place exists, so a
750
- missing assignment can never poison validation with location_id None."""
751
- valid_ids = {p.id for p in places}
752
-
753
- def pick(candidates: List[Any]) -> Optional[str]:
754
- for candidate in candidates:
755
- if candidate and candidate in valid_ids:
756
- return candidate
757
- return None
758
-
759
- text = activity.lower()
760
- has = lambda *words: any(word in text for word in words)
761
-
762
- # 1) Explicit venue words inside the activity label win first.
763
- if has("library"):
764
- return pick(
765
- [p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()]
766
- )
767
- if has("hostel"):
768
- return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")])
769
- if has("mess", "canteen", "dining"):
770
- return pick(
771
- [p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower()
772
- or "canteen" in p.id.lower() or "canteen" in p.name.lower()]
773
- )
774
- if has("gym", "workout"):
775
- return pick([p.id for p in places if "gym" in p.id.lower() or "gym" in p.name.lower()])
776
- if has("sab"):
777
- return pick(["SAB", *[p.id for p in places if p.id == "SAB"]])
778
- if has("lhc", "lecture hall"):
779
- return pick(["LHC", *[p.id for p in places if p.id == "LHC"]])
780
-
781
- # 2) Category-based defaults, matching the venue policy used in prompts.
782
- if has("class", "lecture", "lab", "exam", "study", "project", "tutorial", "seminar"):
783
- return pick(
784
- [p.id for p in places if "department" in p.id.lower() or "department" in p.name.lower()]
785
- + ["LHC", "SAB", "library"]
786
- + [p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()]
787
- )
788
- if has("breakfast", "lunch", "dinner", "meal", "eat", "food"):
789
- return pick(
790
- [p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower()
791
- or "canteen" in p.id.lower() or "canteen" in p.name.lower()]
792
- )
793
- if has("sport", "cricket", "football", "badminton", "exercise", "run", "fitness"):
794
- return pick(
795
- [p.id for p in places if "sport" in p.id.lower() or "sport" in p.name.lower()
796
- or "gym" in p.id.lower() or "gym" in p.name.lower()]
797
- )
798
- if has("sleep", "rest", "nap", "recover", "personal", "wind down", "chat", "chill", "socialize", "room", "bunk"):
799
- return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")])
800
-
801
- # 3) Generic fallback: current position, then hostel, then any place.
802
- return pick([current_loc, persona.get("Hostel")]) or next(iter(valid_ids), None)
803
-
804
 
805
  def decompose_fine(state: DayPlannerState) -> DayPlannerState:
806
  persona = state["persona"]
@@ -824,9 +678,13 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
824
  "Default to locations that make sense for this specific persona. "
825
  "Do not suggest splitting the activity.\n\n"
826
  "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
827
- "Prefer the branch department for classes and labs; SAB and LHC are acceptable.\n\n"
828
- + _WELLBEING_GUIDANCE
829
- + f"{_window_constraint(state)}"
 
 
 
 
830
  )
831
  user_prompt = (
832
  f"PERSONA:\n{_persona_block(persona)}\n\n"
@@ -841,7 +699,6 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
841
  "rest, and personal activities use the hostel unless the activity explicitly "
842
  "requires another place.\n\n"
843
  "Assign a location to each activity now."
844
- + _conflict_feedback(state)
845
  )
846
  result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default")
847
  # Activity labels are not unique (for example, two separate study
@@ -852,40 +709,21 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
852
  loc_by_activity[assignment.activity].append(assignment)
853
 
854
  for b in atomic_blocks:
855
- assignment = _match_atomic_assignment(b["activity"], loc_by_activity)
856
- if assignment:
857
- location_id = assignment.location_id
858
- sub_area = assignment.sub_area
859
- energy_change = assignment.energy_change
860
- emotion_change = assignment.emotion_change
861
- energy_target = (
862
- assignment.energy_target
863
- if assignment.energy_target is not None
864
- else b.get("energy_target")
865
- )
866
- else:
867
- location_id = _fallback_location_id(b["activity"], places, current_loc, persona)
868
- sub_area = None
869
- energy_change = b.get("energy_change", 0.0)
870
- emotion_change = b.get("emotion_change", 0.0)
871
- energy_target = b.get("energy_target")
872
- if location_id is not None:
873
- logger.info(
874
- "[day_planner][%s] no location assignment for '%s' -- deterministic fallback to '%s'",
875
- _agent_name(state),
876
- b["activity"],
877
- location_id,
878
- )
879
  fine_actions.append({
880
  "action": b["activity"],
881
  "start": b["start"],
882
  "end": b["end"],
883
  "parent_activity": b["parent_activity"],
884
- "location_id": location_id,
885
- "sub_area": sub_area,
886
- "energy_change": energy_change,
887
- "emotion_change": emotion_change,
888
- "energy_target": energy_target,
 
 
 
889
  })
890
 
891
  # Flexible blocks: full fine-grained breakdown, as before.
@@ -894,25 +732,23 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
894
  "You refine hourly blocks into fine-grained, directly executable actions "
895
  "at roughly 5-15 minute granularity. Each hourly block should be broken "
896
  "into one or more fine actions spanning exactly its start/end range, no "
897
- "gaps or overlaps. The full day must tile exactly, minute by minute: the "
898
- "first action of the day starts exactly at 00:00, each action starts the "
899
- "instant the previous one ends, and the LAST action of the day ends "
900
- "exactly at 24:00 (write the final boundary as 24:00, never 23:59 or "
901
- "0:00). Each group of fine actions must start exactly at its assigned "
902
- "block's start and end exactly at its block's end -- never spill outside "
903
- "your assigned windows. Every action MUST be assigned a location_id, chosen "
904
  "EXACTLY from the provided list -- never invent one.\n\n"
905
  "Do NOT output walking, commuting, travel, transit, leaving, or arriving "
906
  "as an action. The runtime owns visible routes between places; every action "
907
  "you output must be an on-site activity at its assigned location.\n\n"
908
  "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
909
- "Prefer the branch department for classes and labs; SAB and LHC are acceptable.\n\n"
910
  "Make action boundaries feel natural — group related sub-actions together. "
911
  "Consider typical on-site durations: eating ~20-40min and studying "
912
  "~30-120min. Keep adjacent location changes realistic by leaving enough "
913
  "time for the executor to animate transit before the next activity.\n\n"
914
- + _WELLBEING_GUIDANCE
915
- + f"{_window_constraint(state)}"
 
 
 
 
916
  )
917
  user_prompt = (
918
  f"PERSONA:\n{_persona_block(persona)}\n\n"
@@ -927,21 +763,19 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
927
  "rest, and personal activities use the hostel unless the activity explicitly "
928
  "requires another place.\n\n"
929
  "Produce the fine-grained action plan for these blocks now."
930
- + _conflict_feedback(state)
931
  )
932
  result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default")
933
  raw_actions = [action.model_dump() for action in result.actions]
934
  scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)]
935
  if len(scoped_actions) != len(raw_actions):
936
  logger.warning(
937
- "[day_planner][%s] discarded %d fine action(s) outside flexible source windows",
938
- _agent_name(state),
939
  len(raw_actions) - len(scoped_actions),
940
  )
941
  fine_actions.extend(scoped_actions)
942
 
943
  fine_actions.sort(key=lambda a: a["start"])
944
- logger.info("[day_planner][%s] fine plan: %d total actions", _agent_name(state), len(fine_actions))
945
 
946
  return {**state, "fine_plan": fine_actions}
947
 
@@ -1045,7 +879,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
1045
  state["fine_plan"], state.get("places", [])
1046
  ) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"])
1047
  if local_issue:
1048
- logger.info("[day_planner][%s] local validation failed: %s", _agent_name(state), local_issue)
1049
  return {
1050
  **state,
1051
  "conflict_detected": True,
@@ -1073,7 +907,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
1073
  result = call_gemini(system_prompt, user_prompt, ValidationResult, "default")
1074
 
1075
  if not result.valid:
1076
- logger.info("[day_planner][%s] semantic validation failed: %s", _agent_name(state), result.reason)
1077
  return {
1078
  **state,
1079
  "conflict_detected": True,
@@ -1081,7 +915,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
1081
  "retry_count": state.get("retry_count", 0) + 1,
1082
  }
1083
 
1084
- logger.info("[day_planner][%s] plan validated successfully", _agent_name(state))
1085
  return {
1086
  **state,
1087
  "conflict_detected": False,
@@ -1099,8 +933,7 @@ def route_after_validation(state: DayPlannerState) -> str:
1099
  return "accept"
1100
  if state.get("retry_count", 0) >= MAX_PLAN_RETRIES:
1101
  logger.warning(
1102
- "[day_planner][%s] max retries (%d) reached, force-accepting last plan with error flag",
1103
- _agent_name(state),
1104
  MAX_PLAN_RETRIES,
1105
  )
1106
  return "give_up"
@@ -1261,8 +1094,6 @@ def run(agent: Any, world_state: dict) -> dict:
1261
  "places": places,
1262
  "mode": mode,
1263
  "current_location_id": world_state.get("current_location_id"),
1264
- "current_energy": world_state.get("energy_level"),
1265
- "current_emotion": world_state.get("emotion_state"),
1266
  "handoff_context": world_state.get("handoff_context"),
1267
  "upcoming_events": world_state.get("upcoming_events", []),
1268
  "daily_theme": theme,
 
1
+ """
2
+ A script that plans the day of an agentic personality when handed over required data
3
+ Per plan takes 4 LLM calls (atlest) Coarse, Hourly, Fine, Validation, for Planning a
4
+ day in one agent's life.
5
+
6
+ Tier-1 LangGraph subgraph: agent day-planning.
7
+
8
+ Pipeline (mirrors Generative Agents' Planning module, coarse -> hourly -> fine,
9
+ with a validation/retry loop):
10
+
11
+ generate_coarse_plan -> decompose_hourly -> decompose_fine -> validate_plan
12
+ |
13
+ conflict? --yes-+ (loop back to generate_coarse_plan)
14
+ |
15
+ no -> END
16
+
17
+ LLM backend: Google Gemini via the `google-genai` SDK.
18
+
19
+ For now `relevant_memories` and `yesterday_summary` are expected to arrive
20
+ empty ([] / None) -- the prompts already handle that gracefully so you can
21
+ wire in real retrieval/memory later without touching this file's structure.
22
+
23
+ FILE NOTES:
24
+ Prompt structure can be improved
25
+ Places are being feed in Name : , Desc : format, this can be improved
26
+ disabled location check in validate plan : can add more places
27
+
28
+ Prompt templates have to improve
29
+
30
+ Have to figure out how to run this in the backend server, currently it is running standalone
31
  """
32
 
33
  from __future__ import annotations
 
107
  return "No branch-specific policy is known; choose the listed location that explicitly fits."
108
  return (
109
  f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use "
110
+ f"`{destination}` or Library/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. "
111
 
112
  )
113
 
 
137
  continue
138
  if any(word in description for word in _SHARED_SESSION_WORDS):
139
  continue
140
+ if location != required:
 
141
  return (
142
  f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} "
143
+ f"must use {required}, not {location}"
144
  )
145
  return None
146
 
 
212
  )
213
  energy_change: float = 0.0
214
  emotion_change: float = 0.0
 
 
 
 
 
 
 
 
215
 
216
 
217
  class CoarsePlanOutput(BaseModel):
 
244
  parent_activity: str = Field(description="The coarse block this refines")
245
  energy_change: float = 0.0
246
  emotion_change: float = 0.0
 
 
 
 
247
 
248
 
249
  class HourlyPlanOutput(BaseModel):
 
259
  sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable")
260
  energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring")
261
  emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining")
 
 
 
 
 
 
 
 
262
 
263
 
264
  class FinePlanOutput(BaseModel):
 
271
  sub_area: Optional[str] = None
272
  energy_change: float = 0.0
273
  emotion_change: float = 0.0
 
274
 
275
  class AtomicLocationOutput(BaseModel):
276
  assignments: List[AtomicLocationAssignment]
 
381
  return f"Today's vibe: {emotion}, leaning into {theme}."
382
 
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  def _planning_window(state: DayPlannerState) -> tuple[str, str]:
385
  """Return the exact time window owned by this planner invocation."""
386
  current_time = state.get("current_time", "")
 
443
  # Nodes
444
  # ---------------------------------------------------------------------------
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
447
  persona = state["persona"]
448
  mode = state.get("mode", "full_day")
 
476
  "worth planning separately. Examples: sleeping, attending a class/lecture, "
477
  "sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n"
478
  "- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n"
479
+ "For EACH block, assign realistic energy_change and emotion_change values. Routine classes, labs, study, meals, and chores should be near neutral (usually -0.03 to +0.03); reserve larger positive changes for rare, meaningful events."
480
  )
481
  if current_loc:
482
  loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location."
 
 
 
 
 
 
 
 
483
  user_prompt = (
484
  f"PERSONA:\n{_persona_block(persona)}\n\n"
485
  f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n"
 
490
  f"Plan mode: {mode}\n"
491
  f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n"
492
  f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n"
 
493
  f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n"
494
  "Generate the coarse plan now."
495
  )
496
 
497
  if state.get("conflict_reason"):
498
+ user_prompt += (
499
+ f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n"
500
+ f"{state['conflict_reason']}"
501
+ )
502
 
503
  required_start = _planning_window(state)[0]
504
  result = call_gemini(
 
507
  _coarse_output_schema(required_start),
508
  "default",
509
  )
510
+ logger.info("[day_planner] coarse plan generated: %d blocks", len(result.blocks))
511
 
512
  return {
513
  **state,
 
524
  action_key="activity",
525
  )
526
  if issue:
527
+ logger.info("[day_planner] coarse-window validation failed: %s", issue)
528
  return {
529
  **state,
530
  "conflict_detected": True,
531
  "conflict_reason": issue,
532
  "retry_count": state.get("retry_count", 0) + 1,
533
  }
534
+ return {**state, "conflict_detected": False, "conflict_reason": None}
 
 
535
 
536
 
537
  def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
 
559
  "granularity": "atomic",
560
  "energy_change": b.get("energy_change", 0.0),
561
  "emotion_change": b.get("emotion_change", 0.0),
 
562
  }
563
  for b in atomic_blocks
564
  ]
 
575
  "(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). "
576
  "Only the blocks provided here need refining. Do not create walk, commute, or "
577
  "transit sub-blocks: refine only activities performed at the destination.\n\n"
578
+ "For EACH block, assign realistic energy_change and emotion_change values:\n"
579
+ "- energy_change: positive = restorative, negative = tiring\n"
580
+ "- emotion_change: positive = uplifting, negative = draining\n"
581
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
582
+ "- Be realistic for the persona\n\n"
583
+ f"{_window_constraint(state)}"
584
  )
585
  user_prompt = (
586
  f"PERSONA:\n{_persona_block(persona)}\n\n"
 
590
  f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
591
  "Produce the hourly-resolution plan for the blocks listed under "
592
  "'BLOCKS TO REFINE' only."
 
593
  )
594
  result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default")
595
  raw_refined = [b.model_dump() for b in result.blocks]
596
  refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)]
597
  if len(refined) != len(raw_refined):
598
  logger.warning(
599
+ "[day_planner] discarded %d hourly refinement block(s) outside flexible source windows",
 
600
  len(raw_refined) - len(refined),
601
  )
602
  for b in refined:
 
605
 
606
  hourly_blocks.sort(key=lambda b: b["start"])
607
  logger.info(
608
+ "[day_planner] hourly plan: %d atomic passthrough + %d refined",
 
609
  len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly),
610
  )
611
 
 
647
  issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window"
648
  break
649
  if issue:
650
+ logger.info("[day_planner] hourly refinement validation failed: %s", issue)
651
  return {
652
  **state,
653
  "conflict_detected": True,
654
  "conflict_reason": issue,
655
  "retry_count": state.get("retry_count", 0) + 1,
656
  }
657
+ return {**state, "conflict_detected": False, "conflict_reason": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
 
659
  def decompose_fine(state: DayPlannerState) -> DayPlannerState:
660
  persona = state["persona"]
 
678
  "Default to locations that make sense for this specific persona. "
679
  "Do not suggest splitting the activity.\n\n"
680
  "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
681
+ "Do not use SAB as a generic lecture/lab default.\n\n"
682
+ "For EACH block, assign realistic energy_change and emotion_change values:\n"
683
+ "- energy_change: positive = restorative, negative = tiring\n"
684
+ "- emotion_change: positive = uplifting, negative = draining\n"
685
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
686
+ "- Be realistic for the persona\n\n"
687
+ f"{_window_constraint(state)}"
688
  )
689
  user_prompt = (
690
  f"PERSONA:\n{_persona_block(persona)}\n\n"
 
699
  "rest, and personal activities use the hostel unless the activity explicitly "
700
  "requires another place.\n\n"
701
  "Assign a location to each activity now."
 
702
  )
703
  result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default")
704
  # Activity labels are not unique (for example, two separate study
 
709
  loc_by_activity[assignment.activity].append(assignment)
710
 
711
  for b in atomic_blocks:
712
+ candidates = loc_by_activity.get(b["activity"])
713
+ assignment = candidates.popleft() if candidates else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  fine_actions.append({
715
  "action": b["activity"],
716
  "start": b["start"],
717
  "end": b["end"],
718
  "parent_activity": b["parent_activity"],
719
+ "location_id": assignment.location_id if assignment else None,
720
+ "sub_area": assignment.sub_area if assignment else None,
721
+ "energy_change": (
722
+ assignment.energy_change if assignment else b.get("energy_change", 0.0)
723
+ ),
724
+ "emotion_change": (
725
+ assignment.emotion_change if assignment else b.get("emotion_change", 0.0)
726
+ ),
727
  })
728
 
729
  # Flexible blocks: full fine-grained breakdown, as before.
 
732
  "You refine hourly blocks into fine-grained, directly executable actions "
733
  "at roughly 5-15 minute granularity. Each hourly block should be broken "
734
  "into one or more fine actions spanning exactly its start/end range, no "
735
+ "gaps or overlaps. Every action MUST be assigned a location_id, chosen "
 
 
 
 
 
 
736
  "EXACTLY from the provided list -- never invent one.\n\n"
737
  "Do NOT output walking, commuting, travel, transit, leaving, or arriving "
738
  "as an action. The runtime owns visible routes between places; every action "
739
  "you output must be an on-site activity at its assigned location.\n\n"
740
  "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
741
+ "Branch-specific classes/labs must not silently fall back to SAB.\n\n"
742
  "Make action boundaries feel natural — group related sub-actions together. "
743
  "Consider typical on-site durations: eating ~20-40min and studying "
744
  "~30-120min. Keep adjacent location changes realistic by leaving enough "
745
  "time for the executor to animate transit before the next activity.\n\n"
746
+ "For EACH action, assign realistic energy_change and emotion_change values:\n"
747
+ "- energy_change: positive = restorative, negative = tiring\n"
748
+ "- emotion_change: positive = uplifting, negative = draining\n"
749
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
750
+ "- Be realistic for the persona\n\n"
751
+ f"{_window_constraint(state)}"
752
  )
753
  user_prompt = (
754
  f"PERSONA:\n{_persona_block(persona)}\n\n"
 
763
  "rest, and personal activities use the hostel unless the activity explicitly "
764
  "requires another place.\n\n"
765
  "Produce the fine-grained action plan for these blocks now."
 
766
  )
767
  result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default")
768
  raw_actions = [action.model_dump() for action in result.actions]
769
  scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)]
770
  if len(scoped_actions) != len(raw_actions):
771
  logger.warning(
772
+ "[day_planner] discarded %d fine action(s) outside flexible source windows",
 
773
  len(raw_actions) - len(scoped_actions),
774
  )
775
  fine_actions.extend(scoped_actions)
776
 
777
  fine_actions.sort(key=lambda a: a["start"])
778
+ logger.info("[day_planner] fine plan: %d total actions", len(fine_actions))
779
 
780
  return {**state, "fine_plan": fine_actions}
781
 
 
879
  state["fine_plan"], state.get("places", [])
880
  ) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"])
881
  if local_issue:
882
+ logger.info("[day_planner] local validation failed: %s", local_issue)
883
  return {
884
  **state,
885
  "conflict_detected": True,
 
907
  result = call_gemini(system_prompt, user_prompt, ValidationResult, "default")
908
 
909
  if not result.valid:
910
+ logger.info("[day_planner] semantic validation failed: %s", result.reason)
911
  return {
912
  **state,
913
  "conflict_detected": True,
 
915
  "retry_count": state.get("retry_count", 0) + 1,
916
  }
917
 
918
+ logger.info("[day_planner] plan validated successfully")
919
  return {
920
  **state,
921
  "conflict_detected": False,
 
933
  return "accept"
934
  if state.get("retry_count", 0) >= MAX_PLAN_RETRIES:
935
  logger.warning(
936
+ "[day_planner] max retries (%d) reached, force-accepting last plan with error flag",
 
937
  MAX_PLAN_RETRIES,
938
  )
939
  return "give_up"
 
1094
  "places": places,
1095
  "mode": mode,
1096
  "current_location_id": world_state.get("current_location_id"),
 
 
1097
  "handoff_context": world_state.get("handoff_context"),
1098
  "upcoming_events": world_state.get("upcoming_events", []),
1099
  "daily_theme": theme,
backend/src/agents/memory_index.py CHANGED
@@ -1,14 +1,12 @@
1
- """memory_index — CLI admin tool for the Qdrant memory backend.
2
 
3
- Supports status, migrate-json (one-time import of legacy JSON archives
4
- into Qdrant), and clear operations for per-agent collections.
 
5
 
6
- Architecture: a developer utility that calls vector_memory.py directly;
7
- not part of the simulation runtime.
8
- Design: migration is idempotent (deterministic point ids) so reruns are
9
- safe; --delete-source is explicit and documented.
10
  """
11
-
12
  from __future__ import annotations
13
 
14
  import argparse
 
1
+ """Operate Qdrant-only long-term memory.
2
 
3
+ ``migrate-json`` imports legacy Long_term_db archives once. Pass
4
+ ``--delete-source`` only after checking the reported indexed count; it removes
5
+ the obsolete JSON archives after their idempotent Qdrant upsert succeeds.
6
 
7
+ ``clear`` removes only Valhalla's durable Qdrant collections. It never touches
8
+ short-term runtime files, checkpoints, or the live simulation process.
 
 
9
  """
 
10
  from __future__ import annotations
11
 
12
  import argparse
backend/src/agents/react.py CHANGED
@@ -1,14 +1,16 @@
1
- """react — deterministic 0-LLM "keep going or replan?" filter.
2
-
3
- Given the agent's current action and fresh observations, returns a
4
- decision: no action -> replan, action finished -> replan, mid-action with
5
- no novelty -> continue. The LLM variant of this decision lives in
6
- brain.decide_tick and fires only on novelty.
7
-
8
- Architecture: retained as the cheap reflex layer; the production engine
9
- currently routes decisions through brain.py and WorldEngine phases.
10
- Design: every path has a fixed answer — this step must never stall the
11
- simulation on a model call.
 
 
12
  """
13
 
14
  from __future__ import annotations
 
1
+ """
2
+ React -- decides, given an agent's current action (if any) and what it just
3
+ perceived, whether to keep executing that action or interrupt and replan.
4
+
5
+ Design notes
6
+ ------------
7
+ All calls into this module are cheap heuristic checks with no LLM round-trip:
8
+ - no current action yet -> always replan
9
+ - current action's end_tick has passed -> always replan
10
+ - mid-action, nothing new perceived this tick -> always continue
11
+
12
+ The LLM-based decision layer has moved to brain.decide_tick(), which runs
13
+ only when the perceive phase detects novel observations.
14
  """
15
 
16
  from __future__ import annotations
backend/src/agents/vector_memory.py CHANGED
@@ -1,15 +1,10 @@
1
- """vector_memory persistent semantic long-term memory (Qdrant + Gemini).
2
 
3
- Archives each agent's completed days as memory records, embeds queries
4
- with Gemini embeddings, and retrieves with a 65/20/15 semantic/importance/
5
- recency score plus diversity and storage-quota constraints.
6
-
7
- Architecture: the storage engine behind Long_term.py; called at day
8
- handoff (archive), by planning (retrieval), and by brain decisions.
9
- Design: importance is a static per-kind table, recency decays by real
10
- days, and retention pruning keeps the store within a storage budget.
11
  """
12
-
13
  from __future__ import annotations
14
 
15
  import hashlib
 
1
+ """Persistent, per-persona Cloud Qdrant long-term memory and RAG retrieval.
2
 
3
+ Qdrant is the sole long-term store. The active day's short-term JSON is
4
+ converted into durable memory records during handoff; once indexing succeeds,
5
+ that operational file can be removed. Retrieval returns query-relevant,
6
+ ranked context for model prompts.
 
 
 
 
7
  """
 
8
  from __future__ import annotations
9
 
10
  import hashlib
backend/src/auth/__init__.py CHANGED
@@ -1,9 +0,0 @@
1
- """auth — admin authentication package for the Valhalla web dashboard.
2
-
3
- Exposes the session manager (manager.py) and HTTP routes (routes.py) that
4
- protect simulation-control and roster endpoints in Odin.py.
5
-
6
- Design: viewers can watch the simulation unauthenticated; only control
7
- endpoints require a session.
8
- """
9
-
 
 
 
 
 
 
 
 
 
 
backend/src/auth/manager.py CHANGED
@@ -1,12 +1,9 @@
1
- """manager — in-memory admin session authentication.
2
-
3
- Loads email:password pairs from ADMIN_CREDENTIALS, hashes with salted
4
- scrypt, issues 24-hour bearer tokens, and validates/revokes them.
5
 
6
- Architecture: used by auth/routes.py to guard all sim-control and roster
7
- endpoints in Odin.py.
8
- Design: in-memory sessions are intentionally simple (lost on restart);
9
- when no credentials are configured, login is disabled with a warning.
10
  """
11
 
12
  import os
 
1
+ """
2
+ In-memory session-based authentication for Valhalla web admin.
 
 
3
 
4
+ Admin credentials come from the ADMIN_CREDENTIALS env var as
5
+ email:password pairs separated by semicolons. Sessions are stored
6
+ in-memory (lost on server restart).
 
7
  """
8
 
9
  import os
backend/src/auth/routes.py CHANGED
@@ -1,11 +1,5 @@
1
- """routes — HTTP endpoints for admin login, logout, and session check.
2
-
3
- Mounts POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me on
4
- the FastAPI app, backed by auth/manager.py.
5
-
6
- Architecture: a thin transport layer between the React dashboard and the
7
- session store; consumed by frontend/src/hooks/useAuth.jsx.
8
- Design: tokens travel as bearer headers; no cookie handling.
9
  """
10
 
11
  from fastapi import APIRouter, HTTPException, Header
 
1
+ """
2
+ Auth API routes — login, logout, session check.
 
 
 
 
 
 
3
  """
4
 
5
  from fastapi import APIRouter, HTTPException, Header
backend/src/config.py CHANGED
@@ -1,13 +1,7 @@
1
- """config — single source of truth for every tunable in the simulation.
2
-
3
- Defines paths, clock parameters (tick length, speed), perception and
4
- conversation radii, replan and budget caps, memory and LLM settings, and
5
- API key handling, with precedence CLI > environment > built-in default.
6
-
7
- Architecture: imported by virtually every module; never imports other
8
- project modules, so it can be loaded without side effects.
9
- Design: all settings are overridable via SIM_* environment variables so
10
- experiments can vary parameters without code changes.
11
  """
12
 
13
  from pathlib import Path
@@ -184,15 +178,6 @@ MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env
184
  # Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
185
  MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
186
 
187
- # Deterministic backstop for agents stranded on the "Unscheduled downtime"
188
- # fallback schedule. Every tick, the engine checks remaining plans; an
189
- # agent with upcoming downtime gets a remaining-day replan. The cooldown
190
- # (in ticks) stops a repeatedly-rejected replan from hammering the planner,
191
- # and the horizon (in minutes) skips replans when too little of the day is
192
- # left to be worth one.
193
- DOWNTIME_REPLAN_COOLDOWN_TICKS = _env_int("SIM_DOWNTIME_REPLAN_COOLDOWN_TICKS", 60)
194
- DOWNTIME_REPLAN_MIN_HORIZON = _env_int("SIM_DOWNTIME_REPLAN_MIN_HORIZON", 60)
195
-
196
  # Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
197
  # 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
198
  # defer replans) — the sim keeps running on the 0-LLM executor path.
@@ -219,20 +204,12 @@ SIM_CREATIVITY = min(1.0, max(0.0, _env_float("SIM_CREATIVITY", 1.0)))
219
  # independent from creativity: an observer can ask for more varied plans
220
  # without making students' energy and mood unrealistically volatile.
221
  SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
222
- # How fast the runtime glides an agent's energy toward the day planner's
223
- # declared per-action energy_target. This is a control rate, not an energy
224
- # value: 0.0 disables the glide (pure delta-based energy, as before), larger
225
- # values converge faster (0.03 => ~84% of the gap closed per 60-min action).
226
- SIM_ENERGY_FOLLOW_RATE = _env_float("SIM_ENERGY_FOLLOW_RATE", 0.03)
227
  TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
228
  CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
229
  SUMMARY_TEMPERATURE = 0.5
230
- # The simulation uses a primary model, with a same-key fallback model.
231
- # ``gemini_client`` tries the primary model on each key, then the fallback
232
- # model on the same key, before advancing to the next key; that key
233
- # rotation is the only provider-recovery behaviour.
234
- GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.5-flash-lite")
235
- GEMINI_MODEL_FALLBACK = _env_str("SIM_GEMINI_MODEL_FALLBACK", "gemini-3.1-flash-lite")
236
 
237
  # Support multiple API keys (comma-separated in env var). When numbered
238
  # variables are used, they are read in ascending numeric order.
@@ -305,7 +282,6 @@ _OVERRIDE_MAP = {
305
  "decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
306
  "conversation_min_energy": "CONVERSATION_MIN_ENERGY",
307
  "conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
308
- "energy_follow_rate": "SIM_ENERGY_FOLLOW_RATE",
309
  "day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
310
  }
311
 
@@ -374,11 +350,10 @@ def describe_settings() -> str:
374
  return (
375
  "Valhalla simulation settings\n"
376
  f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
377
- f" Gemini model : {GEMINI_MODEL} (fallback {GEMINI_MODEL_FALLBACK} on same key)\n"
378
  f" Simulation creativity : {SIM_CREATIVITY:.2f} "
379
  f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
380
  f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
381
- f" Energy follow rate : {SIM_ENERGY_FOLLOW_RATE:.3f}/sim-min\n"
382
  f" Memory backend : {MEMORY_BACKEND}\n"
383
  f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
384
  f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
 
1
+ """
2
+ Project-wide path configuration.
3
+ Resolves the project root, backend, frontend, data, and output directories
4
+ so all modules can reference consistent paths.
 
 
 
 
 
 
5
  """
6
 
7
  from pathlib import Path
 
178
  # Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
179
  MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
180
 
 
 
 
 
 
 
 
 
 
181
  # Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
182
  # 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
183
  # defer replans) — the sim keeps running on the 0-LLM executor path.
 
204
  # independent from creativity: an observer can ask for more varied plans
205
  # without making students' energy and mood unrealistically volatile.
206
  SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
 
 
 
 
 
207
  TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
208
  CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
209
  SUMMARY_TEMPERATURE = 0.5
210
+ # The simulation intentionally uses one model. Key traversal, implemented in
211
+ # ``gemini_client``, is the only provider recovery behaviour.
212
+ GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.1-flash-lite")
 
 
 
213
 
214
  # Support multiple API keys (comma-separated in env var). When numbered
215
  # variables are used, they are read in ascending numeric order.
 
282
  "decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
283
  "conversation_min_energy": "CONVERSATION_MIN_ENERGY",
284
  "conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
 
285
  "day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
286
  }
287
 
 
350
  return (
351
  "Valhalla simulation settings\n"
352
  f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
353
+ f" Gemini model : {GEMINI_MODEL}\n"
354
  f" Simulation creativity : {SIM_CREATIVITY:.2f} "
355
  f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
356
  f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
 
357
  f" Memory backend : {MEMORY_BACKEND}\n"
358
  f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
359
  f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
backend/src/core/agent_registry.py CHANGED
@@ -1,13 +1,12 @@
1
- """agent_registry — single source of truth for every agent's runtime state.
 
2
 
3
- Holds one AgentRuntimeState per agent (persona, position, day plan,
4
- wellbeing, conversation state, replan budget) and the AgentRegistry that
5
- the engine, Actions, conversations, and memory all read and write.
6
 
7
- Architecture: central state container consumed by every subsystem; the
8
- engine syncs it to WorldState each tick.
9
- Design: consolidates position into one registry to eliminate the
10
- dual-source position drift of earlier versions.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Agent Registry — single source of truth for every agent's runtime state.
3
 
4
+ The WorldEngine owns one `AgentRegistry` instance. All modules (Actions,
5
+ conversation, day_planner, Short_term) read from and write to it through
6
+ the engine never directly.
7
 
8
+ This replaces the dual-source problem where Actions.py had its own
9
+ position/action and WorldState had a separate copy that drifted.
 
 
10
  """
11
 
12
  from __future__ import annotations
backend/src/core/budget.py CHANGED
@@ -1,14 +1,20 @@
1
- """budget — LLM call budget governor.
2
-
3
- Tracks all LLM calls in a rolling one-real-hour window and answers
4
- can_afford(kind, cost) so cognitive steps degrade gracefully when a soft
5
- hourly ceiling is exceeded; the simulation keeps running on its 0-LLM
6
- executor path.
7
-
8
- Architecture: a process-wide singleton (GOVERNOR) consulted by the
9
- engine's decide/replan phases and by gemini_client.py.
10
- Design: cost weights reflect relative expense (decide=1, replan=4); the
11
- ceiling is opt-in (SIM_LLM_HOURLY_CEILING, default 0 = unlimited).
 
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
 
1
+ """
2
+ LLM budget governor -- one place that knows how much LLM spend has happened
3
+ recently and whether the simulation can afford more.
4
+
5
+ Why this exists
6
+ ---------------
7
+ Free-tier Gemini keys are scarce (a handful of keys, a few requests/minute
8
+ each). Turning on perception + proximity conversations + a decision-making
9
+ brain could, if left ungated, burn the whole quota in minutes. Every
10
+ cognitive call site (day planner, conversation, reflex escalation) asks the
11
+ governor `can_afford()` before spending, and calls `record()` after. When the
12
+ soft ceiling is exceeded the governor says "no", and the caller degrades
13
+ gracefully -- the simulation keeps running on its 0-LLM executor path.
14
+
15
+ The governor is intentionally simple: a rolling one-real-hour window of call
16
+ timestamps, plus lifetime counters for observability (used by the budget
17
+ stress test and the on-screen/logged stats).
18
  """
19
 
20
  from __future__ import annotations
backend/src/core/checkpoint_manager.py CHANGED
@@ -1,13 +1,14 @@
1
- """checkpoint_manager — per-tick world state persistence.
 
2
 
3
- Saves WorldState, AgentRegistry (including the serialized action manager),
4
- engine-owned state, and Python RNG state to gzip JSON after every tick,
5
- and loads them for resume/rewind; prunes to a one-day window.
6
 
7
- Architecture: called by WorldEngine each tick and at day handoff; enables
8
- the server's resume/rewind endpoints and the --resume-checkpoint flag.
9
- Design: atomic writes (.tmp + rename); saved RNG state makes collision
10
- resolution replay identically.
 
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Checkpoint Manager — per-tick state save/load for crash recovery.
3
 
4
+ Saves WorldState + AgentRegistry after every tick to compressed
5
+ ``backend/data/checkpoints/tick_{00001}.json.gz`` files.
 
6
 
7
+ Supports:
8
+ - Save: full simulation state as JSON
9
+ - Load: reconstruct from any saved tick
10
+ - List: available checkpoint ticks
11
+ - Prune: auto-delete old checkpoints, keep last N
12
  """
13
 
14
  from __future__ import annotations
backend/src/core/log.py CHANGED
@@ -1,13 +1,27 @@
1
- """log — centralized logging setup for the whole project.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- Every module imports get_logger(__name__) here so format, destinations,
4
- and level stay consistent across the engine, planner, and server.
5
 
6
- Architecture: a dependency of every backend module; no business logic.
7
- Design: logs land in backend/output/logs with timestamps for post-hoc
8
- analysis of long runs. Console output is off by default — the admin log
9
- terminal (src/core/log_relay.py) mirrors every line to the live log file
10
- and the admin-only /api/logs endpoints instead.
 
11
  """
12
 
13
  from __future__ import annotations
@@ -39,7 +53,7 @@ def _build_log_path(run_id: Optional[str] = None) -> Path:
39
  def setup_logging(
40
  level: int = DEFAULT_LEVEL,
41
  run_id: Optional[str] = None,
42
- console: bool = False,
43
  file: bool = True,
44
  max_bytes: int = 5 * 1024 * 1024,
45
  backup_count: int = 5,
@@ -55,8 +69,7 @@ def setup_logging(
55
  run_id -- optional tag folded into the log filename, e.g. a
56
  persona name or simulation id, so a run's logs are
57
  easy to find in output/logs/
58
- console -- also stream logs to stdout (default off; watch the
59
- admin log terminal instead)
60
  file -- also write logs to output/logs/<timestamp>[_<run_id>].log
61
  max_bytes /
62
  backup_count -- rotation settings for the file handler
@@ -116,14 +129,6 @@ def setup_logging(
116
  logging.config.dictConfig(config)
117
  _configured = True
118
 
119
- # Mirror every emitted line into the admin log terminal's buffer and the
120
- # live log file, regardless of the console/file toggles above.
121
- try:
122
- from src.core.log_relay import install_relay
123
- install_relay()
124
- except Exception:
125
- pass
126
-
127
  if log_path:
128
  logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
129
 
 
1
+ """
2
+ Centralized logging setup for the whole project. Every module imports
3
+ `get_logger(__name__)` from here instead of calling `logging.basicConfig`
4
+ or `logging.getLogger` directly, so the format + destinations stay
5
+ identical everywhere
6
+
7
+ Usage
8
+ -----
9
+ Call `setup_logging()` ONCE, as early as possible in the process (top of
10
+ whatever your real entrypoint is -- backend/main.py, or the `run()` /
11
+ `__main__` block of a standalone script):
12
+
13
+ from src.core.log import setup_logging, get_logger
14
+ setup_logging(run_id="run_name") # run_id is optional
15
+ logger = get_logger(__name__)
16
 
17
+ Every other module then just does:
 
18
 
19
+ from src.core.log import get_logger
20
+ logger = get_logger(__name__)
21
+
22
+ If some module gets imported/used before setup_logging() runs (import
23
+ order accidents happen), get_logger() will lazily call setup_logging()
24
+ with defaults so you still get sane output instead of silence.
25
  """
26
 
27
  from __future__ import annotations
 
53
  def setup_logging(
54
  level: int = DEFAULT_LEVEL,
55
  run_id: Optional[str] = None,
56
+ console: bool = True,
57
  file: bool = True,
58
  max_bytes: int = 5 * 1024 * 1024,
59
  backup_count: int = 5,
 
69
  run_id -- optional tag folded into the log filename, e.g. a
70
  persona name or simulation id, so a run's logs are
71
  easy to find in output/logs/
72
+ console -- also stream logs to stdout
 
73
  file -- also write logs to output/logs/<timestamp>[_<run_id>].log
74
  max_bytes /
75
  backup_count -- rotation settings for the file handler
 
129
  logging.config.dictConfig(config)
130
  _configured = True
131
 
 
 
 
 
 
 
 
 
132
  if log_path:
133
  logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
134
 
backend/src/core/log_relay.py DELETED
@@ -1,82 +0,0 @@
1
- """log_relay — mirror of every project log line for the admin log terminal.
2
-
3
- Attaches a second handler to the ROOT logger: each emitted record is
4
- formatted with the project's standard format and appended to a bounded
5
- in-memory ring buffer (polled by the admin-only /api/logs endpoints) and
6
- to a live file backend/output/logs/live.log, so the same output also
7
- survives restarts and stays viewable without the Space console.
8
-
9
- Architecture: installed once by src/core/log.py setup_logging(); consumed
10
- by Odin.py's admin-gated endpoints and the frontend LogTerminal panel.
11
- Design: stdlib-only, a one-way mirror — never changes existing handlers,
12
- levels, or the file rotation policy.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import logging
18
- import threading
19
- from collections import deque
20
- from itertools import count
21
-
22
- from src.config import LOG_DIR
23
- from src.core.log import DATE_FORMAT, LOG_FORMAT
24
-
25
- MAX_LINES = 2000
26
- LIVE_LOG_PATH = LOG_DIR / "live.log"
27
-
28
- _seq = count(1)
29
- _buffer: deque = deque(maxlen=MAX_LINES)
30
- _latest_seq = 0
31
- _lock = threading.Lock()
32
- _installed = False
33
-
34
-
35
- class LogRelayHandler(logging.Handler):
36
- """Appends each formatted record to the ring buffer and the live file."""
37
-
38
- def __init__(self, level: int = logging.NOTSET) -> None:
39
- super().__init__(level)
40
- self._formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
41
-
42
- def emit(self, record: logging.LogRecord) -> None:
43
- global _latest_seq
44
- try:
45
- text = self._formatter.format(record)
46
- except Exception:
47
- text = f"{record.name} | {record.getMessage()}"
48
- with _lock:
49
- _latest_seq = next(_seq)
50
- _buffer.append({"seq": _latest_seq, "level": record.levelname, "text": text})
51
- try:
52
- LOG_DIR.mkdir(parents=True, exist_ok=True)
53
- with open(LIVE_LOG_PATH, "a", encoding="utf-8") as fh:
54
- fh.write(text + "\n")
55
- except OSError:
56
- pass
57
-
58
-
59
- def install_relay() -> None:
60
- """Attach the relay handler to the root logger exactly once."""
61
- global _installed
62
- if _installed:
63
- return
64
- logging.getLogger().addHandler(LogRelayHandler())
65
- _installed = True
66
-
67
-
68
- def relay_lines(since: int = 0) -> dict:
69
- """Return relayed entries with seq > since, plus the latest seq as cursor.
70
-
71
- The cursor tracks the last emitted sequence, so polling stays
72
- incremental even after the buffer is cleared.
73
- """
74
- with _lock:
75
- lines = [entry for entry in _buffer if entry["seq"] > since]
76
- return {"lines": lines, "next": _latest_seq}
77
-
78
-
79
- def clear_relay() -> None:
80
- """Drop the in-memory buffer (the live file is intentionally kept)."""
81
- with _lock:
82
- _buffer.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/core/perceive.py CHANGED
@@ -1,12 +1,13 @@
1
- """perceive — turns a frozen WorldSnapshot into what one agent can observe.
 
2
 
3
- Pure functions, no LLM, no side effects: builds nearest-first Observation
4
- lists in both tile (Chebyshev) and pixel (Euclidean) spaces.
 
 
5
 
6
- Architecture: used by the engine's perceive phase via snapshot
7
- agents_within_px; the novelty fingerprint that gates LLM decisions is
8
- computed from these observations.
9
- Design: strict purity keeps perception reproducible and cheap.
10
  """
11
 
12
  from __future__ import annotations
 
1
+ """
2
+ Perceive -- turns a WorldSnapshot into what one agent can currently observe.
3
 
4
+ This is a pure function of snapshot data: no LLM calls, no side effects.
5
+ With only one agent registered (your current single-agent phase), this
6
+ naturally returns an empty list every tick -- no special-casing needed to
7
+ "turn on" perception later, it already does the real spatial query.
8
 
9
+ Radius/distance logic itself lives on `WorldSnapshot` (core/snapshot.py) so
10
+ there's exactly one implementation of "who's nearby" in the codebase. (in snapshot.py)
 
 
11
  """
12
 
13
  from __future__ import annotations
backend/src/core/runtime_health.py CHANGED
@@ -1,14 +1,4 @@
1
- """runtime_health bounded per-tick anomaly detection for the live sim.
2
-
3
- Scans for stalled travel, overdue actions, position desyncs, and
4
- conversation timeouts; emits reports every N ticks or immediately on
5
- anomaly, feeding the dashboard debug panel and the sidecar monitor.
6
-
7
- Architecture: called by WorldEngine at the end of each tick; consumed by
8
- the frontend snapshot (health block).
9
- Design: state is O(agents) and history-free, so monitoring never grows
10
- with runtime.
11
- """
12
 
13
  from __future__ import annotations
14
 
 
1
+ """Bounded runtime health checks for a live Valhalla simulation."""
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
backend/src/core/snapshot.py CHANGED
@@ -1,12 +1,34 @@
1
- """snapshot — immutable point-in-time view of WorldState.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- The engine freezes exactly one WorldSnapshot per tick and hands the same
4
- object to every agent's parallel phase, so reads can never observe a
5
- mid-tick mutation.
 
6
 
7
- Architecture: consumed by perceive.py, the engine's observation builder,
8
- and the frontend snapshot projection.
9
- Design: frozen data structures; agents see the same world each tick.
10
  """
11
 
12
  from __future__ import annotations
 
1
+ """
2
+ Snapshot -- point-in-time read-only view of WorldState (Immutable)
3
+
4
+ Every tick takes exactly one `WorldSnapshot` and hands the
5
+ *same* frozen object to every agent's tick graph via asyncio.gather(). That's
6
+ what makes parallel agent decisions safe -- nobody is reading a WorldState
7
+ that's being mutated mid-tick by someone else's action.
8
+
9
+ `WorldSnapshot` is a deliberately separate class from `WorldState`, not just
10
+ a deep copy of it. It exposes zero mutating methods, so there is no method
11
+ an agent's perceive/react/plan code could accidentally call that would
12
+ corrupt the resolve phase's assumptions. If you need a new read-only query
13
+ (e.g. "what's the nearest free table"), add it here as a method on
14
+ `WorldSnapshot` -- don't reach into `.agents`/`.occupancy` directly from
15
+ perceive.py and reimplement the same query logic in multiple places.
16
+
17
+ Usage
18
+ -----
19
+ from src.core.world_state import WorldState, Position
20
+ from src.core.snapshot import take_snapshot
21
+
22
+ world = WorldState()
23
+ world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1"))
24
 
25
+ snap = take_snapshot(world) # take ONCE per tick, before decide phase
26
+ snap.get_agent("gurnoor") # read-only query
27
+ snap.agents_near("gurnoor", radius=3)
28
+ snap.is_free("cafeteria_table_3")
29
 
30
+ # snap.tick = 999 <- raises, frozen model
31
+ # snap.agents["x"] = ... <- raises, frozen model
 
32
  """
33
 
34
  from __future__ import annotations
backend/src/core/tick_graph.py CHANGED
@@ -1,13 +1,20 @@
1
- """tick_graph — per-agent LangGraph pipeline and standalone CLI debug tool.
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- Defines the perceive -> retrieve_memories -> react -> day_planner ->
4
- write_back_memory subgraph and a day-planning CLI.
5
 
6
- Architecture: the graph the engine was designed around; the production
7
- WorldEngine currently implements its own phase loop and calls brain.py
8
- directly, so this module serves as the reference single-agent pipeline.
9
- Design: kept as the canonical per-agent graph for experiments and
10
- debugging; its memory-stream integration is intentionally pluggable.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ Agent -- per-agent LangGraph subgraph + standalone CLI debug tool.
3
+
4
+ Production pipeline (used by WorldEngine via build_tick_graph):
5
+
6
+ perceive -> retrieve_memories -> react --[replan]--> day_planner -> write_back_memory
7
+ \\_[continue]__> keep_current /
8
+
9
+ Only agents where scheduler.py's `agents_ready_for_decision()` returns
10
+ True are invoked each tick -- mid-action agents are skipped entirely.
11
+
12
+ Standalone CLI debug mode (python Agent.py <persona>):
13
 
14
+ retrieve_memories from Short_term -> call day_planner.run() -> print plan table
 
15
 
16
+ Useful for testing a single persona's day plan without standing up the
17
+ full tick loop.
 
 
 
18
  """
19
 
20
  from __future__ import annotations
backend/src/core/world_engine.py CHANGED
@@ -1,15 +1,16 @@
1
- """world_engine — the main simulation orchestrator.
2
-
3
- Owns the tick loop (snapshot, act, perceive, decide, replan, resolve),
4
- day handoff, proximity conversations, events, wellbeing updates, health
5
- observation, and per-tick checkpointing; also runs headless via CLI.
6
-
7
- Architecture: the hub of the backend — every subsystem (brain, planner,
8
- Actions, memory, events, checkpoints) is called from here; Odin.py hosts
9
- it as a background task.
10
- Design: LLM calls run in worker threads and asyncio tasks so latency
11
- never freezes the clock; deterministic phases (resolve) and stochastic
12
- phases (decide) are strictly separated.
 
13
  """
14
 
15
  from __future__ import annotations
@@ -17,7 +18,6 @@ from __future__ import annotations
17
  import asyncio
18
  import hashlib
19
  import json
20
- import math
21
  import random
22
  import sys
23
  import time as _time
@@ -126,10 +126,6 @@ class WorldEngine:
126
  # Decisions are advisory; a slow provider response must not freeze the
127
  # simulation clock or WebSocket snapshots at an action boundary.
128
  self._decision_tasks: Dict[str, asyncio.Task] = {}
129
- # Advisory throttle: last tick an "unscheduled downtime" recovery
130
- # replan was attempted per agent. Not checkpointed — on restore a
131
- # fresh attempt is harmless.
132
- self._downtime_replan_tick: Dict[str, int] = {}
133
 
134
  @staticmethod
135
  def _conversation_key(first_id: str, second_id: str) -> str:
@@ -353,41 +349,69 @@ class WorldEngine:
353
  baseline -= 0.04
354
  return max(0.56, min(0.86, baseline))
355
 
356
- def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any, duration: int) -> tuple[float, float]:
357
- """Return the total wellbeing effect for one action.
358
-
359
- When the day planner declares an energy_target for the action, the
360
- runtime glides the agent's energy from its current level toward that
361
- declared cumulative target (the LLM owns every value; this is only a
362
- smooth, deterministic path to it). The exponential progress factor
363
- means short actions barely move energy while long ones converge, and
364
- because it depends only on stored state and the plan it stays
365
- checkpoint-reproducible.
366
 
367
- Without a declared target the legacy delta path applies: the planner's
368
- energy_change drives the level, with the small deterministic jitter
369
- keyed by agent/action so replays remain reproducible.
 
 
370
  """
371
  description = (getattr(action, "description", "") or "").lower()
372
- emotion = float(getattr(action, "emotion_change", 0.0) or 0.0)
373
-
374
- target = getattr(action, "energy_target", None)
375
- if target is not None:
376
- target = max(0.0, min(1.0, float(target)))
377
- remaining = target - state.energy_level
378
- progress = 1.0 - math.exp(-_cfg.SIM_ENERGY_FOLLOW_RATE * max(1, duration))
379
- energy = remaining * progress
380
- return energy, emotion
381
-
382
- energy = float(getattr(action, "energy_change", 0.0) or 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
  variation = _cfg.SIM_WELLBEING_VARIABILITY
385
  token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}"
386
  digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest()
387
  jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0
388
- energy += jitter * 0.035 * variation
389
- emotion += jitter * 0.045 * variation
390
- return energy, emotion
391
 
392
  def _memory_context(self, persona_name, persona, before_date=None, query_hint=""):
393
  """Build (relevant_memories, rolling_summary) for a day-planner call.
@@ -508,8 +532,6 @@ class WorldEngine:
508
  "persona_name": name,
509
  "mode": "full_day",
510
  "current_location_id": hostel,
511
- "energy_level": self._energy_baseline(persona),
512
- "emotion_state": self._emotion_baseline(persona),
513
  "upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []),
514
  },
515
  ),
@@ -679,26 +701,6 @@ class WorldEngine:
679
  return_exceptions=True,
680
  )
681
 
682
- # ══════ PHASE 5b: Unscheduled downtime recovery (parallel, deterministic) ══════
683
- # A force-accepted fallback plan strands the agent on "Unscheduled
684
- # downtime" for the rest of the day. The LLM decide path may never
685
- # fire for such an agent, so detect it here and replan the remaining
686
- # whole day explicitly.
687
- downtime_agents = [
688
- s for s in agent_states if self._has_unscheduled_downtime(s, current_tick)
689
- ]
690
- if downtime_agents:
691
- for s in downtime_agents:
692
- self._downtime_replan_tick[s.agent_id] = current_tick
693
- logger.info(
694
- "[WorldEngine] agent '%s' stuck on unscheduled downtime — replanning remaining day",
695
- s.persona_name,
696
- )
697
- await asyncio.gather(
698
- *[self._phase_replan(s, current_tick, hhmm) for s in downtime_agents],
699
- return_exceptions=True,
700
- )
701
-
702
  # ══════ PHASE 6: Resolve (sequential) ══════
703
  await self._check_last_action_triggers(current_tick, hhmm)
704
  self._apply_finished_event_effects(self.sim_start_date, hhmm)
@@ -885,8 +887,6 @@ class WorldEngine:
885
  "persona_name": state.persona_name,
886
  "mode": "remaining",
887
  "current_location_id": state.position.location_id,
888
- "energy_level": state.energy_level,
889
- "emotion_state": state.emotion_state,
890
  "upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []),
891
  },
892
  ),
@@ -913,38 +913,6 @@ class WorldEngine:
913
  "[WorldEngine] replan failed for '%s': %s", state.persona_name, e,
914
  )
915
 
916
- def _has_unscheduled_downtime(self, state: AgentRuntimeState, tick: int) -> bool:
917
- """Detect agents stranded on the deterministic fallback schedule.
918
-
919
- The LLM decide path is gated (novelty, energy/emotion, cooldown,
920
- budget), so a force-accepted fallback day can leave an agent stuck on
921
- "Unscheduled downtime" for hours with no replan ever firing. This
922
- backstop scans the remaining plan every tick and flags it."""
923
- if state.paused:
924
- return False
925
- if state.day_archived:
926
- return False
927
- if state.manager is None:
928
- return False
929
- if state.replan_count >= _cfg.MAX_REPLANS_PER_AGENT_PER_DAY:
930
- return False
931
- if (
932
- tick - self._downtime_replan_tick.get(state.agent_id, -10**9)
933
- < _cfg.DOWNTIME_REPLAN_COOLDOWN_TICKS
934
- ):
935
- return False
936
- now_minutes = tick % (24 * 60)
937
- if now_minutes >= 24 * 60 - _cfg.DOWNTIME_REPLAN_MIN_HORIZON:
938
- # Too little of the day remains to justify a replan.
939
- return False
940
- for action in state.day_plan:
941
- end = self._hhmm_to_minutes(str(action.get("end", "")))
942
- if end > now_minutes and "unscheduled downtime" in str(
943
- action.get("action", "")
944
- ).lower():
945
- return True
946
- return False
947
-
948
  async def _run_agent_act(
949
  self, state: AgentRuntimeState, tick: int, hhmm: str
950
  ) -> None:
@@ -1064,11 +1032,15 @@ class WorldEngine:
1064
  end_min = self._hhmm_to_minutes(action.end_time)
1065
  duration = max(1, end_min - start_min)
1066
  tick_step = _cfg.SIM_MINUTES_PER_TICK
1067
- action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action, duration)
1068
  energy_tick = (action_energy_change / duration) * tick_step
1069
  emotion_tick = (action_emotion_change / duration) * tick_step
1070
- state.energy_level = max(0.0, min(1.0, state.energy_level + energy_tick))
1071
- state.emotion_state = max(0.0, min(1.0, state.emotion_state + emotion_tick))
 
 
 
 
1072
  except Exception:
1073
  pass
1074
 
@@ -1511,14 +1483,22 @@ class WorldEngine:
1511
  self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta)
1512
  self.relationship_matrix.save()
1513
  # Conversations affect the people having them, not only their stored
1514
- # relationship score. The LLM decides each participant's net energy
1515
- # and mood change for the chat; only a 0..1 safety clamp is applied.
1516
- for state, energy_delta, emotion_delta in (
1517
- (a, conv_result.energy_delta_a, conv_result.emotion_delta_a),
1518
- (b, conv_result.energy_delta_b, conv_result.emotion_delta_b),
1519
- ):
1520
- state.energy_level = max(0.0, min(1.0, state.energy_level + float(energy_delta or 0.0)))
1521
- state.emotion_state = max(0.0, min(1.0, state.emotion_state + float(emotion_delta or 0.0)))
 
 
 
 
 
 
 
 
1522
  logger.info(
1523
  "[WorldEngine] conversation '%s' <-> '%s' active until tick %d",
1524
  a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes,
@@ -1546,8 +1526,6 @@ class WorldEngine:
1546
  "persona_name": state.persona_name,
1547
  "mode": "remaining",
1548
  "current_location_id": state.position.location_id,
1549
- "energy_level": state.energy_level,
1550
- "emotion_state": state.emotion_state,
1551
  },
1552
  )
1553
  return plan_result.get("day_plan", [])
@@ -1802,10 +1780,7 @@ class WorldEngine:
1802
  f"The previous day ended while the agent was {action_text} at {location}. "
1803
  f"Energy is {state.energy_level:.2f}/1.0 and emotion is "
1804
  f"{state.emotion_state:.2f}/1.0. Continue naturally from this "
1805
- "physical and emotional state; do not abruptly relocate them. "
1806
- "When assigning energy_change/emotion_change for the new day, "
1807
- "keep the cumulative energy and mood totals between 0.0 and "
1808
- "1.0 at all times."
1809
  )
1810
 
1811
  async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]:
@@ -1828,8 +1803,6 @@ class WorldEngine:
1828
  "mode": "next_day",
1829
  "current_location_id": state.position.location_id,
1830
  "handoff_context": _handoff_context(state),
1831
- "energy_level": state.energy_level,
1832
- "emotion_state": state.emotion_state,
1833
  "upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []),
1834
  },
1835
  )
@@ -1858,7 +1831,6 @@ class WorldEngine:
1858
  self._recent_convs.clear()
1859
  self._in_range.clear()
1860
  self._last_decision_tick.clear()
1861
- self._downtime_replan_tick.clear()
1862
  self._last_obs.clear()
1863
  self._tick_observations.clear()
1864
  self._applied_event_effects.clear()
 
1
+ """
2
+ WorldEngine — the main simulation orchestrator.
3
+
4
+ Controls the tick loop: advances time, runs agent actions in parallel,
5
+ detects proximity for conversations, handles end-of-day transitions,
6
+ and keeps WorldState in sync with the agent registry.
7
+
8
+ Usage:
9
+ from src.core.world_engine import WorldEngine
10
+
11
+ engine = WorldEngine()
12
+ await engine.initialize()
13
+ await engine.run(max_ticks=1440) # one full day at 1 tick/sec
14
  """
15
 
16
  from __future__ import annotations
 
18
  import asyncio
19
  import hashlib
20
  import json
 
21
  import random
22
  import sys
23
  import time as _time
 
126
  # Decisions are advisory; a slow provider response must not freeze the
127
  # simulation clock or WebSocket snapshots at an action boundary.
128
  self._decision_tasks: Dict[str, asyncio.Task] = {}
 
 
 
 
129
 
130
  @staticmethod
131
  def _conversation_key(first_id: str, second_id: str) -> str:
 
349
  baseline -= 0.04
350
  return max(0.56, min(0.86, baseline))
351
 
352
+ def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any) -> tuple[float, float]:
353
+ """Compute a deterministic total wellbeing effect for one action.
 
 
 
 
 
 
 
 
354
 
355
+ LLM-supplied deltas are useful hints, but are normally very small. A
356
+ shared local activity model therefore gives classes, travel, rest, and
357
+ social time their ordinary human cost or benefit. The small stable
358
+ variation is keyed by agent/action, rather than sampled each tick, so
359
+ replaying a checkpoint remains reproducible.
360
  """
361
  description = (getattr(action, "description", "") or "").lower()
362
+ action_type = str(getattr(action, "action_type", "")).lower()
363
+ # The planner can add personality-specific flavour, but it must not
364
+ # turn an otherwise restorative meal or quiet break into a day-long
365
+ # energy drain. The local physical activity model is authoritative.
366
+ planner_energy = max(-0.08, min(0.08, float(getattr(action, "energy_change", 0.0))))
367
+ planner_emotion = max(-0.12, min(0.12, float(getattr(action, "emotion_change", 0.0))))
368
+ energy, emotion = 0.0, 0.0
369
+
370
+ if action_type.endswith("move") or any(word in description for word in ("walk", "travel", "commute", "go to")):
371
+ energy, emotion = -0.075, -0.008
372
+ elif "sleep" in description:
373
+ energy, emotion = 0.50, 0.025
374
+ elif any(word in description for word in ("nap", "rest", "recharge", "lie down")):
375
+ energy, emotion = 0.20, 0.020
376
+ elif any(word in description for word in (
377
+ "meme", "memes", "scroll", "social media", "youtube", "video",
378
+ "reading for pleasure", "quiet reading", "reading quietly", "bench", "downtime",
379
+ "free time", "relax", "relaxing", "wind-down", "wind down",
380
+ )):
381
+ energy, emotion = 0.090, 0.025
382
+ elif any(word in description for word in ("class", "lecture", "lab", "tutorial", "study", "assignment", "coding", "project", "exam")):
383
+ energy, emotion = -0.070, -0.025
384
+ elif any(word in description for word in ("gym", "sport", "run", "football", "basketball", "badminton", "workout", "cardio", "weightlift", "training")):
385
+ energy, emotion = -0.180, 0.075
386
+ elif any(word in description for word in ("breakfast", "lunch", "dinner", "meal", "food", "tea", "chai", "eat", "eating")):
387
+ energy, emotion = 0.130, 0.025
388
+ elif any(word in description for word in ("friends", "club", "music", "open mic", "game", "movie", "social", "hangout")):
389
+ energy, emotion = 0.015, 0.075
390
+ elif any(word in description for word in ("laundry", "clean", "errand", "admin", "queue", "chore")):
391
+ energy, emotion = -0.080, -0.025
392
+ elif any(word in description for word in ("stand", "standing", "wait", "waiting")):
393
+ energy, emotion = -0.040, -0.005
394
+ else:
395
+ # Neutral, seated or low-intensity tasks should not silently push
396
+ # every agent toward exhaustion merely because their wording was
397
+ # not anticipated above.
398
+ energy, emotion = -0.005, 0.0
399
+
400
+ # Introverted students generally enjoy a good conversation but spend
401
+ # more energy on it; this keeps personality visible without judging it.
402
+ traits = " ".join(str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower()
403
+ if any(word in description for word in ("friends", "club", "social", "hangout")) and any(
404
+ marker in traits for marker in ("introverted", "quiet", "reserved")
405
+ ):
406
+ energy -= 0.03
407
 
408
  variation = _cfg.SIM_WELLBEING_VARIABILITY
409
  token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}"
410
  digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest()
411
  jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0
412
+ energy += planner_energy + jitter * 0.035 * variation
413
+ emotion += planner_emotion + jitter * 0.045 * variation
414
+ return max(-0.28, min(0.30, energy)), max(-0.18, min(0.16, emotion))
415
 
416
  def _memory_context(self, persona_name, persona, before_date=None, query_hint=""):
417
  """Build (relevant_memories, rolling_summary) for a day-planner call.
 
532
  "persona_name": name,
533
  "mode": "full_day",
534
  "current_location_id": hostel,
 
 
535
  "upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []),
536
  },
537
  ),
 
701
  return_exceptions=True,
702
  )
703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  # ══════ PHASE 6: Resolve (sequential) ══════
705
  await self._check_last_action_triggers(current_tick, hhmm)
706
  self._apply_finished_event_effects(self.sim_start_date, hhmm)
 
887
  "persona_name": state.persona_name,
888
  "mode": "remaining",
889
  "current_location_id": state.position.location_id,
 
 
890
  "upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []),
891
  },
892
  ),
 
913
  "[WorldEngine] replan failed for '%s': %s", state.persona_name, e,
914
  )
915
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916
  async def _run_agent_act(
917
  self, state: AgentRuntimeState, tick: int, hhmm: str
918
  ) -> None:
 
1032
  end_min = self._hhmm_to_minutes(action.end_time)
1033
  duration = max(1, end_min - start_min)
1034
  tick_step = _cfg.SIM_MINUTES_PER_TICK
1035
+ action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action)
1036
  energy_tick = (action_energy_change / duration) * tick_step
1037
  emotion_tick = (action_emotion_change / duration) * tick_step
1038
+ state.energy_level = max(0.08, min(0.97, state.energy_level + energy_tick))
1039
+ baseline = state.emotion_baseline
1040
+ # Mood has a weak pull towards personality baseline, but day
1041
+ # events are allowed to remain visible for several actions.
1042
+ recovery = (baseline - state.emotion_state) * min(0.015, 0.0005 * tick_step)
1043
+ state.emotion_state = max(0.10, min(0.90, state.emotion_state + emotion_tick + recovery))
1044
  except Exception:
1045
  pass
1046
 
 
1483
  self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta)
1484
  self.relationship_matrix.save()
1485
  # Conversations affect the people having them, not only their stored
1486
+ # relationship score. A warm chat is a modest lift; an awkward one is
1487
+ # draining. The effect is applied once per completed conversation.
1488
+ relationship_delta = max(-0.20, min(0.20, conv_result.relationship_delta))
1489
+ sentiment = (getattr(conv_result, "sentiment", "neutral") or "neutral").lower()
1490
+ for state in (a, b):
1491
+ social_cost = 0.045 if any(marker in " ".join(
1492
+ str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")
1493
+ ).lower() for marker in ("introverted", "quiet", "reserved")) else 0.025
1494
+ state.energy_level = max(0.08, min(0.97, state.energy_level - social_cost))
1495
+ if sentiment in ("positive", "warm", "friendly"):
1496
+ mood_delta = 0.035 + max(0.0, relationship_delta) * 0.25
1497
+ elif sentiment in ("negative", "tense", "awkward"):
1498
+ mood_delta = -0.035 + min(0.0, relationship_delta) * 0.25
1499
+ else:
1500
+ mood_delta = relationship_delta * 0.08
1501
+ state.emotion_state = max(0.10, min(0.90, state.emotion_state + mood_delta))
1502
  logger.info(
1503
  "[WorldEngine] conversation '%s' <-> '%s' active until tick %d",
1504
  a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes,
 
1526
  "persona_name": state.persona_name,
1527
  "mode": "remaining",
1528
  "current_location_id": state.position.location_id,
 
 
1529
  },
1530
  )
1531
  return plan_result.get("day_plan", [])
 
1780
  f"The previous day ended while the agent was {action_text} at {location}. "
1781
  f"Energy is {state.energy_level:.2f}/1.0 and emotion is "
1782
  f"{state.emotion_state:.2f}/1.0. Continue naturally from this "
1783
+ "physical and emotional state; do not abruptly relocate them."
 
 
 
1784
  )
1785
 
1786
  async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]:
 
1803
  "mode": "next_day",
1804
  "current_location_id": state.position.location_id,
1805
  "handoff_context": _handoff_context(state),
 
 
1806
  "upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []),
1807
  },
1808
  )
 
1831
  self._recent_convs.clear()
1832
  self._in_range.clear()
1833
  self._last_decision_tick.clear()
 
1834
  self._last_obs.clear()
1835
  self._tick_observations.clear()
1836
  self._applied_event_effects.clear()
backend/src/core/world_events.py CHANGED
@@ -1,13 +1,10 @@
1
- """world_events — deterministic, LLM-free campus events.
2
 
3
- Loads the event calendar, decides attendance from persona interest, social
4
- score, and seeded noise, splices events into flexible plan windows, and
5
- applies wellbeing/relationship effects once events end.
6
-
7
- Architecture: called by WorldEngine at init, day handoff, and restore;
8
- consumes places/personas/relationship matrix and edits day plans.
9
- Design: events only replace entirely flexible time windows (never classes
10
- or sleep), keeping the calendar safe to apply automatically.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """Deterministic, data-driven campus events and safe plan opportunities.
2
 
3
+ The event calendar is deliberately independent of the LLM. It makes an
4
+ attendance decision from a persona, its social context, schedule conflicts,
5
+ and a seeded tie-breaker, then edits only an entirely-flexible time window.
6
+ This keeps festivals and interruptions lively without allowing them to erase
7
+ classes, meals, sleep, exams, or an in-progress route.
 
 
 
8
  """
9
 
10
  from __future__ import annotations
backend/src/core/world_state.py CHANGED
@@ -1,13 +1,39 @@
1
- """world_state — the canonical mutable world.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- Holds agent positions, occupancy, append-only history, the absolute tick
4
- clock, and conversation cooldowns; only the engine's resolve phase may
5
- mutate it.
 
 
 
 
 
6
 
7
- Architecture: the bottom layer under snapshot.py (read view), checkpoint
8
- manager, and the engine's sync step.
9
- Design: mutation is restricted by convention — the engine mirrors the
10
- registry into WorldState exactly twice per tick.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """
2
+ World State -- the single source of truth for the simulation.
3
+
4
+ `WorldState` holds everything that is true about the world at a given tick:
5
+ where every agent is, what they're currently doing, and who/what currently
6
+ holds any contested resource (a chair, an NPC's attention, a location slot).
7
+
8
+ Ownership rule:
9
+ Only `WorldEngine`'s resolve phase should ever call the mutating methods
10
+ on this class directly (`set_agent_action`, `move_agent`, `occupy`, ...).
11
+
12
+ Every agent tick graph (perceive -> retrieve -> react -> day_planner ->
13
+ act) must only ever see a frozen copy produced by `core/snapshot.py`.
14
+
15
+ That separation is what keeps the decide phase safely parallelizable
16
+ with asyncio.gather() -- nobody is reading a WorldState that something
17
+ else is mutating mid-tick.
18
+
19
+ Usage
20
+ -----
21
+ from src.core.world_state import WorldState, Position
22
+
23
+ world = WorldState()
24
+ world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1"))
25
+ world.register_resource("cafeteria_table_3")
26
 
27
+ # inside the resolve phase, after an agent's tick graph proposed an action:
28
+ world.occupy("cafeteria_table_3", "gurnoor")
29
+ world.set_agent_action("gurnoor", CurrentAction(
30
+ description="eating breakfast",
31
+ start_tick=world.tick,
32
+ end_tick=world.tick + 20,
33
+ target_object_id="cafeteria_table_3",
34
+ ))
35
 
36
+ world.advance_tick(minutes=10)
 
 
 
37
  """
38
 
39
  from __future__ import annotations
backend/src/llm/gemini_client.py CHANGED
@@ -1,15 +1,8 @@
1
- """gemini_client the project's only LLM access point.
2
-
3
- Wraps Google Gemini with deterministic head-first key rotation (each call
4
- tries every configured key exactly once), JSON-schema structured output,
5
- embedding support, and a provider_failure circuit breaker that halts the
6
- simulation cleanly when every key fails.
7
-
8
- Architecture: called by day_planner.py, brain.py, conversation.py,
9
- vector_memory.py, and the roster generator; records spend with the budget
10
- governor.
11
- Design: deliberately no retries or timeouts in this module — resilience
12
- lives in the engine's checkpoint/resume path and the budget governor.
13
  """
14
 
15
  from __future__ import annotations
@@ -23,13 +16,7 @@ from google import genai
23
  from google.genai import types
24
  from pydantic import BaseModel
25
 
26
- from src.config import (
27
- API_KEYS,
28
- GEMINI_MODEL,
29
- GEMINI_MODEL_FALLBACK,
30
- MEMORY_EMBEDDING_MODEL,
31
- TEMPERATURE,
32
- )
33
  from src.core.log import get_logger
34
 
35
  logger = get_logger(__name__)
@@ -136,29 +123,28 @@ def call_gemini(
136
  complexity: str = "default",
137
  temperature: float = TEMPERATURE,
138
  ) -> BaseModel:
139
- """Try both configured models on each key before advancing the ring."""
140
  errors: list[Exception] = []
141
  for node in _new_ring().traverse_from_head():
142
- for model in (GEMINI_MODEL, GEMINI_MODEL_FALLBACK):
143
- try:
144
- response = _get_client(node.key).models.generate_content(
145
- model=model,
146
- contents=user_prompt,
147
- config=types.GenerateContentConfig(
148
- system_instruction=system_prompt,
149
- response_mime_type="application/json",
150
- response_schema=schema,
151
- temperature=temperature,
152
- thinking_config=types.ThinkingConfig(thinking_level="medium"),
153
- ),
154
- )
155
- result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text))
156
- logger.info("[gemini] model=%s key_index=%d ok", model, node.index)
157
- _record_success(complexity)
158
- return result
159
- except Exception as exc:
160
- errors.append(exc)
161
- logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", model, node.index, type(exc).__name__)
162
  raise _quota_exhausted(GEMINI_MODEL, errors)
163
 
164
 
 
1
+ """Gemini access through a deterministic, head-first key ring.
2
+
3
+ Every API call starts with the first configured key. On *any* exception it
4
+ tries the next node exactly once. There are deliberately no delays, retries,
5
+ timeouts, cooldowns, key reservations, or model changes in this module.
 
 
 
 
 
 
 
6
  """
7
 
8
  from __future__ import annotations
 
16
  from google.genai import types
17
  from pydantic import BaseModel
18
 
19
+ from src.config import API_KEYS, GEMINI_MODEL, MEMORY_EMBEDDING_MODEL, TEMPERATURE
 
 
 
 
 
 
20
  from src.core.log import get_logger
21
 
22
  logger = get_logger(__name__)
 
123
  complexity: str = "default",
124
  temperature: float = TEMPERATURE,
125
  ) -> BaseModel:
126
+ """Call exactly one model, moving through the ring on any failure."""
127
  errors: list[Exception] = []
128
  for node in _new_ring().traverse_from_head():
129
+ try:
130
+ response = _get_client(node.key).models.generate_content(
131
+ model=GEMINI_MODEL,
132
+ contents=user_prompt,
133
+ config=types.GenerateContentConfig(
134
+ system_instruction=system_prompt,
135
+ response_mime_type="application/json",
136
+ response_schema=schema,
137
+ temperature=temperature,
138
+ thinking_config=types.ThinkingConfig(thinking_level="medium"),
139
+ ),
140
+ )
141
+ result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text))
142
+ logger.info("[gemini] model=%s key_index=%d ok", GEMINI_MODEL, node.index)
143
+ _record_success(complexity)
144
+ return result
145
+ except Exception as exc:
146
+ errors.append(exc)
147
+ logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", GEMINI_MODEL, node.index, type(exc).__name__)
 
148
  raise _quota_exhausted(GEMINI_MODEL, errors)
149
 
150
 
backend/test_gemini_client.py DELETED
@@ -1,60 +0,0 @@
1
- from pydantic import BaseModel
2
-
3
- from src.llm import gemini_client
4
-
5
-
6
- class _Result(BaseModel):
7
- value: str
8
-
9
-
10
- class _Response:
11
- parsed = _Result(value="ok")
12
-
13
-
14
- class _Models:
15
- def __init__(self, calls, failures):
16
- self._calls = calls
17
- self._failures = failures
18
-
19
- def generate_content(self, *, model, **_kwargs):
20
- self._calls.append(model)
21
- if model in self._failures:
22
- raise RuntimeError(model)
23
- return _Response()
24
-
25
-
26
- class _Client:
27
- def __init__(self, calls, failures):
28
- self.models = _Models(calls, failures)
29
-
30
-
31
- def test_fallback_model_runs_on_same_key_before_next_key(monkeypatch):
32
- calls = []
33
- monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"])
34
- monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary")
35
- monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback")
36
- monkeypatch.setattr(gemini_client, "_get_client", lambda key: _Client(calls, {"primary"}))
37
- monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None)
38
-
39
- result = gemini_client.call_gemini("system", "user", _Result)
40
-
41
- assert result.value == "ok"
42
- assert calls == ["primary", "fallback"]
43
-
44
-
45
- def test_key_advances_only_after_both_models_fail(monkeypatch):
46
- calls = []
47
- monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"])
48
- monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary")
49
- monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback")
50
- monkeypatch.setattr(
51
- gemini_client,
52
- "_get_client",
53
- lambda key: _Client(calls, {"primary", "fallback"} if key == "key-1" else set()),
54
- )
55
- monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None)
56
-
57
- result = gemini_client.call_gemini("system", "user", _Result)
58
-
59
- assert result.value == "ok"
60
- assert calls == ["primary", "fallback", "primary"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tools/sidecar_monitor.py CHANGED
@@ -1,13 +1,11 @@
1
- """sidecar_monitor — read-only, non-LLM overnight monitor.
2
 
3
- Polls the same state endpoint the frontend uses and writes machine-
4
- readable JSONL plus a human-readable report of the night's run,
5
- including health anomalies.
6
 
7
- Architecture: an external observer of Odin.py; never touches checkpoints
8
- or control endpoints.
9
- Design: read-only by construction so monitoring can never perturb the
10
- simulation it observes.
11
  """
12
 
13
  from __future__ import annotations
 
1
+ """Read-only, non-LLM overnight monitor for a running Valhalla simulation.
2
 
3
+ It polls the same state endpoint used by the frontend and writes two files:
4
+ * ``*.jsonl``: every sampled snapshot and every detected finding (machine-readable)
5
+ * ``*.txt``: a concise, chronological report suitable for morning review
6
 
7
+ The monitor never imports simulation modules, calls an LLM, writes checkpoints,
8
+ or invokes any control endpoint. Stop it with Ctrl+C; it flushes a final summary.
 
 
9
  """
10
 
11
  from __future__ import annotations
frontend/src/App.jsx CHANGED
@@ -1,17 +1,3 @@
1
- /**
2
- * App — root layout and state composer of the dashboard.
3
- *
4
- * Owns auth + simulation snapshot state, renders the map canvas, the
5
- * agent windows, and all side panels, and handles error banners and
6
- * day-handoff/provider-failure packets from the backend.
7
- *
8
- * Architecture: consumed by main.jsx; renders SimCanvas, InfoBar,
9
- * ConversationFeed, EventsPanel, DebugPanel, RosterManager, LoginButton.
10
- *
11
- * Design: one component per concern, all fed from a single WebSocket
12
- * snapshot hook (useSimState).
13
- */
14
-
15
  import { useState, useEffect, useCallback } from "react";
16
  import useSimState from "./hooks/useSimState";
17
  import { AuthProvider, useAuth } from "./hooks/useAuth";
@@ -23,7 +9,6 @@ import EventsPanel from "./components/EventsPanel";
23
  import DebugPanel from "./components/DebugPanel";
24
  import RosterManager from "./components/RosterManager";
25
  import LoginButton from "./components/LoginButton";
26
- import LogTerminal from "./components/LogTerminal";
27
  import { apiUrl } from "./utils/api";
28
  import "./App.css";
29
 
@@ -31,6 +16,7 @@ function compactTabPosition(index) {
31
  const side = index % 2;
32
  const row = Math.floor(index / 2);
33
  return {
 
34
  // right-hand card can then expand without its controls leaving the view.
35
  x: side ? Math.max(16, window.innerWidth - 276) : 16,
36
  y: 66 + row * 92,
@@ -57,7 +43,6 @@ function AppContent() {
57
  const [controlError, setControlError] = useState(null);
58
  const [simulationRunning, setSimulationRunning] = useState(true);
59
  const [rosterOpen, setRosterOpen] = useState(false);
60
- const [showTerminal, setShowTerminal] = useState(false);
61
 
62
  useEffect(() => {
63
  if (!snapshot) return;
@@ -155,7 +140,6 @@ function AppContent() {
155
  <EventsPanel events={snapshot?.events} />
156
  {showDebug && <DebugPanel health={snapshot?.health} />}
157
  <RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
158
- {isAuthenticated && showTerminal && <LogTerminal onClose={() => setShowTerminal(false)} />}
159
 
160
  {agentIds.map((id, index) => {
161
  const expanded = expandedAgentIds.has(id);
@@ -188,8 +172,6 @@ function AppContent() {
188
  }}
189
  onToggleRoster={() => setRosterOpen((value) => !value)}
190
  isAuthenticated={isAuthenticated}
191
- terminalOpen={showTerminal}
192
- onToggleTerminal={() => setShowTerminal((value) => !value)}
193
  />
194
  <LoginButton />
195
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useState, useEffect, useCallback } from "react";
2
  import useSimState from "./hooks/useSimState";
3
  import { AuthProvider, useAuth } from "./hooks/useAuth";
 
9
  import DebugPanel from "./components/DebugPanel";
10
  import RosterManager from "./components/RosterManager";
11
  import LoginButton from "./components/LoginButton";
 
12
  import { apiUrl } from "./utils/api";
13
  import "./App.css";
14
 
 
16
  const side = index % 2;
17
  const row = Math.floor(index / 2);
18
  return {
19
+ // Reserve the full inspector width even while this card is compact. A
20
  // right-hand card can then expand without its controls leaving the view.
21
  x: side ? Math.max(16, window.innerWidth - 276) : 16,
22
  y: 66 + row * 92,
 
43
  const [controlError, setControlError] = useState(null);
44
  const [simulationRunning, setSimulationRunning] = useState(true);
45
  const [rosterOpen, setRosterOpen] = useState(false);
 
46
 
47
  useEffect(() => {
48
  if (!snapshot) return;
 
140
  <EventsPanel events={snapshot?.events} />
141
  {showDebug && <DebugPanel health={snapshot?.health} />}
142
  <RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
 
143
 
144
  {agentIds.map((id, index) => {
145
  const expanded = expandedAgentIds.has(id);
 
172
  }}
173
  onToggleRoster={() => setRosterOpen((value) => !value)}
174
  isAuthenticated={isAuthenticated}
 
 
175
  />
176
  <LoginButton />
177
  </div>
frontend/src/components/ActionDetail.jsx CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * ActionDetail — renders an agent's current action description.
3
- *
4
- * Shows the action text with its time range and route progress for
5
- * movement actions.
6
- *
7
- * Architecture: rendered inside AgentWindow's expanded inspector.
8
- *
9
- * Design: purely presentational; receives the action object as props.
10
- */
11
-
12
  export default function ActionDetail({ action }) {
13
  if (!action) {
14
  return (
 
 
 
 
 
 
 
 
 
 
 
 
1
  export default function ActionDetail({ action }) {
2
  if (!action) {
3
  return (
frontend/src/components/AgentWindow.jsx CHANGED
@@ -1,17 +1,3 @@
1
- /**
2
- * AgentWindow — per-agent draggable inspector card.
3
- *
4
- * Compact card by default; expanded view shows location, current action
5
- * (via ActionDetail), energy/emotion gauges, pause state, and the agent's
6
- * live conversation transcript (ChatPanel).
7
- *
8
- * Architecture: one instance per agent, laid out by App.jsx; fed from the
9
- * shared simulation snapshot.
10
- *
11
- * Design: windows are draggable (react-draggable) so multiple agents can
12
- * be inspected simultaneously.
13
- */
14
-
15
  import { useRef, useState, useEffect } from "react";
16
  import Draggable from "react-draggable";
17
  import WindowHeader from "./WindowHeader";
@@ -40,6 +26,7 @@ export default function AgentWindow({ agentId, data, speed, defaultPosition, exp
40
  const conversationId = conversation
41
  ? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}`
42
  : null;
 
43
  // card's lifetime. The backend clears this state when the simulated chat
44
  // finishes; rendering only an active/generating conversation automatically
45
  // collapses the chat panel as the agent starts their next task.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useRef, useState, useEffect } from "react";
2
  import Draggable from "react-draggable";
3
  import WindowHeader from "./WindowHeader";
 
26
  const conversationId = conversation
27
  ? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}`
28
  : null;
29
+ // A transcript belongs to the live conversation state, not to the agent
30
  // card's lifetime. The backend clears this state when the simulated chat
31
  // finishes; rendering only an active/generating conversation automatically
32
  // collapses the chat panel as the agent starts their next task.
frontend/src/components/ChatBubble.jsx CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * ChatBubble — a single message bubble in a conversation transcript.
3
- *
4
- * Aligns right for the focused agent (self) and left for the partner,
5
- * colored by the speaker's agent color.
6
- *
7
- * Architecture: rendered by ChatPanel for each revealed message.
8
- *
9
- * Design: purely presentational; no state.
10
- */
11
-
12
  export default function ChatBubble({ text, isSelf, color }) {
13
  return (
14
  <div style={{
 
 
 
 
 
 
 
 
 
 
 
 
1
  export default function ChatBubble({ text, isSelf, color }) {
2
  return (
3
  <div style={{
frontend/src/components/ChatPanel.jsx CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * ChatPanel — read-only live transcript of one agent-to-agent conversation.
3
- *
4
- * Reveals messages one by one to mirror the backend's staged generation,
5
- * and auto-scrolls to the newest message.
6
- *
7
- * Architecture: rendered inside AgentWindow; fed the conversation object
8
- * and the revealed-count from the simulation snapshot.
9
- *
10
- * Design: observation-only — there is deliberately no input to talk to
11
- * agents from the UI.
12
- */
13
-
14
  import { useEffect, useRef } from "react";
15
  import ChatBubble from "./ChatBubble";
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useEffect, useRef } from "react";
2
  import ChatBubble from "./ChatBubble";
3
 
frontend/src/components/ConversationFeed.jsx CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * ConversationFeed — draggable log of recent campus conversations.
3
- *
4
- * Lists recent conversations with sentiment color dots, participants,
5
- * simulation time, and location, with auto-refresh on new entries.
6
- *
7
- * Architecture: rendered by App.jsx from the snapshot's
8
- * recent_conversations block.
9
- *
10
- * Design: the sentiment dot is derived from the conversation's structured
11
- * sentiment field, not guessed from text.
12
- */
13
-
14
  import { useEffect, useRef, useState } from "react";
15
  import Draggable from "react-draggable";
16
 
@@ -28,6 +15,7 @@ export default function ConversationFeed({ conversations, minimized = false, onT
28
  y: 16,
29
  }));
30
 
 
31
  useEffect(() => {
32
  const clampToViewport = () => {
33
  const node = nodeRef.current;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useEffect, useRef, useState } from "react";
2
  import Draggable from "react-draggable";
3
 
 
15
  y: 16,
16
  }));
17
 
18
+ // Keep the panel usable after resizing or minimizing, matching agent cards.
19
  useEffect(() => {
20
  const clampToViewport = () => {
21
  const node = nodeRef.current;
frontend/src/components/DebugPanel.jsx CHANGED
@@ -1,15 +1,3 @@
1
- /**
2
- * DebugPanel — engine health readout for researchers.
3
- *
4
- * Shows tick, agent/moving/paused counts, background task counts, and
5
- * per-agent anomalies reported by the backend health monitor.
6
- *
7
- * Architecture: toggled from InfoBar; fed by the snapshot's health block.
8
- *
9
- * Design: keeps runtime anomalies visible without cluttering the main
10
- * dashboard.
11
- */
12
-
13
  export default function DebugPanel({ health }) {
14
  if (!health) return null;
15
  return (
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  export default function DebugPanel({ health }) {
2
  if (!health) return null;
3
  return (
frontend/src/components/EventsPanel.jsx CHANGED
@@ -1,15 +1,3 @@
1
- /**
2
- * EventsPanel — "campus pulse" list of live and upcoming events.
3
- *
4
- * Renders active events as LIVE and upcoming events with time range,
5
- * category color, and attendance count vs capacity.
6
- *
7
- * Architecture: rendered by App.jsx from the snapshot's events block.
8
- *
9
- * Design: read-only; event data originates in the deterministic event
10
- * calendar on the backend.
11
- */
12
-
13
  const CATEGORY_COLOR = {
14
  "technical-cultural": "#5b9bd5",
15
  sports: "#51cf66",
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  const CATEGORY_COLOR = {
2
  "technical-cultural": "#5b9bd5",
3
  sports: "#51cf66",
frontend/src/components/InfoBar.jsx CHANGED
@@ -1,19 +1,6 @@
1
- /**
2
- * InfoBar — status bar and timeline controls.
3
- *
4
- * Shows tick/time/day/pace and agent counts, plus auth-gated controls:
5
- * rewind (ticks or hours), fast-forward, slow-down, stop/start, and the
6
- * roster button.
7
- *
8
- * Architecture: rendered by App.jsx; posts to the backend's sim-control
9
- * endpoints with the admin bearer token.
10
- *
11
- * Design: controls are hidden for unauthenticated viewers.
12
- */
13
-
14
  import { useState } from "react";
15
 
16
- export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated, terminalOpen, onToggleTerminal }) {
17
  const [rewindAmount, setRewindAmount] = useState("10");
18
  const [rewindUnit, setRewindUnit] = useState("ticks");
19
  if (!snapshot) return null;
@@ -140,20 +127,11 @@ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForw
140
  {showDebug ? "DEBUG ON" : "DEBUG"}
141
  </button>
142
  {isAuthenticated && (
143
- <>
144
- <button onClick={onToggleRoster} style={{
145
- pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
146
- background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
147
- fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
148
- }} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
149
- <button onClick={onToggleTerminal} style={{
150
- pointerEvents: "auto", border: "1px solid rgba(91,155,213,.3)", borderRadius: 3,
151
- background: terminalOpen ? "rgba(91,155,213,.22)" : "transparent", color: "#8fbbe8", padding: "2px 5px",
152
- fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
153
- }} title="Open the live backend log terminal">
154
- {terminalOpen ? "TERMINAL ON" : "TERMINAL"}
155
- </button>
156
- </>
157
  )}
158
  </div>
159
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useState } from "react";
2
 
3
+ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) {
4
  const [rewindAmount, setRewindAmount] = useState("10");
5
  const [rewindUnit, setRewindUnit] = useState("ticks");
6
  if (!snapshot) return null;
 
127
  {showDebug ? "DEBUG ON" : "DEBUG"}
128
  </button>
129
  {isAuthenticated && (
130
+ <button onClick={onToggleRoster} style={{
131
+ pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
132
+ background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
133
+ fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
134
+ }} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
 
 
 
 
 
 
 
 
 
135
  )}
136
  </div>
137
  );
frontend/src/components/Legend.jsx CHANGED
@@ -1,16 +1,4 @@
1
- /**
2
- * Legend — color-to-name roster legend (unused, kept for reference).
3
- *
4
- * Maps each agent's dot color to their name and lets the user focus the
5
- * camera by clicking a name.
6
- *
7
- * Architecture: not currently rendered — SimCanvas draws labels directly;
8
- * retained as the planned clickable roster.
9
- *
10
- * Design: superseded by on-canvas labels; left in the tree until the
11
- * focus UX is finalized.
12
- */
13
-
14
  export default function Legend({ agents, focusedId, onFocus }) {
15
  if (!agents) return null;
16
  const entries = Object.entries(agents);
 
1
+ // Color legend: maps each agent's dot color to their name. Click to focus.
 
 
 
 
 
 
 
 
 
 
 
 
2
  export default function Legend({ agents, focusedId, onFocus }) {
3
  if (!agents) return null;
4
  const entries = Object.entries(agents);