livepilot 1.27.1 → 1.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -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.27.1"
8
+ __version__ = "1.27.3"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -98,22 +98,28 @@ def _force_reload_handlers(cs=None):
98
98
  except Exception:
99
99
  pass
100
100
 
101
+ failures = []
102
+
101
103
  try:
102
104
  importlib.reload(router)
103
105
  except Exception as exc:
104
- _log("reload(router) FAILED — %s: %s" % (type(exc).__name__, exc))
106
+ msg = "reload(router) FAILED — %s: %s" % (type(exc).__name__, exc)
107
+ _log(msg)
108
+ failures.append(("LivePilot.router", "%s: %s" % (type(exc).__name__, exc)))
105
109
 
106
110
  try:
107
111
  importlib.reload(utils)
108
112
  except Exception as exc:
109
- _log("reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc))
113
+ msg = "reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc)
114
+ _log(msg)
115
+ failures.append(("LivePilot.utils", "%s: %s" % (type(exc).__name__, exc)))
110
116
 
111
117
  # Invalidate caches so iter_modules sees newly-added files even if
112
118
  # an importer cached the previous directory listing.
113
119
  importlib.invalidate_caches()
114
120
  pkg = _sys.modules.get("LivePilot")
115
121
  if pkg is None or getattr(pkg, "__path__", None) is None:
116
- return
122
+ return {"discovered": 0, "reloaded": 0, "first_imported": 0, "failures": failures}
117
123
 
118
124
  discovered = reloaded = first_imported = 0
119
125
  for _finder, modname, _is_pkg in pkgutil.iter_modules(pkg.__path__):
@@ -130,8 +136,9 @@ def _force_reload_handlers(cs=None):
130
136
  importlib.import_module(full_name)
131
137
  first_imported += 1
132
138
  except Exception as exc:
133
- _log("reload(%s) FAILED — %s: %s" % (
134
- full_name, type(exc).__name__, exc))
139
+ msg = "reload(%s) FAILED — %s: %s" % (full_name, type(exc).__name__, exc)
140
+ _log(msg)
141
+ failures.append((full_name, "%s: %s" % (type(exc).__name__, exc)))
135
142
 
136
143
  # reload_handlers_cmd lives in __init__.py (not a handler module),
137
144
  # so the step-3 loop does not cover it. Re-register manually.
@@ -140,15 +147,21 @@ def _force_reload_handlers(cs=None):
140
147
  _log("reload complete — %d discovered (%d reloaded, %d first-imported)" % (
141
148
  discovered, reloaded, first_imported))
142
149
 
150
+ return {"discovered": discovered, "reloaded": reloaded,
151
+ "first_imported": first_imported, "failures": failures}
152
+
143
153
 
144
154
  def reload_handlers_cmd(song, params):
145
155
  """TCP-accessible reload trigger. Lets automation refresh handlers
146
156
  without a UI Control Surface toggle — the core dev-loop improvement.
147
- Returns the handler count so the caller can assert before/after."""
148
- _force_reload_handlers(cs=None)
157
+ Returns the handler count so the caller can assert before/after.
158
+ Returns any reload failures so the caller can detect silent errors."""
159
+ result = _force_reload_handlers(cs=None)
160
+ failures = result.get("failures", []) if isinstance(result, dict) else []
149
161
  return {
150
162
  "reloaded": True,
151
163
  "handler_count": len(router._handlers),
164
+ "errors": [{"module": m, "error": e} for m, e in failures],
152
165
  }
153
166
 
154
167
 
@@ -47,7 +47,12 @@ def create_arrangement_clip(song, params):
47
47
 
48
48
  Uses Live 12's Track.duplicate_clip_to_arrangement(clip, time) API.
49
49
  When the requested length exceeds the source clip, multiple adjacent
50
- copies are placed to fill the timeline region seamlessly.
50
+ copies are placed to fill the timeline region seamlessly. Copies are
51
+ tiled every min(loop_length, source_length) beats so that a
52
+ loop_length larger than the source never leaves a silent gap between
53
+ copies. loop_length sets the internal loop region only when
54
+ loop_length < source_length; when larger, copies tile by source_length
55
+ and each plays its full native content.
51
56
 
52
57
  Required: track_index, clip_slot_index, start_time, length
53
58
  Optional: loop_length (defaults to session clip length), name, color_index
@@ -83,40 +88,61 @@ def create_arrangement_clip(song, params):
83
88
  name = str(params.get("name", ""))
84
89
  color_index = params.get("color_index")
85
90
 
86
- # Place adjacent copies to fill the requested length
91
+ # Tile the source across [start_time, end_pos). A single duplicate only
92
+ # carries `source_length` beats of content, so to fill the region
93
+ # seamlessly we step by `min(loop_length, source_length)` — stepping by a
94
+ # larger loop_length would leave a silent (loop_length - source_length)
95
+ # gap between copies (P2-54). loop_length sets the internal loop region
96
+ # only when loop_length < source_length; when larger, copies tile by
97
+ # source_length and each plays its full native content.
98
+ step = min(loop_length, source_length)
99
+ # A zero/negative effective step would make the tiling loop below never
100
+ # advance -> unbounded duplicate_clip_to_arrangement on Live's main thread
101
+ # = DAW hard-freeze. A source clip can report length 0 (LOM state), so
102
+ # floor the step into a clean structured error instead of looping forever.
103
+ if step <= 0:
104
+ raise ValueError(
105
+ "source clip has zero/invalid length; cannot tile arrangement copies")
87
106
  song.begin_undo_step()
88
107
  try:
89
108
  pos = start_time
90
109
  end_pos = start_time + length
91
110
  clip_count = 0
92
- first_clip_index = None
111
+ # Record only the placement position per copy. We defer
112
+ # name/color/loop-region edits to a single post-loop pass below so
113
+ # the hot loop does NOT re-materialize the full arrangement_clips
114
+ # vector every iteration (was O(K^2) list builds + K linear scans —
115
+ # P2-52). duplicate_clip_to_arrangement places the new clip at `pos`,
116
+ # which is the key we use to find it once afterward.
117
+ placements = [] # list of (pos, target_len)
93
118
 
94
119
  while pos < end_pos:
95
120
  track.duplicate_clip_to_arrangement(source_clip, pos)
121
+ # When loop_length < source_length, only loop_length beats of
122
+ # content should play per copy; otherwise the copy plays its full
123
+ # source content (capped at the remaining region).
124
+ remaining = end_pos - pos
125
+ target_len = min(loop_length, remaining)
126
+ placements.append((pos, target_len))
127
+ clip_count += 1
128
+ pos += step
96
129
 
97
- # Find the new clip by position (id()-based detection is unreliable
98
- # because CPython can reuse addresses of GC'd LOM wrappers)
130
+ # Single post-loop pass: one list() materialization, then apply
131
+ # name/color/loop-region to each placed clip located by start_time.
132
+ if placements:
99
133
  arr_clips = list(track.arrangement_clips)
100
- new_clip = None
101
- new_clip_idx = None
102
- for i, c in enumerate(arr_clips):
103
- if abs(c.start_time - pos) < 0.01:
104
- new_clip = c
105
- new_clip_idx = i
106
- break
107
-
108
- if new_clip is not None:
109
- if first_clip_index is None:
110
- first_clip_index = new_clip_idx
134
+ # Index clips by rounded start_time for O(1) lookup.
135
+ by_start = {}
136
+ for c in arr_clips:
137
+ by_start.setdefault(round(c.start_time, 2), c)
138
+ for place_pos, target_len in placements:
139
+ new_clip = by_start.get(round(place_pos, 2))
140
+ if new_clip is None:
141
+ continue
111
142
  if name:
112
143
  new_clip.name = name
113
144
  if color_index is not None:
114
145
  new_clip.color_index = int(color_index)
115
-
116
- # When loop_length < source_length, set the internal
117
- # loop region so only loop_length beats of content play.
118
- remaining = end_pos - pos
119
- target_len = min(loop_length, remaining)
120
146
  if target_len < source_length:
121
147
  try:
122
148
  new_clip.looping = True
@@ -125,15 +151,21 @@ def create_arrangement_clip(song, params):
125
151
  except (AttributeError, RuntimeError):
126
152
  pass
127
153
 
128
- clip_count += 1
129
- pos += loop_length
130
-
131
154
  # Trim the last clip's overshoot: if the last duplicate extends
132
155
  # past end_pos, remove notes beyond the requested region and
133
156
  # set loop_end so only the needed portion plays.
157
+ #
158
+ # Restrict the scan to clips THIS call placed (matched by their
159
+ # placement start). Scanning all arrangement_clips would also match a
160
+ # pre-existing clip on the same track that happens to start at/after
161
+ # start_time and end past end_pos — trimming/removing notes from the
162
+ # user's existing content (silent data loss).
134
163
  if clip_count > 0:
164
+ placed_starts = {round(p, 2) for p, _ in placements}
135
165
  arr_clips = list(track.arrangement_clips)
136
166
  for c in arr_clips:
167
+ if round(c.start_time, 2) not in placed_starts:
168
+ continue
137
169
  clip_end = c.start_time + c.length
138
170
  if c.start_time >= start_time and clip_end > end_pos + 0.01:
139
171
  # This clip overshoots — trim its content
@@ -840,14 +872,33 @@ def force_arrangement(song, params):
840
872
  song.stop_playing()
841
873
 
842
874
  # 2. Stop playing clip slots individually to release session overrides
843
- # (track.stop_all_clips() throws STATE_ERROR when tracks have no clips)
844
- for track in list(song.tracks) + list(song.return_tracks):
845
- try:
846
- for slot in track.clip_slots:
847
- if slot.has_clip and slot.is_playing:
875
+ # (track.stop_all_clips() throws STATE_ERROR when tracks have no clips).
876
+ # §9c: a session clip that fails to stop re-asserts the override and
877
+ # makes playback start mid-song, so genuine stop failures must NOT be
878
+ # silently discarded collect them and surface to the caller (P2-53).
879
+ stop_errors = []
880
+ # Only regular tracks hold session clips. Return tracks (and the master)
881
+ # cannot have clip slots — iterating their `clip_slots` raises
882
+ # AttributeError on the Live 12.4.x LOM, which would either crash this
883
+ # tool or (when guarded) inject one spurious stop-error per return track,
884
+ # forcing arrangement_active=False on a clean run and breaking the §9c
885
+ # finalize workflow. Session overrides only ever come from song.tracks.
886
+ for track_idx, track in enumerate(song.tracks):
887
+ for slot_idx, slot in enumerate(track.clip_slots):
888
+ try:
889
+ # ClipSlot.is_playing is not exposed on every Live build —
890
+ # guard it (mirrors the defensive read elsewhere in this file)
891
+ # so a missing attribute isn't logged as a spurious stop-error
892
+ # for every clip-bearing slot, which would force
893
+ # arrangement_active=False on a clean run (P2-53).
894
+ if slot.has_clip and getattr(slot, "is_playing", False):
848
895
  slot.clip.stop()
849
- except Exception:
850
- pass
896
+ except Exception as exc:
897
+ stop_errors.append({
898
+ "track": track_idx,
899
+ "slot": slot_idx,
900
+ "error": str(exc),
901
+ })
851
902
 
852
903
  # 3. Global back-to-arranger
853
904
  song.back_to_arranger = True
@@ -867,12 +918,21 @@ def force_arrangement(song, params):
867
918
  if play:
868
919
  song.start_playing()
869
920
 
870
- return {
871
- "arrangement_active": True,
921
+ # A clip that failed to stop may still be overriding the arrangement, so
922
+ # report partial failure rather than claiming unconditional success (P2-53).
923
+ result = {
924
+ "arrangement_active": not stop_errors,
872
925
  "position": song.current_song_time,
873
926
  "is_playing": song.is_playing,
874
927
  "loop": song.loop,
928
+ "stop_errors": stop_errors,
875
929
  }
930
+ if stop_errors:
931
+ result["warning"] = (
932
+ "%d session clip(s) failed to stop and may still override the "
933
+ "arrangement — playback could start mid-song" % len(stop_errors)
934
+ )
935
+ return result
876
936
 
877
937
 
878
938
  # ── Session-record arrangement automation (T5 workaround) ────────────
@@ -282,6 +282,10 @@ def load_browser_item(song, params):
282
282
  "loaded": True,
283
283
  "name": found.name,
284
284
  "device_count": device_count,
285
+ # Live appends the loaded device at the END of the chain, so the
286
+ # just-loaded device is the last one. The MCP wrapper applies
287
+ # role-defaults / hygiene to THIS index (see tools/browser.py).
288
+ "device_index": device_count - 1 if device_count > 0 else 0,
285
289
  }
286
290
 
287
291
  # ── Strategy 2: extract name from URI, search by name ────────────
@@ -337,6 +341,8 @@ def load_browser_item(song, params):
337
341
  "loaded": True,
338
342
  "name": found.name,
339
343
  "device_count": device_count,
344
+ # Loaded device is appended last → its index is count-1.
345
+ "device_index": device_count - 1 if device_count > 0 else 0,
340
346
  }
341
347
 
342
348
  raise ValueError(
@@ -394,6 +400,10 @@ def load_browser_item(song, params):
394
400
  "loaded": True,
395
401
  "name": found.name,
396
402
  "device_count": device_count,
403
+ # Live appends the loaded device at the END of the chain, so the
404
+ # just-loaded device is the last one. The MCP wrapper applies
405
+ # role-defaults / hygiene to THIS index (see tools/browser.py).
406
+ "device_index": device_count - 1 if device_count > 0 else 0,
397
407
  }
398
408
 
399
409
  raise ValueError(
@@ -401,6 +411,13 @@ def load_browser_item(song, params):
401
411
  )
402
412
 
403
413
 
414
+ # Global safety bound on total LOM child-accesses across the ENTIRE scan
415
+ # (all categories combined) — see P3-47. This used to reset per category
416
+ # because `_counter` defaulted to a fresh [0] on every top-level
417
+ # `_scan_recursive` call, so the real worst case was
418
+ # _SCAN_MAX_ITERATIONS * len(categories) synchronous main-thread LOM
419
+ # accesses. scan_browser_deep now threads a single shared counter through
420
+ # every category's recursion so this constant is an honest total budget.
404
421
  _SCAN_MAX_ITERATIONS = 100000
405
422
 
406
423
 
@@ -438,22 +455,61 @@ def scan_browser_deep(song, params):
438
455
  Parameters
439
456
  ----------
440
457
  max_per_category : int, optional
441
- Maximum items to collect per top-level category (default 1000).
458
+ Maximum items to collect per top-level category (default 25000).
459
+ Raised from the original 1000 (BUG-2026-06-21 #11 /
460
+ DEEP_REVIEW P1-11): a default of 1000 silently truncated large
461
+ alphabetically-ordered categories before reaching most of the
462
+ alphabet — e.g. drum_kits stopped at "Crash" (0 kicks, 2 hats),
463
+ and `sounds` stopped inside "Brass" (no Pads/Keys/Leads at all).
464
+ 25000 comfortably covers every known category in the full
465
+ factory + pack library while `_SCAN_MAX_ITERATIONS` still bounds
466
+ worst-case main-thread work.
442
467
  max_depth : int, optional
443
468
  Maximum recursion depth into the browser tree (default 4).
469
+
470
+ Returns
471
+ -------
472
+ dict
473
+ ``categories``: ``{cat_name: [{"name", "uri", "is_loadable"}, ...]}``
474
+ ``counts``: ``{cat_name: <item count>}`` — per-category item counts,
475
+ always present so callers can detect truncation without
476
+ recomputing `len()` on the (potentially large) categories dict.
477
+ ``category_truncated``: ``{cat_name: bool}`` — True when a category
478
+ either hit `max_per_category` directly, OR the scan overall ran
479
+ out of its shared `_SCAN_MAX_ITERATIONS` iteration budget before
480
+ finishing that category (P3-47) — in both cases the category's
481
+ item list is a lower bound, not the true total.
444
482
  """
445
- max_per_category = int(params.get("max_per_category", 1000))
483
+ max_per_category = int(params.get("max_per_category", 25000))
446
484
  max_depth = int(params.get("max_depth", 4))
447
485
  browser = _get_browser()
448
486
  categories = _get_categories(browser)
449
487
 
450
488
  result = {}
489
+ counts = {}
490
+ truncated = {}
491
+ # P3-47 fix: ONE counter object shared across every top-level category's
492
+ # _scan_recursive call, instead of a fresh [0] per category. This makes
493
+ # _SCAN_MAX_ITERATIONS a true global ceiling on main-thread LOM accesses
494
+ # for the whole scan_browser_deep call, not a per-category one.
495
+ shared_counter = [0]
451
496
  for cat_name, cat_item in categories.items():
452
497
  items = []
453
- _scan_recursive(cat_item, items, 0, max_depth, max_per_category)
498
+ _scan_recursive(
499
+ cat_item, items, 0, max_depth, max_per_category, shared_counter
500
+ )
454
501
  result[cat_name] = items
502
+ counts[cat_name] = len(items)
503
+ truncated[cat_name] = (
504
+ len(items) >= max_per_category
505
+ or shared_counter[0] > _SCAN_MAX_ITERATIONS
506
+ )
455
507
 
456
- return {"categories": result}
508
+ return {
509
+ "categories": result,
510
+ "counts": counts,
511
+ "category_truncated": truncated,
512
+ }
457
513
 
458
514
 
459
515
  @register("get_device_presets")
@@ -14,6 +14,30 @@ def _get_browser():
14
14
  return Live.Application.get_application().browser
15
15
 
16
16
 
17
+ def _safe_value_string(param):
18
+ """Best-effort ``str_for_value`` readback that never raises.
19
+
20
+ Live raises RuntimeError("Invalid display value") from str_for_value on
21
+ Operator / Compressor2 / AutoFilter2 for some in-range values. On the
22
+ write path the ``param.value`` assignment has ALREADY landed before this
23
+ readback runs, so a raise here would convert a successful write into a
24
+ reported error (the agent then wrongly retries/undoes). Fall back to None
25
+ so a display-string failure never masks an applied write.
26
+ """
27
+ try:
28
+ return param.str_for_value(param.value)
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def _safe_display_value(param):
34
+ """Best-effort ``display_value`` readback that never raises (see above)."""
35
+ try:
36
+ return param.display_value
37
+ except Exception:
38
+ return None
39
+
40
+
17
41
  @register("get_device_info")
18
42
  def get_device_info(song, params):
19
43
  """Return detailed info for a single device."""
@@ -118,24 +142,33 @@ def set_device_parameter(song, params):
118
142
  raise ValueError("Must provide parameter_name or parameter_index")
119
143
 
120
144
  param.value = value
145
+ # Readbacks are best-effort: the write above already landed, so a
146
+ # str_for_value / display_value failure must not surface a successful
147
+ # write as an error.
121
148
  result = {
122
149
  "name": param.name,
123
150
  "value": param.value,
124
- "value_string": param.str_for_value(param.value),
151
+ "value_string": _safe_value_string(param),
125
152
  "min": param.min,
126
153
  "max": param.max,
154
+ "display_value": _safe_display_value(param),
127
155
  }
128
- # 12.2+: include display_value
129
- try:
130
- result["display_value"] = param.display_value
131
- except AttributeError:
132
- pass
133
156
  return result
134
157
 
135
158
 
136
159
  @register("batch_set_parameters")
137
160
  def batch_set_parameters(song, params):
138
- """Set multiple device parameters in one call."""
161
+ """Set multiple device parameters in one call.
162
+
163
+ Partial-success contract: LOM cannot truly roll back per-parameter
164
+ writes, so a mid-batch failure (typo'd name, out-of-range index) must
165
+ NOT abort the call and strand earlier writes as an opaque exception.
166
+ Instead every entry gets an ``{ok, ...}`` / ``{ok: False, error}`` result,
167
+ the whole loop is wrapped in one begin/end_undo_step so the applied
168
+ writes are a single undo unit, and the response reports ``applied`` /
169
+ ``failed`` counts plus a top-level ``ok`` that is True only if all
170
+ entries succeeded.
171
+ """
139
172
  track_index = int(params["track_index"])
140
173
  device_index = int(params["device_index"])
141
174
  parameters = params["parameters"]
@@ -144,58 +177,86 @@ def batch_set_parameters(song, params):
144
177
 
145
178
  dev_params = list(device.parameters)
146
179
  results = []
147
- for entry in parameters:
148
- value = float(entry["value"])
149
- name_or_index = entry.get("name_or_index")
150
-
151
- if isinstance(name_or_index, int) or (
152
- isinstance(name_or_index, str) and name_or_index.isdigit()
153
- ):
154
- idx = int(name_or_index)
155
- if idx < 0 or idx >= len(dev_params):
156
- raise IndexError(
157
- "Parameter index %d out of range (0..%d)"
158
- % (idx, len(dev_params) - 1)
159
- )
160
- param = dev_params[idx]
161
- else:
162
- param = None
163
- target = str(name_or_index)
164
- # Try exact match first
165
- for p in dev_params:
166
- if p.name == target:
167
- param = p
168
- break
169
- # Fallback: case-insensitive match
170
- if param is None:
171
- target_lower = target.lower()
172
- for p in dev_params:
173
- if p.name.lower() == target_lower:
174
- param = p
175
- break
176
- if param is None:
177
- # List similar parameter names for debugging
178
- available = [p.name for p in dev_params[:20]]
179
- raise ValueError(
180
- "Parameter '%s' not found on device '%s'. "
181
- "Available (first 20): %s"
182
- % (name_or_index, device.name, ", ".join(available))
183
- )
184
-
185
- param.value = value
186
- result_entry = {
187
- "name": param.name,
188
- "value": param.value,
189
- "value_string": param.str_for_value(param.value),
190
- }
191
- # 12.2+: include display_value
192
- try:
193
- result_entry["display_value"] = param.display_value
194
- except AttributeError:
195
- pass
196
- results.append(result_entry)
180
+ applied = 0
181
+ failed = 0
182
+ song.begin_undo_step()
183
+ try:
184
+ for entry in parameters:
185
+ name_or_index = entry.get("name_or_index")
186
+ try:
187
+ # A missing key would otherwise fall through to the name-search
188
+ # branch as str(None) == "None" and surface as a misleading
189
+ # "Parameter 'None' not found" — making the agent retry a
190
+ # phantom name instead of fixing the malformed entry.
191
+ if name_or_index is None:
192
+ raise ValueError(
193
+ "entry missing required 'name_or_index' key (got keys: %s)"
194
+ % sorted(entry.keys())
195
+ )
196
+ value = float(entry["value"])
197
+
198
+ if isinstance(name_or_index, int) or (
199
+ isinstance(name_or_index, str) and name_or_index.isdigit()
200
+ ):
201
+ idx = int(name_or_index)
202
+ if idx < 0 or idx >= len(dev_params):
203
+ raise IndexError(
204
+ "Parameter index %d out of range (0..%d)"
205
+ % (idx, len(dev_params) - 1)
206
+ )
207
+ param = dev_params[idx]
208
+ else:
209
+ param = None
210
+ target = str(name_or_index)
211
+ # Try exact match first
212
+ for p in dev_params:
213
+ if p.name == target:
214
+ param = p
215
+ break
216
+ # Fallback: case-insensitive match
217
+ if param is None:
218
+ target_lower = target.lower()
219
+ for p in dev_params:
220
+ if p.name.lower() == target_lower:
221
+ param = p
222
+ break
223
+ if param is None:
224
+ # List similar parameter names for debugging
225
+ available = [p.name for p in dev_params[:20]]
226
+ raise ValueError(
227
+ "Parameter '%s' not found on device '%s'. "
228
+ "Available (first 20): %s"
229
+ % (name_or_index, device.name, ", ".join(available))
230
+ )
231
+
232
+ param.value = value
233
+ # Readbacks are best-effort: the write already landed, so a
234
+ # str_for_value / display_value failure must not flip this
235
+ # entry to failed.
236
+ results.append({
237
+ "ok": True,
238
+ "name": param.name,
239
+ "value": param.value,
240
+ "value_string": _safe_value_string(param),
241
+ "display_value": _safe_display_value(param),
242
+ })
243
+ applied += 1
244
+ except Exception as exc:
245
+ results.append({
246
+ "ok": False,
247
+ "name_or_index": name_or_index,
248
+ "error": str(exc),
249
+ })
250
+ failed += 1
251
+ finally:
252
+ song.end_undo_step()
197
253
 
198
- return {"parameters": results}
254
+ return {
255
+ "ok": failed == 0,
256
+ "applied": applied,
257
+ "failed": failed,
258
+ "parameters": results,
259
+ }
199
260
 
200
261
 
201
262
  @register("toggle_device")
@@ -1299,10 +1360,19 @@ def _get_wavetable(song, params):
1299
1360
  track = get_track(song, int(params["track_index"]))
1300
1361
  device = get_device(track, int(params["device_index"]))
1301
1362
  class_name = str(getattr(device, "class_name", ""))
1302
- if "Wavetable" not in class_name:
1303
- raise ValueError("Device at index %d is not Wavetable (class_name=%s)"
1304
- % (int(params["device_index"]), class_name))
1305
- return device
1363
+ # Fast path: explicit Wavetable class_name.
1364
+ if "Wavetable" in class_name:
1365
+ return device
1366
+ # Ableton reports the Wavetable instrument as "InstrumentVector" in some
1367
+ # Live versions. Accept it only when a Wavetable-specific parameter
1368
+ # ("Osc 1 Pos") is present so that M4L devices that also use the
1369
+ # InstrumentVector class (e.g. Vector FM / Vector Grain) are rejected.
1370
+ if class_name == "InstrumentVector":
1371
+ param_names = {str(getattr(p, "name", "")) for p in getattr(device, "parameters", [])}
1372
+ if any("Osc 1 Pos" in n for n in param_names):
1373
+ return device
1374
+ raise ValueError("Device at index %d is not Wavetable (class_name=%s)"
1375
+ % (int(params["device_index"]), class_name))
1306
1376
 
1307
1377
 
1308
1378
  @register("get_wavetable_mod_targets")
@@ -426,6 +426,36 @@ def _collect_routing_diagnostic(device):
426
426
  return " | ".join(parts) if parts else "<none>"
427
427
 
428
428
 
429
+ def _match_routing_type(available, source_type):
430
+ """Resolve a caller-supplied sidechain source against Live's routing menu.
431
+
432
+ Matching ladder (first hit wins):
433
+ 1. Exact display-name match — preserves the "1-KICK" contract.
434
+ 2. Case-insensitive exact match — Live 12.4 routing menus can show the
435
+ BARE track name with no index prefix ("Kick"), so a lowercase caller
436
+ ("kick") must still resolve. Found live 2026-07-10: the suffix
437
+ fallback below never fires for unprefixed display names.
438
+ 3. Case-insensitive "-<name>" suffix match — callers (e.g. the
439
+ semantic-move compilers) may pass a bare track name while the menu
440
+ is index-prefixed ("1-Kick"); casefold both sides so "1-KICK" /
441
+ "1-Kick" / "1-kick" all resolve regardless of menu case convention.
442
+
443
+ Returns the matched routing object, or None.
444
+ """
445
+ for rt in available:
446
+ if rt.display_name == source_type:
447
+ return rt
448
+ want_cf = str(source_type).casefold()
449
+ for rt in available:
450
+ if rt.display_name.casefold() == want_cf:
451
+ return rt
452
+ suffix = "-" + want_cf
453
+ for rt in available:
454
+ if rt.display_name.casefold().endswith(suffix):
455
+ return rt
456
+ return None
457
+
458
+
429
459
  @register("set_compressor_sidechain")
430
460
  def set_compressor_sidechain(song, params):
431
461
  """Configure a Compressor's sidechain input routing (BUG-A3).
@@ -497,11 +527,7 @@ def set_compressor_sidechain(song, params):
497
527
 
498
528
  if want_type:
499
529
  available = surface["types"]
500
- matched = None
501
- for rt in available:
502
- if rt.display_name == source_type:
503
- matched = rt
504
- break
530
+ matched = _match_routing_type(available, source_type)
505
531
  if matched is None:
506
532
  options = [rt.display_name for rt in available]
507
533
  raise ValueError(