livepilot 1.27.1 → 1.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -13,6 +13,20 @@ from typing import Any, Dict, List, Optional
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
+ # BUG-B39: the real atlas scanner emits "instruments" / "audio_effects" but
17
+ # older callers and test fixtures sometimes pass the singular "instrument" /
18
+ # "effect". Hoisted to module scope (was previously re-declared inline in
19
+ # search()) so both search() and the truncation-warning helper below share
20
+ # one alias table instead of drifting independently.
21
+ _CATEGORY_ALIASES: Dict[str, set] = {
22
+ "instrument": {"instrument", "instruments"},
23
+ "instruments": {"instrument", "instruments"},
24
+ "effect": {"effect", "effects", "audio_effects"},
25
+ "effects": {"effect", "effects", "audio_effects"},
26
+ "audio_effect": {"effect", "effects", "audio_effects", "audio_effect"},
27
+ "audio_effects": {"effect", "effects", "audio_effects", "audio_effect"},
28
+ }
29
+
16
30
 
17
31
  class AtlasManager:
18
32
  """In-memory device atlas with indexed lookups."""
@@ -34,15 +48,50 @@ class AtlasManager:
34
48
  self._meta["version"] = data["version"]
35
49
  self._devices: List[Dict[str, Any]] = data.get("devices", [])
36
50
 
51
+ # P3-47 truncation observability: scan_full_library persists
52
+ # stats.category_truncated (keyed by atlas device-category, e.g.
53
+ # "drum_kits"/"sounds"/"samples") whenever the last scan hit its
54
+ # max_per_category cap — or ran out of the remote script's shared
55
+ # iteration safety bound — mid-category. Read it once here so
56
+ # search()/suggest() callers can be warned their results for a
57
+ # truncated category are a lower bound, not the full inventory.
58
+ raw_stats = data.get("stats", {})
59
+ raw_truncated = (
60
+ raw_stats.get("category_truncated", {})
61
+ if isinstance(raw_stats, dict) else {}
62
+ )
63
+ self._category_truncated: Dict[str, bool] = {
64
+ str(cat): bool(hit)
65
+ for cat, hit in raw_truncated.items()
66
+ } if isinstance(raw_truncated, dict) else {}
67
+
37
68
  # ── Build indexes ───────────────────────────────────────────
38
- self._by_id: Dict[str, Dict[str, Any]] = {}
39
- self._by_name: Dict[str, Dict[str, Any]] = {} # lowercase key
69
+ # P2-12: _by_id / _by_name keep a LIST of every device sharing a
70
+ # key, in scan order, so colliding ids/names no longer shadow each
71
+ # other (last-wins). 719 ids + 702 names collide in the shipped
72
+ # atlas while URIs are 100% unique, so every device stays reachable
73
+ # via lookup_all() / atlas_device_info's surfaced URIs. lookup()
74
+ # still returns a single Dict (the first, deterministic match) to
75
+ # preserve its Optional[Dict] contract for compare()/browser.py/
76
+ # _research_engine.py.
77
+ self._by_id: Dict[str, List[Dict[str, Any]]] = {}
78
+ self._by_name: Dict[str, List[Dict[str, Any]]] = {} # lowercase key
40
79
  self._by_uri: Dict[str, Dict[str, Any]] = {}
41
80
  self._by_category: Dict[str, List[Dict[str, Any]]] = {}
42
81
  self._by_tag: Dict[str, List[Dict[str, Any]]] = {}
43
82
  self._by_genre: Dict[str, List[Dict[str, Any]]] = {}
44
83
  self._by_pack: Dict[str, List[Dict[str, Any]]] = {}
45
84
 
85
+ # Perf batch (v1.27.3): a real 40k-device user atlas can carry
86
+ # ~16k duplicate ids/names/uris, and warning once per duplicate
87
+ # produced ~16k logger.warning calls (~122ms + multi-MB of stderr)
88
+ # at index-build time. Cap each category at _DUP_WARN_CAP explicit
89
+ # warnings and emit one summary line for the rest.
90
+ _DUP_WARN_CAP = 20
91
+ _dup_id_count = 0
92
+ _dup_name_count = 0
93
+ _dup_uri_count = 0
94
+
46
95
  for dev in self._devices:
47
96
  dev_id = dev.get("id", "")
48
97
  dev_name = dev.get("name", "")
@@ -50,26 +99,38 @@ class AtlasManager:
50
99
  dev_category = dev.get("category", "")
51
100
 
52
101
  if dev_id:
