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
@@ -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],
@@ -15,6 +15,9 @@ from fastmcp import Context
15
15
 
16
16
  from ..server import mcp
17
17
  from . import engine
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
18
21
 
19
22
 
20
23
  def _get_ableton(ctx: Context):
@@ -39,10 +42,9 @@ def _find_wonder_session_by_preview(set_id: str):
39
42
  try:
40
43
  from ..wonder_mode.session import find_session_by_preview_set
41
44
  return find_session_by_preview_set(set_id)
42
- except Exception:
45
+ except Exception as exc:
46
+ logger.debug("_find_wonder_session_by_preview failed: %s", exc)
43
47
  return None
44
-
45
-
46
48
  @mcp.tool()
47
49
  def create_preview_set(
48
50
  ctx: Context,
@@ -148,8 +150,8 @@ def create_preview_set(
148
150
  # Fallback: if no keyword match, take top 3 from full registry
149
151
  if not available_moves:
150
152
  available_moves = registry.list_moves()[:3]
151
- except Exception:
152
- pass
153
+ except Exception as exc:
154
+ logger.debug("create_preview_set failed: %s", exc)
153
155
 
154
156
  # Get song brain if available
155
157
  song_brain: dict = {}
@@ -177,8 +179,18 @@ def create_preview_set(
177
179
  persistent_store=persistent,
178
180
  )
179
181
  taste_graph = graph.to_dict()
180
- except Exception:
181
- pass
182
+ except Exception as exc:
183
+ logger.debug("create_preview_set failed: %s", exc)
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)
182
194
 
183
195
  ps = engine.create_preview_set(
184
196
  request_text=request_text,
@@ -187,6 +199,7 @@ def create_preview_set(
187
199
  available_moves=available_moves,
188
200
  song_brain=song_brain,
189
201
  taste_graph=taste_graph,
202
+ kernel=live_kernel,
190
203
  )
191
204
 
192
205
  return ps.to_dict()
@@ -345,8 +358,8 @@ async def commit_preview_variant(
345
358
  )
346
359
  if ws.creative_thread_id:
347
360
  resolve_thread(ws.creative_thread_id)
348
- except Exception:
349
- pass
361
+ except Exception as exc:
362
+ logger.debug("commit_preview_variant failed: %s", exc)
350
363
 
351
364
  # Update taste graph (with persistent backing)
352
365
  try:
@@ -373,8 +386,8 @@ async def commit_preview_variant(
373
386
  family=family,
374
387
  kept=True,
375
388
  )
376
- except Exception:
377
- pass
389
+ except Exception as exc:
390
+ logger.debug("commit_preview_variant failed: %s", exc)
378
391
 
379
392
  result["wonder_session_id"] = ws.session_id
380
393
 
@@ -434,7 +447,12 @@ async def render_preview_variant(
434
447
  plan = variant.compiled_plan
435
448
  steps = plan if isinstance(plan, list) else plan.get("steps", [])
436
449
 
437
- 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)
438
456
 
439
457
  applied_count = 0
440
458
  playback_started = False
@@ -451,16 +469,16 @@ async def render_preview_variant(
451
469
  # ── 1. Capture BEFORE metadata ──
452
470
  before_info = ableton.send_command("get_session_info", {}) or {}
453
471
 
454
- # ── 2. Apply the variant ──
472
+ # ── 2. Apply the variant (write steps only) ──
455
473
  exec_results = await execute_plan_steps_async(
456
- steps,
474
+ apply_steps,
457
475
  ableton=ableton,
458
476
  bridge=bridge,
459
477
  mcp_registry=mcp_registry,
460
478
  ctx=ctx,
461
479
  )
462
480
  applied_count = sum(1 for r in exec_results if r.ok)
463
- if applied_count == 0 and steps:
481
+ if applied_count == 0 and apply_steps:
464
482
  return {
465
483
  "error": "Variant failed to apply any steps",
466
484
  "variant_id": variant_id,
@@ -487,8 +505,9 @@ async def render_preview_variant(
487
505
  ableton.send_command("start_playback", {})
488
506
  playback_started = True
489
507
 
490
- import time as _time
491
- _time.sleep(play_seconds)
508
+ import asyncio as _asyncio
509
+
510
+ await _asyncio.sleep(play_seconds)
492
511
 
493
512
  spectral_after = cache.get_all()
494
513
 
@@ -496,7 +515,8 @@ async def render_preview_variant(
496
515
  playback_started = False
497
516
 
498
517
  preview_mode = "audible_preview"
499
- except Exception:
518
+ except Exception as exc:
519
+ logger.debug("render_preview_variant failed: %s", exc)
500
520
  # Spectral capture is best-effort; keep preview_mode as metadata_only
501
521
  pass
502
522
 
@@ -507,12 +527,14 @@ async def render_preview_variant(
507
527
  if playback_started:
508
528
  try:
509
529
  ableton.send_command("stop_playback", {})
510
- except Exception:
511
- pass
530
+ except Exception as exc:
531
+ logger.debug("render_preview_variant failed: %s", exc)
532
+
512
533
  for _ in range(applied_count):
513
534
  try:
514
535
  ableton.send_command("undo")
515
- except Exception:
536
+ except Exception as exc:
537
+ logger.debug("render_preview_variant failed: %s", exc)
516
538
  break
517
539
 
518
540
  variant.status = "rendered"
@@ -11,8 +11,10 @@ from .models import AutomationGraph
11
11
  def build_automation_graph(
12
12
  track_infos: list[dict],
13
13
  sections: list[dict] | None = None,
14
+ clip_automation: list[dict] | None = None,
14
15
  ) -> AutomationGraph:
15
- """Build an AutomationGraph by scanning track device info for automation.
16
+ """Build an AutomationGraph covering both device-parameter automation
17
+ hints and real clip envelopes (BUG-E2).
16
18
 
17
19
  Args:
18
20
  track_infos: list of per-track info dicts. Each may contain:
@@ -20,18 +22,52 @@ def build_automation_graph(
20
22
  - name: track name
21
23
  - devices: [{name, class_name, parameters: [{name, value, is_automated, ...}]}]
22
24
  sections: optional list of section dicts (for density_by_section).
25
+ clip_automation: optional list of per-clip envelope descriptors:
26
+ [{section_id, track_index, track_name, clip_index,
27
+ parameter_name, parameter_type, device_name}].
28
+ This is the ground truth — `device.parameters[i].is_automated`
29
+ only reflects mapping state, not the presence of an envelope.
23
30
 
24
31
  Returns:
25
32
  AutomationGraph with automated_params and density_by_section.
26
33
  """
27
34
  graph = AutomationGraph()
28
35
 
29
- if not track_infos:
36
+ if not track_infos and not clip_automation:
30
37
  return graph
31
38
 
32
- automated_params = []
39
+ automated_params: list[dict] = []
40
+ # Track which (track_index, device_name, param_name) we've already seen
41
+ # so device-hint entries don't duplicate clip-envelope entries.
42
+ seen: set[tuple[int, str, str]] = set()
33
43
 
34
- for track in track_infos:
44
+ # 1) Seed with real clip envelopes. These are the source of truth.
45
+ per_section_counts: dict[str, int] = {}
46
+ for env in clip_automation or []:
47
+ t_idx = int(env.get("track_index", 0))
48
+ dev = str(env.get("device_name") or env.get("parameter_type") or "")
49
+ name = str(env.get("parameter_name") or "")
50
+ key = (t_idx, dev, name)
51
+ if key in seen:
52
+ continue
53
+ seen.add(key)
54
+ automated_params.append({
55
+ "track_index": t_idx,
56
+ "track_name": env.get("track_name", ""),
57
+ "device_name": dev or None,
58
+ "param_name": name,
59
+ "parameter_type": env.get("parameter_type", ""),
60
+ "clip_index": env.get("clip_index"),
61
+ "section_id": env.get("section_id"),
62
+ "source": "clip_envelope",
63
+ })
64
+ sec = env.get("section_id")
65
+ if sec:
66
+ per_section_counts[sec] = per_section_counts.get(sec, 0) + 1
67
+
68
+ # 2) Add device-level hints (track-wide is_automated flags) that
69
+ # aren't already covered by an envelope entry.
70
+ for track in track_infos or []:
35
71
  t_idx = track.get("index", 0)
36
72
  t_name = track.get("name", "")
37
73
  devices = track.get("devices", [])
@@ -41,29 +77,45 @@ def build_automation_graph(
41
77
  parameters = device.get("parameters", [])
42
78
 
43
79
  for param in parameters:
44
- if param.get("is_automated", False) or param.get("automation_state", 0) > 0:
45
- automated_params.append({
46
- "track_index": t_idx,
47
- "track_name": t_name,
48
- "device_name": device_name,
49
- "param_name": param.get("name", ""),
50
- "param_value": param.get("value"),
51
- })
80
+ is_flagged = (
81
+ param.get("is_automated", False)
82
+ or param.get("automation_state", 0) > 0
83
+ )
84
+ if not is_flagged:
85
+ continue
86
+ p_name = param.get("name", "")
87
+ key = (t_idx, str(device_name), str(p_name))
88
+ if key in seen:
89
+ continue
90
+ seen.add(key)
91
+ automated_params.append({
92
+ "track_index": t_idx,
93
+ "track_name": t_name,
94
+ "device_name": device_name,
95
+ "param_name": p_name,
96
+ "param_value": param.get("value"),
97
+ "source": "device_hint",
98
+ })
52
99
 
53
100
  graph.automated_params = automated_params
54
101
 
55
- # Compute density_by_section if sections are provided
102
+ # Compute density_by_section.
56
103
  if sections:
57
104
  total_automated = len(automated_params)
58
105
  for sec in sections:
59
106
  section_id = sec.get("section_id", "")
60
- # Without per-section automation data, distribute evenly
61
- # and weight by section density (more active tracks = more automation)
62
- sec_density = sec.get("density", 0.0)
63
- # Automation density approximation: section density * param count ratio
64
- if total_automated > 0:
107
+ if per_section_counts:
108
+ # Use real per-section counts when we have them.
109
+ count = per_section_counts.get(section_id, 0)
110
+ # Normalize by max(1, largest section count) so the
111
+ # densest section is 1.0 and others fall below.
112
+ max_ct = max(per_section_counts.values()) or 1
113
+ graph.density_by_section[section_id] = round(count / max_ct, 3)
114
+ elif total_automated > 0:
115
+ # Fallback: approximate from section density (old behavior)
116
+ sec_density = sec.get("density", 0.0)
65
117
  graph.density_by_section[section_id] = round(
66
- sec_density * min(total_automated / max(len(track_infos), 1), 1.0),
118
+ sec_density * min(total_automated / max(len(track_infos or []), 1), 1.0),
67
119
  3,
68
120
  )
69
121
  else:
@@ -23,6 +23,7 @@ def build_project_state_from_data(
23
23
  track_infos: Optional[list[dict]] = None,
24
24
  notes_map: Optional[dict[str, dict[int, list[dict]]]] = None,
25
25
  arrangement_clips: Optional[dict] = None,
26
+ clip_automation: Optional[list[dict]] = None,
26
27
  analyzer_ok: bool = False,
27
28
  flucoma_ok: bool = False,
28
29
  plugin_health: Optional[dict[str, Any]] = None,
@@ -105,6 +106,7 @@ def build_project_state_from_data(
105
106
  state.automation_graph = build_automation_graph(
106
107
  track_infos=track_infos or [],
107
108
  sections=section_dicts_for_auto,
109
+ clip_automation=clip_automation or [],
108
110
  )
109
111
  state.automation_graph.freshness.mark_fresh(state.revision)
110
112
 
@@ -11,6 +11,10 @@ from fastmcp import Context
11
11
 
12
12
  from ..server import mcp
13
13
  from .builder import build_project_state_from_data
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
14
18
 
15
19
 
16
20
  def _get_ableton(ctx: Context):
@@ -39,7 +43,8 @@ def build_project_brain(ctx: Context) -> dict:
39
43
  try:
40
44
  scenes_resp = ableton.send_command("get_scenes_info")
41
45
  scenes = scenes_resp.get("scenes", [])
42
- except Exception:
46
+ except Exception as exc:
47
+ logger.debug("build_project_brain failed: %s", exc)
43
48
  scenes = session_info.get("scenes", [])
44
49
 
45
50
  # 3. Get clip matrix (scene_matrix)
@@ -47,8 +52,8 @@ def build_project_brain(ctx: Context) -> dict:
47
52
  try:
48
53
  matrix_resp = ableton.send_command("get_scene_matrix")
49
54
  clip_matrix = matrix_resp.get("matrix", [])
50
- except Exception:
51
- pass
55
+ except Exception as exc:
56
+ logger.debug("build_project_brain failed: %s", exc)
52
57
 
53
58
  # 4. Gather per-track info with devices
54
59
  track_infos = []
@@ -58,7 +63,8 @@ def build_project_brain(ctx: Context) -> dict:
58
63
  "track_index": track["index"],
59
64
  })
60
65
  track_infos.append(info)
61
- except Exception:
66
+ except Exception as exc:
67
+ logger.debug("build_project_brain failed: %s", exc)
62
68
  track_infos.append({
63
69
  "index": track.get("index", 0),
64
70
  "name": track.get("name", ""),
@@ -75,21 +81,27 @@ def build_project_brain(ctx: Context) -> dict:
75
81
  clips = arr.get("clips", [])
76
82
  if clips:
77
83
  arrangement_clips[track["index"]] = clips
78
- except Exception:
79
- pass
84
+ except Exception as exc:
85
+ logger.debug("build_project_brain failed: %s", exc)
80
86
 
81
87
  # 5b. Build notes_map for role inference.
82
88
  # Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
83
89
  # falls back to "assume all tracks active in every section" which destroys
84
90
  # section-scoped role confidence.
91
+ #
92
+ # BUG-E1: section_id must match what build_section_graph_from_scenes emits.
93
+ # The composition engine emits `sec_{i:02d}` using the RAW enumerate index
94
+ # of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
95
+ # ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
96
+ # Our notes_map must mirror that or keys won't align.
85
97
  notes_map: dict[str, dict[int, list[dict]]] = {}
86
98
  try:
87
99
  for scene_idx, scene in enumerate(scenes or []):
88
- section_id = str(
89
- scene.get("section_id")
90
- or scene.get("name")
91
- or f"scene_{scene_idx}"
92
- )
100
+ scene_name = str(scene.get("name", "")).strip()
101
+ if not scene_name:
102
+ continue # mirror _ce_build_sections: unnamed scenes skipped
103
+ section_id = f"sec_{scene_idx:02d}"
104
+
93
105
  per_track: dict[int, list[dict]] = {}
94
106
  for track in tracks:
95
107
  t_idx = track.get("index", 0)
@@ -102,15 +114,60 @@ def build_project_brain(ctx: Context) -> dict:
102
114
  notes = notes_resp.get("notes", [])
103
115
  if notes:
104
116
  per_track[t_idx] = notes
105
- except Exception:
117
+ except Exception as exc:
118
+ logger.debug("build_project_brain failed: %s", exc)
106
119
  # Individual note fetch failing is fine — continue with others
107
120
  continue
108
121
  if per_track:
109
122
  notes_map[section_id] = per_track
110
- except Exception:
123
+ except Exception as exc:
124
+ logger.debug("build_project_brain failed: %s", exc)
111
125
  # Overall failure: empty map, degrade to "all tracks active" fallback
112
126
  notes_map = {}
113
127
 
128
+ # 5c. Scan clip automation across the session (BUG-E2).
129
+ # Device-parameter is_automated flags only reflect whether a parameter
130
+ # is mapped somewhere — they don't reveal clip envelopes. Ableton's
131
+ # automation actually lives on each clip (session + arrangement). We
132
+ # walk every clip slot that has a clip and ask get_clip_automation, then
133
+ # aggregate into a flat list keyed by section.
134
+ clip_automation: list[dict] = []
135
+ try:
136
+ # Iterate session scenes x tracks, plus arrangement clips we already have.
137
+ # Use the raw enumerate index for section_id so it stays aligned with
138
+ # arrangement_graph sections (which use the same scheme — see E1 fix).
139
+ for scene_idx, scene in enumerate(scenes or []):
140
+ scene_name = str(scene.get("name", "")).strip()
141
+ if not scene_name:
142
+ continue
143
+ section_id = f"sec_{scene_idx:02d}"
144
+ for track in tracks:
145
+ t_idx = track.get("index", 0)
146
+ try:
147
+ auto_resp = ableton.send_command("get_clip_automation", {
148
+ "track_index": t_idx,
149
+ "clip_index": scene_idx,
150
+ })
151
+ except Exception as exc:
152
+ # No clip in slot, or remote script rejected — skip
153
+ logger.debug("build_project_brain automation skip: %s", exc)
154
+ continue
155
+ if not isinstance(auto_resp, dict):
156
+ continue
157
+ envs = auto_resp.get("envelopes") or []
158
+ for env in envs:
159
+ clip_automation.append({
160
+ "section_id": section_id,
161
+ "track_index": t_idx,
162
+ "track_name": track.get("name", ""),
163
+ "clip_index": scene_idx,
164
+ "parameter_name": env.get("parameter_name", ""),
165
+ "parameter_type": env.get("parameter_type", ""),
166
+ "device_name": env.get("device_name"),
167
+ })
168
+ except Exception as exc:
169
+ logger.debug("build_project_brain automation scan failed: %s", exc)
170
+
114
171
  # 6. Probe capabilities (direct SpectralCache access, not TCP)
115
172
  analyzer_ok = False
116
173
  analyzer_fresh = False
@@ -127,8 +184,8 @@ def build_project_brain(ctx: Context) -> dict:
127
184
  if spectral.get(key) is not None:
128
185
  flucoma_ok = True
129
186
  break
130
- except Exception:
131
- pass
187
+ except Exception as exc:
188
+ logger.debug("build_project_brain failed: %s", exc)
132
189
 
133
190
  # 7. Build state
134
191
  state = build_project_state_from_data(
@@ -138,6 +195,7 @@ def build_project_brain(ctx: Context) -> dict:
138
195
  track_infos=track_infos if track_infos else None,
139
196
  notes_map=notes_map if notes_map else None,
140
197
  arrangement_clips=arrangement_clips if arrangement_clips else None,
198
+ clip_automation=clip_automation if clip_automation else None,
141
199
  analyzer_ok=analyzer_ok,
142
200
  flucoma_ok=flucoma_ok,
143
201
  session_ok=True,