livepilot 1.27.0 → 1.27.2

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 (66) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +26 -7
  3. package/bin/livepilot.js +86 -23
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +48 -11
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/fast/brief_builder.py +6 -2
  16. package/mcp_server/composer/full/apply.py +9 -0
  17. package/mcp_server/composer/full/layer_planner.py +16 -6
  18. package/mcp_server/composer/tools.py +12 -6
  19. package/mcp_server/connection.py +2 -1
  20. package/mcp_server/creative_constraints/engine.py +46 -0
  21. package/mcp_server/experiment/engine.py +10 -3
  22. package/mcp_server/grader/client.py +30 -2
  23. package/mcp_server/grader/tools.py +8 -2
  24. package/mcp_server/hook_hunter/analyzer.py +15 -2
  25. package/mcp_server/m4l_bridge.py +145 -54
  26. package/mcp_server/memory/taste_graph.py +16 -0
  27. package/mcp_server/memory/technique_store.py +23 -3
  28. package/mcp_server/mix_engine/state_builder.py +4 -2
  29. package/mcp_server/musical_intelligence/detectors.py +11 -2
  30. package/mcp_server/musical_intelligence/tools.py +15 -2
  31. package/mcp_server/preview_studio/engine.py +30 -1
  32. package/mcp_server/project_brain/tools.py +56 -52
  33. package/mcp_server/reference_engine/tools.py +22 -2
  34. package/mcp_server/runtime/execution_router.py +6 -0
  35. package/mcp_server/runtime/live_version.py +27 -8
  36. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  37. package/mcp_server/runtime/remote_commands.py +9 -4
  38. package/mcp_server/runtime/safety_kernel.py +11 -0
  39. package/mcp_server/sample_engine/critics.py +7 -3
  40. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  41. package/mcp_server/sample_engine/tools.py +53 -21
  42. package/mcp_server/server.py +1 -1
  43. package/mcp_server/song_brain/builder.py +17 -1
  44. package/mcp_server/sound_design/tools.py +1 -1
  45. package/mcp_server/splice_client/http_bridge.py +43 -1
  46. package/mcp_server/splice_client/models.py +7 -3
  47. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  48. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  49. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  50. package/mcp_server/tools/_composition_engine/models.py +6 -0
  51. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  52. package/mcp_server/tools/clips.py +5 -4
  53. package/mcp_server/tools/composition.py +5 -1
  54. package/mcp_server/tools/midi_io.py +40 -1
  55. package/mcp_server/tools/transport.py +1 -1
  56. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  57. package/mcp_server/user_corpus/runner.py +7 -1
  58. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  59. package/mcp_server/user_corpus/tools.py +45 -9
  60. package/mcp_server/wonder_mode/tools.py +66 -27
  61. package/package.json +1 -1
  62. package/remote_script/LivePilot/__init__.py +21 -8
  63. package/remote_script/LivePilot/server.py +23 -3
  64. package/remote_script/LivePilot/tracks.py +11 -5
  65. package/requirements.txt +1 -1
  66. package/server.json +2 -2
