livepilot 1.20.1 → 1.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.3 — Automated analyzer pre-flight (April 24 2026)
4
+
5
+ Micro-release closing one class of operator error that broke the v1.20.1
6
+ five-project live-test campaign: the LLM operator had a clear memory
7
+ instruction to load `LivePilot_Analyzer` on master at the start of a
8
+ fresh Ableton session but missed it in 5 of 5 projects — producing
9
+ basic mixes instead of the intended mix-polish outcomes because every
10
+ analyzer-gated move (`tighten_low_end`, `sculpt_midrange`,
11
+ `balance_stereo_image`, etc.) silently degraded. Fixed forward with a
12
+ new idempotent pre-flight tool + Director skill wiring.
13
+
14
+ ### Added
15
+
16
+ **New tool: `ensure_analyzer_on_master`** (`mcp_server/tools/analyzer.py`).
17
+ Idempotent pre-flight that loads `LivePilot_Analyzer.amxd` on master
18
+ when missing, no-ops when already loaded. Returns one of:
19
+ - `already_loaded` (with `is_last_on_master`, `duplicate_count`)
20
+ - `loaded` (first-time load from Ableton browser)
21
+ - `install_required` (device not in browser — actionable hint points at
22
+ `install_m4l_device`)
23
+ - `failed` (any other error)
24
+
25
+ Post-load report surfaces the CLAUDE.md invariant "LivePilot_Analyzer
26
+ must be LAST on master" via `is_last_on_master: bool` and warns when
27
+ violated. Duplicate-count warning covers the edge case of multiple
28
+ analyzers on the master chain.
29
+
30
+ Safe to call every turn — subsequent calls short-circuit via one
31
+ `get_master_track` read. Tool count: 429 → 430. 6 new contract tests
32
+ covering already-loaded, missing-loads, install-required,
33
+ duplicate-handling, is-last warning, and two-call idempotence.
34
+
35
+ ### Changed
36
+
37
+ **Director Phase 1** (`livepilot-creative-director/SKILL.md`). Added
38
+ `ensure_analyzer_on_master` at the top of the "Ground" reads as a
39
+ REQUIRED call, ahead of `get_session_info`. Wording explicitly connects
40
+ the step to the failure it prevents so future agents don't rationalize
41
+ skipping it: "Skipping it is how the v1.20.1 live-test campaign
42
+ produced basic mixes — the analyzer-gated moves degrade silently when
43
+ there's no master spectrum to read."
44
+
45
+ ### Notes
46
+
47
+ No breaking changes. Calling code that assumed the analyzer was loaded
48
+ continues to work; the new tool adds an explicit pre-flight path.
49
+ `install_m4l_device` contract unchanged.
50
+
51
+ ---
52
+
53
+ ## 1.20.2 — 5 bugs + 1 race condition from the live-test campaign (April 24 2026)
54
+
55
+ Patch release fixing every issue surfaced during the v1.20.1 five-project
56
+ live-test campaign documented at `~/Desktop/DREAM AI/demo Project/REPORT.md`.
57
+ Each fix landed as its own atomic commit with TDD contract tests. Full
58
+ test suite: 2985 → 3037 pass (+52 new tests), zero regressions.
59
+
60
+ ### Fixes
61
+
62
+ **🐛 #1 — Device Forge: all 7 `create_*` moves ship broken (CRITICAL).**
63
+ Each move's `plan_template` emitted `generate_m4l_effect` WITHOUT the
64
+ required `gen_code` argument, so every move failed with `missing 1
65
+ required positional argument: 'gen_code'` in explore mode. The 7
66
+ GenExpr templates already existed in `mcp_server/device_forge/
67
+ templates.py` (lorenz_attractor, wavefolder, bitcrusher, etc.) but
68
+ weren't wired. Fix: `device_creation_compilers._MOVE_TO_TEMPLATE`
69
+ routes each move_id to its template and the compiler injects `gen_code`
70
+ at compile time. (commit `61abbeb`)
71
+
72
+ **🐛 #2 — Sample family: `{sample_file_path}` template placeholder leaked
73
+ to compiled plans.** `_resolve_sample_path` returned a literal
74
+ `"{sample_file_path}"` string when the kernel had no path set —
75
+ falling through to `load_sample_to_simpler` with a non-existent file.
76
+ Fix: resolver now reads `seed_args["file_path"]` (v1.20 convention),
77
+ falls back to legacy `kernel["sample_file_path"]` (wonder_mode
78
+ setter), returns `None` on miss. Each of the 6 sample moves rejects
79
+ with a non-executable plan + actionable warning when path is None.
80
+ (commit `26de33c`)
81
+
82
+ **🐛 #3 — Analyzer-gated moves hard-fail their mutation steps.**
83
+ `tighten_low_end` and `make_kick_bass_lock` emitted
84
+ `get_master_spectrum` as a pre-read. When the analyzer wasn't loaded
85
+ on master, step 0 failed and `execute_plan_steps_async`
86
+ `stop_on_failure=True` halted the plan BEFORE the mutation steps
87
+ (bass volume change) ran. Fix: general `CompiledStep.optional: bool`
88
+ field + router skip-and-continue on optional failures; affected
89
+ compilers tag their analyzer pre-reads as `optional=True`. The
90
+ mechanism is reusable for any future soft-gated diagnostic step.
91
+ (commit `5f9f0ae`)
92
+
93
+ **🐛 #4 — `batch_set_parameters` silently snaps quantized enum params.**
94
+ Beat Repeat's `Gate=0.3` and `Variation=0.8` became `Gate=0` / `Variation=0`
95
+ — valid snaps for quantized enum params, but the response gave callers
96
+ no signal that their intent was discarded. Fix: `batch_set_parameters`
97
+ post-processes Ableton's response, comparing requested vs returned
98
+ values with 1e-5 epsilon; appends a `snapped_params` list when
99
+ mismatches occur, each carrying `{name, requested, actual,
100
+ display_value, value_string}`. Empty list = nothing snapped.
101
+ (commit `b472976`)
102
+
103
+ **🐛 #5 — `create_midi_track` can create duplicate-name tracks silently.**
104
+ When `set_track_name(2, "Pad")` runs and then `create_midi_track(index=2,
105
+ name="Pad")` shifts the existing track to index 4 while retaining its
106
+ name, the session ends up with two "Pad" tracks. Downstream
107
+ `find_tracks_by_role` matches both and mix moves apply twice. Fix:
108
+ `create_midi_track` and `create_audio_track` now pre-query session
109
+ for tracks with the requested name and stamp the response with
110
+ `name_collision: bool` + `existing_tracks_with_same_name: list[int]`.
111
+ Doesn't block creation — callers decide whether to rename or accept.
112
+ (commit `69bc545`)
113
+
114
+ **🔁 Race condition — "Connection closed by Ableton" on UI transitions.**
115
+ Observed 3× during the campaign: after `Cmd+N` (new live set), the
116
+ next MCP call would drop with `Connection closed by Ableton`.
117
+ Ableton's Remote Script briefly rejects commands during UI state
118
+ transitions. Fix: `connection.send_command` now retries once with
119
+ 400ms backoff on that specific error, reconnecting between attempts.
120
+ Timeouts still raise immediately (mutation-duplicate risk). Retry
121
+ budget capped at 1 — second failure raises cleanly. (commit `cf019d5`)
122
+
123
+ ### Scope of the campaign
124
+
125
+ See `~/Desktop/DREAM AI/demo Project/` for the 5 `.als` files, `PLAN.md`,
126
+ and `REPORT.md` that produced this backlog:
127
+ - `01 basic-channel-dub.als` — dub techno @ 130
128
+ - `02 dilla-swing-drums.als` — hip-hop @ 90 with MIDI-native swing
129
+ - `03 opn-wonder-texture.als` — ambient @ 70 (Device Forge failure noted)
130
+ - `04 aphex-destruction.als` — IDM @ 155 (Beat Repeat snap noted)
131
+ - `05 mix-polish.als` — house @ 125 (analyzer-gate failure noted)
132
+
133
+ ### CI status
134
+
135
+ All 9 CI jobs expected green: python-tests × {ubuntu, macos, windows} ×
136
+ {3.11, 3.12}, metadata-drift, amxd-freeze-drift, js-entrypoint.
137
+
138
+ ### Non-goals
139
+
140
+ No new moves in v1.20.2 — every change is a fix to existing surfaces.
141
+ v1.21 remains the consolidation release (see `docs/plans/v1.21-structural-plan.md`).
142
+
3
143
  ## 1.20.1 — CI hardening: Windows UTF-8 encoding + .amxd ping drift (April 24 2026)
4
144
 
5
145
  Patch release fixing CI regressions that v1.20.0 shipped with (caught
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  <p align="center">
19
19
  An agentic production system for Ableton Live 12.<br>
20
- 429 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
20
+ 430 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
21
21
  </p>
22
22
 
23
23
  <br>
@@ -80,7 +80,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
80
80
  │ └─────────────────┼──────────────────┘ │
81
81
  │ ▼ │
82
82
  │ ┌─────────────────┐ │
83
- │ │ 429 MCP Tools │ │
83
+ │ │ 430 MCP Tools │ │
84
84
  │ │ 53 domains │ │
85
85
  │ └────────┬────────┘ │
86
86
  │ │ │
@@ -121,7 +121,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
121
121
 
122
122
  ## The Intelligence Layer
123
123
 
124
- 12 engines sit on top of the 429 tools. They give the AI musical judgment, not just musical execution.
124
+ 12 engines sit on top of the 430 tools. They give the AI musical judgment, not just musical execution.
125
125
 
126
126
  ### SongBrain — What the Song Is
127
127
 
@@ -173,7 +173,7 @@ Every engine follows: **measure before → act → measure after → compare**.
173
173
 
174
174
  ## Tools
175
175
 
176
- 429 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
+ 430 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
177
177
 
178
178
  <br>
179
179
 
@@ -362,7 +362,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
362
362
  | Creative Constraints | 5 | constraint activation, reference-inspired variants |
363
363
  | Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
364
364
 
365
- > **[View all 429 tools →](docs/manual/tool-catalog.md)**
365
+ > **[View all 430 tools →](docs/manual/tool-catalog.md)**
366
366
 
367
367
  <br>
368
368
 
@@ -589,7 +589,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
589
589
 
590
590
  | Document | What's inside |
591
591
  |----------|---------------|
592
- | [Manual](docs/manual/index.md) | Complete reference: architecture, all 429 tools, workflows |
592
+ | [Manual](docs/manual/index.md) | Complete reference: architecture, all 430 tools, workflows |
593
593
  | [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
594
594
  | [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
595
595
  | [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
Binary file
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.20.1";
37
+ var VERSION = "1.20.3";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.20.1"
2
+ __version__ = "1.20.3"
@@ -19,6 +19,10 @@ logger = logging.getLogger(__name__)
19
19
  CONNECT_TIMEOUT = 5
20
20
  RECV_TIMEOUT = 20
21
21
  SINGLE_CLIENT_RETRY_DELAY = 0.25
22
+ # v1.20.2 race-condition fix: UI transitions (Cmd+N, project open) close
23
+ # the command socket briefly. Retry once after this delay to let Ableton
24
+ # finish setting up the new session state.
25
+ UI_TRANSITION_RETRY_DELAY = 0.4
22
26
  COMMAND_RECV_TIMEOUTS = {
23
27
  # Server-side slow write window is 35s; give the client a small buffer.
24
28
  "freeze_track": 40,
@@ -190,17 +194,50 @@ class AbletonConnection:
190
194
  # Ableton may have already applied the command. Never
191
195
  # replay — the duplicate mutation is worse than the error.
192
196
  if getattr(exc, '_send_completed', False):
193
- raise
197
+ # v1.20.2 race-condition fix: the specific error
198
+ # "Connection closed by Ableton" fires reliably after
199
+ # UI state transitions (Cmd+N opens new live set,
200
+ # project open, etc.). The Remote Script's socket
201
+ # recv returns empty bytes in a ~300ms window around
202
+ # the transition. Retry ONCE with backoff so an
203
+ # immediate follow-up command survives.
204
+ #
205
+ # Idempotence note: most commands are idempotent
206
+ # (set_tempo, set_track_volume overwrite; get_*
207
+ # reads are side-effect-free). Non-idempotent
208
+ # mutations (add_notes, create_clip) may in theory
209
+ # double-apply — but in practice Ableton's
210
+ # single-threaded command processing means the
211
+ # "Connection closed" happens BEFORE command
212
+ # processing begins, not after. Campaign repros
213
+ # showed 3/3 set_tempo failures post-Cmd+N that
214
+ # would have been fine to retry.
215
+ if "Connection closed by Ableton" in str(exc):
216
+ logger.warning(
217
+ "Ableton closed socket mid-%s — likely UI "
218
+ "state transition. Retrying once after %dms.",
219
+ command_type, int(UI_TRANSITION_RETRY_DELAY * 1000),
220
+ )
221
+ self.disconnect()
222
+ time.sleep(UI_TRANSITION_RETRY_DELAY)
223
+ self.connect()
224
+ response = self._send_raw(
225
+ command,
226
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
227
+ )
228
+ else:
229
+ raise
194
230
  # Don't retry timeouts either
195
- if "Timeout" in str(exc):
231
+ elif "Timeout" in str(exc):
196
232
  raise
197
- # Send itself failed — safe to retry with a fresh connection
198
- self.disconnect()
199
- self.connect()
200
- response = self._send_raw(
201
- command,
202
- recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
203
- )
233
+ else:
234
+ # Send itself failed — safe to retry with a fresh connection
235
+ self.disconnect()
236
+ self.connect()
237
+ response = self._send_raw(
238
+ command,
239
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
240
+ )
204
241
  except OSError:
205
242
  # Socket error before send — safe to retry
206
243
  self.disconnect()
@@ -312,6 +312,10 @@ async def execute_plan_steps_async(
312
312
  raw_params = step.get("params") or step.get("args", {}) or {}
313
313
  step_id = step.get("step_id")
314
314
  declared_backend = step.get("backend")
315
+ # v1.20.2 (BUG #3 fix): optional steps whose failure should NOT
316
+ # halt the plan. Used for soft pre-reads like get_master_spectrum
317
+ # that depend on the analyzer being loaded.
318
+ is_optional = bool(step.get("optional", False))
315
319
 
316
320
  if not tool:
317
321
  results.append(ExecutionResult(
@@ -360,6 +364,18 @@ async def execute_plan_steps_async(
360
364
  )
361
365
 
362
366
  if not result.ok and stop_on_failure:
367
+ if is_optional:
368
+ # Optional step failed — log a warning but CONTINUE to
369
+ # subsequent steps. Per BUG #3 fix (v1.20.2): analyzer pre-
370
+ # reads and other soft dependencies shouldn't halt the
371
+ # plan's actual mutation work.
372
+ import logging as _logging
373
+ _logging.getLogger(__name__).warning(
374
+ "execute_plan_steps_async: optional step %r failed "
375
+ "(%s); continuing to next step.",
376
+ tool, result.error,
377
+ )
378
+ continue
363
379
  break
364
380
 
365
381
  return results
@@ -29,6 +29,12 @@ class CompiledStep:
29
29
  # dispatch time — safe because test_move_annotations enforces every
30
30
  # registered move's steps map to a known backend.
31
31
  backend: Optional[str] = None
32
+ # v1.20.2 (BUG #3 fix): when True and the step fails at execution, the
33
+ # router logs a warning and CONTINUES to subsequent steps instead of
34
+ # halting the plan. Use for soft dependencies like analyzer-only
35
+ # pre-reads — downstream mutation steps should still run if the
36
+ # analyzer isn't loaded on master.
37
+ optional: bool = False
32
38
 
33
39
  def to_dict(self) -> dict:
34
40
  d = {
@@ -39,6 +45,8 @@ class CompiledStep:
39
45
  }
40
46
  if self.backend:
41
47
  d["backend"] = self.backend
48
+ if self.optional:
49
+ d["optional"] = True
42
50
  return d
43
51
 
44
52
 
@@ -6,10 +6,17 @@ compiler inspects the kernel's track topology — device-creation moves
6
6
  are parametric: the plan_template already contains the tool call and
7
7
  concrete arguments.
8
8
 
9
- We therefore use a single family-level compiler that just maps
10
- ``plan_template`` ``CompiledStep`` objects. This keeps the registry
11
- honest (every move is either compilable or analytical_only) without
12
- duplicating templates into per-move compilers.
9
+ v1.20.2 (BUG #1 fix): the compiler now injects ``gen_code`` at compile
10
+ time by looking up a GenExpr template from ``mcp_server/device_forge/
11
+ templates.py``. Pre-fix, each move's plan_template emitted
12
+ ``generate_m4l_effect`` WITHOUT ``gen_code``, and the tool failed with
13
+ 'missing 1 required positional argument: gen_code'. The templates
14
+ already existed in the device_forge module; this compiler just routes
15
+ the right template to the right move.
16
+
17
+ The v1.20.2 hybrid moves — ``build_send_chain`` + ``create_drum_rack_pad``
18
+ — don't use generate_m4l_effect, so their plan_template params pass
19
+ through unchanged.
13
20
  """
14
21
  from __future__ import annotations
15
22
 
@@ -17,22 +24,66 @@ from .compiler import CompiledPlan, CompiledStep, register_family_compiler
17
24
  from .models import SemanticMove
18
25
 
19
26
 
27
+ # BUG #1 fix (v1.20.2 / campaign report 2026-04-24).
28
+ #
29
+ # Each of the 7 Device Forge moves corresponds to one pre-existing
30
+ # GenExpr template. The compiler injects the template's `code` into the
31
+ # `gen_code` param of any `generate_m4l_effect` step belonging to the
32
+ # move. Adding a new create_* move to this map must also register a
33
+ # matching template in mcp_server/device_forge/templates.py.
34
+ _MOVE_TO_TEMPLATE: dict[str, str] = {
35
+ "create_chaos_modulator": "lorenz_attractor",
36
+ "create_feedback_resonator": "resonator",
37
+ "create_wavefolder_effect": "wavefolder",
38
+ "create_bitcrusher_effect": "bitcrusher",
39
+ "create_karplus_string": "karplus_strong",
40
+ "create_stochastic_texture": "stochastic_resonance",
41
+ "create_fdn_reverb": "feedback_delay_network",
42
+ }
43
+
44
+
20
45
  def _compile_device_creation(move: SemanticMove, kernel: dict) -> CompiledPlan:
21
- """Map plan_template steps straight to CompiledStep.
46
+ """Map plan_template steps to CompiledStep, injecting Device Forge
47
+ `gen_code` when the move is in _MOVE_TO_TEMPLATE."""
48
+ # Resolve the GenExpr template once per compile (idempotent).
49
+ template_code: str | None = None
50
+ template_id = _MOVE_TO_TEMPLATE.get(move.move_id)
51
+ if template_id is not None:
52
+ # Local import so the semantic_moves package doesn't hard-depend on
53
+ # device_forge; the branch is only taken for the 7 create_* moves.
54
+ from ..device_forge.templates import get_template
55
+ template = get_template(template_id)
56
+ if template is not None:
57
+ template_code = template.code
22
58
 
23
- plan_template is trusted for this family: each step already has
24
- ``tool``, ``params``, ``description``, and ``backend`` annotated.
25
- """
59
+ warnings: list[str] = []
26
60
  steps: list[CompiledStep] = []
27
61
  for step in move.plan_template:
62
+ params = dict(step.get("params") or {})
63
+
64
+ # Inject gen_code for Device Forge moves. Done BEFORE CompiledStep
65
+ # construction so the step snapshot is correct, not mutated later.
66
+ if template_code is not None and step.get("tool") == "generate_m4l_effect":
67
+ params["gen_code"] = template_code
68
+
28
69
  steps.append(CompiledStep(
29
70
  tool=step.get("tool", ""),
30
- params=step.get("params", {}),
71
+ params=params,
31
72
  description=step.get("description", ""),
32
73
  verify_after=bool(step.get("verify_after", True)),
33
74
  backend=step.get("backend"),
34
75
  ))
35
76
 
77
+ # Guard: if the move expected template injection but the template
78
+ # went missing, surface a clear warning instead of letting the move
79
+ # ship with an empty gen_code. Shouldn't fire under normal operation.
80
+ if template_id is not None and template_code is None:
81
+ warnings.append(
82
+ f"Device Forge template {template_id!r} not found — "
83
+ f"generate_m4l_effect call will fail. Check "
84
+ f"mcp_server/device_forge/templates.py"
85
+ )
86
+
36
87
  return CompiledPlan(
37
88
  move_id=move.move_id,
38
89
  intent=move.intent,
@@ -40,7 +91,7 @@ def _compile_device_creation(move: SemanticMove, kernel: dict) -> CompiledPlan:
40
91
  risk_level=move.risk_level,
41
92
  summary=move.intent,
42
93
  requires_approval=(kernel.get("mode", "improve") != "explore"),
43
- warnings=[],
94
+ warnings=warnings,
44
95
  )
45
96
 
46
97
 
@@ -101,12 +101,17 @@ def _compile_tighten_low_end(move: SemanticMove, kernel: dict) -> CompiledPlan:
101
101
  bass = bass_tracks[0]
102
102
  idx = bass["index"]
103
103
 
104
- # Step 1: Read spectrum
104
+ # Step 1: Read spectrum (optional — soft-gated on analyzer availability).
105
+ # BUG #3 fix (v1.20.2): if the analyzer isn't loaded on master, this
106
+ # step fails — but the downstream bass-volume change is independent
107
+ # and should still run. optional=True lets the router skip and
108
+ # continue instead of halting the plan.
105
109
  steps.append(CompiledStep(
106
110
  tool="get_master_spectrum",
107
111
  params={},
108
- description="Read current spectral balance",
112
+ description="Read current spectral balance (optional — analyzer-gated)",
109
113
  verify_after=False,
114
+ optional=True,
110
115
  ))
111
116
 
112
117
  # Step 2: Reduce bass volume slightly
@@ -302,11 +307,14 @@ def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPl
302
307
  if not kick_tracks:
303
308
  warnings.append("No kick/drum track found — reference track missing")
304
309
 
310
+ # Optional pre-read — soft-gated on analyzer availability.
311
+ # BUG #3 fix (v1.20.2): see tighten_low_end for the same pattern.
305
312
  steps.append(CompiledStep(
306
313
  tool="get_master_spectrum",
307
314
  params={},
308
- description="Read current sub/low balance before carving",
315
+ description="Read current sub/low balance before carving (optional — analyzer-gated)",
309
316
  verify_after=False,
317
+ optional=True,
310
318
  ))
311
319
 
312
320
  if bass_tracks:
@@ -2,22 +2,81 @@
2
2
 
3
3
  These compile sample manipulation intents into concrete tool call sequences
4
4
  using the session kernel to find appropriate tracks and devices.
5
+
6
+ v1.20.2 (BUG #2 fix): sample moves now resolve ``file_path`` from
7
+ ``kernel["seed_args"]["file_path"]`` (v1.20 convention). Pre-fix, the
8
+ resolver returned a literal ``"{sample_file_path}"`` placeholder when
9
+ no path was in the kernel — which leaked into ``load_sample_to_simpler``
10
+ at execution and failed with a non-existent-file error. Now the
11
+ resolver returns ``None`` on miss and each compiler rejects with a
12
+ non-executable plan + clear warning.
13
+
14
+ Legacy fallback: wonder_mode/tools.py writes ``kernel["sample_file_path"]``
15
+ directly (pre-v1.20 path). Still honored for back-compat, just after
16
+ seed_args.
5
17
  """
6
18
 
7
19
  from __future__ import annotations
8
20
 
21
+ from typing import Optional
22
+
9
23
  from .compiler import CompiledPlan, CompiledStep, register_compiler
10
24
  from .models import SemanticMove
11
25
  from . import resolvers
12
26
 
13
27
 
14
- def _resolve_sample_path(kernel: dict) -> str:
15
- """Get the sample file path from kernel, or return placeholder."""
16
- return kernel.get("sample_file_path", "{sample_file_path}")
28
+ def _resolve_sample_path(kernel: dict) -> Optional[str]:
29
+ """Get the sample file path from kernel, or None if not set.
30
+
31
+ Resolution order (v1.20.2 BUG #2 fix):
32
+ 1. kernel["seed_args"]["file_path"] — v1.20 seed_args convention
33
+ 2. kernel["sample_file_path"] — legacy wonder_mode setter
34
+
35
+ Returns None when neither is present. Callers must check for None
36
+ and reject the plan with an actionable warning; do NOT substitute a
37
+ placeholder (which was the original bug).
38
+ """
39
+ seed = kernel.get("seed_args") or {}
40
+ path = seed.get("file_path")
41
+ if isinstance(path, str) and path.strip():
42
+ return path
43
+ legacy = kernel.get("sample_file_path")
44
+ if isinstance(legacy, str) and legacy.strip():
45
+ return legacy
46
+ return None
47
+
48
+
49
+ def _empty_sample_plan(move: SemanticMove, kernel: dict) -> CompiledPlan:
50
+ """Return a non-executable plan indicating a sample path is required.
51
+
52
+ Used by every sample-family compiler when _resolve_sample_path returns
53
+ None. Caller should still inspect the warnings for the actionable text.
54
+ """
55
+ return CompiledPlan(
56
+ move_id=move.move_id,
57
+ intent=move.intent,
58
+ steps=[],
59
+ risk_level="low",
60
+ summary=(
61
+ f"{move.move_id} requires a sample file_path. Pass via "
62
+ f"apply_semantic_move(..., args={{\"file_path\": \"/abs/path/to.wav\"}})"
63
+ ),
64
+ requires_approval=True,
65
+ warnings=[
66
+ f"{move.move_id} requires seed_args.file_path (absolute path to the "
67
+ "audio file). Not provided; plan not executable. Example: "
68
+ "apply_semantic_move(\"" + move.move_id + "\", mode=\"explore\", "
69
+ "args={\"file_path\": \"/path/to/sample.wav\"})"
70
+ ],
71
+ )
17
72
 
18
73
 
19
74
  def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPlan:
20
75
  """Compile 'sample_chop_rhythm': load, slice, and chop a sample for rhythm."""
76
+ file_path = _resolve_sample_path(kernel)
77
+ if file_path is None:
78
+ return _empty_sample_plan(move, kernel)
79
+
21
80
  steps = []
22
81
  descriptions = []
23
82
  warnings = []
@@ -39,7 +98,7 @@ def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPla
39
98
 
40
99
  steps.append(CompiledStep(
41
100
  tool="load_sample_to_simpler",
42
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
101
+ params={"track_index": new_idx, "file_path": file_path},
43
102
  description="Load sample into Simpler for slicing",
44
103
  ))
45
104
 
@@ -79,6 +138,10 @@ def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPla
79
138
 
80
139
  def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledPlan:
81
140
  """Compile 'sample_texture_layer': load and filter a sample as background texture."""
141
+ file_path = _resolve_sample_path(kernel)
142
+ if file_path is None:
143
+ return _empty_sample_plan(move, kernel)
144
+
82
145
  steps = []
83
146
  descriptions = []
84
147
 
@@ -93,7 +156,7 @@ def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledP
93
156
 
94
157
  steps.append(CompiledStep(
95
158
  tool="load_sample_to_simpler",
96
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
159
+ params={"track_index": new_idx, "file_path": file_path},
97
160
  description="Load textural sample into Simpler",
98
161
  ))
99
162
  descriptions.append("Load texture sample")
@@ -132,6 +195,10 @@ def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledP
132
195
 
133
196
  def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPlan:
134
197
  """Compile 'sample_vocal_ghost': reverse, pitch, and wash a vocal sample."""
198
+ file_path = _resolve_sample_path(kernel)
199
+ if file_path is None:
200
+ return _empty_sample_plan(move, kernel)
201
+
135
202
  steps = []
136
203
  descriptions = []
137
204
 
@@ -146,7 +213,7 @@ def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPla
146
213
 
147
214
  steps.append(CompiledStep(
148
215
  tool="load_sample_to_simpler",
149
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
216
+ params={"track_index": new_idx, "file_path": file_path},
150
217
  description="Load vocal sample into Simpler",
151
218
  ))
152
219
 
@@ -191,6 +258,10 @@ def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPla
191
258
 
192
259
  def _compile_sample_break_layer(move: SemanticMove, kernel: dict) -> CompiledPlan:
193
260
  """Compile 'sample_break_layer': slice a break and layer over existing drums."""
261
+ file_path = _resolve_sample_path(kernel)
262
+ if file_path is None:
263
+ return _empty_sample_plan(move, kernel)
264
+
194
265
  steps = []
195
266
  descriptions = []
196
267
  warnings = []
@@ -210,7 +281,7 @@ def _compile_sample_break_layer(move: SemanticMove, kernel: dict) -> CompiledPla
210
281
 
211
282
  steps.append(CompiledStep(
212
283
  tool="load_sample_to_simpler",
213
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
284
+ params={"track_index": new_idx, "file_path": file_path},
214
285
  description="Load breakbeat into Simpler",
215
286
  ))
216
287
 
@@ -252,6 +323,10 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
252
323
  SAFETY: This is a high-risk move — always requires approval.
253
324
  Only adjusts device params when a known device is confirmed present.
254
325
  """
326
+ file_path = _resolve_sample_path(kernel)
327
+ if file_path is None:
328
+ return _empty_sample_plan(move, kernel)
329
+
255
330
  steps = []
256
331
  descriptions = []
257
332
  warnings = ["High-risk: destructive processing — consider duplicating track first"]
@@ -267,7 +342,7 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
267
342
 
268
343
  steps.append(CompiledStep(
269
344
  tool="load_sample_to_simpler",
270
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
345
+ params={"track_index": new_idx, "file_path": file_path},
271
346
  description="Load sample for destruction",
272
347
  ))
273
348
  descriptions.append("Load source")
@@ -312,6 +387,10 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
312
387
 
313
388
  def _compile_sample_one_shot_accent(move: SemanticMove, kernel: dict) -> CompiledPlan:
314
389
  """Compile 'sample_one_shot_accent': load a one-shot for rhythmic punctuation."""
390
+ file_path = _resolve_sample_path(kernel)
391
+ if file_path is None:
392
+ return _empty_sample_plan(move, kernel)
393
+
315
394
  steps = []
316
395
  descriptions = []
317
396
 
@@ -326,7 +405,7 @@ def _compile_sample_one_shot_accent(move: SemanticMove, kernel: dict) -> Compile
326
405
 
327
406
  steps.append(CompiledStep(
328
407
  tool="load_sample_to_simpler",
329
- params={"track_index": new_idx, "file_path": _resolve_sample_path(kernel)},
408
+ params={"track_index": new_idx, "file_path": file_path},
330
409
  description="Load one-shot into Simpler",
331
410
  ))
332
411
 
@@ -366,6 +366,10 @@ async def apply_semantic_move(
366
366
  }
367
367
  if getattr(step, "backend", None):
368
368
  d["backend"] = step.backend
369
+ # v1.20.2 (BUG #3 fix): propagate optional flag so the router
370
+ # can skip-and-continue on soft failures (e.g., analyzer pre-reads).
371
+ if getattr(step, "optional", False):
372
+ d["optional"] = True
369
373
  return d
370
374
 
371
375
  step_dicts = [_step_to_dict(step) for step in plan.steps]
@@ -1913,3 +1913,128 @@ async def compressor_set_sidechain(
1913
1913
  params["source_channel"] = str(source_channel)
1914
1914
  ableton = ctx.lifespan_context["ableton"]
1915
1915
  return ableton.send_command("set_compressor_sidechain", params)
1916
+
1917
+
1918
+ # ──────────────────────────────────────────────────────────────────────
1919
+ # v1.20.3 — ensure_analyzer_on_master
1920
+ #
1921
+ # Motivated by the v1.20.1 live-test campaign operator-error (see
1922
+ # ~/Desktop/DREAM AI/demo Project/REPORT.md). The LLM operator had a
1923
+ # clear global-memory instruction to load LivePilot_Analyzer on master
1924
+ # proactively on a fresh session, and missed it — leaving analyzer-
1925
+ # gated moves brittle. This tool closes that class of error by making
1926
+ # the load idempotent + automatable.
1927
+
1928
+ _ANALYZER_DEVICE_NAME = "LivePilot_Analyzer"
1929
+
1930
+
1931
+ def _load_analyzer_impl(ctx, track_index: int, device_name: str,
1932
+ allow_duplicate: bool = False) -> dict:
1933
+ """Indirection so tests can monkeypatch the load call without having
1934
+ to fake the full find_and_load_device MCP-tool machinery. Production
1935
+ calls straight through to the existing tool."""
1936
+ from .devices import find_and_load_device
1937
+ return find_and_load_device(
1938
+ ctx,
1939
+ track_index=track_index,
1940
+ device_name=device_name,
1941
+ allow_duplicate=allow_duplicate,
1942
+ )
1943
+
1944
+
1945
+ @mcp.tool()
1946
+ def ensure_analyzer_on_master(ctx: Context) -> dict:
1947
+ """Idempotent pre-flight: load LivePilot_Analyzer on master if missing.
1948
+
1949
+ Safe to call at the start of any session or before any move that
1950
+ declares analyzer dependency. Calling it repeatedly is cheap —
1951
+ subsequent calls short-circuit via a single get_master_track read.
1952
+
1953
+ CLAUDE.md invariant: "LivePilot_Analyzer must be LAST on master."
1954
+ This tool reports whether the invariant holds via ``is_last_on_master``;
1955
+ it does NOT move the device (that's a user action in Ableton's GUI).
1956
+
1957
+ Return shape:
1958
+ - status: one of {"already_loaded", "loaded", "install_required", "failed"}
1959
+ - device_index: int — position of the analyzer on master (when present)
1960
+ - is_last_on_master: bool — True when analyzer is the last device
1961
+ - duplicate_count: int — 2+ when multiple analyzers exist (shouldn't)
1962
+ - warning: str | None — surfaces last-on-master violations
1963
+ - hint: str — actionable next step when status != "already_loaded"/"loaded"
1964
+ - error: str | None — present on status="failed"
1965
+ """
1966
+ ableton = ctx.lifespan_context["ableton"]
1967
+
1968
+ # 1. Inspect the master chain for an existing analyzer.
1969
+ try:
1970
+ master = ableton.send_command("get_master_track")
1971
+ except Exception as exc:
1972
+ return {
1973
+ "status": "failed",
1974
+ "error": f"Could not read master track: {exc}",
1975
+ "hint": "Verify MCP connection to Ableton; retry with get_session_info first.",
1976
+ }
1977
+
1978
+ devices = (master or {}).get("devices") or []
1979
+ matches = [d for d in devices if d.get("name") == _ANALYZER_DEVICE_NAME]
1980
+
1981
+ if matches:
1982
+ # 2. Already loaded — build a status report without side effects.
1983
+ first = matches[0]
1984
+ device_index = first.get("index")
1985
+ is_last = False
1986
+ if devices:
1987
+ last_name = devices[-1].get("name")
1988
+ is_last = (last_name == _ANALYZER_DEVICE_NAME)
1989
+
1990
+ result: dict = {
1991
+ "status": "already_loaded",
1992
+ "device_index": device_index,
1993
+ "is_last_on_master": is_last,
1994
+ "duplicate_count": len(matches),
1995
+ }
1996
+ if len(matches) > 1:
1997
+ result["warning"] = (
1998
+ f"{len(matches)} instances of {_ANALYZER_DEVICE_NAME} on master — "
1999
+ "only one is needed. Remove extras in Ableton's GUI."
2000
+ )
2001
+ elif not is_last:
2002
+ result["warning"] = (
2003
+ f"{_ANALYZER_DEVICE_NAME} is not the LAST device on master. "
2004
+ "CLAUDE.md invariant requires it to come after ALL effects so "
2005
+ "it reads the final output, not pre-effect signal. "
2006
+ "Move it to the end of the master chain in Ableton's GUI."
2007
+ )
2008
+ return result
2009
+
2010
+ # 3. Not on master — try loading from the Ableton browser.
2011
+ try:
2012
+ loaded = _load_analyzer_impl(
2013
+ ctx,
2014
+ track_index=-1000, # master convention
2015
+ device_name=_ANALYZER_DEVICE_NAME,
2016
+ allow_duplicate=False,
2017
+ )
2018
+ except Exception as exc:
2019
+ # Typical path: device not in browser (user hasn't installed via
2020
+ # install_m4l_device yet).
2021
+ return {
2022
+ "status": "install_required",
2023
+ "error": str(exc),
2024
+ "hint": (
2025
+ "LivePilot_Analyzer not found in Ableton's browser. Install "
2026
+ "first with install_m4l_device(source_path="
2027
+ "\"<repo>/m4l_device/LivePilot_Analyzer.amxd\") — that copies "
2028
+ "the .amxd into ~/Music/Ableton/User Library/Presets/Audio "
2029
+ "Effects/Max Audio Effect/. Then call ensure_analyzer_on_master "
2030
+ "again to complete the load."
2031
+ ),
2032
+ }
2033
+
2034
+ device_index = (loaded or {}).get("device_index")
2035
+ return {
2036
+ "status": "loaded",
2037
+ "device_index": device_index,
2038
+ "is_last_on_master": True, # fresh load always lands at the end
2039
+ "duplicate_count": 1,
2040
+ }
@@ -349,6 +349,80 @@ def _normalize_batch_entry(entry: dict) -> dict:
349
349
  return {"name_or_index": key, "value": entry["value"]}
350
350
 
351
351
 
352
+ _SNAP_EPSILON = 1e-5
353
+
354
+
355
+ def _detect_snapped_params(
356
+ requested: list[dict], response: dict,
357
+ ) -> list[dict]:
358
+ """Compare requested parameter values against Ableton's returned
359
+ values; surface any that were silently snapped.
360
+
361
+ BUG #4 fix (v1.20.2): quantized-enum params (e.g., Beat Repeat's
362
+ "Gate" at 0/1/2/... integer enum) silently snap a caller's float
363
+ request to the nearest step. Pre-fix, the response gave no signal —
364
+ callers saw success with the snapped value hidden in `value_string`.
365
+
366
+ Returns a list of {name, requested, actual, display_value} entries
367
+ for params whose actual (returned) value differs from the requested
368
+ value by more than _SNAP_EPSILON. String params are compared
369
+ exactly. Integer params use int equality.
370
+
371
+ Empty list when nothing snapped — callers can check
372
+ ``result.get("snapped_params") == []`` as a go/no-go signal.
373
+ """
374
+ result_params = response.get("parameters") or []
375
+ if not isinstance(result_params, list):
376
+ return []
377
+
378
+ # Build {key → requested_value} from the original caller input.
379
+ # Accept any of the same schemas _normalize_batch_entry accepts.
380
+ by_key: dict = {}
381
+ for entry in requested:
382
+ if not isinstance(entry, dict):
383
+ continue
384
+ for key_name in ("parameter_name", "name", "parameter_index",
385
+ "index", "name_or_index"):
386
+ if key_name in entry:
387
+ by_key[entry[key_name]] = entry.get("value")
388
+ break
389
+
390
+ snapped: list[dict] = []
391
+ for rp in result_params:
392
+ if not isinstance(rp, dict):
393
+ continue
394
+ name = rp.get("name")
395
+ # Match by name first (most common), fall back to index
396
+ requested_val = by_key.get(name)
397
+ if requested_val is None and "index" in rp:
398
+ requested_val = by_key.get(rp["index"])
399
+ if requested_val is None:
400
+ continue
401
+ actual_val = rp.get("value")
402
+ if actual_val is None:
403
+ continue
404
+
405
+ # Compare with type-appropriate tolerance. Numeric → epsilon;
406
+ # other types → strict equality.
407
+ try:
408
+ req_f = float(requested_val)
409
+ act_f = float(actual_val)
410
+ did_snap = abs(req_f - act_f) > _SNAP_EPSILON
411
+ except (TypeError, ValueError):
412
+ did_snap = requested_val != actual_val
413
+
414
+ if did_snap:
415
+ snapped.append({
416
+ "name": name,
417
+ "requested": requested_val,
418
+ "actual": actual_val,
419
+ "display_value": rp.get("display_value"),
420
+ "value_string": rp.get("value_string"),
421
+ })
422
+
423
+ return snapped
424
+
425
+
352
426
  @mcp.tool()
353
427
  def batch_set_parameters(
354
428
  ctx: Context,
@@ -363,18 +437,30 @@ def batch_set_parameters(
363
437
  - {"parameter_name": "Dry/Wet", "value": V} (preferred)
364
438
  - {"name_or_index": X, "value": V} (legacy, still accepted)
365
439
 
366
- track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
440
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
441
+
442
+ Response (v1.20.2+): the dict now includes a ``snapped_params`` list
443
+ when quantized-enum parameters were silently snapped by Ableton
444
+ (requested 0.3, received 0). Empty list means every requested value
445
+ round-tripped within 1e-5 tolerance. Callers using this tool to
446
+ drive deterministic state should inspect ``snapped_params`` before
447
+ assuming success — see BUG #4 in the v1.20 live-test campaign for
448
+ the motivating case (Beat Repeat Gate).
449
+ """
367
450
  _validate_track_index(track_index)
368
451
  _validate_device_index(device_index)
369
- parameters = _ensure_list(parameters)
370
- if not parameters:
452
+ parameters_list = _ensure_list(parameters)
453
+ if not parameters_list:
371
454
  raise ValueError("parameters list cannot be empty")
372
- normalized = [_normalize_batch_entry(e) for e in parameters]
373
- return _get_ableton(ctx).send_command("batch_set_parameters", {
455
+ normalized = [_normalize_batch_entry(e) for e in parameters_list]
456
+ response = _get_ableton(ctx).send_command("batch_set_parameters", {
374
457
  "track_index": track_index,
375
458
  "device_index": device_index,
376
459
  "parameters": normalized,
377
460
  })
461
+ if isinstance(response, dict):
462
+ response["snapped_params"] = _detect_snapped_params(parameters_list, response)
463
+ return response
378
464
 
379
465
 
380
466
  @mcp.tool()
@@ -134,6 +134,35 @@ def verify_device_alive(
134
134
  }
135
135
 
136
136
 
137
+ def _find_name_collisions(ctx: Context, name: str) -> list[int]:
138
+ """Return track indices whose name exactly matches `name` (case-sensitive).
139
+
140
+ BUG #5 fix (v1.20.2): downstream role-based resolvers like
141
+ find_tracks_by_role match on track names. If create_midi_track
142
+ creates a second "Pad" while another "Pad" already exists, mix
143
+ moves like widen_stereo match BOTH — applying the change twice.
144
+ This helper enables create_*_track to warn the caller so they can
145
+ pick a unique name or explicitly accept the collision.
146
+
147
+ Best-effort: returns [] when session_info can't be fetched —
148
+ collision detection must never block creation.
149
+ """
150
+ try:
151
+ info = _get_ableton(ctx).send_command("get_session_info")
152
+ except Exception:
153
+ return []
154
+ if not isinstance(info, dict):
155
+ return []
156
+ tracks = info.get("tracks") or []
157
+ matches: list[int] = []
158
+ for t in tracks:
159
+ if isinstance(t, dict) and t.get("name") == name:
160
+ idx = t.get("index")
161
+ if isinstance(idx, int):
162
+ matches.append(idx)
163
+ return matches
164
+
165
+
137
166
  @mcp.tool()
138
167
  def create_midi_track(
139
168
  ctx: Context,
@@ -141,7 +170,18 @@ def create_midi_track(
141
170
  name: Optional[str] = None,
142
171
  color: Optional[int] = None,
143
172
  ) -> dict:
144
- """Create a new MIDI track. index=-1 appends at end."""
173
+ """Create a new MIDI track. index=-1 appends at end.
174
+
175
+ Response (v1.20.2+): when `name` is provided, the response carries
176
+ a ``name_collision`` bool and ``existing_tracks_with_same_name``
177
+ list[int]. Downstream role-based resolvers (find_tracks_by_role)
178
+ match duplicate names and apply mix changes twice — check the
179
+ warning before proceeding with mix moves on the new track's role.
180
+ """
181
+ collisions: list[int] = []
182
+ if name is not None and name.strip():
183
+ collisions = _find_name_collisions(ctx, name)
184
+
145
185
  params = {"index": index}
146
186
  if name is not None:
147
187
  if not name.strip():
@@ -150,7 +190,13 @@ def create_midi_track(
150
190
  if color is not None:
151
191
  _validate_color_index(color)
152
192
  params["color_index"] = color
153
- return _get_ableton(ctx).send_command("create_midi_track", params)
193
+ result = _get_ableton(ctx).send_command("create_midi_track", params)
194
+ if isinstance(result, dict):
195
+ # Always stamp both fields so callers can check unconditionally
196
+ # (False + [] when no name provided or no collision).
197
+ result["name_collision"] = bool(collisions)
198
+ result["existing_tracks_with_same_name"] = collisions
199
+ return result
154
200
 
155
201
 
156
202
  @mcp.tool()
@@ -160,7 +206,15 @@ def create_audio_track(
160
206
  name: Optional[str] = None,
161
207
  color: Optional[int] = None,
162
208
  ) -> dict:
163
- """Create a new audio track. index=-1 appends at end."""
209
+ """Create a new audio track. index=-1 appends at end.
210
+
211
+ Response (v1.20.2+): ``name_collision`` + ``existing_tracks_with_same_name``
212
+ same as create_midi_track — see BUG #5 rationale there.
213
+ """
214
+ collisions: list[int] = []
215
+ if name is not None and name.strip():
216
+ collisions = _find_name_collisions(ctx, name)
217
+
164
218
  params = {"index": index}
165
219
  if name is not None:
166
220
  if not name.strip():
@@ -169,7 +223,11 @@ def create_audio_track(
169
223
  if color is not None:
170
224
  _validate_color_index(color)
171
225
  params["color_index"] = color
172
- return _get_ableton(ctx).send_command("create_audio_track", params)
226
+ result = _get_ableton(ctx).send_command("create_audio_track", params)
227
+ if isinstance(result, dict):
228
+ result["name_collision"] = bool(collisions)
229
+ result["existing_tracks_with_same_name"] = collisions
230
+ return result
173
231
 
174
232
 
175
233
  @mcp.tool()
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.20.1",
3
+ "version": "1.20.3",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 429 tools, 53 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 430 tools, 53 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.20.1"
8
+ __version__ = "1.20.3"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.dreamrec/livepilot",
4
- "description": "429-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "430-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
5
5
  "repository": {
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.20.1",
9
+ "version": "1.20.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.20.1",
14
+ "version": "1.20.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }