This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. backend/Odin.py +5 -37
  2. backend/Pathfinder_test.py +4 -8
  3. backend/data/environment/relationship_matrix.json +288 -2
  4. backend/data/personalities/amitabh/amitabh.json +22 -0
  5. backend/data/personalities/jarvis/jarvis.json +22 -0
  6. backend/pathfinder.py +4 -9
  7. backend/src/agents/Actions.py +18 -11
  8. backend/src/agents/Long_term.py +5 -10
  9. backend/src/agents/Short_term.py +8 -8
  10. backend/src/agents/Single_agent.py +21 -9
  11. backend/src/agents/autonomy.py +5 -8
  12. backend/src/agents/body.py +20 -10
  13. backend/src/agents/brain.py +8 -8
  14. backend/src/agents/conversation.py +28 -18
  15. backend/src/agents/daily_flavor.py +5 -9
  16. backend/src/agents/day_planner.py +82 -251
  17. backend/src/agents/memory_index.py +6 -8
  18. backend/src/agents/react.py +13 -11
  19. backend/src/agents/vector_memory.py +5 -10
  20. backend/src/auth/__init__.py +0 -9
  21. backend/src/auth/manager.py +5 -8
  22. backend/src/auth/routes.py +2 -8
  23. backend/src/config.py +8 -33
  24. backend/src/core/agent_registry.py +7 -8
  25. backend/src/core/budget.py +17 -11
  26. backend/src/core/checkpoint_manager.py +9 -8
  27. backend/src/core/log.py +24 -19
  28. backend/src/core/log_relay.py +0 -82
  29. backend/src/core/perceive.py +8 -7
  30. backend/src/core/runtime_health.py +1 -11
  31. backend/src/core/snapshot.py +29 -7
  32. backend/src/core/tick_graph.py +15 -8
  33. backend/src/core/world_engine.py +92 -120
  34. backend/src/core/world_events.py +6 -9
  35. backend/src/core/world_state.py +34 -8
  36. backend/src/llm/gemini_client.py +26 -40
  37. backend/test_gemini_client.py +0 -60
  38. backend/tools/sidecar_monitor.py +6 -8
  39. frontend/src/App.jsx +1 -19
  40. frontend/src/components/ActionDetail.jsx +0 -11
  41. frontend/src/components/AgentWindow.jsx +1 -14
  42. frontend/src/components/ChatBubble.jsx +0 -11
  43. frontend/src/components/ChatPanel.jsx +0 -13
  44. frontend/src/components/ConversationFeed.jsx +1 -13
  45. frontend/src/components/DebugPanel.jsx +0 -12
  46. frontend/src/components/EventsPanel.jsx +0 -12
  47. frontend/src/components/InfoBar.jsx +6 -28
  48. frontend/src/components/Legend.jsx +1 -13
  49. frontend/src/components/LogTerminal.jsx +0 -189
  50. frontend/src/components/LoginButton.jsx +0 -12
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
@@ -235,30 +230,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 +688,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 +701,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)
 
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
 
230
  return user
231
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def _print_agent_plans(engine):
234
  """Print each agent's full action plan to the CLI."""
235
  from src.core.agent_registry import AgentRuntimeState
 
688
  "current_time": f"{current_date} {current_hhmm}", "places": None,
689
  "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
690
  "current_location_id": generated.hostel, "upcoming_events": [],
 
 
691
  }))
692
  day_plan = plan_result.get("day_plan", [])
693
  if not day_plan:
 
701
  engine.registry.register(AgentRuntimeState(
702
  agent_id=agent_id, persona=persona, persona_name=generated.name,
703
  manager=manager, position=position, day_plan=day_plan,
 
704
  emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
705
  ))