53
- if dev_id in self._by_id and self._by_id[dev_id] is not dev:
54
- logger.warning(
55
- "atlas: duplicate device id %r shadows a prior entry "
56
- "(name=%r); last-wins", dev_id, dev_name,
57
- )
58
- self._by_id[dev_id] = dev
102
+ bucket = self._by_id.setdefault(dev_id, [])
103
+ if bucket and dev not in bucket:
104
+ _dup_id_count += 1
105
+ if _dup_id_count <= _DUP_WARN_CAP:
106
+ logger.warning(
107
+ "atlas: duplicate device id %r (name=%r); both kept, "
108
+ "lookup() returns the first — disambiguate by uri %r",
109
+ dev_id, dev_name, dev_uri,
110
+ )
111
+ if dev not in bucket:
112
+ bucket.append(dev)
59
113
  if dev_name:
60
114
  name_key = dev_name.lower()
61
- if name_key in self._by_name and self._by_name[name_key] is not dev:
62
- logger.warning(
63
- "atlas: duplicate device name %r shadows a prior entry "
64
- "(id=%r); last-wins", dev_name, dev_id,
65
- )
66
- self._by_name[name_key] = dev
115
+ name_bucket = self._by_name.setdefault(name_key, [])
116
+ if name_bucket and dev not in name_bucket:
117
+ _dup_name_count += 1
118
+ if _dup_name_count <= _DUP_WARN_CAP:
119
+ logger.warning(
120
+ "atlas: duplicate device name %r (id=%r); both kept, "
121
+ "lookup() returns the first — disambiguate by uri %r",
122
+ dev_name, dev_id, dev_uri,
123
+ )
124
+ if dev not in name_bucket:
125
+ name_bucket.append(dev)
67
126
  if dev_uri:
68
127
  if dev_uri in self._by_uri and self._by_uri[dev_uri] is not dev:
69
- logger.warning(
70
- "atlas: duplicate device uri %r shadows a prior entry "
71
- "(id=%r); last-wins", dev_uri, dev_id,
72
- )
128
+ _dup_uri_count += 1
129
+ if _dup_uri_count <= _DUP_WARN_CAP:
130
+ logger.warning(
131
+ "atlas: duplicate device uri %r shadows a prior entry "
132
+ "(id=%r); last-wins", dev_uri, dev_id,
133
+ )
73
134
  self._by_uri[dev_uri] = dev
74
135
 
75
136
  # Category index
@@ -124,6 +185,22 @@ class AtlasManager:
124
185
  if pack_name:
125
186
  self._by_pack.setdefault(pack_name, []).append(dev)
126
187
 
188
+ if _dup_id_count > _DUP_WARN_CAP:
189
+ logger.warning(
190
+ "atlas: %d more duplicate device ids suppressed",
191
+ _dup_id_count - _DUP_WARN_CAP,
192
+ )
193
+ if _dup_name_count > _DUP_WARN_CAP:
194
+ logger.warning(
195
+ "atlas: %d more duplicate device names suppressed",
196
+ _dup_name_count - _DUP_WARN_CAP,
197
+ )
198
+ if _dup_uri_count > _DUP_WARN_CAP:
199
+ logger.warning(
200
+ "atlas: %d more duplicate device uris suppressed",
201
+ _dup_uri_count - _DUP_WARN_CAP,
202
+ )
203
+
127
204
  # ── Properties ──────────────────────────────────────────────────
128
205
 
129
206
  @property
@@ -155,6 +232,49 @@ class AtlasManager:
155
232
  },
156
233
  }
157
234
 
