livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -278,26 +278,68 @@ class SpectralReceiver(asyncio.DatagramProtocol):
278
278
  self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
279
279
 
280
280
  def _handle_response(self, encoded: str) -> None:
281
- """Decode a single-packet base64 response."""
281
+ """Decode a single-packet base64 response.
282
+
283
+ Resolves _response_callback exactly once, then clears it. Without the
284
+ clear, a second late packet could overwrite a future belonging to a
285
+ different in-flight command. The protocol has no request id yet
286
+ (livepilot_bridge.js:666 emits bare /response), so correlation relies
287
+ on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
288
+ plus this one-shot clear.
289
+ """
282
290
  try:
283
291
  # URL-safe base64 decode (- and _ instead of + and /)
284
292
  padded = encoded + "=" * (-len(encoded) % 4)
285
293
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
286
294
  result = _normalize_bridge_payload(json.loads(decoded))
287
- if self._response_callback and not self._response_callback.done():
288
- self._response_callback.set_result(result)
295
+ cb = self._response_callback
296
+ if cb and not cb.done():
297
+ cb.set_result(result)
298
+ # Clear regardless — either we consumed it, or it was already
299
+ # done/abandoned. Future packets with no owner get dropped.
300
+ self._response_callback = None
289
301
  except Exception as exc:
290
302
  import sys
291
303
  print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
292
304
 
293
305
  def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
294
- """Reassemble chunked responses."""
306
+ """Reassemble chunked responses.
307
+
308
+ The previous implementation incremented ``_chunk_id`` only when
309
+ ``index == 0`` and assumed the first chunk always arrived first.
310
+ Under UDP reordering (rare on loopback but possible under system
311
+ load), a chunk with ``index > 0`` arriving before ``index 0`` would
312
+ be dropped into the PREVIOUS sequence's bucket — silently corrupting
313
+ that earlier response's payload.
314
+
315
+ Until the wire protocol adds an explicit sequence id, the safer
316
+ behavior is: if we see an out-of-order first-chunk (``index > 0``
317
+ with no open bucket), start a fresh bucket but log a warning. That
318
+ way we never poison a prior sequence, and the problem surfaces in
319
+ logs if it happens.
320
+ """
295
321
  if index == 0:
296
322
  self._chunk_id += 1
297
- key = str(self._chunk_id)
298
- if key not in self._chunks:
323
+ key = str(self._chunk_id)
299
324
  self._chunks[key] = {"parts": {}, "total": total}
300
325
  self._chunk_times[key] = time.monotonic()
326
+ else:
327
+ key = str(self._chunk_id)
328
+ if key not in self._chunks:
329
+ # Out-of-order arrival. Start a new bucket rather than append
330
+ # to the previous sequence's parts — that's the corruption
331
+ # path. Log once so it's diagnosable.
332
+ import sys
333
+ print(
334
+ f"LivePilot: chunk index={index}/{total} arrived before "
335
+ f"index=0 — starting fresh bucket. UDP reordering on "
336
+ f"loopback suggests system load.",
337
+ file=sys.stderr,
338
+ )
339
+ self._chunk_id += 1
340
+ key = str(self._chunk_id)
341
+ self._chunks[key] = {"parts": {}, "total": total}
342
+ self._chunk_times[key] = time.monotonic()
301
343
 
302
344
  self._chunks[key]["parts"][index] = encoded
303
345
 
@@ -357,14 +399,26 @@ class M4LBridge:
357
399
  if not self.cache.is_connected:
358
400
  return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
359
401
 
402
+ # Fail fast if there is no receiver to correlate the response. The
403
+ # previous version sent the OSC packet anyway, dropped the reply
404
+ # inside _handle_response (no future registered), and waited out
405
+ # the full 5s timeout before returning a misleading "device may be
406
+ # busy or removed" error. The real cause was "no receiver wired",
407
+ # which the caller should see immediately.
408
+ if self.receiver is None:
409
+ return {
410
+ "error": "M4L bridge has no active receiver — the UDP 9880 "
411
+ "listener did not start. Check server startup logs "
412
+ "for a bind failure on port 9880."
413
+ }
414
+
360
415
  if self._cmd_lock is None:
361
416
  self._cmd_lock = asyncio.Lock()
362
417
  async with self._cmd_lock:
363
418
  # Create a future for the response
364
419
  loop = asyncio.get_running_loop()
365
420
  future = loop.create_future()
366
- if self.receiver:
367
- self.receiver.set_response_future(future)
421
+ self.receiver.set_response_future(future)
368
422
 
369
423
  # Build and send OSC message (no leading / — Max udpreceive
370
424
  # passes messagename with / intact to JS, breaking dispatch)
@@ -376,11 +430,13 @@ class M4LBridge:
376
430
  result = await asyncio.wait_for(future, timeout=timeout)
377
431
  return result
378
432
  except asyncio.TimeoutError:
379
- # Clear the stale future so a delayed response doesn't resolve
380
- # a future that no caller is waiting on
381
- if self.receiver:
382
- self.receiver.set_response_future(None)
383
433
  return {"error": "M4L bridge timeout — device may be busy or removed"}
434
+ finally:
435
+ # Always clear the future — on success the receiver has already
436
+ # cleared it inside _handle_response, but calling again is a
437
+ # no-op. On timeout this is what prevents a delayed packet from
438
+ # resolving a future belonging to the next command.
439
+ self.receiver.set_response_future(None)
384
440
 
385
441
  async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
386
442
  """Send a capture command to the M4L device and wait for /capture_complete."""
@@ -208,11 +208,43 @@ def detect_role_conflicts(
208
208
  "Layer drum parts into one Drum Rack or pan them apart"),
209
209
  }
210
210
 
211
+ # BUG-B1 fix: intentional drum + percussion layering is the core
212
+ # aesthetic in hip-hop / Dilla / lo-fi / beat-scene music, not a
213
+ # conflict. Heuristic to demote drum-role conflicts when the track
214
+ # names make that layering obvious (one "DRUMS" + one "PERC/CONGA/
215
+ # SHAKER" is distinct instruments, not a fight for the same role).
216
+ _PERC_NAMES = {
217
+ "perc", "percussion", "conga", "congas", "shaker",
218
+ "tambourine", "cowbell", "triangle", "bongo",
219
+ "djembe", "claves", "hi-hat", "hihat", "hat",
220
+ }
221
+
222
+ def _looks_like_layering(group: list[dict]) -> bool:
223
+ """True if at least one of the tracks has a percussion-specific
224
+ name (distinct from the main drum kit)."""
225
+ if len(group) < 2:
226
+ return False
227
+ perc_track_count = 0
228
+ for track in group:
229
+ name = str(track.get("name", "")).lower()
230
+ if any(tok in name for tok in _PERC_NAMES):
231
+ perc_track_count += 1
232
+ # Needs at least one main "drums" track AND one perc track
233
+ return 1 <= perc_track_count < len(group)
234
+
211
235
  conflicts = []
212
236
  for role, (desc, rec) in UNIQUE_ROLES.items():
213
237
  group = role_groups.get(role, [])
214
238
  if len(group) > 1:
215
239
  severity = min(0.9, 0.3 + (len(group) - 1) * 0.2)
240
+ if role == "drums" and _looks_like_layering(group):
241
+ # Demote severity — this looks intentional, not a conflict
242
+ severity = max(0.1, severity - 0.4)
243
+ rec = (
244
+ "Drum + percussion layering detected — if this is "
245
+ "intentional (hip-hop / Dilla / lo-fi), ignore. "
246
+ "Otherwise: " + rec
247
+ )
216
248
  conflicts.append(RoleConflict(
217
249
  role=role,
218
250
  tracks=group,
@@ -21,34 +21,59 @@ logger = logging.getLogger(__name__)
21
21
  # ── Helpers ─────────────────────────────────────────────────────────
22
22
 
23
23
 
24
- def _infer_role(name: str, index: int, scene_count: int) -> str:
25
- """Infer a scene's role from its name or position."""
26
- lower = name.lower()
27
- for role in ("intro", "verse", "chorus", "build", "drop", "breakdown", "outro", "transition"):
28
- if role in lower:
29
- return role
30
- # Positional fallback
24
+ # BUG-E4 / E5 fix: performance_engine used to have its own _infer_role() keyword
25
+ # list and _infer_energy() static {role number} table. Those diverged from
26
+ # _composition_engine's richer section classifier, which caused
27
+ # get_performance_state and analyze_composition to label the same scenes
28
+ # differently (Deep Flow: drop vs verse, Sun Peak: drop vs chorus) and to
29
+ # report dissimilar energies (composition derived from active-track density,
30
+ # performance looked up a hard-coded 0.2/0.4/0.7 table). Now performance
31
+ # consumes composition's section graph as the source of truth and only keeps
32
+ # a positional fallback for scenes without enough data.
33
+ _POSITIONAL_FALLBACK_ROLES = {
34
+ "first": "intro",
35
+ "last": "outro",
36
+ "early": "intro",
37
+ "middle_low": "verse",
38
+ "middle_high": "chorus",
39
+ "late": "outro",
40
+ "default": "verse",
41
+ }
42
+
43
+
44
+ def _positional_fallback_role(index: int, scene_count: int) -> str:
45
+ """Map a scene index to a role when no composition data is available.
46
+
47
+ Kept only as a last-resort so we still produce a sensible answer for
48
+ unnamed scenes or when build_section_graph_from_scenes returns empty.
49
+ Callers should prefer the composition-engine result when it exists.
50
+ """
51
+ if scene_count <= 0:
52
+ return _POSITIONAL_FALLBACK_ROLES["default"]
31
53
  if index == 0:
32
- return "intro"
54
+ return _POSITIONAL_FALLBACK_ROLES["first"]
33
55
  if index == scene_count - 1:
34
- return "outro"
56
+ return _POSITIONAL_FALLBACK_ROLES["last"]
35
57
  if scene_count > 4:
36
- quarter = scene_count / 4
58
+ quarter = scene_count / 4.0
37
59
  if index < quarter:
38
- return "intro"
39
- elif index < quarter * 2:
40
- return "verse"
41
- elif index < quarter * 3:
42
- return "chorus"
43
- else:
44
- return "outro"
45
- return "verse"
60
+ return _POSITIONAL_FALLBACK_ROLES["early"]
61
+ if index < quarter * 2:
62
+ return _POSITIONAL_FALLBACK_ROLES["middle_low"]
63
+ if index < quarter * 3:
64
+ return _POSITIONAL_FALLBACK_ROLES["middle_high"]
65
+ return _POSITIONAL_FALLBACK_ROLES["late"]
66
+ return _POSITIONAL_FALLBACK_ROLES["default"]
67
+
46
68
 
69
+ def _positional_fallback_energy(role: str) -> float:
70
+ """Static energy map used only when density is unavailable.
47
71
 
48
- def _infer_energy(role: str) -> float:
49
- """Infer energy level from scene role."""
50
- energy_map = {
51
- "intro": 0.2,
72
+ Kept tiny and explicit so the fallback path is obvious — the primary
73
+ source of energy is _composition_engine's density-based value.
74
+ """
75
+ return {
76
+ "intro": 0.3,
52
77
  "verse": 0.4,
53
78
  "build": 0.6,
54
79
  "chorus": 0.7,
@@ -56,23 +81,82 @@ def _infer_energy(role: str) -> float:
56
81
  "breakdown": 0.3,
57
82
  "transition": 0.5,
58
83
  "outro": 0.2,
59
- }
60
- return energy_map.get(role, 0.5)
84
+ }.get(role, 0.5)
61
85
 
62
86
 
63
87
  def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
64
- """Fetch scene info from Ableton and build SceneRole list."""
88
+ """Fetch scene info + composition graph from Ableton and build SceneRole list.
89
+
90
+ BUG-E4 / E5 fix: roles + energies now flow from composition_engine's
91
+ build_section_graph_from_scenes, which uses keyword matching + active-
92
+ track density for energy. Unnamed scenes fall back to the positional
93
+ heuristic. This keeps get_performance_state in sync with
94
+ get_section_graph / analyze_composition.
95
+ """
96
+ from ..tools._composition_engine import (
97
+ build_section_graph_from_scenes,
98
+ SectionNode as CESectionNode,
99
+ )
100
+
65
101
  ableton = ctx.lifespan_context["ableton"]
66
102
 
67
103
  scenes_info = ableton.send_command("get_scenes_info", {})
68
104
  scenes_list = scenes_info.get("scenes", [])
69
105
  scene_count = len(scenes_list)
70
106
 
107
+ # Pull session topology + clip matrix so composition engine can compute
108
+ # active-track density. If any of these fails we fall back to the
109
+ # positional heuristic — preserving the old behavior as a safety net.
110
+ track_count = 0
111
+ clip_matrix: list[list[dict]] = []
112
+ try:
113
+ session_info = ableton.send_command("get_session_info", {})
114
+ track_count = int(session_info.get("track_count", 0))
115
+ except Exception as exc:
116
+ logger.debug("_fetch_scene_data session_info failed: %s", exc)
117
+ try:
118
+ mtx = ableton.send_command("get_scene_matrix", {})
119
+ if isinstance(mtx, dict):
120
+ clip_matrix = mtx.get("matrix", []) or []
121
+ except Exception as exc:
122
+ logger.debug("_fetch_scene_data scene_matrix failed: %s", exc)
123
+
124
+ # Build the composition section graph. Each SectionNode has
125
+ # section_id = f"sec_{raw_enumerate_index:02d}" per BUG-E1 fix, so we
126
+ # can index by scene position directly.
127
+ ce_sections: list[CESectionNode] = []
128
+ try:
129
+ if scenes_list and clip_matrix and track_count > 0:
130
+ ce_sections = build_section_graph_from_scenes(
131
+ scenes_list, clip_matrix, track_count,
132
+ )
133
+ except Exception as exc:
134
+ logger.debug("_fetch_scene_data section graph failed: %s", exc)
135
+
136
+ ce_by_scene_idx: dict[int, CESectionNode] = {}
137
+ for sec in ce_sections:
138
+ # section_id format "sec_02" → scene index 2 (raw enumerate index)
139
+ sid = str(sec.section_id)
140
+ if sid.startswith("sec_"):
141
+ try:
142
+ ce_by_scene_idx[int(sid[4:])] = sec
143
+ except ValueError:
144
+ pass
145
+
71
146
  scene_roles: list[SceneRole] = []
72
147
  for i, scene_data in enumerate(scenes_list):
73
148
  name = scene_data.get("name", f"Scene {i}")
74
- role = _infer_role(name, i, scene_count)
75
- energy = _infer_energy(role)
149
+ ce_sec = ce_by_scene_idx.get(i)
150
+ if ce_sec is not None:
151
+ # SectionType is an enum; .value gives the string vocabulary
152
+ stype = ce_sec.section_type
153
+ role = stype.value if hasattr(stype, "value") else str(stype)
154
+ energy = float(ce_sec.energy)
155
+ else:
156
+ # Unnamed scene or build failed — positional fallback
157
+ role = _positional_fallback_role(i, scene_count)
158
+ energy = _positional_fallback_energy(role)
159
+
76
160
  scene_roles.append(SceneRole(
77
161
  scene_index=i,
78
162
  name=name,
@@ -85,14 +169,13 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
85
169
  current_scene = 0
86
170
  try:
87
171
  session_info = ableton.send_command("get_session_info", {})
88
- # Check if any scene is marked as triggered/playing
89
172
  session_scenes = session_info.get("scenes", [])
90
173
  for i, s in enumerate(session_scenes):
91
174
  if s.get("is_triggered", False):
92
175
  current_scene = i
93
176
  break
94
177
  except Exception as exc:
95
- logger.debug("_fetch_scene_data failed: %s", exc)
178
+ logger.debug("_fetch_scene_data current_scene failed: %s", exc)
96
179
 
97
180
  return scene_roles, current_scene
98
181
 
@@ -42,11 +42,17 @@ def create_preview_set(
42
42
  available_moves: Optional[list[dict]] = None,
43
43
  song_brain: Optional[dict] = None,
44
44
  taste_graph: Optional[dict] = None,
45
+ kernel: Optional[dict] = None,
45
46
  ) -> PreviewSet:
46
47
  """Create a preview set with variant slots.
47
48
 
48
49
  For creative_triptych, generates 3 variants: safe, strong, unexpected.
49
50
  Each variant gets a move_id from available_moves ranked by novelty.
51
+
52
+ kernel: the live session kernel (track topology + device chains). Compilers
53
+ resolve targets from it — without it, variants degrade into no-ops or
54
+ generic reads. Callers that have a `ctx` should fetch a real kernel
55
+ via runtime.tools.get_session_kernel(ctx).
50
56
  """
51
57
  set_id = _compute_set_id(request_text, kernel_id)
52
58
  now = int(time.time() * 1000)
@@ -56,11 +62,15 @@ def create_preview_set(
56
62
  taste_graph = taste_graph or {}
57
63
 
58
64
  if strategy == "creative_triptych":
59
- variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
65
+ variants = _build_triptych(
66
+ request_text, moves, song_brain, taste_graph, set_id, now, kernel,
67
+ )
60
68
  elif strategy == "binary":
61
69
  variants = _build_binary(request_text, moves, song_brain, set_id, now)
62
70
  else:
63
- variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
71
+ variants = _build_triptych(
72
+ request_text, moves, song_brain, taste_graph, set_id, now, kernel,
73
+ )
64
74
 
65
75
  ps = PreviewSet(
66
76
  set_id=set_id,
@@ -81,6 +91,7 @@ def _build_triptych(
81
91
  taste_graph: dict,
82
92
  set_id: str,
83
93
  now: int,
94
+ kernel: Optional[dict] = None,
84
95
  ) -> list[PreviewVariant]:
85
96
  """Build safe / strong / unexpected variants."""
86
97
  identity = song_brain.get("identity_core", "")
@@ -114,20 +125,34 @@ def _build_triptych(
114
125
  },
115
126
  ]
116
127
 
128
+ # Normalize kernel for the compiler. If the caller supplied a real kernel
129
+ # use it; otherwise fall back to an empty-but-valid shape so compilers
130
+ # degrade to no-op steps and emit warnings instead of crashing.
131
+ compile_kernel = kernel if kernel else {
132
+ "session_info": {"tempo": 120, "tracks": []},
133
+ "mode": "improve",
134
+ }
135
+
117
136
  variants = []
118
137
  for i, profile in enumerate(profiles):
119
138
  # Pick a move if available
120
139
  move_id = ""
121
140
  compiled_plan = None
122
- if moves and i < len(moves):
123
- move_id = moves[i].get("move_id", "")
141
+ move = moves[i] if moves and i < len(moves) else None
142
+ if move is not None:
143
+ move_id = move.get("move_id", "")
124
144
  # Compile through the semantic compiler — single source of truth
125
145
  from ..wonder_mode.engine import _compile_variant_plan
126
- kernel = {"session_info": {"tempo": 120, "tracks": []}, "mode": "improve"}
127
- compiled_plan = _compile_variant_plan(moves[i], kernel)
146
+ compiled_plan = _compile_variant_plan(move, compile_kernel)
128
147
  # No fallback to plan_template — uncompilable moves stay analytical
129
148
 
130
- variants.append(PreviewVariant(
149
+ # BUG-B44 / B45: populate user-facing description fields and flag
150
+ # variants that lack a compiled_plan as not-executable (so callers
151
+ # don't commit shells).
152
+ description = _describe_variant(move, compiled_plan, profile)
153
+ executable = compiled_plan is not None and bool(move_id)
154
+
155
+ variant = PreviewVariant(
131
156
  variant_id=f"{set_id}_{profile['label']}",
132
157
  label=profile["label"],
133
158
  intent=profile["intent"],
@@ -139,11 +164,67 @@ def _build_triptych(
139
164
  compiled_plan=compiled_plan,
140
165
  taste_fit=_estimate_taste_fit(profile["novelty"], taste_graph),
141
166
  created_at_ms=now,
142
- ))
167
+ what_changed=description["what_changed"],
168
+ summary=description["summary"],
169
+ )
170
+ # Non-executable variants get status='blocked' so callers know to
171
+ # skip preview/commit. Stored as status since executable/blocked_reason
172
+ # aren't modeled yet.
173
+ if not executable:
174
+ variant.status = "blocked"
175
+ variants.append(variant)
143
176
 
144
177
  return variants
145
178
 
146
179
 
180
+ def _describe_variant(
181
+ move: Optional[dict],
182
+ compiled_plan: Optional[dict],
183
+ profile: dict,
184
+ ) -> dict:
185
+ """Build user-facing description fields for a variant (BUG-B45).
186
+
187
+ Priority order:
188
+ 1. Move's `intent` or `description` — the authored one-liner
189
+ 2. Compiled plan's step descriptions joined with " → "
190
+ 3. The profile label + novelty level as a last-resort fallback
191
+
192
+ Returns {"what_changed": str, "summary": str}.
193
+ """
194
+ what_changed = ""
195
+ summary = ""
196
+ if move:
197
+ # Move-level narrative beats plan-level — captures intent, not execution
198
+ move_intent = str(move.get("intent") or move.get("description") or "")
199
+ if move_intent:
200
+ what_changed = move_intent
201
+ summary = move_intent[:120]
202
+
203
+ if not what_changed and compiled_plan:
204
+ steps = compiled_plan.get("steps") or []
205
+ step_descriptions = [
206
+ str(s.get("description") or s.get("summary") or s.get("intent") or "")
207
+ for s in steps
208
+ ]
209
+ step_descriptions = [d for d in step_descriptions if d]
210
+ if step_descriptions:
211
+ what_changed = " → ".join(step_descriptions[:4])
212
+ summary = (
213
+ step_descriptions[0][:120]
214
+ if step_descriptions else ""
215
+ )
216
+
217
+ if not what_changed:
218
+ # Final fallback — describe the profile so the UI has something
219
+ what_changed = (
220
+ f"{profile['label'].title()} variant at novelty "
221
+ f"{profile['novelty']:.1f} (no executable plan available)"
222
+ )
223
+ summary = what_changed
224
+
225
+ return {"what_changed": what_changed, "summary": summary}
226
+
227
+
147
228
  def _build_binary(
148
229
  request_text: str,
149
230
  moves: list[dict],
@@ -182,6 +182,16 @@ def create_preview_set(
182
182
  except Exception as exc:
183
183
  logger.debug("create_preview_set failed: %s", exc)
184
184
 
185
+ # Fetch a real session kernel so compilers resolve targets from the live
186
+ # set instead of an empty placeholder. Degrades gracefully when Ableton
187
+ # is unreachable (unit tests, no-connection environments).
188
+ live_kernel: dict = {}
189
+ try:
190
+ from ..runtime.tools import get_session_kernel
191
+ live_kernel = get_session_kernel(ctx, request_text=request_text) or {}
192
+ except Exception as exc:
193
+ logger.debug("create_preview_set: could not fetch session kernel: %s", exc)
194
+
185
195
  ps = engine.create_preview_set(
186
196
  request_text=request_text,
187
197
  kernel_id=kernel_id,
@@ -189,6 +199,7 @@ def create_preview_set(
189
199
  available_moves=available_moves,
190
200
  song_brain=song_brain,
191
201
  taste_graph=taste_graph,
202
+ kernel=live_kernel,
192
203
  )
193
204
 
194
205
  return ps.to_dict()
@@ -436,7 +447,12 @@ async def render_preview_variant(
436
447
  plan = variant.compiled_plan
437
448
  steps = plan if isinstance(plan, list) else plan.get("steps", [])
438
449
 
439
- from ..runtime.execution_router import execute_plan_steps_async
450
+ from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
451
+
452
+ # Read-only verification steps (meters/spectrum/info) don't create undo
453
+ # points in Ableton — counting them and then undoing walks back earlier
454
+ # user edits. Separate writes from reads before the apply pass.
455
+ apply_steps = filter_apply_steps(steps)
440
456
 
441
457
  applied_count = 0
442
458
  playback_started = False
@@ -453,16 +469,16 @@ async def render_preview_variant(
453
469
  # ── 1. Capture BEFORE metadata ──
454
470
  before_info = ableton.send_command("get_session_info", {}) or {}
455
471
 
456
- # ── 2. Apply the variant ──
472
+ # ── 2. Apply the variant (write steps only) ──
457
473
  exec_results = await execute_plan_steps_async(
458
- steps,
474
+ apply_steps,
459
475
  ableton=ableton,
460
476
  bridge=bridge,
461
477
  mcp_registry=mcp_registry,
462
478
  ctx=ctx,
463
479
  )
464
480
  applied_count = sum(1 for r in exec_results if r.ok)
465
- if applied_count == 0 and steps:
481
+ if applied_count == 0 and apply_steps:
466
482
  return {
467
483
  "error": "Variant failed to apply any steps",
468
484
  "variant_id": variant_id,
@@ -489,9 +505,9 @@ async def render_preview_variant(
489
505
  ableton.send_command("start_playback", {})
490
506
  playback_started = True
491
507
 
492
- import time as _time
508
+ import asyncio as _asyncio
493
509
 
494
- _time.sleep(play_seconds)
510
+ await _asyncio.sleep(play_seconds)
495
511
 
496
512
  spectral_after = cache.get_all()
497
513