livepilot 1.23.2 → 1.23.4

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 (46) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +108 -10
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +39 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/runtime/remote_commands.py +10 -0
  18. package/mcp_server/server.py +45 -24
  19. package/mcp_server/tools/agent_os.py +33 -9
  20. package/mcp_server/tools/analyzer.py +84 -23
  21. package/mcp_server/tools/browser.py +20 -1
  22. package/mcp_server/tools/devices.py +78 -11
  23. package/mcp_server/tools/perception.py +5 -1
  24. package/mcp_server/tools/tracks.py +39 -2
  25. package/mcp_server/user_corpus/__init__.py +48 -0
  26. package/mcp_server/user_corpus/manifest.py +142 -0
  27. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  28. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  29. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  30. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  31. package/mcp_server/user_corpus/runner.py +261 -0
  32. package/mcp_server/user_corpus/scanner.py +115 -0
  33. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  34. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  35. package/mcp_server/user_corpus/scanners/als.py +144 -0
  36. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  37. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  38. package/mcp_server/user_corpus/tools.py +904 -0
  39. package/mcp_server/user_corpus/wizard.py +224 -0
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/browser.py +7 -2
  43. package/remote_script/LivePilot/devices.py +9 -0
  44. package/remote_script/LivePilot/simpler_sample.py +98 -0
  45. package/requirements.txt +3 -3
  46. package/server.json +2 -2
@@ -0,0 +1,786 @@
1
+ """Pack-Atlas Phase E — Extract Chain.
2
+
3
+ Surgically rebuilds a specific demo track's device chain in the user's current
4
+ project. Returns a dry-run execution plan (or live execution result when
5
+ target_track_index >= 0). All source data from local JSON sidecars.
6
+
7
+ Real sidecar schema note (from Phase C appendix):
8
+ Track devices: {class, user_name, params (null in demos), macros [{index, value}]}
9
+ Macros in demo sidecars have NO names — only {index, value}.
10
+ The device class identifies the device type; user_name is the preset's name.
11
+
12
+ Parameter fidelity modes:
13
+ "exact" — emit set_device_parameter for every non-default macro
14
+ "approximate" — top 5 most-committed macros (highest deviation from 0)
15
+ "structure-only"— skip parameter setting; chain structure only
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Any
22
+
23
+ from .transplant import (
24
+ DEMO_PARSES_ROOT,
25
+ _load_demo_sidecar,
26
+ _resolve_demo_slug,
27
+ )
28
+ from .preset_resolver import resolve_preset_for_device
29
+
30
+ # ─── Known native Live device class names ────────────────────────────────────
31
+ # These are built-in Live devices loadable by class name via insert_device.
32
+ # GroupDevice/Rack classes handled separately.
33
+
34
+ _NATIVE_INSTRUMENT_CLASSES = {
35
+ # NOTE: "PluginDevice" intentionally excluded — third-party VST/AU plugins
36
+ # cannot be inserted by class name. See BUG-E2#PluginDevice fix.
37
+ "OriginalSimpler", "MultiSampler",
38
+ "AnalogSynth", "InstrumentVector", "DrumSynthBass", "DrumSynthBell",
39
+ "DrumSynthCymbal", "DrumSynthHihat", "DrumSynthSnare",
40
+ "Tension", "Electric", "Collision", "Mallet",
41
+ # Common aliases
42
+ "Simpler", "Sampler", "Operator", "Analog", "Drift", "Wavetable",
43
+ }
44
+
45
+ # Third-party plugin classes that cannot be inserted by class name.
46
+ # These require a manual_rebuild step with plugin name/vendor from the user.
47
+ _PLUGIN_DEVICE_CLASSES = {"PluginDevice"}
48
+
49
+ _NATIVE_AUDIO_EFFECT_CLASSES = {
50
+ "Reverb", "Delay", "Echo", "Chorus", "Phaser", "Flanger",
51
+ "Compressor2", "MultibandDynamics", "Limiter", "GlueCompressor",
52
+ "EQ8", "EQ3", "AutoFilter", "AutoPan",
53
+ "Saturator", "DynamicTube", "Redux", "Erosion",
54
+ "Vinyl Distortion", "VinylDistortion", "Cabinet",
55
+ "Corpus", "Resonator", "FrequencyShifter", "PitchHack",
56
+ "BeatRepeat", "Grain", "Looper",
57
+ "Amp", "Overdrive", "PedalDistortion", "ExternalInstrument",
58
+ "FilterDelay", "SpectralBlur", "SpectralResonator", "SpectralTime",
59
+ }
60
+
61
+ _NATIVE_MIDI_EFFECT_CLASSES = {
62
+ "MidiRandom", "MidiArpeggiator", "MidiChord", "MidiPitcher",
63
+ "MidiScale", "MidiVelocity", "MidiSysexFlag",
64
+ "MidiNoteLength", "MidiSustain",
65
+ }
66
+
67
+ _GROUP_DEVICE_CLASSES = {
68
+ "InstrumentGroupDevice",
69
+ "AudioEffectGroupDevice",
70
+ "DrumGroupDevice",
71
+ "MidiEffectGroupDevice",
72
+ }
73
+
74
+ _MAX_DEVICE_CLASSES = {
75
+ "MaxAudioEffect",
76
+ "MaxInstrument",
77
+ "MaxMidiEffect",
78
+ }
79
+
80
+ _ALL_KNOWN_NATIVE = (
81
+ _NATIVE_INSTRUMENT_CLASSES
82
+ | _NATIVE_AUDIO_EFFECT_CLASSES
83
+ | _NATIVE_MIDI_EFFECT_CLASSES
84
+ )
85
+
86
+
87
+ def _is_group_device(class_name: str) -> bool:
88
+ return class_name in _GROUP_DEVICE_CLASSES
89
+
90
+
91
+ def _is_max_device(class_name: str) -> bool:
92
+ return class_name in _MAX_DEVICE_CLASSES
93
+
94
+
95
+ def _is_native_device(class_name: str) -> bool:
96
+ return class_name in _ALL_KNOWN_NATIVE
97
+
98
+
99
+ # ─── Track finder ────────────────────────────────────────────────────────────
100
+
101
+ def _find_track_by_name(
102
+ demo_dict: dict, track_name: str
103
+ ) -> tuple[dict | None, list[str]]:
104
+ """Fuzzy-match a track by name.
105
+
106
+ Resolution order:
107
+ 1. Exact match
108
+ 2. Case-insensitive substring match (track_name in t_name)
109
+ 3. Tokenized any-token match (any word of track_name in t_name)
110
+ 4. Reverse substring (t_name in track_name, i.e. partial query)
111
+
112
+ Returns (matched_track_dict_or_None, other_candidate_names).
113
+ When fuzzy match has >=2 candidates, other_candidate_names is populated
114
+ so callers can surface an ambiguity_warning.
115
+ """
116
+ tracks = demo_dict.get("tracks") or []
117
+ name_lower = track_name.lower().strip()
118
+
119
+ # Pass 1: exact — unambiguous
120
+ for t in tracks:
121
+ if t.get("name", "") == track_name:
122
+ return t, []
123
+
124
+ # Pass 2: case-insensitive substring — collect all candidates
125
+ candidates_2 = [t for t in tracks if name_lower in t.get("name", "").lower()]
126
+ if candidates_2:
127
+ others = [t.get("name", "") for t in candidates_2[1:]]
128
+ return candidates_2[0], others
129
+
130
+ # Pass 3: any token of query present in track name
131
+ tokens = [tok for tok in re.split(r"\s+", name_lower) if tok]
132
+ candidates_3 = [
133
+ t for t in tracks
134
+ if any(tok in t.get("name", "").lower() for tok in tokens)
135
+ ]
136
+ if candidates_3:
137
+ others = [t.get("name", "") for t in candidates_3[1:]]
138
+ return candidates_3[0], others
139
+
140
+ # Pass 4: reverse — track name is substring of query
141
+ candidates_4 = [
142
+ t for t in tracks if t.get("name", "").lower() in name_lower
143
+ ]
144
+ if candidates_4:
145
+ others = [t.get("name", "") for t in candidates_4[1:]]
146
+ return candidates_4[0], others
147
+
148
+ return None, []
149
+
150
+
151
+ # ─── Device chain walker ─────────────────────────────────────────────────────
152
+
153
+ _MAX_CHAIN_DEPTH = 4 # matches transplant._walk_device_chain cap and als_deep_parse
154
+
155
+
156
+ def _collect_inner_chain_classes(dev: dict, depth: int = 0) -> list[str]:
157
+ """Recursively collect class names from a rack device's inner chains.
158
+
159
+ Used to build chain_summary for group-device steps without blowing up
160
+ the execution plan with nested steps.
161
+
162
+ Caps at _MAX_CHAIN_DEPTH to match the parser's depth limit.
163
+ """
164
+ if depth > _MAX_CHAIN_DEPTH:
165
+ return []
166
+ result: list[str] = []
167
+ for chain in dev.get("chains") or []:
168
+ for inner_dev in chain.get("devices") or []:
169
+ cls = inner_dev.get("class", "") or ""
170
+ uname = inner_dev.get("user_name") or ""
171
+ label = uname if (uname and uname != cls) else cls
172
+ if label:
173
+ result.append(label)
174
+ # Recurse into nested racks (e.g. rack-within-rack)
175
+ result.extend(_collect_inner_chain_classes(inner_dev, depth + 1))
176
+ return result
177
+
178
+
179
+ def _walk_device_chain(track: dict) -> list[dict]:
180
+ """Return devices in topological order from a track dict.
181
+
182
+ For each device, returns:
183
+ {class, user_name, macros, depth, inner_chain_classes}
184
+
185
+ BUG-INT#2 fix: v1.23.5+ sidecars include a `chains` field on rack
186
+ devices (Schema A — nested). We now populate `inner_chain_classes` by
187
+ recursing into dev.chains up to _MAX_CHAIN_DEPTH levels deep so that
188
+ callers (device_chain_summary, _emit_execution_steps) can surface inner
189
+ devices without emitting thousands of nested execution steps.
190
+ """
191
+ devices = track.get("devices") or []
192
+ result = []
193
+ for dev in devices:
194
+ inner_classes = _collect_inner_chain_classes(dev)
195
+ result.append({
196
+ "class": dev.get("class", ""),
197
+ "user_name": dev.get("user_name") or "",
198
+ "macros": dev.get("macros") or [],
199
+ "params": dev.get("params"),
200
+ "depth": 0,
201
+ "inner_chain_classes": inner_classes,
202
+ })
203
+ return result
204
+
205
+
206
+ # ─── Macro extraction helpers ─────────────────────────────────────────────────
207
+
208
+ def _safe_float(v: Any) -> float:
209
+ try:
210
+ return float(str(v))
211
+ except (ValueError, TypeError):
212
+ return 0.0
213
+
214
+
215
+ def _get_nonzero_macros(macros: list[dict]) -> list[dict]:
216
+ """Return macro entries with non-zero values."""
217
+ return [m for m in macros if _safe_float(m.get("value", "0")) != 0.0]
218
+
219
+
220
+ def _load_preset_defaults(pack_name: str, preset_user_name: str) -> dict[int, float] | None:
221
+ """Load factory-default macro values from a preset sidecar JSON.
222
+
223
+ Sidecars live at:
224
+ ~/.livepilot/atlas-overlays/packs/_preset_parses/<pack>/<preset_slug>.json
225
+
226
+ Matching: tries exact name match on sidecar["name"] field, then falls back
227
+ to filename-slug comparison (lowercased, spaces→hyphens).
228
+
229
+ Returns {macro_index: default_value} or None if no sidecar found.
230
+ """
231
+ import json
232
+ import os
233
+
234
+ preset_root = os.path.expanduser(
235
+ "~/.livepilot/atlas-overlays/packs/_preset_parses"
236
+ )
237
+ pack_dir = os.path.join(preset_root, pack_name)
238
+ if not os.path.isdir(pack_dir):
239
+ return None
240
+
241
+ name_lower = preset_user_name.lower().strip()
242
+ slug = re.sub(r"[^a-z0-9]+", "-", name_lower).strip("-")
243
+
244
+ for fname in os.listdir(pack_dir):
245
+ if not fname.endswith(".json"):
246
+ continue
247
+ fpath = os.path.join(pack_dir, fname)
248
+ try:
249
+ with open(fpath, encoding="utf-8") as fh:
250
+ data = json.load(fh)
251
+ except Exception:
252
+ continue
253
+ # Name match
254
+ if data.get("name", "").lower().strip() == name_lower:
255
+ macros = data.get("macros") or []
256
+ return {int(m["index"]): _safe_float(m.get("value", "0")) for m in macros}
257
+ # Slug match
258
+ file_slug = re.sub(r"[^a-z0-9]+", "-", fname[:-5].lower()).strip("-")
259
+ if file_slug == slug or file_slug.endswith("-" + slug) or slug in file_slug:
260
+ macros = data.get("macros") or []
261
+ return {int(m["index"]): _safe_float(m.get("value", "0")) for m in macros}
262
+
263
+ return None
264
+
265
+
266
+ def _top_k_macros_by_deviation(
267
+ macros: list[dict],
268
+ k: int = 5,
269
+ preset_defaults: dict[int, float] | None = None,
270
+ ) -> list[dict]:
271
+ """Return the k macros with the largest deviation from factory default.
272
+
273
+ In "approximate" fidelity mode — the macros most committed away from their
274
+ factory default are the most production-meaningful.
275
+
276
+ If preset_defaults is provided (from a matching preset sidecar), deviation
277
+ is computed as abs(live_value - factory_default). If not provided (no
278
+ sidecar match), falls back to abs(live_value) (deviation from zero).
279
+ """
280
+ nonzero = _get_nonzero_macros(macros)
281
+ if preset_defaults is not None:
282
+ def _deviation(m: dict) -> float:
283
+ idx = int(m.get("index", 0))
284
+ live = _safe_float(m.get("value", "0"))
285
+ default = preset_defaults.get(idx, 0.0)
286
+ return abs(live - default)
287
+ else:
288
+ # Fallback: deviation from zero
289
+ def _deviation(m: dict) -> float: # type: ignore[misc]
290
+ return abs(_safe_float(m.get("value", "0")))
291
+
292
+ sorted_macros = sorted(nonzero, key=_deviation, reverse=True)
293
+ return sorted_macros[:k]
294
+
295
+
296
+ # ─── Execution step emitter ───────────────────────────────────────────────────
297
+
298
+ def _emit_execution_steps(
299
+ device: dict,
300
+ fidelity: str,
301
+ track_name: str,
302
+ device_index: int,
303
+ pack_name: str = "",
304
+ ) -> tuple[list[dict], list[str]]:
305
+ """Emit executable plan steps for one device.
306
+
307
+ Returns (steps_list, warnings_list).
308
+
309
+ Logic:
310
+ - PluginDevice (third-party VST/AU)
311
+ → manual_rebuild: cannot be inserted by class name; agent must locate
312
+ the plugin by vendor/name. See BUG-E2#PluginDevice.
313
+ - GroupDevice rack (InstrumentGroupDevice, AudioEffectGroupDevice, etc.)
314
+ → try atlas resolution; emit load_browser_item if name is meaningful,
315
+ else manual_rebuild with macro values
316
+ - MaxAudioEffect / MaxInstrument / MaxMidiEffect
317
+ → load_browser_item for the .amxd (user_name is the device label)
318
+ - Known native device
319
+ → insert_device + set_device_parameter (based on fidelity)
320
+ - Unknown class
321
+ → insert_device best-effort + warning
322
+
323
+ Parameter fidelity "approximate" uses preset sidecar factory defaults when
324
+ available to sort by deviation-from-default rather than abs(value).
325
+ # TODO(URI-helper): emit load_device_by_uri when a browser URI is resolved.
326
+ """
327
+ steps: list[dict] = []
328
+ warnings: list[str] = []
329
+
330
+ cls = device["class"]
331
+ uname = device["user_name"] or ""
332
+ macros = device["macros"]
333
+
334
+ label = uname or cls or "unknown-device"
335
+
336
+ # ── PluginDevice — third-party VST/AU, not insertable by class name ───────
337
+ if cls in _PLUGIN_DEVICE_CLASSES:
338
+ plugin_meta = device.get("plugin") or {}
339
+ plugin_name = plugin_meta.get("name") or uname or "unknown plugin"
340
+ manufacturer = plugin_meta.get("manufacturer") or ""
341
+ plugin_format = plugin_meta.get("format") or "unknown"
342
+ # Build a browser-search-hint the agent can pass to search_browser
343
+ # to locate the plugin in Live's plugins folder. The plugin's display
344
+ # name (from PluginDesc) is more reliable than the rack's user_name
345
+ # since the user might have renamed the rack but not the plugin.
346
+ load_step = {
347
+ "action": "manual_rebuild",
348
+ "device_class": cls,
349
+ "device_name": uname or plugin_name,
350
+ "plugin": plugin_meta if plugin_meta else None,
351
+ "browser_search_hint": {
352
+ "name_filter": plugin_name,
353
+ "suggested_path": "plugins",
354
+ },
355
+ "note": (
356
+ f"'{uname or plugin_name}' is a {plugin_format} plugin"
357
+ + (f" by {manufacturer}" if manufacturer else "")
358
+ + ". Cannot be inserted via insert_device(class='PluginDevice'). "
359
+ "Use search_browser(**browser_search_hint) to locate it under "
360
+ "the plugins/ category, then load_browser_item with the resolved URI. "
361
+ "Parameter values are stored in an opaque per-plugin binary buffer "
362
+ "and aren't recoverable from the .als — agent must re-dial by ear."
363
+ ),
364
+ "device_index": device_index,
365
+ }
366
+ # Drop the plugin field if empty — keeps step shape clean for old corpora
367
+ if not plugin_meta:
368
+ load_step.pop("plugin")
369
+ steps.append(load_step)
370
+ warnings.append(
371
+ f"Device {device_index} '{uname or plugin_name}' is a "
372
+ f"{plugin_format} plugin"
373
+ + (f" ({manufacturer})" if manufacturer else "")
374
+ + ". manual_rebuild step emitted; param values opaque."
375
+ )
376
+ return steps, warnings
377
+
378
+ # ── Group device (rack) ───────────────────────────────────────────────────
379
+ if _is_group_device(cls):
380
+ # Look up preset sidecar for deviation-from-default macro sorting
381
+ preset_defaults: dict[int, float] | None = None
382
+ if fidelity == "approximate" and uname and pack_name:
383
+ preset_defaults = _load_preset_defaults(pack_name, uname)
384
+
385
+ # BUG-INT#2: surface inner chain devices via chain_summary so the agent
386
+ # has visibility into nested devices without emitting nested steps.
387
+ inner_classes: list[str] = device.get("inner_chain_classes") or []
388
+ inner_chain_summary: str | None = (
389
+ " → ".join(inner_classes) if inner_classes else None
390
+ )
391
+
392
+ if uname:
393
+ # Resolve matching preset sidecar → producer-assigned macro names + URI hint.
394
+ # BUG-E2#1 (Macro N labeling) + BUG-E2#4 (load_browser_item URI hint).
395
+ preset_match = (
396
+ resolve_preset_for_device(pack_name, cls, uname)
397
+ if pack_name else {"found": False, "macro_names": {}, "browser_search_hint": None}
398
+ )
399
+ macro_names: dict[int, str] = preset_match.get("macro_names") or {}
400
+
401
+ load_step = {
402
+ "action": "load_browser_item",
403
+ "name": uname,
404
+ "device_class": cls,
405
+ "comment": (
406
+ f"Load '{uname}' rack from browser. "
407
+ "Resolve URI via search_browser(**browser_search_hint) "
408
+ "before calling load_browser_item. "
409
+ "If not found, use manual_rebuild steps below."
410
+ ),
411
+ "device_index": device_index,
412
+ }
413
+ if inner_chain_summary:
414
+ load_step["chain_summary"] = inner_chain_summary
415
+ if preset_match.get("found") and preset_match.get("browser_search_hint"):
416
+ load_step["browser_search_hint"] = preset_match["browser_search_hint"]
417
+ load_step["preset_match"] = preset_match.get("match_type")
418
+ else:
419
+ load_step["browser_search_hint"] = {
420
+ "name_filter": uname,
421
+ "suggested_path": "sounds",
422
+ }
423
+ load_step["preset_match"] = "none"
424
+ steps.append(load_step)
425
+
426
+ # Emit macro set steps based on fidelity
427
+ if fidelity != "structure-only":
428
+ macro_subset = (
429
+ _get_nonzero_macros(macros)
430
+ if fidelity == "exact"
431
+ else _top_k_macros_by_deviation(
432
+ macros, k=5, preset_defaults=preset_defaults
433
+ )
434
+ )
435
+ for m in macro_subset:
436
+ macro_idx = m["index"]
437
+ # Prefer producer-assigned macro name from preset sidecar; fall back
438
+ # to "Macro N" when no name was recorded for that index.
439
+ resolved_name = macro_names.get(macro_idx) or f"Macro {macro_idx + 1}"
440
+ val = round(_safe_float(m.get("value", "0")), 2)
441
+ steps.append({
442
+ "action": "set_device_parameter",
443
+ "device_index": device_index,
444
+ "parameter_name": resolved_name,
445
+ "parameter_index": macro_idx + 1, # fallback addressing
446
+ "value": val,
447
+ "comment": (
448
+ f"Set {resolved_name} (idx {macro_idx + 1}) = {val} "
449
+ f"[SOURCE: als-parse{'+adg-parse' if resolved_name != f'Macro {macro_idx + 1}' else ''}]"
450
+ ),
451
+ })
452
+ else:
453
+ # No user_name — structural rack with no named preset
454
+ # Emit manual_rebuild with macro values as structured data
455
+ nonzero = _get_nonzero_macros(macros)
456
+ macro_data = [
457
+ {"index": m["index"], "value": round(_safe_float(m.get("value", "0")), 2)}
458
+ for m in nonzero
459
+ ]
460
+ unnamed_step: dict = {
461
+ "action": "manual_rebuild",
462
+ "device_class": cls,
463
+ "note": (
464
+ f"Unnamed {cls} rack — no browser-loadable preset available. "
465
+ "Rebuild manually using the macro values below."
466
+ ),
467
+ "macro_values": macro_data,
468
+ "device_index": device_index,
469
+ }
470
+ if inner_chain_summary:
471
+ unnamed_step["chain_summary"] = inner_chain_summary
472
+ steps.append(unnamed_step)
473
+ if nonzero:
474
+ warnings.append(
475
+ f"Device {device_index} is an unnamed {cls} rack — "
476
+ "no browser URI available. "
477
+ f"Macro values ({len(nonzero)} non-default) documented in manual_rebuild step."
478
+ )
479
+ return steps, warnings
480
+
481
+ # ── Max device (.amxd) ────────────────────────────────────────────────────
482
+ if _is_max_device(cls):
483
+ name_for_load = uname or cls
484
+ steps.append({
485
+ "action": "load_browser_item",
486
+ "name": name_for_load,
487
+ "device_class": cls,
488
+ "comment": (
489
+ f"Load M4L device '{name_for_load}' from browser. "
490
+ "Search in Max for Live category."
491
+ ),
492
+ "device_index": device_index,
493
+ })
494
+ if fidelity != "structure-only" and macros:
495
+ macro_subset = (
496
+ _get_nonzero_macros(macros)
497
+ if fidelity == "exact"
498
+ else _top_k_macros_by_deviation(macros, k=5)
499
+ )
500
+ for m in macro_subset:
501
+ steps.append({
502
+ "action": "set_device_parameter",
503
+ "device_index": device_index,
504
+ "parameter_name": f"Macro {m['index'] + 1}",
505
+ "value": round(_safe_float(m.get("value", "0")), 2),
506
+ "comment": f"M4L macro {m['index'] + 1} [SOURCE: als-parse]",
507
+ })
508
+ return steps, warnings
509
+
510
+ # ── Native Live device ────────────────────────────────────────────────────
511
+ if _is_native_device(cls) or cls:
512
+ device_name = uname or cls
513
+ steps.append({
514
+ "action": "insert_device",
515
+ "device_class": cls,
516
+ "device_name": device_name,
517
+ "comment": f"Insert {device_name}",
518
+ "device_index": device_index,
519
+ })
520
+ if fidelity != "structure-only" and macros:
521
+ macro_subset = (
522
+ _get_nonzero_macros(macros)
523
+ if fidelity == "exact"
524
+ else _top_k_macros_by_deviation(macros, k=5)
525
+ )
526
+ for m in macro_subset:
527
+ steps.append({
528
+ "action": "set_device_parameter",
529
+ "device_index": device_index,
530
+ "parameter_name": f"Macro {m['index'] + 1}",
531
+ "value": round(_safe_float(m.get("value", "0")), 2),
532
+ "comment": f"[SOURCE: als-parse]",
533
+ })
534
+
535
+ if not _is_native_device(cls):
536
+ warnings.append(
537
+ f"Device class '{cls}' is not a recognised native Live device. "
538
+ "insert_device step is best-effort — verify in Live."
539
+ )
540
+ return steps, warnings
541
+
542
+ # Unknown class
543
+ warnings.append(f"Unknown device class '{cls}' — skipped.")
544
+ return steps, warnings
545
+
546
+
547
+ # ─── Execution plan builder ───────────────────────────────────────────────────
548
+
549
+ def _build_execution_plan(
550
+ track: dict,
551
+ target_track_index: int,
552
+ fidelity: str,
553
+ demo_entity_id: str,
554
+ track_name: str,
555
+ pack_name: str = "",
556
+ ) -> tuple[list[dict], list[str]]:
557
+ """Build the full execution plan for extracting one track's device chain.
558
+
559
+ Returns (steps_list, warnings_list).
560
+ The plan is a DRY-RUN — executed: false always from this function.
561
+
562
+ Track-type → create action mapping (BUG-E2#3+#7 fix):
563
+ MidiTrack → create_midi_track
564
+ GroupTrack → manual_step (no create_group_track MCP tool exists)
565
+ ReturnTrack → create_return_track
566
+ AudioTrack → create_audio_track (default)
567
+ """
568
+ steps: list[dict] = []
569
+ warnings: list[str] = []
570
+
571
+ t_name = track.get("name", track_name)
572
+ t_type = track.get("type", "")
573
+
574
+ # Step 0 (conditional): create a new track if target_track_index < 0
575
+ if target_track_index < 0:
576
+ if t_type == "MidiTrack":
577
+ steps.append({
578
+ "action": "create_midi_track",
579
+ "name": f"{t_name} (extracted from {demo_entity_id})",
580
+ "comment": "Create new MIDI track for extracted chain",
581
+ })
582
+ elif t_type == "GroupTrack":
583
+ # LivePilot has no create_group_track MCP tool — manual step required
584
+ steps.append({
585
+ "action": "manual_step",
586
+ "note": (
587
+ f"Source track '{t_name}' is a GroupTrack. "
588
+ "LivePilot does not have a create_group_track tool. "
589
+ "Manually group the target tracks in Ableton (Cmd+G / Ctrl+G), "
590
+ "then re-run extract_chain with target_track_index pointing at "
591
+ "the new group track."
592
+ ),
593
+ "name": f"{t_name} (extracted from {demo_entity_id})",
594
+ "comment": "GroupTrack requires manual creation — no MCP tool available",
595
+ })
596
+ warnings.append(
597
+ f"Track '{t_name}' is a GroupTrack. No create_group_track MCP tool "
598
+ "exists in LivePilot. A manual_step was emitted — create the group "
599
+ "in Ableton manually, then pass its index via target_track_index."
600
+ )
601
+ elif t_type == "ReturnTrack":
602
+ steps.append({
603
+ "action": "create_return_track",
604
+ "name": f"{t_name} (extracted from {demo_entity_id})",
605
+ "comment": "Create new return track for extracted chain",
606
+ })
607
+ else:
608
+ # AudioTrack and any unknown type default to audio
609
+ steps.append({
610
+ "action": "create_audio_track",
611
+ "name": f"{t_name} (extracted from {demo_entity_id})",
612
+ "comment": "Create new audio track for extracted chain",
613
+ })
614
+ else:
615
+ steps.append({
616
+ "action": "target_existing_track",
617
+ "track_index": target_track_index,
618
+ "comment": f"Write chain to existing track {target_track_index}",
619
+ })
620
+
621
+ # Walk device chain and emit steps
622
+ device_chain = _walk_device_chain(track)
623
+ for i, dev in enumerate(device_chain):
624
+ dev_steps, dev_warnings = _emit_execution_steps(
625
+ dev, fidelity, t_name, i, pack_name=pack_name
626
+ )
627
+ steps.extend(dev_steps)
628
+ warnings.extend(dev_warnings)
629
+
630
+ if not device_chain:
631
+ warnings.append(f"Track '{t_name}' has no devices — only a track creation step emitted.")
632
+
633
+ return steps, warnings
634
+
635
+
636
+ # ─── Main entry point ─────────────────────────────────────────────────────────
637
+
638
+ def extract_chain(
639
+ demo_entity_id: str,
640
+ track_name: str,
641
+ target_track_index: int = -1,
642
+ parameter_fidelity: str = "exact",
643
+ ) -> dict:
644
+ """Build a dry-run device-chain extraction plan for a demo track.
645
+
646
+ Called by the MCP tool wrapper in tools.py.
647
+
648
+ Parameters
649
+ ----------
650
+ demo_entity_id : str
651
+ Entity ID, e.g. "drone_lab__emergent_planes".
652
+ track_name : str
653
+ Name of the track to extract (fuzzy matched).
654
+ target_track_index : int
655
+ Target track index in the live project. -1 = create new track (dry-run).
656
+ Phase E ships dry-run only; execution against live project is Phase F.
657
+ parameter_fidelity : str
658
+ "exact" | "approximate" | "structure-only"
659
+
660
+ Returns
661
+ -------
662
+ dict matching the spec Extract Chain return shape.
663
+ """
664
+ # ── 0. Guard: track_name must not be empty ────────────────────────────────
665
+ # BUG-EDGE#8: "" matches every track via the fuzzy pass-2 (`"" in any_string`
666
+ # is always True), silently returning the first track. Reject before loading.
667
+ if not track_name or not track_name.strip():
668
+ # Load sidecar to return available_tracks in the error (best-effort).
669
+ _sidecar_for_avail = _load_demo_sidecar(demo_entity_id)
670
+ _available: list[str] = []
671
+ if _sidecar_for_avail is not None:
672
+ _available = [t.get("name", "") for t in (_sidecar_for_avail.get("tracks") or [])]
673
+ return {
674
+ "error": "track_name is required and cannot be empty.",
675
+ "status": "error",
676
+ "entity_id": demo_entity_id,
677
+ "available_tracks": _available,
678
+ "executed": False,
679
+ "sources": [],
680
+ }
681
+
682
+ # ── 1. Load sidecar ───────────────────────────────────────────────────────
683
+ sidecar = _load_demo_sidecar(demo_entity_id)
684
+ if sidecar is None:
685
+ return {
686
+ "error": (
687
+ f"Demo sidecar not found for entity_id='{demo_entity_id}'. "
688
+ "Check ~/.livepilot/atlas-overlays/packs/_demo_parses/."
689
+ ),
690
+ "entity_id": demo_entity_id,
691
+ "executed": False,
692
+ "sources": [],
693
+ }
694
+
695
+ sidecar_path = _resolve_demo_slug(demo_entity_id)
696
+
697
+ # ── 2. Find track ─────────────────────────────────────────────────────────
698
+ track, fuzzy_other_candidates = _find_track_by_name(sidecar, track_name)
699
+ if track is None:
700
+ available = [t.get("name", "") for t in (sidecar.get("tracks") or [])]
701
+ return {
702
+ "error": (
703
+ f"Track '{track_name}' not found in demo '{demo_entity_id}'. "
704
+ "Fuzzy match (substring, token) also failed."
705
+ ),
706
+ "available_tracks": available,
707
+ "entity_id": demo_entity_id,
708
+ "executed": False,
709
+ "sources": [f"als-parse: {sidecar_path} [SOURCE: als-parse]"],
710
+ }
711
+
712
+ resolved_track_name = track.get("name", track_name)
713
+ # BUG-extract_chain-fuzzy: surface ambiguity when multiple candidates matched
714
+ ambiguity_warning: str | None = None
715
+ if fuzzy_other_candidates:
716
+ ambiguity_warning = (
717
+ f"Fuzzy match picked '{resolved_track_name}' but {len(fuzzy_other_candidates)} "
718
+ f"other candidate(s) also matched: {fuzzy_other_candidates}. "
719
+ "Use an exact track name to disambiguate."
720
+ )
721
+
722
+ # ── 3. Walk device chain ──────────────────────────────────────────────────
723
+ device_chain = _walk_device_chain(track)
724
+
725
+ # Build device_chain summary for return shape
726
+ device_chain_summary = []
727
+ for dev in device_chain:
728
+ cls = dev["class"]
729
+ uname = dev["user_name"]
730
+ macros = dev["macros"]
731
+ nonzero = [m for m in macros if _safe_float(m.get("value", "0")) != 0.0]
732
+ entry: dict[str, Any] = {
733
+ "class": cls,
734
+ "user_name": uname,
735
+ "chain_depth": dev["depth"],
736
+ }
737
+ if nonzero:
738
+ entry["macros"] = [
739
+ {"index": m["index"], "value": round(_safe_float(m.get("value", "0")), 2)}
740
+ for m in nonzero
741
+ ]
742
+ # BUG-INT#2: include inner_chain_classes in device_chain_summary so the
743
+ # agent can see nested devices (e.g. Erosion inside InstrumentGroupDevice)
744
+ inner = dev.get("inner_chain_classes") or []
745
+ if inner:
746
+ entry["inner_chain_classes"] = inner
747
+ device_chain_summary.append(entry)
748
+
749
+ # ── 4. Build execution plan ───────────────────────────────────────────────
750
+ # Derive pack_name from demo_entity_id (first segment before "__")
751
+ # e.g. "drone_lab__emergent_planes" → "drone-lab" (match preset_parses dir)
752
+ pack_slug = demo_entity_id.split("__")[0].replace("_", "-")
753
+
754
+ steps, warnings = _build_execution_plan(
755
+ track=track,
756
+ target_track_index=target_track_index,
757
+ fidelity=parameter_fidelity,
758
+ demo_entity_id=demo_entity_id,
759
+ track_name=resolved_track_name,
760
+ pack_name=pack_slug,
761
+ )
762
+
763
+ result: dict = {
764
+ "source": {
765
+ "demo": demo_entity_id,
766
+ "track": resolved_track_name,
767
+ "track_type": track.get("type", ""),
768
+ "device_count": len(device_chain),
769
+ "device_chain": device_chain_summary,
770
+ },
771
+ "execution_plan": steps,
772
+ "executed": False, # Phase E is dry-run only
773
+ "parameter_fidelity": parameter_fidelity,
774
+ "warnings": warnings,
775
+ "sources": [
776
+ f"als-parse: {sidecar_path} [SOURCE: als-parse]",
777
+ "agent-inference: execution step generation [SOURCE: agent-inference]",
778
+ ],
779
+ }
780
+
781
+ # BUG-extract_chain-fuzzy: include ambiguity fields when fuzzy match was ambiguous
782
+ if ambiguity_warning:
783
+ result["matched_track"] = resolved_track_name
784
+ result["ambiguity_warning"] = ambiguity_warning
785
+
786
+ return result