@@ -17,13 +17,20 @@ from . import engine
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def _build_synth_profiles_for_wonder(ctx, request_text: str, diagnosis: dict) -> list:
20
+ def _build_synth_profiles_for_wonder(
21
+ ctx, request_text: str, diagnosis: dict, session_info: dict | None = None
22
+ ) -> list:
21
23
  """Build SynthProfile objects for every track holding a native synth.
22
24
 
23
25
  Wires the synthesis_brain producer into enter_wonder_mode's runtime
24
26
  flow — without this helper, ``propose_synth_branches`` is registered
25
27
  but unreachable from the MCP surface.
26
28
 
29
+ ``session_info``: optional pre-fetched get_session_info payload. When
30
+ a non-empty dict is supplied the helper reuses it instead of issuing
31
+ its own round-trip on the single-client TCP socket. Callers should
32
+ always thread the shared payload to avoid redundant fetches.
33
+
27
34
  Returns a list of SynthProfile objects (possibly empty). All errors
28
35
  are swallowed and logged — missing devices, disconnected Ableton,
29
36
  unsupported devices all land as "no synth branches" rather than
@@ -39,11 +46,16 @@ def _build_synth_profiles_for_wonder(ctx, request_text: str, diagnosis: dict) ->
39
46
  if ableton is None:
40
47
  return []
41
48
 
42
- try:
43
- session = ableton.send_command("get_session_info", {})
44
- except Exception as exc:
45
- logger.debug("session fetch for synth profiles failed: %s", exc)
46
- return []
49
+ # Reuse the pre-fetched session payload when available; only fall back
50
+ # to a fresh round-trip when called without one.
51
+ if isinstance(session_info, dict) and session_info and "error" not in session_info:
52
+ session = session_info
53
+ else:
54
+ try:
55
+ session = ableton.send_command("get_session_info", {})
56
+ except Exception as exc:
57
+ logger.debug("session fetch for synth profiles failed: %s", exc)
58
+ return []
47
59
  if not isinstance(session, dict) or "error" in session:
48
60
  return []
49
61
 
@@ -164,21 +176,35 @@ def _get_ledger_entries(ctx: Context) -> list[dict]:
164
176
  return []
165
177
 
166
178
 
167
- def _get_stuckness_report(ctx: Context, song_brain: dict) -> dict | None:
168
- """Run stuckness detection on recent actions if available."""
179
+ def _get_stuckness_report(
180
+ ctx: Context,
181
+ song_brain: dict,
182
+ action_ledger: list[dict] | None = None,
183
+ session_info: dict | None = None,
184
+ ) -> dict | None:
185
+ """Run stuckness detection on recent actions if available.
186
+
187
+ ``action_ledger`` and ``session_info`` may be supplied pre-fetched by
188
+ the caller. enter_wonder_mode already fetches both once at the top of
189
+ the flow, so threading them here avoids a duplicate ledger read and a
190
+ redundant get_session_info round-trip on the single-client TCP socket.
191
+ """
169
192
  try:
170
193
  from ..stuckness_detector.detector import detect_stuckness
171
- action_ledger = _get_ledger_entries(ctx)
194
+ if action_ledger is None:
195
+ action_ledger = _get_ledger_entries(ctx)
172
196
  if not action_ledger:
173
197
  return None
174
- # Pass session_info if available for better accuracy
175
- session_info = {}
176
- try:
177
- ableton = ctx.lifespan_context.get("ableton")
178
- if ableton:
179
- session_info = ableton.send_command("get_session_info", {})
180
- except Exception as exc:
181
- logger.warning("session_info fetch for stuckness failed: %s", exc)
198
+ # Reuse the pre-fetched session payload; only round-trip if the
199
+ # caller did not supply one.
200
+ if session_info is None:
201
+ session_info = {}
202
+ try:
203
+ ableton = ctx.lifespan_context.get("ableton")
204
+ if ableton:
205
+ session_info = ableton.send_command("get_session_info", {})
206
+ except Exception as exc:
207
+ logger.warning("session_info fetch for stuckness failed: %s", exc)
182
208
  report = detect_stuckness(
183
209
  action_history=action_ledger,
184
210
  session_info=session_info,
@@ -218,7 +244,24 @@ def enter_wonder_mode(
218
244
  taste_graph = _get_taste_graph(ctx)
219
245
  active_constraints = _get_active_constraints()
220
246
  action_ledger = _get_ledger_entries(ctx)
221
- stuckness_report = _get_stuckness_report(ctx, song_brain)
247
+
248
+ # Single get_session_info round-trip for the whole flow. The Remote
249
+ # Script is single-client on TCP 9878, so each redundant fetch is a
250
+ # serialized round-trip — fetch once and thread the payload into the
251
+ # stuckness report, the kernel dict, and the synth-profile builder.
252
+ session_info: dict = {}
253
+ try:
254
+ ableton = ctx.lifespan_context.get("ableton")
255
+ if ableton:
256
+ session_info = ableton.send_command("get_session_info", {})
257
+ except Exception as exc:
258
+ logger.warning("session_info fetch failed: %s", exc)
259
+ if not isinstance(session_info, dict) or "error" in session_info:
260
+ session_info = {}
261
+
262
+ stuckness_report = _get_stuckness_report(
263
+ ctx, song_brain, action_ledger=action_ledger, session_info=session_info
264
+ )
222
265
 
223
266
  # 1. Build diagnosis
224
267
  diagnosis = build_diagnosis(
@@ -249,14 +292,8 @@ def enter_wonder_mode(
249
292
  # Graceful degradation — analytical variants still work
250
293
  logger.warning("sample opportunity search failed: %s", exc)
251
294
 
252
- # 1c. Get session info for kernel
253
- session_info = {}
254
- try:
255
- ableton = ctx.lifespan_context.get("ableton")
256
- if ableton:
257
- session_info = ableton.send_command("get_session_info", {})
258
- except Exception as exc:
259
- logger.warning("session_info fetch for kernel failed: %s", exc)
295
+ # 1c. session_info for the kernel was already fetched once above and
296
+ # threaded into the stuckness report — reuse it here, no extra round-trip.
260
297
 
261
298
  # 2. Generate variants (legacy path)
262
299
  result = engine.generate_wonder_variants(
@@ -291,7 +328,9 @@ def enter_wonder_mode(
291
328
  # track that holds a native synth. Lets propose_synth_branches reach
292
329
  # the runtime — without this the producer is dark despite being
293
330
  # registered in the conductor.
294
- synth_profiles = _build_synth_profiles_for_wonder(ctx, request_text, diag_dict)
331
+ synth_profiles = _build_synth_profiles_for_wonder(
332
+ ctx, request_text, diag_dict, session_info=session_info
333
+ )
295
334
 
296
335
  # Composer branches fire when the base conductor routes to
297
336
  # composition — otherwise we'd be emitting composition scaffolding
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 467 tools, 56 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
6
6
  "author": "Pilot Studio",
@@ -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.0"
8
+ __version__ = "1.27.2"
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
 
@@ -346,9 +346,14 @@ class LivePilotServer(object):
346
346
  else:
347
347
  timeout = 10
348
348
 
349
- # Per-command response queue
349
+ # Per-command response queue + cancellation flag. The flag is shared
350
+ # between this TCP thread and the main thread that dequeues the item:
351
+ # if we time out below, we set it so _process_next_command skips the
352
+ # (now-abandoned) dispatch instead of mutating Live after the client
353
+ # has already been told the command timed out (phantom write).
350
354
  response_queue = queue.Queue()
351
- self._command_queue.put((command, response_queue))
355
+ cancelled = threading.Event()
356
+ self._command_queue.put((command, response_queue, cancelled))
352
357
 
353
358
  # Schedule processing on Ableton's main thread
354
359
  try:
@@ -385,6 +390,13 @@ class LivePilotServer(object):
385
390
  try:
386
391
  resp = response_queue.get(timeout=timeout)
387
392
  except queue.Empty:
393
+ # Mark the queued command as abandoned. If it hasn't been dequeued
394
+ # yet (or is awaiting its settle hop), _process_next_command sees
395
+ # the flag and skips router.dispatch — preventing a write that the
396
+ # client was just told timed out from executing on Live's main
397
+ # thread later. If dispatch already happened, setting the flag is
398
+ # a harmless no-op.
399
+ cancelled.set()
388
400
  resp = {
389
401
  "id": request_id,
390
402
  "ok": False,
@@ -399,10 +411,18 @@ class LivePilotServer(object):
399
411
  """Called on Ableton's main thread via schedule_message.