235
+ # ── Truncation observability (P3-47) ──────────────────────────
236
+ def truncated_categories(self) -> List[str]:
237
+ """Categories where the atlas's last scan_full_library run hit
238
+ its max_per_category cap (or the remote script's shared iteration
239
+ safety bound) mid-category. Devices in these categories are a
240
+ lower bound, not the full inventory, until a rescan completes."""
241
+ return sorted(
242
+ cat for cat, hit in self._category_truncated.items() if hit
243
+ )
244
+
245
+ def truncation_warning(self, category: str = "all") -> Optional[str]:
246
+ """Return a warning string if `category` overlaps a category
247
+ flagged truncated in the loaded atlas's scan stats, else None.
248
+
249
+ category="all" (the default `atlas_search`/`atlas_suggest` filter)
250
+ warns if ANY category was truncated, since an unfiltered query
251
+ silently includes those partial results alongside complete ones.
252
+ A specific category only warns when it (or an alias — see
253
+ _CATEGORY_ALIASES) is in the truncated set.
254
+ """
255
+ truncated = self.truncated_categories()
256
+ if not truncated:
257
+ return None
258
+
259
+ if category == "all":
260
+ hit = truncated
261
+ else:
262
+ allowed = _CATEGORY_ALIASES.get(category, {category})
263
+ hit = [cat for cat in truncated if cat in allowed]
264
+ if not hit:
265
+ return None
266
+
267
+ return (
268
+ "Atlas categor%s %s may be incomplete — the last scan hit its "
269
+ "size cap for %s. Run scan_full_library(force=True) (raise "
270
+ "max_per_category if your library is unusually large) for a "
271
+ "complete inventory before trusting an exhaustive search here."
272
+ ) % (
273
+ "y" if len(hit) == 1 else "ies",
274
+ ", ".join(sorted(hit)),
275
+ "this category" if len(hit) == 1 else "these categories",
276
+ )
277
+
158
278
  # ── Pack lookup ────────────────────────────────────────────────
159
279
  def pack_info(self, pack_name: str) -> Dict[str, Any]:
160
280
  """Return summary of a pack — device list + enrichment coverage.
@@ -217,19 +337,52 @@ class AtlasManager:
217
337
  # ── Lookup ──────────────────────────────────────────────────────
218
338
 
219
339
  def lookup(self, name_or_id: str) -> Optional[Dict[str, Any]]:
220
- """Exact match by ID, name (case-insensitive), or URI. Returns None on miss."""
340
+ """Exact match by ID, name (case-insensitive), or URI. Returns None on miss.
341
+
342
+ When an id/name collides across devices (719 ids / 702 names in the
343
+ shipped atlas), this returns the FIRST device in scan order
344
+ deterministically. Use lookup_all() to reach every colliding device,
345
+ or pass a unique URI (URIs never collide) to target a specific one.
346
+ """
221
347
  # Try ID first
222
- if name_or_id in self._by_id:
223
- return self._by_id[name_or_id]
348
+ bucket = self._by_id.get(name_or_id)
349
+ if bucket:
350
+ return bucket[0]
224
351
  # Try name (case-insensitive)
225
- lower = name_or_id.lower()
226
- if lower in self._by_name:
227
- return self._by_name[lower]
228
- # Try URI
352
+ name_bucket = self._by_name.get(name_or_id.lower())
353
+ if name_bucket:
354
+ return name_bucket[0]
355
+ # Try URI — the only key that is guaranteed unique
229
356
  if name_or_id in self._by_uri:
230
357
  return self._by_uri[name_or_id]
231
358
  return None
232
359
 
360
+ def lookup_all(self, name_or_id: str) -> List[Dict[str, Any]]:
361
+ """Return EVERY device matching an id, name, or URI — collision-aware.
362
+
363
+ P2-12: lookup() returns a single Dict (its contract), which shadows
364
+ the ~719 devices sharing a colliding id and ~702 sharing a name. This
365
+ companion returns all of them so id/name-keyed tools can disambiguate
366
+ (e.g. surface each candidate's unique URI). Matches are returned in
367
+ scan order with duplicates removed; an exact URI match alone returns
368
+ the single device for that URI.
369
+ """
370
+ out: List[Dict[str, Any]] = []
371
+ seen: set[int] = set()
372
+
373
+ def _extend(devs: List[Dict[str, Any]]) -> None:
374
+ for d in devs:
375
+ if id(d) not in seen:
376
+ seen.add(id(d))
377
+ out.append(d)
378
+
379
+ _extend(self._by_id.get(name_or_id, []))
380
+ _extend(self._by_name.get(name_or_id.lower(), []))
381
+ uri_hit = self._by_uri.get(name_or_id)
382
+ if uri_hit is not None:
383
+ _extend([uri_hit])
384
+ return out
385
+
233
386
  # ── Search ──────────────────────────────────────────────────────
234
387
 
235
388
  def search(
@@ -243,22 +396,10 @@ class AtlasManager:
243
396
  query_words = query_lower.split()
244
397
  results: List[Dict[str, Any]] = []
245
398
 
246
- # BUG-B39: the real atlas scanner emits "instruments" /
247
- # "audio_effects" but older callers and test fixtures sometimes
248
- # pass the singular "instrument" / "effect". Build a tolerant
249
- # category alias set so both forms work.
250
- _CAT_ALIASES = {
251
- "instrument": {"instrument", "instruments"},
252
- "instruments": {"instrument", "instruments"},
253
- "effect": {"effect", "effects", "audio_effects"},
254
- "effects": {"effect", "effects", "audio_effects"},
255
- "audio_effect": {"effect", "effects", "audio_effects",
256
- "audio_effect"},
257
- "audio_effects": {"effect", "effects", "audio_effects",
258
- "audio_effect"},
259
- }
399
+ # Tolerant category alias set so "instrument" and "instruments"
400
+ # (etc.) both work see module-level _CATEGORY_ALIASES.
260
401
  allowed_cats = (
261
- _CAT_ALIASES.get(category, {category})
402
+ _CATEGORY_ALIASES.get(category, {category})
262
403
  if category != "all" else None
263
404
  )
264
405
 
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import re
15
+ import threading
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from typing import Optional
@@ -20,6 +21,15 @@ import yaml
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
24
+ # Prefer the libyaml-backed CSafeLoader (~8.5x faster than the pure-Python
25
+ # SafeLoader) when the yaml package was built with libyaml support. Fall
26
+ # back to SafeLoader if CSafeLoader isn't available (e.g. libyaml missing
27
+ # from the environment) so overlay loading never hard-fails on this.
28
+ try:
29
+ _YAML_LOADER = yaml.CSafeLoader
30
+ except AttributeError:
31
+ _YAML_LOADER = yaml.SafeLoader
32
+
23
33
 
24
34
  # ─── Tokenizer (used by OverlayIndex.search) ─────────────────────────────────
25
35
  # Producer-style queries use natural language with hyphenated phrases
@@ -318,7 +328,14 @@ def load_overlays(root: Optional[Path] = None,
318
328
  if root is None:
319
329
  root = _resolve_overlay_root()
320
330
 
321
- idx = get_overlay_index()
331
+ # Mark loaded up front (not just on success) so a call to load_overlays()
332
+ # — with either the default or an explicit root — always short-circuits
333
+ # the lazy _ensure_loaded() path on subsequent get_overlay_index() calls,
334
+ # including the "root doesn't exist" early-return below.
335
+ global _overlay_loaded
336
+ _overlay_loaded = True
337
+
338
+ idx = _overlay_index
322
339
  idx.clear()
323
340
 
324
341
  if not root.exists():
@@ -337,7 +354,7 @@ def load_overlays(root: Optional[Path] = None,
337
354
  continue
338
355
  try:
339
356
  with yaml_path.open("r") as f:
340
- parsed = yaml.safe_load(f)
357
+ parsed = yaml.load(f, Loader=_YAML_LOADER)
341
358
  except yaml.YAMLError as e:
342
359
  log.warning(f"overlays: skipped {yaml_path}: {e}")
343
360
  continue
@@ -367,8 +384,34 @@ def load_overlays(root: Optional[Path] = None,
367
384
  # request time so they always see current state (never capture a reference).
368
385
  _overlay_index: "OverlayIndex" = OverlayIndex()
369
386
 
387
+ # Lazy-population state (v1.27.3 perf batch). The singleton used to be
388
+ # populated unconditionally at server import time (server.py boot hook),
389
+ # which cost ~1.1s of a ~2s import on a machine with a populated
390
+ # ~/.livepilot/atlas-overlays tree. Now the first call to
391
+ # get_overlay_index() populates it under a lock; subsequent calls are a
392
+ # cheap flag check. Explicit load_overlays() calls (tests, reload tools)
393
+ # always force a fresh scan and mark the singleton as loaded.
394
+ _overlay_loaded = False
395
+ _overlay_load_lock = threading.Lock()
396
+
397
+
398
+ def _ensure_loaded() -> None:
399
+ """Populate the singleton on first access only. Double-checked locking
400
+ avoids taking the lock on the (overwhelmingly common) already-loaded path."""
401
+ global _overlay_loaded
402
+ if _overlay_loaded:
403
+ return
404
+ with _overlay_load_lock:
405
+ if _overlay_loaded:
406
+ return
407
+ load_overlays()
408
+ _overlay_loaded = True
409
+
370
410
 
371
411
  def get_overlay_index() -> "OverlayIndex":
372
412
  """Accessor for the live overlay singleton. Always returns the same
373
- instance — load_overlays() mutates it in place rather than replacing."""
413
+ instance — load_overlays() mutates it in place rather than replacing.
414
+
415
+ Triggers lazy population on first call (see _ensure_loaded)."""
416
+ _ensure_loaded()
374
417
  return _overlay_index