livepilot 1.10.5 → 1.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -17,7 +17,9 @@ from fastmcp import Context
17
17
  from ..server import mcp
18
18
  from . import engine
19
19
  from .models import CONSTRAINT_MODES
20
+ import logging
20
21
 
22
+ logger = logging.getLogger(__name__)
21
23
 
22
24
  # Module-level cache for active constraints and distillations
23
25
  _active_constraints: Optional[engine.ConstraintSet] = None
@@ -92,10 +94,13 @@ def distill_reference_principles(
92
94
  if not reference_description.strip() and not style_name.strip():
93
95
  return {"error": "Provide reference_description or style_name"}
94
96
 
95
- # Build a reference profile from available data
97
+ # BUG-B17 fix: collect profile fragments from all sources and MERGE.
98
+ # The old flow stopped at the first non-empty source, so if
99
+ # get_style_tactics returned a half-filled profile the text-keyword
100
+ # fallback never ran and the description's rich content was lost.
101
+ # Now we always run the text fallback too and fill missing fields.
96
102
  reference_profile: dict = {}
97
103
 
98
- # Try to get style tactics if style_name is provided
99
104
  if style_name:
100
105
  try:
101
106
  from ..tools._research_engine import get_style_tactics
@@ -110,20 +115,36 @@ def distill_reference_principles(
110
115
  "groove_posture": tactics.get("groove_posture", {}),
111
116
  "harmonic_character": tactics.get("harmonic_character", ""),
112
117
  }
113
- except Exception:
114
- pass
118
+ except Exception as exc:
119
+ logger.debug("distill_reference_principles failed: %s", exc)
115
120
 
116
- # Try to get a reference profile from the reference engine
121
+ # Try the built-in style profile builder
117
122
  if not reference_profile:
118
123
  try:
119
124
  from ..reference_engine.profile_builder import build_style_reference_profile
120
125
  profile = build_style_reference_profile(
121
126
  style_name or reference_description
122
127
  )
123
- reference_profile = profile.to_dict()
124
- except Exception:
125
- # Fallback: build from description keywords
126
- reference_profile = _profile_from_description(reference_description)
128
+ reference_profile = profile.to_dict() if profile else {}
129
+ except Exception as exc:
130
+ logger.debug("distill_reference_principles failed: %s", exc)
131
+
132
+ # Text-keyword fallback ALWAYS merges in. Style tactics + profile
133
+ # builder typically leave some fields empty; the description's
134
+ # keywords fill those gaps. This is the B17 fix that makes the
135
+ # Dabrye reproducer produce non-empty principles.
136
+ if reference_description.strip():
137
+ text_profile = _profile_from_description(reference_description)
138
+ for key, value in text_profile.items():
139
+ existing = reference_profile.get(key)
140
+ is_empty = (
141
+ existing is None
142
+ or existing == ""
143
+ or existing == []
144
+ or existing == {}
145
+ )
146
+ if is_empty and value:
147
+ reference_profile[key] = value
127
148
 
128
149
  distillation = engine.distill_reference_principles(
129
150
  reference_profile=reference_profile,
@@ -204,9 +225,8 @@ def generate_constrained_variants(
204
225
  taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
205
226
  anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
206
227
  taste_graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
207
- except Exception:
208
- pass
209
-
228
+ except Exception as exc:
229
+ logger.debug("generate_constrained_variants failed: %s", exc)
210
230
  ps = ps_engine.create_preview_set(
211
231
  request_text=f"[Constrained: {', '.join(active.constraints)}] {request_text}",
212
232
  kernel_id=kernel_id,
@@ -215,23 +235,65 @@ def generate_constrained_variants(
215
235
  taste_graph=taste_graph,
216
236
  )
217
237
 
218
- # Validate each variant's compiled_plan against constraints
238
+ # Validate each variant's compiled_plan against constraints.
239
+ # BUG-B46: two problems in the old code —
240
+ # 1) iterating `for step in v.compiled_plan` yields dict KEYS
241
+ # (compiled_plan is {'move_id': ..., 'steps': [...]}), so
242
+ # the validation ran on strings and silently passed.
243
+ # 2) when a variant was filtered, we only blanked compiled_plan
244
+ # and left status='pending' — callers had no way to tell
245
+ # which variants became shells.
246
+ # Now we iterate .get("steps", []) correctly, flip filtered
247
+ # variants to status='blocked', and count blocked_count in the
248
+ # response so callers can detect the "all variants filtered" case.
249
+ blocked_count = 0
219
250
  for v in ps.variants:
220
- v.what_preserved = f"{v.what_preserved} | Constraints: {', '.join(active.constraints)}"
251
+ v.what_preserved = (
252
+ f"{v.what_preserved} | Constraints: "
253
+ f"{', '.join(active.constraints)}"
254
+ )
221
255
  if v.compiled_plan:
222
- plan = {"steps": [
223
- {"action": step.get("tool", ""), **step}
224
- for step in v.compiled_plan
225
- ]}
226
- validation = engine.validate_plan_against_constraints(plan, active)
256
+ steps = v.compiled_plan.get("steps", []) if isinstance(
257
+ v.compiled_plan, dict
258
+ ) else []
259
+ plan = {
260
+ "steps": [
261
+ {"action": step.get("tool", ""), **step}
262
+ for step in steps
263
+ ]
264
+ }
265
+ validation = engine.validate_plan_against_constraints(
266
+ plan, active,
267
+ )
227
268
  if not validation["valid"]:
228
269
  v.compiled_plan = None
229
- v.what_changed = f"[FILTERED] {v.what_changed} — violates {', '.join(active.constraints)}"
270
+ v.status = "blocked"
271
+ v.what_changed = (
272
+ f"[FILTERED] {v.what_changed} — violates "
273
+ f"{', '.join(active.constraints)}"
274
+ )
275
+ blocked_count += 1
276
+ elif v.status == "blocked":
277
+ # Already blocked upstream (no compilable move)
278
+ blocked_count += 1
279
+
280
+ note = (
281
+ f"Variants with violating plans have been filtered "
282
+ f"({blocked_count}/{len(ps.variants)} blocked)"
283
+ )
284
+ if blocked_count == len(ps.variants) and ps.variants:
285
+ note = (
286
+ f"All {blocked_count} variants violate constraints "
287
+ f"{active.constraints!r}. Try loosening constraints or a "
288
+ f"different request."
289
+ )
230
290
 
231
291
  return {
232
292
  "preview_set": ps.to_dict(),
233
293
  "constraints_applied": active.constraints,
234
- "note": "Variants with violating plans have been filtered",
294
+ "blocked_count": blocked_count,
295
+ "executable_count": len(ps.variants) - blocked_count,
296
+ "note": note,
235
297
  }
236
298
  except Exception as e:
237
299
  return {"error": f"Failed to generate constrained variants: {e}"}
@@ -255,9 +317,29 @@ def generate_reference_inspired_variants(
255
317
  if _cached_distillation is None:
256
318
  return {"error": "No reference distilled yet — call distill_reference_principles first"}
257
319
 
320
+ # BUG-B54: the reference-engine chain (distill → map → generate_variants)
321
+ # used to silently degrade when distill_reference_principles returned
322
+ # empty principles (BUG-B17). Callers got 3 shell variants branded
323
+ # "reference-inspired" with no reference material driving them.
324
+ # Refuse to run when principles are empty — the user should fix the
325
+ # distillation step first.
326
+ principles_list = list(_cached_distillation.principles or [])
327
+ if not principles_list:
328
+ return {
329
+ "error": (
330
+ "distill_reference_principles returned no principles — "
331
+ "reference-inspired variant generation refuses to run on "
332
+ "empty input (would produce meaningless 'reference-inspired' "
333
+ "shell variants). Try a more specific reference description "
334
+ "or pick a reference covered by the built-in style corpus."
335
+ ),
336
+ "reference": _cached_distillation.reference_description,
337
+ "principles_applied": [],
338
+ }
339
+
258
340
  # Build request text from reference principles
259
341
  principles_text = ", ".join(
260
- p.principle for p in _cached_distillation.principles[:3]
342
+ p.principle for p in principles_list[:3]
261
343
  )
262
344
  full_request = (
263
345
  f"Inspired by: {_cached_distillation.reference_description}. "
@@ -287,13 +369,14 @@ def generate_reference_inspired_variants(
287
369
  return {
288
370
  "preview_set": ps.to_dict(),
289
371
  "reference": _cached_distillation.reference_description,
290
- "principles_applied": [p.to_dict() for p in _cached_distillation.principles[:5]],
372
+ "principles_applied": [
373
+ p.to_dict() for p in principles_list[:5]
374
+ ],
291
375
  "note": "Variants are shaped by reference principles, not surface imitation",
292
376
  }
293
377
  except Exception as e:
294
378
  return {"error": f"Failed to generate reference-inspired variants: {e}"}
295
379
 
296
-
297
380
  # ── Helpers ───────────────────────────────────────────────────────
298
381
 
299
382
 
@@ -305,37 +388,128 @@ def _get_song_brain_dict() -> dict:
305
388
  except Exception as _e:
306
389
  if __debug__:
307
390
  import sys
391
+
308
392
  print(f"LivePilot: SongBrain unavailable in creative_constraints: {_e}", file=sys.stderr)
309
393
  return {}
310
394
 
311
395
 
312
396
  def _profile_from_description(description: str) -> dict:
313
- """Build a rough reference profile from text description."""
397
+ """Build a rough reference profile from a free-text description.
398
+
399
+ BUG-B17 fix: the old version only mapped 8 emotion keywords and
400
+ left every other field empty, so distill_reference_principles
401
+ returned empty principles for any description that didn't include
402
+ exactly one of those 8 words. We now scan for a rich keyword set
403
+ across emotional / spectral / width / groove / harmonic / density
404
+ dimensions so a description like "cold 90s hip-hop with ghostly
405
+ vocal chops and dusty drums" actually produces principles.
406
+ """
314
407
  desc_lower = description.lower()
315
408
 
409
+ # Emotional stance
316
410
  emotional_map = {
317
- "dark": "tense",
318
- "bright": "euphoric",
319
- "sad": "melancholic",
320
- "aggressive": "aggressive",
321
- "dreamy": "dreamy",
322
- "chill": "relaxed",
323
- "intense": "aggressive",
324
- "minimal": "restrained",
411
+ "dark": "tense", "cold": "tense", "ominous": "tense", "eerie": "tense",
412
+ "bright": "euphoric", "warm": "warm", "sunny": "euphoric",
413
+ "sad": "melancholic", "longing": "melancholic", "wistful": "melancholic",
414
+ "nostalgic": "nostalgic", "dust": "nostalgic", "dusty": "nostalgic",
415
+ "aggressive": "aggressive", "violent": "aggressive", "intense": "aggressive",
416
+ "dreamy": "dreamy", "dream": "dreamy", "floaty": "dreamy",
417
+ "chill": "relaxed", "meditative": "relaxed",
418
+ "minimal": "restrained", "restrained": "restrained",
419
+ "ghostly": "haunted", "haunted": "haunted", "ghost": "haunted",
420
+ "euphoric": "euphoric", "ecstatic": "euphoric",
325
421
  }
326
-
327
422
  emotional = ""
328
423
  for keyword, stance in emotional_map.items():
329
424
  if keyword in desc_lower:
330
425
  emotional = stance
331
426
  break
332
427
 
428
+ # Spectral contour — from brightness / color keywords
429
+ spectral_contour: dict = {}
430
+ if any(k in desc_lower for k in ("dark", "muddy", "lo-fi", "lofi",
431
+ "dusty", "cold", "underwater",
432
+ "warm", "vintage")):
433
+ spectral_contour = {
434
+ "band_balance": {"sub": 0.4, "low": 0.5, "mid": 0.35,
435
+ "high_mid": 0.2, "high": 0.1},
436
+ "centroid_hint": "dark / roll-off near 4kHz",
437
+ }
438
+ elif any(k in desc_lower for k in ("bright", "crisp", "shiny", "airy",
439
+ "glittery", "sparkly", "cinematic")):
440
+ spectral_contour = {
441
+ "band_balance": {"sub": 0.25, "low": 0.3, "mid": 0.4,
442
+ "high_mid": 0.55, "high": 0.6},
443
+ "centroid_hint": "bright / open high shelf",
444
+ }
445
+
446
+ # Width / depth — mono vs wide vs deep
447
+ width_depth: dict = {}
448
+ if any(k in desc_lower for k in ("narrow", "mono", "focused", "tight",
449
+ "centered")):
450
+ width_depth = {"stereo_width": 0.25, "depth_hint": "close, upfront"}
451
+ elif any(k in desc_lower for k in ("wide", "spacious", "spatial",
452
+ "ambient", "washy", "drifting")):
453
+ width_depth = {"stereo_width": 0.85, "depth_hint": "deep, spatial"}
454
+ elif any(k in desc_lower for k in ("intimate", "dry")):
455
+ width_depth = {"stereo_width": 0.4, "depth_hint": "dry, intimate"}
456
+
457
+ # Groove posture — rhythm keywords
458
+ groove_posture: dict = {}
459
+ if any(k in desc_lower for k in ("swing", "shuffle", "dilla", "slouchy")):
460
+ groove_posture = {"feel": "swung", "stiffness": 0.25}
461
+ elif any(k in desc_lower for k in ("tight", "clean", "quantized",
462
+ "precise", "crispy")):
463
+ groove_posture = {"feel": "straight", "stiffness": 0.9}
464
+ elif any(k in desc_lower for k in ("loose", "sloppy", "drunk",
465
+ "organic", "human")):
466
+ groove_posture = {"feel": "humanized", "stiffness": 0.3}
467
+ elif any(k in desc_lower for k in ("driving", "motorik", "pulsing",
468
+ "throbbing", "hypnotic")):
469
+ groove_posture = {"feel": "driving", "stiffness": 0.8}
470
+
471
+ # Density motion — when the user hints at pacing
472
+ density_arc: list[float] = []
473
+ if any(k in desc_lower for k in ("slow burn", "patient", "gradually",
474
+ "builds", "buildup")):
475
+ density_arc = [0.2, 0.3, 0.5, 0.7, 0.9]
476
+ elif any(k in desc_lower for k in ("explodes", "immediate", "front-loaded",
477
+ "hits from the start")):
478
+ density_arc = [0.9, 0.85, 0.8, 0.5, 0.3]
479
+ elif any(k in desc_lower for k in ("dual drop", "return", "second wind")):
480
+ density_arc = [0.4, 0.8, 0.5, 0.3, 0.9]
481
+
482
+ # Harmonic character
483
+ harmonic = ""
484
+ if any(k in desc_lower for k in ("minor", "dorian", "phrygian",
485
+ "melancholic", "tense")):
486
+ harmonic = "minor_modal"
487
+ elif any(k in desc_lower for k in ("major", "ionian", "lydian",
488
+ "euphoric", "triumphant")):
489
+ harmonic = "major_modal"
490
+ elif any(k in desc_lower for k in ("dissonant", "dense", "clusters",
491
+ "microtonal")):
492
+ harmonic = "dissonant_clustered"
493
+ elif any(k in desc_lower for k in ("ambient", "drone", "pad",
494
+ "atmospheric", "washy")):
495
+ harmonic = "atmospheric_filtered"
496
+
497
+ # Payoff / section pacing
498
+ section_pacing: list[dict] = []
499
+ if any(k in desc_lower for k in ("sparse intro", "sparse", "slow start")):
500
+ section_pacing.append({"label": "sparse_intro", "bars": 16})
501
+ if any(k in desc_lower for k in ("buildup", "builds", "growing")):
502
+ section_pacing.append({"label": "gradual_buildup", "bars": 16})
503
+ if any(k in desc_lower for k in ("drop", "peak", "payoff",
504
+ "strip back", "pulled out")):
505
+ section_pacing.append({"label": "strip_back_payoff", "bars": 16})
506
+
333
507
  return {
334
508
  "emotional_stance": emotional,
335
- "density_arc": [],
336
- "section_pacing": [],
337
- "width_depth": {},
338
- "spectral_contour": {},
339
- "groove_posture": {},
340
- "harmonic_character": "",
509
+ "density_arc": density_arc,
510
+ "section_pacing": section_pacing,
511
+ "width_depth": width_depth,
512
+ "spectral_contour": spectral_contour,
513
+ "groove_posture": groove_posture,
514
+ "harmonic_character": harmonic,
341
515
  }
@@ -22,7 +22,9 @@ import time
22
22
  from typing import Optional
23
23
 
24
24
  from .models import ExperimentSet, ExperimentBranch, BranchSnapshot
25
+ import logging
25
26
 
27
+ logger = logging.getLogger(__name__)
26
28
 
27
29
  # ── In-memory experiment store ───────────────────────────────────────────────
28
30
 
@@ -34,9 +36,9 @@ def _gen_id(prefix: str, seed: str) -> str:
34
36
  h = hashlib.sha256(f"{prefix}:{seed}:{time.time()}".encode()).hexdigest()[:8]
35
37
  return f"{prefix}_{h}"
36
38
 
37
-
38
39
  # ── Create experiments ───────────────────────────────────────────────────────
39
40
 
41
+
40
42
  def create_experiment(
41
43
  request_text: str,
42
44
  move_ids: list[str],
@@ -83,9 +85,9 @@ def list_experiments() -> list[dict]:
83
85
  """List all experiment sets."""
84
86
  return [exp.to_dict() for exp in _EXPERIMENTS.values()]
85
87
 
86
-
87
88
  # ── Run experiments (requires Ableton connection) ────────────────────────────
88
89
 
90
+
89
91
  def run_branch(
90
92
  branch: ExperimentBranch,
91
93
  ableton, # AbletonConnection
@@ -120,6 +122,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
120
122
  branch.compiled_plan = compiled_plan
121
123
  branch.before_snapshot = capture_fn()
122
124
 
125
+ from ..runtime.execution_router import READ_ONLY_TOOLS
126
+
123
127
  steps_executed = 0
124
128
  log = []
125
129
  for step in compiled_plan.get("steps", []):
@@ -127,7 +131,7 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
127
131
  params = step.get("params", {})
128
132
  if not tool:
129
133
  continue
130
- if tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
134
+ if tool in READ_ONLY_TOOLS:
131
135
  continue
132
136
  try:
133
137
  result = ableton.send_command(tool, params)
@@ -143,7 +147,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
143
147
  for _ in range(steps_executed):
144
148
  try:
145
149
  ableton.send_command("undo", {})
146
- except Exception:
150
+ except Exception as exc:
151
+ logger.debug("_run_branch_sync failed: %s", exc)
147
152
  break
148
153
 
149
154
  branch.status = "evaluated" if steps_executed > 0 else "failed"
@@ -170,21 +175,17 @@ async def run_branch_async(
170
175
  analyze_mix) are skipped in the apply pass — they're used for snapshot
171
176
  capture separately.
172
177
  """
173
- from ..runtime.execution_router import execute_plan_steps_async
178
+ from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
174
179
 
175
180
  branch.status = "running"
176
181
  branch.compiled_plan = compiled_plan
177
182
 
178
183
  branch.before_snapshot = capture_fn()
179
184
 
180
- # Filter out read-only verification steps from the apply pass
185
+ # Filter out read-only verification steps from the apply pass (canonical
186
+ # list lives in execution_router.READ_ONLY_TOOLS).
181
187
  all_steps = compiled_plan.get("steps", []) or []
182
- apply_steps = [
183
- s for s in all_steps
184
- if s.get("tool") and s.get("tool") not in (
185
- "get_track_meters", "get_master_spectrum", "analyze_mix",
186
- )
187
- ]
188
+ apply_steps = filter_apply_steps(all_steps)
188
189
 
189
190
  exec_results = await execute_plan_steps_async(
190
191
  apply_steps,
@@ -215,7 +216,8 @@ async def run_branch_async(
215
216
  for _ in range(steps_executed):
216
217
  try:
217
218
  ableton.send_command("undo", {})
218
- except Exception:
219
+ except Exception as exc:
220
+ logger.debug("run_branch_async failed: %s", exc)
219
221
  break
220
222
 
221
223
  # A branch is "evaluated" only if it actually applied at least one step.
@@ -244,9 +246,9 @@ def evaluate_branch(
244
246
  branch.score = result.get("score", 0.0)
245
247
  return branch
246
248
 
247
-
248
249
  # ── Commit / discard ─────────────────────────────────────────────────────────
249
250
 
251
+
250
252
  async def commit_branch_async(
251
253
  experiment: ExperimentSet,
252
254
  branch_id: str,
@@ -18,6 +18,9 @@ from fastmcp import Context
18
18
  from ..server import mcp
19
19
  from . import engine
20
20
  from .models import BranchSnapshot
21
+ import logging
22
+
23
+ logger = logging.getLogger(__name__)
21
24
 
22
25
 
23
26
  def _get_ableton(ctx: Context):
@@ -35,25 +38,22 @@ def _capture_snapshot(ctx: Context) -> BranchSnapshot:
35
38
  try:
36
39
  meters = ableton.send_command("get_track_meters", {"include_stereo": True})
37
40
  snapshot.track_meters = meters.get("tracks", [])
38
- except Exception:
39
- pass
40
-
41
+ except Exception as exc:
42
+ logger.debug("_capture_snapshot failed: %s", exc)
41
43
  # Spectral data (requires M4L analyzer)
42
44
  if spectral and spectral.is_connected:
43
45
  try:
44
46
  spec = spectral.get("spectrum")
45
47
  if spec:
46
48
  snapshot.spectrum = spec.get("value", {})
47
- except Exception:
48
- pass
49
-
49
+ except Exception as exc:
50
+ logger.debug("_capture_snapshot failed: %s", exc)
50
51
  try:
51
52
  rms_data = spectral.get("rms")
52
53
  if rms_data:
53
54
  snapshot.rms = rms_data.get("value")
54
- except Exception:
55
- pass
56
-
55
+ except Exception as exc:
56
+ logger.debug("_capture_snapshot failed: %s", exc)
57
57
  return snapshot
58
58
 
59
59
 
@@ -33,14 +33,30 @@ def find_hook_candidates(
33
33
  candidates: list[HookCandidate] = []
34
34
 
35
35
  # 1. Motif-based hooks
36
- for motif in motif_data.get("motifs", []):
36
+ #
37
+ # BUG-B8 fix: the old code used motif.get('name', 'unknown'); the motif
38
+ # engine emits `motif_id` (not `name`), so every candidate collapsed
39
+ # onto hook_id="motif_unknown" and rank_hook_candidates returned 4+
40
+ # duplicate rows with empty location strings. We now prefer motif_id,
41
+ # then name, then a per-iteration index fallback to guarantee uniqueness.
42
+ # A final post-filter dedupes by (hook_id, hook_type, description) in
43
+ # case another producer slips a duplicate in.
44
+ for idx, motif in enumerate(motif_data.get("motifs", [])):
37
45
  salience = motif.get("salience", 0)
38
46
  recurrence = motif.get("recurrence", 0)
39
47
  if salience > 0.2 or recurrence > 0.3:
48
+ identifier = (
49
+ motif.get("motif_id")
50
+ or motif.get("name")
51
+ or f"idx{idx}"
52
+ )
40
53
  candidates.append(HookCandidate(
41
- hook_id=f"motif_{motif.get('name', 'unknown')}",
54
+ hook_id=f"motif_{identifier}",
42
55
  hook_type="melodic",
43
- description=motif.get("description", motif.get("name", "motif")),
56
+ description=motif.get(
57
+ "description",
58
+ motif.get("name") or motif.get("motif_id") or f"motif #{idx}",
59
+ ),
44
60
  location=motif.get("location", ""),
45
61
  memorability=min(1.0, salience * 1.2),
46
62
  recurrence=recurrence,
@@ -109,6 +125,20 @@ def find_hook_candidates(
109
125
  if "groove" in c.hook_id:
110
126
  c.evidence_sources.append("clip_reuse")
111
127
 
128
+ # BUG-B8: post-filter dedupe. Even after the motif_id fix above, other
129
+ # producers (track-name, groove-pattern) could collide on the same
130
+ # hook_id if session conventions repeat (e.g. two tracks named "Lead").
131
+ # Keep the first occurrence (sorted by salience below picks the winner
132
+ # among the originals), drop later duplicates by hook_id.
133
+ seen_ids: set[str] = set()
134
+ unique_candidates: list[HookCandidate] = []
135
+ for c in candidates:
136
+ if c.hook_id in seen_ids:
137
+ continue
138
+ seen_ids.add(c.hook_id)
139
+ unique_candidates.append(c)
140
+ candidates = unique_candidates
141
+
112
142
  # Sort by salience
113
143
  candidates.sort(key=lambda c: c.salience, reverse=True)
114
144
  return candidates
@@ -154,17 +184,40 @@ def score_phrase_impact(
154
184
  # Anticipation: was there a dip before?
155
185
  anticipation = min(1.0, max(0.0, (0.5 - prev_energy) * 2)) if prev_energy < 0.5 else 0.2
156
186
 
157
- # Contrast: density or energy change
158
- contrast = min(1.0, abs(density - prev_density) + abs(energy_delta))
187
+ # BUG-B51: note-content signals differentiate sections with
188
+ # identical energy/density. Without these, compare_phrase_impact
189
+ # emitted identical scores for every pair of same-density sections.
190
+ pitch_classes = int(section_data.get("unique_pitch_classes", 0) or 0)
191
+ note_count = int(section_data.get("note_count", 0) or 0)
192
+ velocity_variance = float(section_data.get("velocity_variance", 0) or 0)
193
+ # Pitch-class diversity → contrast lift: 0 classes = 0, 7+ = +0.3
194
+ pc_contrast_bonus = min(0.3, pitch_classes * 0.04)
195
+ # Note-density signal: more notes = richer content
196
+ note_density_signal = min(1.0, note_count / 50.0)
197
+ # Velocity variance → dynamic interest
198
+ dynamic_interest = min(1.0, velocity_variance / 200.0)
199
+
200
+ # Contrast: density / energy change + pitch-class diversity
201
+ contrast = min(
202
+ 1.0,
203
+ abs(density - prev_density) + abs(energy_delta) + pc_contrast_bonus,
204
+ )
159
205
 
160
- # Repetition fatigue: high density with no change = fatiguing
161
- fatigue = max(0.0, 1.0 - contrast) * 0.5
206
+ # Repetition fatigue: high density + low dynamic variance = fatiguing
207
+ base_fatigue = max(0.0, 1.0 - contrast) * 0.5
208
+ # Flat velocity → more fatigue; dynamic variation → less
209
+ fatigue = round(max(0.0, base_fatigue - dynamic_interest * 0.15), 3)
162
210
 
163
- # Section clarity: does it have a clear role?
164
- clarity = 0.7 if section_data.get("label") else 0.3
211
+ # Section clarity: does it have a clear role + content to back it up?
212
+ label_clarity = 0.7 if section_data.get("label") else 0.3
213
+ content_clarity = 0.1 * min(1.0, note_count / 20.0)
214
+ clarity = min(1.0, label_clarity + content_clarity)
165
215
 
166
216
  # Groove continuity: rhythm present
167
217
  groove = 0.7 if section_data.get("has_drums", True) else 0.3
218
+ # Boost groove continuity when the section has genuine rhythmic
219
+ # activity (note_density_signal nudges it up, flat sections down)
220
+ groove = min(1.0, groove + note_density_signal * 0.1)
168
221
 
169
222
  # Payoff balance
170
223
  payoff = min(1.0, (arrival + anticipation) / 2)