400
412
  Processes one command from the queue."""
401
413
  try:
402
- command, response_queue = self._command_queue.get_nowait()
414
+ command, response_queue, cancelled = self._command_queue.get_nowait()
403
415
  except queue.Empty:
404
416
  return
405
417
 
418
+ # The TCP thread already gave up on this command (timed out) and told
419
+ # the client so. Do NOT dispatch it — running the write now would be a
420
+ # phantom mutation. Nobody is waiting on response_queue, so just keep
421
+ # the main-thread pump alive by draining whatever is left.
422
+ if cancelled.is_set():
423
+ self._drain_queue()
424
+ return
425
+
406
426
  cmd_type = command.get("type", "")
407
427
  is_write = is_write_command(cmd_type)
408
428
 
@@ -91,12 +91,18 @@ def get_track_info(song, params):
91
91
  if track.is_foldable:
92
92
  result["fold_state"] = bool(track.fold_state)
93
93
 
94
- # Regular tracks have arm and input type; return tracks get null values
94
+ # Regular tracks expose arm + input type; Group/Return/Master tracks raise
95
+ # RuntimeError on these LOM properties. hasattr() does NOT catch it (Live
96
+ # raises rather than omitting the attribute), so a Group track at a positive
97
+ # index used to crash get_track_info. Guard each access — mirrors the
98
+ # transport.py get_session_info fix (commit 4aec8e6, closing PR #35).
95
99
  if track_index >= 0:
96
- result["arm"] = track.arm
97
- result["has_midi_input"] = track.has_midi_input
98
- result["has_audio_input"] = track.has_audio_input
99
- result["current_monitoring_state"] = track.current_monitoring_state
100
+ for _prop in ("arm", "has_midi_input", "has_audio_input",
101
+ "current_monitoring_state"):
102
+ try:
103
+ result[_prop] = getattr(track, _prop)
104
+ except Exception:
105
+ result[_prop] = None
100
106
  else:
101
107
  result["arm"] = None
102
108
  result["has_midi_input"] = None
package/requirements.txt CHANGED
@@ -13,7 +13,7 @@ mutagen>=1.47.0
13
13
  # falls back to the SQLite sounds.db which only returns locally downloaded
14
14
  # samples (see docs/2026-04-14-bugs-discovered.md — P0-2).
15
15
  grpcio>=1.81.1
16
- protobuf>=7.35.0
16
+ protobuf>=7.35.1
17
17
 
18
18
  # Known benign warning during install:
19
19
  # ERROR: pip's dependency resolver does not currently take into account
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.27.0",
9
+ "version": "1.27.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.27.0",
14
+ "version": "1.27.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }