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.
- package/CHANGELOG.md +86 -0
- package/README.md +10 -7
- package/bin/livepilot.js +156 -37
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +16 -9
- package/mcp_server/connection.py +69 -10
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +217 -62
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +57 -1
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +27 -25
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/tracks.py +11 -5
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
223
|
-
|
|
348
|
+
bucket = self._by_id.get(name_or_id)
|
|
349
|
+
if bucket:
|
|
350
|
+
return bucket[0]
|
|
224
351
|
# Try name (case-insensitive)
|
|
225
|
-
|
|
226
|
-
if
|
|
227
|
-
return
|
|
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
|
-
#
|
|
247
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|