livepilot 1.10.5 → 1.10.7

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 (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -96,11 +96,22 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
96
96
  # Estimate density from arrangement pattern count
97
97
  density_arc = _estimate_density_from_patterns(style_tactics)
98
98
 
99
+ # BUG-B50 fix: the old code hardcoded loudness_posture=0.0 and
100
+ # empty spectral_contour / width_depth because StyleTactic entries
101
+ # don't carry those fields. We now derive them heuristically from
102
+ # the device_chain (each device's params leak its intended sonic
103
+ # shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
104
+ # Utility Width > 0.6 = wider stereo). Rough but non-empty values
105
+ # are better than zeros for downstream reference-gap analysis.
106
+ loudness_posture = _derive_loudness_posture(style_tactics)
107
+ spectral_contour = _derive_spectral_contour(style_tactics)
108
+ width_depth = _derive_width_depth(style_tactics)
109
+
99
110
  return ReferenceProfile(
100
111
  source_type="style",
101
- loudness_posture=0.0, # style doesn't specify loudness
102
- spectral_contour={}, # style doesn't specify spectrum
103
- width_depth={},
112
+ loudness_posture=loudness_posture,
113
+ spectral_contour=spectral_contour,
114
+ width_depth=width_depth,
104
115
  density_arc=density_arc,
105
116
  section_pacing=section_pacing,
106
117
  harmonic_character=harmonic_character,
@@ -147,3 +158,118 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
147
158
  densities.append(min(0.9, 0.3 + n * 0.15))
148
159
 
149
160
  return densities
161
+
162
+
163
+ # ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
164
+
165
+
166
+ def _derive_loudness_posture(style_tactics: list[dict]) -> float:
167
+ """Heuristic: compression / saturation / limiter devices imply a
168
+ specific loudness posture. Glue / Ratio>3 → loud, saturator drive
169
+ high → cranked/loud, delay/reverb-heavy → quieter mix."""
170
+ loud_signals = 0
171
+ quiet_signals = 0
172
+ for tactic in style_tactics:
173
+ for dev in tactic.get("device_chain", []):
174
+ name = str(dev.get("name", "")).lower()
175
+ params = dev.get("params", {}) or {}
176
+ if "glue" in name or "limiter" in name:
177
+ loud_signals += 2
178
+ if name == "saturator" or "overdrive" in name:
179
+ drive = float(params.get("Drive", 0) or 0)
180
+ if drive >= 6:
181
+ loud_signals += 1
182
+ if name == "compressor":
183
+ ratio = float(params.get("Ratio", 0) or 0)
184
+ if ratio >= 4:
185
+ loud_signals += 1
186
+ if name == "reverb":
187
+ # Heavy reverb tails push the mix quieter
188
+ wet = float(params.get("Dry/Wet", 0) or 0)
189
+ if wet >= 0.6:
190
+ quiet_signals += 1
191
+ # Normalize to -1..+1 range (loud=+1, quiet=-1, neutral=0)
192
+ if loud_signals == 0 and quiet_signals == 0:
193
+ return 0.0
194
+ net = (loud_signals - quiet_signals) / max(
195
+ loud_signals + quiet_signals, 1
196
+ )
197
+ return round(net, 2)
198
+
199
+
200
+ def _derive_spectral_contour(style_tactics: list[dict]) -> dict:
201
+ """Heuristic: Auto Filter frequencies + device names leak the
202
+ target spectrum. Low-pass filters → dark, EQ Eight with no params
203
+ → neutral, saturators → mid-forward."""
204
+ brightness = 0.5 # neutral starting point
205
+ mid_emphasis = 0.5
206
+ dark_hits = 0
207
+ bright_hits = 0
208
+ for tactic in style_tactics:
209
+ for dev in tactic.get("device_chain", []):
210
+ name = str(dev.get("name", "")).lower()
211
+ params = dev.get("params", {}) or {}
212
+ if "auto filter" in name or name == "autofilter":
213
+ freq = float(params.get("Frequency", 1500) or 1500)
214
+ # Ableton Auto Filter Frequency is 20-22000; below 2k hints dark
215
+ if freq < 2000:
216
+ dark_hits += 1
217
+ elif freq > 5000:
218
+ bright_hits += 1
219
+ if "saturator" in name or "overdrive" in name:
220
+ mid_emphasis += 0.1
221
+ if dark_hits > bright_hits:
222
+ brightness = 0.3
223
+ elif bright_hits > dark_hits:
224
+ brightness = 0.75
225
+ return {
226
+ "band_balance": {
227
+ "sub": 0.45,
228
+ "low": 0.5,
229
+ "mid": min(1.0, mid_emphasis),
230
+ "high_mid": round(0.3 + brightness * 0.4, 2),
231
+ "high": round(0.2 + brightness * 0.5, 2),
232
+ },
233
+ "brightness": round(brightness, 2),
234
+ "centroid_hint": "dark" if brightness < 0.4
235
+ else "bright" if brightness > 0.65
236
+ else "neutral",
237
+ }
238
+
239
+
240
+ def _derive_width_depth(style_tactics: list[dict]) -> dict:
241
+ """Heuristic: Utility Width, Chorus-Ensemble, or heavy reverb
242
+ implies a wider stereo field; dry chains imply narrow."""
243
+ width_value = 0.5
244
+ depth_value = 0.5
245
+ for tactic in style_tactics:
246
+ for dev in tactic.get("device_chain", []):
247
+ name = str(dev.get("name", "")).lower()
248
+ params = dev.get("params", {}) or {}
249
+ if name == "utility":
250
+ w = params.get("Width")
251
+ if w is not None:
252
+ try:
253
+ width_value = max(width_value, float(w))
254
+ except (TypeError, ValueError):
255
+ pass
256
+ if "reverb" in name:
257
+ wet = float(params.get("Dry/Wet", 0) or 0)
258
+ if wet > 0.4:
259
+ depth_value = max(depth_value, 0.7)
260
+ width_value = max(width_value, 0.65)
261
+ if "chorus" in name or "ensemble" in name:
262
+ width_value = max(width_value, 0.75)
263
+ if "delay" in name:
264
+ wet = float(params.get("Dry/Wet", 0) or 0)
265
+ if wet > 0.2:
266
+ depth_value = max(depth_value, 0.6)
267
+ return {
268
+ "stereo_width": round(width_value, 2),
269
+ "depth": round(depth_value, 2),
270
+ "depth_hint": (
271
+ "deep" if depth_value > 0.6 else
272
+ "intimate" if depth_value < 0.4 else
273
+ "neutral"
274
+ ),
275
+ }
@@ -13,7 +13,9 @@ from ..tools._research_engine import get_style_tactics
13
13
  from .profile_builder import build_audio_reference_profile, build_style_reference_profile
14
14
  from .gap_analyzer import analyze_gaps, classify_gap_relevance, detect_identity_warnings
15
15
  from .tactic_router import build_reference_plan
16
+ import logging
16
17
 
18
+ logger = logging.getLogger(__name__)
17
19
 
18
20
  # ── Helpers ────────────────────────────────────────────────────────
19
21
 
@@ -28,6 +30,20 @@ def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) ->
28
30
  return compare_to_reference(mix_path, reference_path, normalize=True)
29
31
 
30
32
 
33
+ def _fetch_memory_tactics(style: str) -> list[dict]:
34
+ """BUG-B19: pull user-saved techniques tagged with *style* so they
35
+ feed into the reference-profile lookup path. Best-effort — returns
36
+ empty list when the memory store isn't available."""
37
+ if not style:
38
+ return []
39
+ try:
40
+ from ..tools.research import _memory_store
41
+ return _memory_store.search(query=style, limit=10)
42
+ except Exception as exc:
43
+ logger.debug("_fetch_memory_tactics failed for %r: %s", style, exc)
44
+ return []
45
+
46
+
31
47
  def _fetch_project_snapshot(ctx: Context) -> dict:
32
48
  """Build a lightweight project snapshot for gap analysis."""
33
49
  ableton = ctx.lifespan_context["ableton"]
@@ -50,6 +66,7 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
50
66
  rms = rms_snap["value"] if isinstance(rms_snap["value"], (int, float)) else 0.0
51
67
  if rms > 0:
52
68
  import math
69
+
53
70
  snapshot["loudness"] = round(20 * math.log10(max(rms, 1e-10)), 2)
54
71
 
55
72
  spec_data = spectral.get("spectrum")
@@ -58,8 +75,8 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
58
75
  key_data = spectral.get("key")
59
76
  if key_data:
60
77
  snapshot["spectral"]["detected_key"] = key_data["value"]
61
- except Exception:
62
- pass
78
+ except Exception as exc:
79
+ logger.debug("_fetch_project_snapshot failed: %s", exc)
63
80
 
64
81
  # Try to get session info for pacing / density
65
82
  try:
@@ -69,12 +86,11 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
69
86
  # Rough density estimate
70
87
  snapshot["density"] = min(1.0, track_count / 20.0)
71
88
  snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
72
- except Exception:
73
- pass
89
+ except Exception as exc:
90
+ logger.debug("_fetch_project_snapshot failed: %s", exc)
74
91
 
75
92
  return snapshot
76
93
 
77
-
78
94
  # ── MCP Tools ──────────────────────────────────────────────────────
79
95
 
80
96
 
@@ -108,10 +124,21 @@ def build_reference_profile(
108
124
  return profile.to_dict()
109
125
 
110
126
  if style:
111
- tactics = get_style_tactics(style)
127
+ # BUG-B19 fix: also pass user-memory tactics so custom styles
128
+ # (e.g. "prefuse73" saved via memory_learn) resolve to real
129
+ # profiles instead of NOT_FOUND. Silently ignore if memory
130
+ # isn't available (keeps the built-in path working).
131
+ memory_tactics = _fetch_memory_tactics(style)
132
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
112
133
  if not tactics:
113
134
  return {
114
- "error": f"No style tactics found for '{style}'",
135
+ "error": (
136
+ f"No style tactics found for '{style}' — neither in the "
137
+ f"built-in library (burial / daft punk / techno / ambient / "
138
+ f"trap / lo-fi) nor in your saved memories. Use "
139
+ f"memory_learn to save a '{style}' technique or try "
140
+ f"reference_path=<audio-file> for an audio-based profile."
141
+ ),
115
142
  "code": "NOT_FOUND",
116
143
  }
117
144
  tactic_dicts = [t.to_dict() for t in tactics]
@@ -156,9 +183,17 @@ def analyze_reference_gaps(
156
183
  return comparison
157
184
  profile = build_audio_reference_profile(comparison)
158
185
  elif style:
159
- tactics = get_style_tactics(style)
186
+ # BUG-B19: hydrate from saved memories too
187
+ memory_tactics = _fetch_memory_tactics(style)
188
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
160
189
  if not tactics:
161
- return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
190
+ return {
191
+ "error": (
192
+ f"No style tactics found for '{style}' — neither in the "
193
+ f"built-in library nor in saved memories."
194
+ ),
195
+ "code": "NOT_FOUND",
196
+ }
162
197
  tactic_dicts = [t.to_dict() for t in tactics]
163
198
  profile = build_style_reference_profile(tactic_dicts)
164
199
  else:
@@ -210,9 +245,17 @@ def plan_reference_moves(
210
245
  return comparison
211
246
  profile = build_audio_reference_profile(comparison)
212
247
  elif style:
213
- tactics = get_style_tactics(style)
248
+ # BUG-B19: hydrate from saved memories too
249
+ memory_tactics = _fetch_memory_tactics(style)
250
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
214
251
  if not tactics:
215
- return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
252
+ return {
253
+ "error": (
254
+ f"No style tactics found for '{style}' — neither in the "
255
+ f"built-in library nor in saved memories."
256
+ ),
257
+ "code": "NOT_FOUND",
258
+ }
216
259
  tactic_dicts = [t.to_dict() for t in tactics]
217
260
  profile = build_style_reference_profile(tactic_dicts)
218
261
  else:
@@ -9,6 +9,9 @@ from __future__ import annotations
9
9
  import os
10
10
  from pathlib import Path
11
11
  from typing import Any
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
12
15
 
13
16
 
14
17
  def probe_capabilities(
@@ -27,8 +30,9 @@ def probe_capabilities(
27
30
  try:
28
31
  info = ableton.send_command("ping")
29
32
  ableton_ok = info is not None
30
- except Exception:
31
- pass
33
+ except Exception as exc:
34
+ logger.debug("probe_capabilities failed: %s", exc)
35
+
32
36
  report["ableton"] = {
33
37
  "status": "ok" if ableton_ok else "unavailable",
34
38
  "detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
@@ -40,8 +44,9 @@ def probe_capabilities(
40
44
  try:
41
45
  info = ableton.send_command("get_session_info")
42
46
  live_version_str = info.get("live_version", "12.0.0")
43
- except Exception:
44
- pass
47
+ except Exception as exc:
48
+ logger.debug("probe_capabilities failed: %s", exc)
49
+
45
50
  from .live_version import LiveVersionCapabilities
46
51
  version_caps = LiveVersionCapabilities.from_version_string(live_version_str)
47
52
  report["live_version"] = {
@@ -75,6 +80,7 @@ def probe_capabilities(
75
80
  numpy_ok = False
76
81
  try:
77
82
  import numpy # noqa: F401
83
+
78
84
  numpy_ok = True
79
85
  except ImportError:
80
86
  pass
@@ -50,6 +50,56 @@ MCP_TOOLS: frozenset[str] = frozenset({
50
50
  })
51
51
 
52
52
 
53
+ # Tools that observe session state without mutating it. Executors use this set
54
+ # to separate "apply pass" steps (writes) from "verification reads". Counting
55
+ # reads toward applied_count and then calling undo that many times walks back
56
+ # earlier user edits — see preview_studio/tools.py and experiment/engine.py.
57
+ READ_ONLY_TOOLS: frozenset[str] = frozenset({
58
+ "get_track_meters",
59
+ "get_master_spectrum",
60
+ "get_master_meters",
61
+ "get_master_rms",
62
+ "get_mix_snapshot",
63
+ "get_session_info",
64
+ "get_track_info",
65
+ "get_track_routing",
66
+ "get_return_tracks",
67
+ "get_device_info",
68
+ "get_device_parameters",
69
+ "get_clip_info",
70
+ "get_notes",
71
+ "get_arrangement_notes",
72
+ "get_arrangement_clips",
73
+ "get_scenes_info",
74
+ "get_scene_matrix",
75
+ "get_playing_clips",
76
+ "get_cue_points",
77
+ "get_rack_chains",
78
+ "get_clip_automation",
79
+ "analyze_mix",
80
+ "get_emotional_arc",
81
+ "get_motif_graph",
82
+ "get_session_diagnostics",
83
+ "ping",
84
+ })
85
+
86
+
87
+ def filter_apply_steps(steps: list) -> list:
88
+ """Return only the steps that mutate session state.
89
+
90
+ Read-only steps (meters, spectrum, info) do not create undo points in
91
+ Ableton. Including them in an applied_count and then undoing that many
92
+ times walks back earlier user edits. Always filter writes from reads
93
+ before the apply pass and before the undo loop.
94
+ """
95
+ out = []
96
+ for s in steps:
97
+ tool = (s.get("tool") if isinstance(s, dict) else getattr(s, "tool", "")) or ""
98
+ if tool and tool not in READ_ONLY_TOOLS:
99
+ out.append(s)
100
+ return out
101
+
102
+
53
103
  @dataclass
54
104
  class ExecutionResult:
55
105
  """Result of executing a single plan step."""
@@ -14,18 +14,33 @@ To add a new in-process tool to plans:
14
14
  2. Add an _adapter function here that imports the real implementation and
15
15
  adapts its kwargs from a plan-style params dict.
16
16
  3. Register the adapter in build_mcp_dispatch_registry.
17
+
18
+ Every entry in MCP_TOOLS must have a matching adapter here — the contract
19
+ test tests/test_mcp_dispatch_contract.py enforces this.
17
20
  """
18
21
 
19
22
  from __future__ import annotations
20
23
 
24
+ import inspect
21
25
  from typing import Any, Callable
22
26
 
23
27
 
24
- async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
25
- """Adapter for mcp_server.tools.analyzer.load_sample_to_simpler.
28
+ async def _call(fn, ctx, params: dict) -> Any:
29
+ """Call an MCP tool with ctx + kwargs from a plan params dict.
26
30
 
27
- Accepts the plan-step params dict and unpacks into the real tool's kwargs.
31
+ Filters params to the tool's declared parameters, awaits if coroutine.
32
+ Unknown params are dropped silently — plans may carry extra metadata.
28
33
  """
34
+ sig = inspect.signature(fn)
35
+ accepted = set(sig.parameters.keys())
36
+ kwargs = {k: v for k, v in params.items() if k in accepted}
37
+ result = fn(ctx, **kwargs)
38
+ if inspect.isawaitable(result):
39
+ result = await result
40
+ return result
41
+
42
+
43
+ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
29
44
  from ..tools.analyzer import load_sample_to_simpler
30
45
  return await load_sample_to_simpler(
31
46
  ctx,
@@ -35,12 +50,69 @@ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
35
50
  )
36
51
 
37
52
 
53
+ async def _apply_automation_shape(params: dict, ctx: Any = None) -> dict:
54
+ from ..tools.automation import apply_automation_shape
55
+ return await _call(apply_automation_shape, ctx, params)
56
+
57
+
58
+ async def _apply_gesture_template(params: dict, ctx: Any = None) -> dict:
59
+ from ..tools.composition import apply_gesture_template
60
+ return await _call(apply_gesture_template, ctx, params)
61
+
62
+
63
+ async def _analyze_mix(params: dict, ctx: Any = None) -> dict:
64
+ from ..mix_engine.tools import analyze_mix
65
+ return await _call(analyze_mix, ctx, params)
66
+
67
+
68
+ async def _get_master_spectrum(params: dict, ctx: Any = None) -> dict:
69
+ from ..tools.analyzer import get_master_spectrum
70
+ return await _call(get_master_spectrum, ctx, params)
71
+
72
+
73
+ async def _get_emotional_arc(params: dict, ctx: Any = None) -> dict:
74
+ from ..tools.research import get_emotional_arc
75
+ return await _call(get_emotional_arc, ctx, params)
76
+
77
+
78
+ async def _get_motif_graph(params: dict, ctx: Any = None) -> dict:
79
+ from ..tools.motif import get_motif_graph
80
+ return await _call(get_motif_graph, ctx, params)
81
+
82
+
83
+ async def _generate_m4l_effect(params: dict, ctx: Any = None) -> dict:
84
+ from ..device_forge.tools import generate_m4l_effect
85
+ return await _call(generate_m4l_effect, ctx, params)
86
+
87
+
88
+ async def _install_m4l_device(params: dict, ctx: Any = None) -> dict:
89
+ from ..device_forge.tools import install_m4l_device
90
+ return await _call(install_m4l_device, ctx, params)
91
+
92
+
93
+ async def _list_genexpr_templates(params: dict, ctx: Any = None) -> dict:
94
+ from ..device_forge.tools import list_genexpr_templates
95
+ return await _call(list_genexpr_templates, ctx, params)
96
+
97
+
38
98
  def build_mcp_dispatch_registry() -> dict[str, Callable]:
39
99
  """Return the canonical registry of MCP-only tools for plan execution.
40
100
 
41
101
  Callers (typically the server lifespan init) should call this once and
42
102
  pass the registry to execute_plan_steps_async via the mcp_registry kwarg.
103
+
104
+ INVARIANT: the set of keys here must equal MCP_TOOLS in execution_router.
105
+ Enforced by tests/test_mcp_dispatch_contract.py.
43
106
  """
44
107
  return {
45
108
  "load_sample_to_simpler": _load_sample_to_simpler,
109
+ "apply_automation_shape": _apply_automation_shape,
110
+ "apply_gesture_template": _apply_gesture_template,
111
+ "analyze_mix": _analyze_mix,
112
+ "get_master_spectrum": _get_master_spectrum,
113
+ "get_emotional_arc": _get_emotional_arc,
114
+ "get_motif_graph": _get_motif_graph,
115
+ "generate_m4l_effect": _generate_m4l_effect,
116
+ "install_m4l_device": _install_m4l_device,
117
+ "list_genexpr_templates": _list_genexpr_templates,
46
118
  }
@@ -21,18 +21,20 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
21
21
  "set_track_solo", "set_track_arm", "stop_track_clips",
22
22
  "set_group_fold", "set_track_input_monitoring",
23
23
  "get_freeze_status", "freeze_track", "flatten_track",
24
- # clips (11)
24
+ # clips (12)
25
25
  "get_clip_info", "create_clip", "delete_clip", "duplicate_clip",
26
26
  "fire_clip", "stop_clip", "set_clip_name", "set_clip_color",
27
27
  "set_clip_loop", "set_clip_launch", "set_clip_warp_mode",
28
+ "set_clip_pitch",
28
29
  # notes (8)
29
30
  "add_notes", "get_notes", "remove_notes", "remove_notes_by_id",
30
31
  "modify_notes", "duplicate_notes", "transpose_notes", "quantize_clip",
31
- # mixing (11)
32
+ # mixing (12)
32
33
  "set_track_volume", "set_track_pan", "set_track_send",
33
34
  "get_return_tracks", "get_master_track", "set_master_volume",
34
35
  "get_track_routing", "get_track_meters", "get_master_meters",
35
36
  "get_mix_snapshot", "set_track_routing",
37
+ "set_compressor_sidechain", # BUG-A3 — Python LOM path (was M4L bridge)
36
38
  # scenes (12)
37
39
  "get_scenes_info", "create_scene", "delete_scene", "duplicate_scene",
38
40
  "fire_scene", "set_scene_name", "set_scene_color", "set_scene_tempo",
@@ -12,6 +12,9 @@ from fastmcp import Context
12
12
  from ..server import mcp
13
13
  from ..memory.technique_store import TechniqueStore
14
14
  from .capability_state import build_capability_state
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
15
18
 
16
19
  _memory_store = TechniqueStore()
17
20
 
@@ -31,7 +34,8 @@ def get_capability_state(ctx: Context) -> dict:
31
34
  try:
32
35
  result = ableton.send_command("get_session_info")
33
36
  session_ok = isinstance(result, dict) and "error" not in result
34
- except Exception:
37
+ except Exception as exc:
38
+ logger.debug("get_capability_state failed: %s", exc)
35
39
  session_ok = False
36
40
 
37
41
  # ── Probe analyzer (M4L bridge) ─────────────────────────────────
@@ -49,7 +53,8 @@ def get_capability_state(ctx: Context) -> dict:
49
53
  try:
50
54
  _memory_store.list_techniques(limit=1)
51
55
  memory_ok = True
52
- except Exception:
56
+ except Exception as exc:
57
+ logger.debug("get_capability_state failed: %s", exc)
53
58
  memory_ok = False
54
59
 
55
60
  # ── Web / FluCoMa — not probed live, default to False ───────────
@@ -180,6 +185,7 @@ def get_session_kernel(
180
185
  if request_text.strip():
181
186
  try:
182
187
  from ..tools._conductor import classify_request
188
+
183
189
  plan = classify_request(request_text)
184
190
  kernel.recommended_engines = [r.engine for r in plan.routes[:3]]
185
191
  kernel.recommended_workflow = plan.workflow_mode