livepilot 1.9.24 → 1.10.1

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +223 -0
  4. package/CONTRIBUTING.md +2 -2
  5. package/LICENSE +62 -21
  6. package/README.md +291 -276
  7. package/bin/livepilot.js +87 -0
  8. package/installer/codex.js +147 -0
  9. package/livepilot/.Codex-plugin/plugin.json +2 -2
  10. package/livepilot/.claude-plugin/plugin.json +2 -2
  11. package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
  12. package/livepilot/skills/livepilot-core/SKILL.md +22 -5
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  19. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  20. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  21. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  22. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  23. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  24. package/livepilot/skills/livepilot-devices/SKILL.md +39 -4
  25. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  26. package/livepilot/skills/livepilot-release/SKILL.md +23 -19
  27. package/livepilot/skills/livepilot-sample-engine/SKILL.md +105 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  29. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  30. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  31. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  32. package/livepilot/skills/livepilot-wonder/SKILL.md +17 -0
  33. package/livepilot.mcpb +0 -0
  34. package/m4l_device/livepilot_bridge.js +1 -1
  35. package/manifest.json +4 -4
  36. package/mcp_server/__init__.py +1 -1
  37. package/mcp_server/atlas/__init__.py +357 -0
  38. package/mcp_server/atlas/device_atlas.json +44067 -0
  39. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  73. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  74. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  75. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  76. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  77. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  78. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  79. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  80. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  81. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  82. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  83. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  84. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  85. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  86. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  87. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  88. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  89. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  90. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  101. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  102. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  103. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  104. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  105. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  106. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  107. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  108. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  109. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  110. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  111. package/mcp_server/atlas/scanner.py +236 -0
  112. package/mcp_server/atlas/tools.py +224 -0
  113. package/mcp_server/composer/__init__.py +1 -0
  114. package/mcp_server/composer/engine.py +532 -0
  115. package/mcp_server/composer/layer_planner.py +427 -0
  116. package/mcp_server/composer/prompt_parser.py +329 -0
  117. package/mcp_server/composer/sample_resolver.py +153 -0
  118. package/mcp_server/composer/tools.py +211 -0
  119. package/mcp_server/connection.py +53 -8
  120. package/mcp_server/corpus/__init__.py +377 -0
  121. package/mcp_server/device_forge/__init__.py +1 -0
  122. package/mcp_server/device_forge/builder.py +377 -0
  123. package/mcp_server/device_forge/models.py +142 -0
  124. package/mcp_server/device_forge/templates.py +483 -0
  125. package/mcp_server/device_forge/tools.py +162 -0
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_accessors.py +47 -0
  128. package/mcp_server/preview_studio/engine.py +9 -2
  129. package/mcp_server/preview_studio/tools.py +78 -35
  130. package/mcp_server/project_brain/tools.py +34 -0
  131. package/mcp_server/runtime/capability_probe.py +21 -2
  132. package/mcp_server/runtime/execution_router.py +184 -38
  133. package/mcp_server/runtime/live_version.py +102 -0
  134. package/mcp_server/runtime/mcp_dispatch.py +46 -0
  135. package/mcp_server/runtime/remote_commands.py +13 -5
  136. package/mcp_server/runtime/tools.py +66 -29
  137. package/mcp_server/sample_engine/__init__.py +1 -0
  138. package/mcp_server/sample_engine/analyzer.py +216 -0
  139. package/mcp_server/sample_engine/critics.py +390 -0
  140. package/mcp_server/sample_engine/models.py +193 -0
  141. package/mcp_server/sample_engine/moves.py +127 -0
  142. package/mcp_server/sample_engine/planner.py +186 -0
  143. package/mcp_server/sample_engine/slice_workflow.py +190 -0
  144. package/mcp_server/sample_engine/sources.py +540 -0
  145. package/mcp_server/sample_engine/techniques.py +908 -0
  146. package/mcp_server/sample_engine/tools.py +545 -0
  147. package/mcp_server/semantic_moves/__init__.py +3 -0
  148. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  149. package/mcp_server/semantic_moves/mix_moves.py +8 -8
  150. package/mcp_server/semantic_moves/models.py +7 -7
  151. package/mcp_server/semantic_moves/performance_moves.py +4 -4
  152. package/mcp_server/semantic_moves/sample_compilers.py +377 -0
  153. package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
  154. package/mcp_server/semantic_moves/tools.py +63 -10
  155. package/mcp_server/semantic_moves/transition_moves.py +4 -4
  156. package/mcp_server/server.py +71 -1
  157. package/mcp_server/session_continuity/tracker.py +4 -1
  158. package/mcp_server/sound_design/critics.py +89 -1
  159. package/mcp_server/splice_client/__init__.py +1 -0
  160. package/mcp_server/splice_client/client.py +347 -0
  161. package/mcp_server/splice_client/models.py +96 -0
  162. package/mcp_server/splice_client/protos/__init__.py +1 -0
  163. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  164. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  165. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  166. package/mcp_server/tools/_conductor.py +16 -0
  167. package/mcp_server/tools/_planner_engine.py +24 -0
  168. package/mcp_server/tools/analyzer.py +2 -0
  169. package/mcp_server/tools/arrangement.py +69 -0
  170. package/mcp_server/tools/automation.py +15 -2
  171. package/mcp_server/tools/devices.py +117 -6
  172. package/mcp_server/tools/notes.py +37 -4
  173. package/mcp_server/tools/planner.py +3 -0
  174. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  175. package/mcp_server/wonder_mode/engine.py +144 -14
  176. package/mcp_server/wonder_mode/tools.py +33 -1
  177. package/package.json +14 -4
  178. package/remote_script/LivePilot/__init__.py +8 -1
  179. package/remote_script/LivePilot/arrangement.py +114 -0
  180. package/remote_script/LivePilot/browser.py +56 -1
  181. package/remote_script/LivePilot/devices.py +246 -6
  182. package/remote_script/LivePilot/mixing.py +8 -3
  183. package/remote_script/LivePilot/server.py +5 -1
  184. package/remote_script/LivePilot/transport.py +3 -0
  185. package/remote_script/LivePilot/version_detect.py +78 -0
@@ -121,7 +121,11 @@ def _build_triptych(
121
121
  compiled_plan = None
122
122
  if moves and i < len(moves):
123
123
  move_id = moves[i].get("move_id", "")
124
- compiled_plan = moves[i].get("compile_plan")
124
+ # Compile through the semantic compiler — single source of truth
125
+ from ..wonder_mode.engine import _compile_variant_plan
126
+ kernel = {"session_info": {"tempo": 120, "tracks": []}, "mode": "improve"}
127
+ compiled_plan = _compile_variant_plan(moves[i], kernel)
128
+ # No fallback to plan_template — uncompilable moves stay analytical
125
129
 
126
130
  variants.append(PreviewVariant(
127
131
  variant_id=f"{set_id}_{profile['label']}",
@@ -264,7 +268,10 @@ def _compute_set_id(request_text: str, kernel_id: str) -> str:
264
268
 
265
269
  def _estimate_taste_fit(novelty: float, taste_graph: dict) -> float:
266
270
  """Estimate how well a novelty level fits user taste."""
267
- boldness = taste_graph.get("transition_boldness", 0.5)
271
+ # Routes through the canonical accessor so dimension_weights.transition_boldness
272
+ # is honored. Previously read the top-level key directly and always got 0.5.
273
+ from ..memory.taste_accessors import get_dimension_pref
274
+ boldness = get_dimension_pref(taste_graph, "transition_boldness", default=0.5)
268
275
  # Users who like boldness prefer higher novelty
269
276
  fit = 1.0 - abs(novelty - boldness) * 0.5
270
277
  return round(max(0.0, min(1.0, fit)), 3)
@@ -23,7 +23,15 @@ def _get_ableton(ctx: Context):
23
23
 
24
24
  def _should_refuse_analytical(compiled_plan, wonder_linked: bool) -> bool:
25
25
  """Check if an analytical variant should be refused in Wonder context."""
26
- return compiled_plan is None and wonder_linked
26
+ if not wonder_linked:
27
+ return False
28
+ if compiled_plan is None:
29
+ return True
30
+ if isinstance(compiled_plan, dict):
31
+ return len(compiled_plan.get("steps", [])) == 0
32
+ if isinstance(compiled_plan, list):
33
+ return len(compiled_plan) == 0
34
+ return True
27
35
 
28
36
 
29
37
  def _find_wonder_session_by_preview(set_id: str):
@@ -308,7 +316,7 @@ def commit_preview_variant(
308
316
 
309
317
 
310
318
  @mcp.tool()
311
- def render_preview_variant(
319
+ async def render_preview_variant(
312
320
  ctx: Context,
313
321
  set_id: str = "",
314
322
  variant_id: str = "",
@@ -352,7 +360,7 @@ def render_preview_variant(
352
360
  "analytical_only": True,
353
361
  }
354
362
 
355
- # If the variant has a compiled plan, we could apply-capture-undo.
363
+ # If the variant has a compiled plan, apply -> capture audible -> undo.
356
364
  # Without a compiled plan, return the variant's analytical preview.
357
365
  if variant.compiled_plan:
358
366
  ableton = _get_ableton(ctx)
@@ -360,52 +368,87 @@ def render_preview_variant(
360
368
  plan = variant.compiled_plan
361
369
  steps = plan if isinstance(plan, list) else plan.get("steps", [])
362
370
 
363
- from ..runtime.execution_router import execute_plan_steps
371
+ from ..runtime.execution_router import execute_plan_steps_async
364
372
 
365
373
  applied_count = 0
366
- try:
367
- # Capture before state
368
- before_info = ableton.send_command("get_session_info", {})
374
+ playback_started = False
375
+ preview_mode = "metadata_only_preview"
376
+ spectral_before: Optional[dict] = None
377
+ spectral_after: Optional[dict] = None
378
+ before_info: dict = {}
379
+ after_info: dict = {}
380
+
381
+ bridge = ctx.lifespan_context.get("m4l")
382
+ mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
369
383
 
370
- # Execute through unified router
371
- exec_results = execute_plan_steps(steps, ableton=ableton, ctx=ctx)
384
+ try:
385
+ # ── 1. Capture BEFORE metadata ──
386
+ before_info = ableton.send_command("get_session_info", {}) or {}
387
+
388
+ # ── 2. Apply the variant ──
389
+ exec_results = await execute_plan_steps_async(
390
+ steps,
391
+ ableton=ableton,
392
+ bridge=bridge,
393
+ mcp_registry=mcp_registry,
394
+ ctx=ctx,
395
+ )
372
396
  applied_count = sum(1 for r in exec_results if r.ok)
397
+ if applied_count == 0 and steps:
398
+ return {
399
+ "error": "Variant failed to apply any steps",
400
+ "variant_id": variant_id,
401
+ "step_errors": [r.error for r in exec_results if not r.ok],
402
+ }
403
+
404
+ # ── 3. Capture AFTER metadata (variant is live) ──
405
+ after_info = ableton.send_command("get_session_info", {}) or {}
406
+
407
+ # ── 4. Audible capture WHILE variant is still applied ──
408
+ # This is the critical ordering fix: previously this block ran AFTER
409
+ # the finally's undo loop, so "audible_preview" captured pre-variant
410
+ # audio and lied about it. Now playback + spectrum sampling happens
411
+ # while the variant is actually in effect, then the finally undoes it.
412
+ try:
413
+ from ..m4l_bridge import SpectralCache
414
+ cache = ctx.lifespan_context.get("spectral")
415
+ if cache and isinstance(cache, SpectralCache) and cache.is_connected:
416
+ spectral_before = cache.get_all()
417
+
418
+ tempo = before_info.get("tempo", 120) or 120
419
+ play_seconds = min(bars * (60.0 / tempo) * 4, 8.0)
420
+
421
+ ableton.send_command("start_playback", {})
422
+ playback_started = True
423
+
424
+ import time as _time
425
+ _time.sleep(play_seconds)
426
+
427
+ spectral_after = cache.get_all()
428
+
429
+ ableton.send_command("stop_playback", {})
430
+ playback_started = False
431
+
432
+ preview_mode = "audible_preview"
433
+ except Exception:
434
+ # Spectral capture is best-effort; keep preview_mode as metadata_only
435
+ pass
373
436
 
374
- # Capture after state
375
- after_info = ableton.send_command("get_session_info", {})
376
437
  except Exception as e:
377
438
  return {"error": f"Render failed: {e}", "variant_id": variant_id}
378
439
  finally:
379
- # Undo all applied changes regardless of success/failure
440
+ # ── 5. Cleanup: stop playback if still running, then undo everything ──
441
+ if playback_started:
442
+ try:
443
+ ableton.send_command("stop_playback", {})
444
+ except Exception:
445
+ pass
380
446
  for _ in range(applied_count):
381
447
  try:
382
448
  ableton.send_command("undo")
383
449
  except Exception:
384
450
  break
385
451
 
386
- # Determine preview mode: audible (M4L available) or metadata-only
387
- preview_mode = "metadata_only_preview"
388
- spectral_before = None
389
- spectral_after = None
390
-
391
- # Try audible preview — capture spectrum via M4L spectral cache
392
- try:
393
- from ..m4l_bridge import SpectralCache
394
- cache = ctx.lifespan_context.get("spectral_cache")
395
- if cache and isinstance(cache, SpectralCache) and cache.has_data():
396
- spectral_before = cache.get_snapshot()
397
- # Play for the requested bar count
398
- tempo = before_info.get("tempo", 120)
399
- play_seconds = bars * (60.0 / tempo) * 4 # bars * beat_duration * 4 beats
400
- ableton.send_command("start_playback", {})
401
- import time as _time
402
- _time.sleep(min(play_seconds, 8.0)) # cap at 8 seconds
403
- spectral_after = cache.get_snapshot()
404
- ableton.send_command("stop_playback", {})
405
- preview_mode = "audible_preview"
406
- except Exception:
407
- pass # fall back to metadata_only
408
-
409
452
  variant.status = "rendered"
410
453
  variant.preview_mode = preview_mode
411
454
  variant.render_ref = f"render_{variant_id}_{bars}bars"
@@ -78,6 +78,39 @@ def build_project_brain(ctx: Context) -> dict:
78
78
  except Exception:
79
79
  pass
80
80
 
81
+ # 5b. Build notes_map for role inference.
82
+ # Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
83
+ # falls back to "assume all tracks active in every section" which destroys
84
+ # section-scoped role confidence.
85
+ notes_map: dict[str, dict[int, list[dict]]] = {}
86
+ try:
87
+ for scene_idx, scene in enumerate(scenes or []):
88
+ section_id = str(
89
+ scene.get("section_id")
90
+ or scene.get("name")
91
+ or f"scene_{scene_idx}"
92
+ )
93
+ per_track: dict[int, list[dict]] = {}
94
+ for track in tracks:
95
+ t_idx = track.get("index", 0)
96
+ try:
97
+ notes_resp = ableton.send_command("get_notes", {
98
+ "track_index": t_idx,
99
+ "clip_index": scene_idx,
100
+ })
101
+ if isinstance(notes_resp, dict):
102
+ notes = notes_resp.get("notes", [])
103
+ if notes:
104
+ per_track[t_idx] = notes
105
+ except Exception:
106
+ # Individual note fetch failing is fine — continue with others
107
+ continue
108
+ if per_track:
109
+ notes_map[section_id] = per_track
110
+ except Exception:
111
+ # Overall failure: empty map, degrade to "all tracks active" fallback
112
+ notes_map = {}
113
+
81
114
  # 6. Probe capabilities (direct SpectralCache access, not TCP)
82
115
  analyzer_ok = False
83
116
  analyzer_fresh = False
@@ -103,6 +136,7 @@ def build_project_brain(ctx: Context) -> dict:
103
136
  scenes=scenes if scenes and clip_matrix else None,
104
137
  clip_matrix=clip_matrix if clip_matrix else None,
105
138
  track_infos=track_infos if track_infos else None,
139
+ notes_map=notes_map if notes_map else None,
106
140
  arrangement_clips=arrangement_clips if arrangement_clips else None,
107
141
  analyzer_ok=analyzer_ok,
108
142
  flucoma_ok=flucoma_ok,
@@ -34,6 +34,23 @@ def probe_capabilities(
34
34
  "detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
35
35
  }
36
36
 
37
+ # 1b. Live version capabilities
38
+ live_version_str = "12.0.0"
39
+ if ableton_ok:
40
+ try:
41
+ info = ableton.send_command("get_session_info")
42
+ live_version_str = info.get("live_version", "12.0.0")
43
+ except Exception:
44
+ pass
45
+ from .live_version import LiveVersionCapabilities
46
+ version_caps = LiveVersionCapabilities.from_version_string(live_version_str)
47
+ report["live_version"] = {
48
+ "status": "ok",
49
+ "version": live_version_str,
50
+ "capability_tier": version_caps.capability_tier,
51
+ "features": version_caps.to_dict(),
52
+ }
53
+
37
54
  # 2. Remote Script parity
38
55
  from .remote_commands import REMOTE_COMMANDS
39
56
  report["remote_script"] = {
@@ -45,8 +62,10 @@ def probe_capabilities(
45
62
  # 3. M4L bridge
46
63
  bridge_ok = False
47
64
  if ctx is not None:
48
- bridge = getattr(ctx, "lifespan_context", {}).get("m4l_bridge") if hasattr(ctx, "lifespan_context") else None
49
- bridge_ok = bridge is not None
65
+ lifespan_context = getattr(ctx, "lifespan_context", {}) if hasattr(ctx, "lifespan_context") else {}
66
+ bridge = lifespan_context.get("m4l")
67
+ spectral = lifespan_context.get("spectral")
68
+ bridge_ok = bridge is not None and spectral is not None and getattr(spectral, "is_connected", False)
50
69
  report["m4l_bridge"] = {
51
70
  "status": "ok" if bridge_ok else "unavailable",
52
71
  "detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 30 analyzer tools unavailable",
@@ -1,34 +1,52 @@
1
- """Unified execution router for compiled plan steps.
1
+ """Unified async execution router for compiled plan steps.
2
2
 
3
3
  Classifies each step by backend (remote_command, mcp_tool, bridge_command)
4
- and dispatches to the correct execution path. Replaces the pattern of
5
- sending everything through ableton.send_command() blindly.
4
+ and dispatches through the correct transport. Async-only there is no
5
+ sync path. Callers that need to execute plans live inside an async tool
6
+ and await execute_plan_steps_async.
6
7
 
7
8
  Step backends:
8
- remote_command — valid Remote Script handler, goes through TCP
9
- bridge_command — M4L bridge handler, goes through TCP (requires bridge)
10
- mcp_toolMCP-layer Python function, called directly
11
- unknown not a valid target anywhere
9
+ remote_command — valid Remote Script handler, goes through the sync TCP
10
+ client (ableton.send_command)
11
+ bridge_commandM4L bridge handler, goes through the async UDP M4L bridge
12
+ client (bridge.send_command), NOT through ableton
13
+ mcp_tool — in-process Python function, dispatched via an mcp_registry
14
+ dict supplied by the server lifespan
15
+ unknown — not a valid target anywhere; returns a clear error
16
+
17
+ Step-result binding:
18
+ Any step may carry an optional step_id. Later steps may reference an
19
+ earlier result by setting a param to {"$from_step": "<id>", "path": "a.b"}.
20
+ Resolved recursively BEFORE dispatch.
12
21
  """
13
22
 
14
23
  from __future__ import annotations
15
24
 
25
+ import inspect
16
26
  from dataclasses import dataclass
17
- from typing import Any, Optional
27
+ from typing import Any, Awaitable, Callable, Optional
18
28
 
19
29
  from .remote_commands import BRIDGE_COMMANDS, REMOTE_COMMANDS
20
30
 
21
31
 
22
32
  # MCP-only tools that exist as Python functions but NOT as TCP handlers.
23
33
  # These must be called through direct import, not ableton.send_command().
34
+ # NOTE: capture_audio is a BRIDGE command (livepilot_bridge.js:146), not MCP.
35
+ # It used to be duplicated here; removed to keep classification unambiguous.
24
36
  MCP_TOOLS: frozenset[str] = frozenset({
25
37
  "apply_automation_shape",
26
38
  "apply_gesture_template",
27
39
  "analyze_mix",
28
40
  "get_master_spectrum",
29
41
  "get_emotional_arc",
30
- "capture_audio",
31
42
  "get_motif_graph",
43
+ # Sample-engine workflow tools — async Python that orchestrates multiple
44
+ # sub-commands (search_browser + load_browser_item + bridge.replace_simpler_sample).
45
+ "load_sample_to_simpler",
46
+ # Device Forge tools (MCP-only, no TCP handler)
47
+ "generate_m4l_effect",
48
+ "install_m4l_device",
49
+ "list_genexpr_templates",
32
50
  })
33
51
 
34
52
 
@@ -62,17 +80,75 @@ def classify_step(tool: str) -> str:
62
80
  return "unknown"
63
81
 
64
82
 
65
- def execute_step(
83
+ # ── Step-result binding ─────────────────────────────────────────────────
84
+
85
+ def _resolve_binding(binding: dict, step_results: dict) -> Any:
86
+ """Resolve a {"$from_step": step_id, "path": "a.b.c"} binding.
87
+
88
+ Raises ValueError with a clear message on missing step_id or missing key.
89
+ """
90
+ step_id = binding["$from_step"]
91
+ path = binding.get("path", "")
92
+
93
+ if step_id not in step_results:
94
+ available = sorted(step_results.keys())
95
+ raise ValueError(
96
+ f"Step binding failed: step_id '{step_id}' not found. "
97
+ f"Available: {available or '(no earlier results)'}"
98
+ )
99
+
100
+ current = step_results[step_id]
101
+ if not isinstance(current, dict):
102
+ raise ValueError(
103
+ f"Step binding failed: result of '{step_id}' is "
104
+ f"{type(current).__name__}, not a dict"
105
+ )
106
+
107
+ if not path:
108
+ return current
109
+
110
+ for segment in path.split("."):
111
+ if not isinstance(current, dict) or segment not in current:
112
+ keys = list(current.keys()) if isinstance(current, dict) else type(current).__name__
113
+ raise ValueError(
114
+ f"Step binding failed: path '{path}' not found in result of "
115
+ f"'{step_id}'. Available at this level: {keys}"
116
+ )
117
+ current = current[segment]
118
+
119
+ return current
120
+
121
+
122
+ def _resolve_params(params: Any, step_results: dict) -> Any:
123
+ """Recursively walk params and resolve any $from_step bindings."""
124
+ if isinstance(params, dict):
125
+ if "$from_step" in params:
126
+ return _resolve_binding(params, step_results)
127
+ return {k: _resolve_params(v, step_results) for k, v in params.items()}
128
+ if isinstance(params, list):
129
+ return [_resolve_params(v, step_results) for v in params]
130
+ return params
131
+
132
+
133
+ # ── Async execution path ────────────────────────────────────────────────
134
+
135
+ async def _execute_step_async(
66
136
  tool: str,
67
137
  params: dict,
68
- ableton: Any = None,
69
- ctx: Any = None,
70
- declared_backend: str | None = None,
138
+ ableton: Any,
139
+ bridge: Any,
140
+ mcp_registry: dict[str, Callable],
141
+ ctx: Any,
142
+ declared_backend: Optional[str] = None,
71
143
  ) -> ExecutionResult:
72
- """Execute a single plan step through the correct backend."""
73
- backend = declared_backend if declared_backend in ("remote_command", "bridge_command", "mcp_tool") else classify_step(tool)
144
+ """Dispatch a single step through the correct transport, async-aware."""
145
+ backend = (
146
+ declared_backend
147
+ if declared_backend in ("remote_command", "bridge_command", "mcp_tool")
148
+ else classify_step(tool)
149
+ )
74
150
 
75
- if backend in ("remote_command", "bridge_command"):
151
+ if backend == "remote_command":
76
152
  if ableton is None:
77
153
  return ExecutionResult(
78
154
  ok=False, backend=backend, tool=tool,
@@ -80,45 +156,95 @@ def execute_step(
80
156
  )
81
157
  try:
82
158
  result = ableton.send_command(tool, params)
159
+ if isinstance(result, dict) and "error" in result:
160
+ return ExecutionResult(ok=False, backend=backend, tool=tool, error=result["error"])
83
161
  return ExecutionResult(ok=True, backend=backend, tool=tool, result=result)
84
162
  except Exception as e:
85
163
  return ExecutionResult(ok=False, backend=backend, tool=tool, error=str(e))
86
164
 
87
- elif backend == "mcp_tool":
88
- # MCP tools require direct Python dispatch.
89
- # For now, return a clear error — full MCP dispatch is wired per-tool
90
- # in the callers (apply_semantic_move, render_preview_variant).
91
- return ExecutionResult(
92
- ok=False, backend=backend, tool=tool,
93
- error=f"MCP tool '{tool}' requires direct Python dispatch — "
94
- f"not executable through TCP. Use the MCP layer directly.",
95
- )
165
+ if backend == "bridge_command":
166
+ if bridge is None:
167
+ return ExecutionResult(
168
+ ok=False, backend=backend, tool=tool,
169
+ error="M4L bridge unavailable — cannot dispatch bridge command",
170
+ )
171
+ try:
172
+ # M4LBridge.send_command accepts (command, *args) and OSC-encodes
173
+ # each arg positionally. Plan authors construct params dicts in
174
+ # the order the bridge command expects; we unpack by insertion
175
+ # order (Python 3.7+ guarantees this). This keeps plans readable
176
+ # while matching the real bridge's positional wire format.
177
+ positional = list(params.values()) if params else []
178
+ call = bridge.send_command(tool, *positional)
179
+ result = await call if inspect.isawaitable(call) else call
180
+ if isinstance(result, dict) and "error" in result:
181
+ return ExecutionResult(ok=False, backend=backend, tool=tool, error=result["error"])
182
+ return ExecutionResult(ok=True, backend=backend, tool=tool, result=result)
183
+ except Exception as e:
184
+ return ExecutionResult(ok=False, backend=backend, tool=tool, error=str(e))
96
185
 
97
- else:
98
- return ExecutionResult(
99
- ok=False, backend="unknown", tool=tool,
100
- error=f"Unknown tool '{tool}' — not a Remote Script command, "
101
- f"bridge command, or registered MCP tool",
102
- )
186
+ if backend == "mcp_tool":
187
+ fn = mcp_registry.get(tool) if mcp_registry else None
188
+ if fn is None:
189
+ return ExecutionResult(
190
+ ok=False, backend=backend, tool=tool,
191
+ error=(
192
+ f"MCP tool '{tool}' not registered in async router dispatch map. "
193
+ f"Add it to mcp_server.runtime.mcp_dispatch.build_mcp_dispatch_registry()."
194
+ ),
195
+ )
196
+ try:
197
+ sig = inspect.signature(fn)
198
+ kwargs = {"ctx": ctx} if "ctx" in sig.parameters else {}
199
+ call = fn(params, **kwargs)
200
+ result = await call if inspect.isawaitable(call) else call
201
+ if isinstance(result, dict) and "error" in result:
202
+ return ExecutionResult(ok=False, backend=backend, tool=tool, error=result["error"])
203
+ return ExecutionResult(ok=True, backend=backend, tool=tool, result=result)
204
+ except Exception as e:
205
+ return ExecutionResult(ok=False, backend=backend, tool=tool, error=str(e))
206
+
207
+ return ExecutionResult(
208
+ ok=False, backend="unknown", tool=tool,
209
+ error=(
210
+ f"Unknown tool '{tool}' — not a Remote Script command, "
211
+ f"bridge command, or registered MCP tool"
212
+ ),
213
+ )
103
214
 
104
215
 
105
- def execute_plan_steps(
216
+ async def execute_plan_steps_async(
106
217
  steps: list[dict],
107
218
  ableton: Any = None,
219
+ bridge: Any = None,
220
+ mcp_registry: Optional[dict[str, Callable]] = None,
108
221
  ctx: Any = None,
109
222
  stop_on_failure: bool = True,
110
223
  ) -> list[ExecutionResult]:
111
- """Execute a list of plan steps, returning results for each.
224
+ """Async plan executor with step-result binding and correct bridge transport.
112
225
 
113
- Stops on first failure by default. Set stop_on_failure=False
114
- to continue past errors (useful for best-effort execution).
226
+ Supports three backends:
227
+ - remote_command via ableton.send_command (sync TCP client)
228
+ - bridge_command via bridge.send_command (async UDP M4L bridge client)
229
+ - mcp_tool via mcp_registry[tool](params, ctx=ctx)
230
+
231
+ Step-result binding:
232
+ Any step may carry an optional "step_id". Later steps may reference an
233
+ earlier result by setting a param to {"$from_step": "<id>", "path": "a.b"}.
234
+ The router walks params recursively and resolves bindings before dispatch.
235
+ Missing ids or missing paths fail that step with a clear error.
236
+
237
+ stop_on_failure: Stop the plan on the first failing step (default). Set to
238
+ False for best-effort execution (each result still recorded).
115
239
  """
116
240
  results: list[ExecutionResult] = []
241
+ step_results: dict[str, Any] = {}
242
+ mcp_registry = mcp_registry or {}
117
243
 
118
244
  for step in steps:
119
245
  tool = step.get("tool") or step.get("command", "")
120
- params = step.get("params") or step.get("args", {})
121
- # Honor declared backend from step annotations (PR5) if present
246
+ raw_params = step.get("params") or step.get("args", {}) or {}
247
+ step_id = step.get("step_id")
122
248
  declared_backend = step.get("backend")
123
249
 
124
250
  if not tool:
@@ -130,9 +256,29 @@ def execute_plan_steps(
130
256
  break
131
257
  continue
132
258
 
133
- result = execute_step(tool, params, ableton=ableton, ctx=ctx, declared_backend=declared_backend)
259
+ # Resolve any $from_step bindings in params BEFORE dispatch.
260
+ try:
261
+ params = _resolve_params(raw_params, step_results)
262
+ except ValueError as e:
263
+ results.append(ExecutionResult(
264
+ ok=False, backend="binding", tool=tool, error=str(e),
265
+ ))
266
+ if stop_on_failure:
267
+ break
268
+ continue
269
+
270
+ result = await _execute_step_async(
271
+ tool, params,
272
+ ableton=ableton, bridge=bridge,
273
+ mcp_registry=mcp_registry, ctx=ctx,
274
+ declared_backend=declared_backend,
275
+ )
134
276
  results.append(result)
135
277
 
278
+ # Record successful step result for future bindings
279
+ if result.ok and step_id and isinstance(result.result, dict):
280
+ step_results[step_id] = result.result
281
+
136
282
  if not result.ok and stop_on_failure:
137
283
  break
138
284
 
@@ -0,0 +1,102 @@
1
+ """MCP-side Live version capabilities model.
2
+
3
+ Pure data model — no I/O. Parses version info from get_session_info
4
+ responses and exposes feature flags for tool routing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class LiveVersionCapabilities:
14
+ """Feature availability based on detected Live version."""
15
+
16
+ major: int = 12
17
+ minor: int = 0
18
+ patch: int = 0
19
+
20
+ @classmethod
21
+ def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
22
+ """Parse '12.3.6' into a capabilities instance."""
23
+ parts = version_str.split(".")
24
+ major = int(parts[0]) if len(parts) > 0 else 12
25
+ minor = int(parts[1]) if len(parts) > 1 else 0
26
+ patch = int(parts[2]) if len(parts) > 2 else 0
27
+ return cls(major=major, minor=minor, patch=patch)
28
+
29
+ @classmethod
30
+ def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
31
+ """Extract version from get_session_info response.
32
+
33
+ Looks for 'live_version' field. Falls back to 12.0.0 if absent
34
+ (pre-upgrade Remote Script).
35
+ """
36
+ version_str = session_info.get("live_version", "12.0.0")
37
+ return cls.from_version_string(version_str)
38
+
39
+ @property
40
+ def _version_tuple(self) -> tuple[int, int, int]:
41
+ return (self.major, self.minor, self.patch)
42
+
43
+ # ── Feature flags ──────────────────────────────────────────────
44
+
45
+ @property
46
+ def has_native_arrangement_clips(self) -> bool:
47
+ """Track.create_midi_clip(start, length) — 12.1.10+"""
48
+ return self._version_tuple >= (12, 1, 10)
49
+
50
+ @property
51
+ def has_display_value(self) -> bool:
52
+ """DeviceParameter.display_value — 12.2+"""
53
+ return self._version_tuple >= (12, 2, 0)
54
+
55
+ @property
56
+ def has_insert_device(self) -> bool:
57
+ """Track.insert_device(name, index?) — 12.3+"""
58
+ return self._version_tuple >= (12, 3, 0)
59
+
60
+ @property
61
+ def has_drum_rack_construction(self) -> bool:
62
+ """insert_chain + DrumChain.in_note — 12.3+"""
63
+ return self._version_tuple >= (12, 3, 0)
64
+
65
+ @property
66
+ def has_take_lanes(self) -> bool:
67
+ """Take Lanes API — 12.2+"""
68
+ return self._version_tuple >= (12, 2, 0)
69
+
70
+ @property
71
+ def has_stem_separation(self) -> bool:
72
+ """Stem separation via MFL — 12.3+"""
73
+ return self._version_tuple >= (12, 3, 0)
74
+
75
+ @property
76
+ def has_replace_sample_native(self) -> bool:
77
+ """SimplerDevice.replace_sample(path) — 12.4+"""
78
+ return self._version_tuple >= (12, 4, 0)
79
+
80
+ @property
81
+ def capability_tier(self) -> str:
82
+ """Human-readable tier: core | enhanced_arrangement | full_intelligence."""
83
+ if self._version_tuple >= (12, 3, 0):
84
+ return "full_intelligence"
85
+ elif self._version_tuple >= (12, 1, 10):
86
+ return "enhanced_arrangement"
87
+ else:
88
+ return "core"
89
+
90
+ def to_dict(self) -> dict:
91
+ """Serialize for API responses and capability probes."""
92
+ return {
93
+ "version": f"{self.major}.{self.minor}.{self.patch}",
94
+ "capability_tier": self.capability_tier,
95
+ "native_arrangement_clips": self.has_native_arrangement_clips,
96
+ "display_value": self.has_display_value,
97
+ "insert_device": self.has_insert_device,
98
+ "drum_rack_construction": self.has_drum_rack_construction,
99
+ "take_lanes": self.has_take_lanes,
100
+ "stem_separation": self.has_stem_separation,
101
+ "replace_sample_native": self.has_replace_sample_native,
102
+ }