706
  engine.world.register_agent(agent_id, position)
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,12 +1,7 @@
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
 
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
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);
frontend/src/components/LogTerminal.jsx DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * LogTerminal — admin-only live view of the backend log relay.
3
- *
4
- * Polls GET /api/logs?since=<cursor> with the admin bearer token and
5
- * renders the captured logger output as a read-only terminal.
6
- *
7
- * Architecture: rendered by App.jsx only for authenticated users; toggled
8
- * by the TERMINAL button in InfoBar.
9
- *
10
- * Design: 2s REST polling reuses the existing bearer-token flow instead of
11
- * adding WebSocket authentication; auto-scroll halts while the pointer is
12
- * over the terminal so lines can be read mid-stream.
13
- */
14
-
15
- import { useEffect, useRef, useState, useCallback } from "react";
16
- import { useAuth } from "../hooks/useAuth";
17
- import { apiUrl } from "../utils/api";
18
-
19
- const POLL_MS = 2000;
20
- const LEVEL_COLORS = {
21
- DEBUG: "#6b6b78",
22
- INFO: "#8fbbe8",
23
- WARNING: "#e7bd70",
24
- ERROR: "#ff8b8b",
25
- CRITICAL: "#ff6b6b",
26
- };
27
-
28
- export default function LogTerminal({ onClose }) {
29
- const { token } = useAuth();
30
- const [lines, setLines] = useState([]);
31
- const [cursor, setCursor] = useState(0);
32
- const [error, setError] = useState(null);
33
- const [paused, setPaused] = useState(false);
34
- const bodyRef = useRef(null);
35
- const pausedRef = useRef(false);
36
-
37
- const fetchNew = useCallback(async () => {
38
- try {
39
- const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), {
40
- headers: token ? { Authorization: `Bearer ${token}` } : {},
41
- });
42
- if (!res.ok) throw new Error(`Log fetch failed (${res.status})`);
43
- const data = await res.json();
44
- if (data.lines?.length) {
45
- setLines((previous) => [...previous.slice(-2000), ...data.lines]);
46
- }
47
- if (typeof data.next === "number") setCursor(data.next);
48
- setError(null);
49
- } catch (err) {
50
- setError(err.message);
51
- }
52
- }, [cursor, token]);
53
-
54
- useEffect(() => {
55
- fetchNew();
56
- const timer = setInterval(fetchNew, POLL_MS);
57
- return () => clearInterval(timer);
58
- }, [fetchNew]);
59
-
60
- useEffect(() => {
61
- const el = bodyRef.current;
62
- if (el && !pausedRef.current) el.scrollTop = el.scrollHeight;
63
- }, [lines]);
64
-
65
- async function clearLogs() {
66
- try {
67
- const res = await fetch(apiUrl("/api/logs/clear"), {
68
- method: "POST",
69
- headers: token ? { Authorization: `Bearer ${token}` } : {},
70
- });
71
- if (!res.ok) throw new Error(`Clear failed (${res.status})`);
72
- setLines([]);
73
- } catch (err) {
74
- setError(err.message);
75
- }
76
- }
77
-
78
- async function copyLogs() {
79
- try {
80
- await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n"));
81
- } catch {
82
- /* clipboard unavailable — ignore */
83
- }
84
- }
85
-
86
- const headerButton = {
87
- background: "rgba(91,155,213,.08)",
88
- border: "1px solid rgba(91,155,213,.30)",
89
- borderRadius: 3,
90
- color: "#8fbbe8",
91
- padding: "2px 6px",
92
- fontFamily: "'Space Mono', monospace",
93
- fontSize: 8,
94
- cursor: "pointer",
95
- };
96
-
97
- return (
98
- <aside
99
- style={{
100
- position: "fixed",
101
- right: 16,
102
- top: 66,
103
- bottom: 96,
104
- width: 460,
105
- maxWidth: "calc(100vw - 32px)",
106
- display: "flex",
107
- flexDirection: "column",
108
- background: "rgba(8,10,12,0.95)",
109
- backdropFilter: "blur(8px)",
110
- border: "1px solid rgba(91,155,213,0.30)",
111
- borderRadius: 6,
112
- boxShadow: "0 14px 44px rgba(0,0,0,0.6)",
113
- fontFamily: "'Space Mono', monospace",
114
- zIndex: 1300,
115
- }}
116
- >
117
- <header
118
- style={{
119
- display: "flex",
120
- alignItems: "center",
121
- gap: 8,
122
- padding: "8px 12px",
123
- borderBottom: "1px solid rgba(255,255,255,0.07)",
124
- }}
125
- >
126
- <span style={{ color: "#8fbbe8", fontWeight: 700, fontSize: 10, letterSpacing: "0.14em" }}>
127
- LOG TERMINAL
128
- </span>
129
- <span style={{ fontSize: 9, color: "#6b6b78" }}>{lines.length} lines</span>
130
- <span style={{ flex: 1 }} />
131
- <button onClick={copyLogs} style={headerButton} title="Copy all relayed lines to the clipboard">
132
- COPY
133
- </button>
134
- <button onClick={clearLogs} style={headerButton} title="Clear the in-memory relay buffer">
135
- CLEAR
136
- </button>
137
- <button onClick={onClose} style={headerButton} title="Close the log terminal">
138
- X
139
- </button>
140
- </header>
141
-
142
- <div
143
- ref={bodyRef}
144
- onMouseEnter={() => {
145
- pausedRef.current = true;
146
- setPaused(true);
147
- }}
148
- onMouseLeave={() => {
149
- pausedRef.current = false;
150
- setPaused(false);
151
- }}
152
- style={{
153
- flex: 1,
154
- overflowY: "auto",
155
- padding: "10px 12px",
156
- fontSize: 9,
157
- lineHeight: 1.65,
158
- }}
159
- >
160
- {lines.length === 0 && (
161
- <div style={{ color: "#6b6b78" }}>no lines yet — the relay captures log output once the simulation starts</div>
162
- )}
163
- {lines.map((entry) => (
164
- <div
165
- key={entry.seq}
166
- style={{
167
- color: LEVEL_COLORS[entry.level] || "#d0d0da",
168
- whiteSpace: "pre-wrap",
169
- wordBreak: "break-word",
170
- }}
171
- >
172
- {entry.text}
173
- </div>
174
- ))}
175
- </div>
176
-
177
- <footer
178
- style={{
179
- padding: "6px 12px",
180
- borderTop: "1px solid rgba(255,255,255,0.07)",
181
- fontSize: 9,
182
- color: error ? "#ff8b8b" : paused ? "#e7bd70" : "#6b6b78",
183
- }}
184
- >
185
- {error ? `ERROR: ${error}` : paused ? "PAUSED — move the pointer away to resume auto-scroll" : "TAILING — 2s poll"}
186
- </footer>
187
- </aside>
188
- );
189
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/LoginButton.jsx CHANGED
@@ -1,15 +1,3 @@
1
- /**
2
- * LoginButton — auth entry point for admin controls.
3
- *
4
- * Shows a LOGIN pill when unauthenticated (opens LoginModal) and a user
5
- * dropdown with logout when a session is active.
6
- *
7
- * Architecture: rendered by App.jsx; uses the useAuth context shared with
8
- * InfoBar and RosterManager.
9
- *
10
- * Design: anonymous users can watch; only admins get controls.
11
- */
12
-
13
  import { useState } from "react";
14
  import { useAuth } from "../hooks/useAuth";
15
  import LoginModal from "./LoginModal";
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { useState } from "react";
2
  import { useAuth } from "../hooks/useAuth";
3
  import LoginModal from "./LoginModal";