livepilot 1.9.14 → 1.9.16

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 (108) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +82 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +8 -8
  6. package/livepilot/.Codex-plugin/plugin.json +2 -2
  7. package/livepilot/.claude-plugin/plugin.json +2 -2
  8. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  9. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  10. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  11. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  12. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  13. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  14. package/m4l_device/livepilot_bridge.js +32 -15
  15. package/mcp_server/__init__.py +1 -1
  16. package/mcp_server/connection.py +24 -2
  17. package/mcp_server/curves.py +14 -6
  18. package/mcp_server/evaluation/__init__.py +1 -0
  19. package/mcp_server/evaluation/fabric.py +575 -0
  20. package/mcp_server/evaluation/feature_extractors.py +84 -0
  21. package/mcp_server/evaluation/policy.py +67 -0
  22. package/mcp_server/evaluation/tools.py +53 -0
  23. package/mcp_server/m4l_bridge.py +9 -1
  24. package/mcp_server/memory/__init__.py +11 -2
  25. package/mcp_server/memory/anti_memory.py +78 -0
  26. package/mcp_server/memory/promotion.py +94 -0
  27. package/mcp_server/memory/session_memory.py +108 -0
  28. package/mcp_server/memory/taste_memory.py +158 -0
  29. package/mcp_server/memory/technique_store.py +27 -18
  30. package/mcp_server/memory/tools.py +112 -0
  31. package/mcp_server/mix_engine/__init__.py +1 -0
  32. package/mcp_server/mix_engine/critics.py +299 -0
  33. package/mcp_server/mix_engine/models.py +152 -0
  34. package/mcp_server/mix_engine/planner.py +103 -0
  35. package/mcp_server/mix_engine/state_builder.py +316 -0
  36. package/mcp_server/mix_engine/tools.py +220 -0
  37. package/mcp_server/performance_engine/__init__.py +1 -0
  38. package/mcp_server/performance_engine/models.py +148 -0
  39. package/mcp_server/performance_engine/planner.py +267 -0
  40. package/mcp_server/performance_engine/safety.py +165 -0
  41. package/mcp_server/performance_engine/tools.py +183 -0
  42. package/mcp_server/project_brain/__init__.py +6 -0
  43. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  44. package/mcp_server/project_brain/automation_graph.py +72 -0
  45. package/mcp_server/project_brain/builder.py +123 -0
  46. package/mcp_server/project_brain/capability_graph.py +64 -0
  47. package/mcp_server/project_brain/models.py +282 -0
  48. package/mcp_server/project_brain/refresh.py +86 -0
  49. package/mcp_server/project_brain/role_graph.py +103 -0
  50. package/mcp_server/project_brain/session_graph.py +51 -0
  51. package/mcp_server/project_brain/tools.py +144 -0
  52. package/mcp_server/reference_engine/__init__.py +1 -0
  53. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  54. package/mcp_server/reference_engine/models.py +105 -0
  55. package/mcp_server/reference_engine/profile_builder.py +149 -0
  56. package/mcp_server/reference_engine/tactic_router.py +117 -0
  57. package/mcp_server/reference_engine/tools.py +236 -0
  58. package/mcp_server/runtime/__init__.py +1 -0
  59. package/mcp_server/runtime/action_ledger.py +117 -0
  60. package/mcp_server/runtime/action_ledger_models.py +91 -0
  61. package/mcp_server/runtime/action_tools.py +57 -0
  62. package/mcp_server/runtime/capability_state.py +219 -0
  63. package/mcp_server/runtime/safety_kernel.py +339 -0
  64. package/mcp_server/runtime/safety_tools.py +42 -0
  65. package/mcp_server/runtime/tools.py +67 -0
  66. package/mcp_server/server.py +17 -0
  67. package/mcp_server/sound_design/__init__.py +1 -0
  68. package/mcp_server/sound_design/critics.py +297 -0
  69. package/mcp_server/sound_design/models.py +147 -0
  70. package/mcp_server/sound_design/planner.py +104 -0
  71. package/mcp_server/sound_design/tools.py +297 -0
  72. package/mcp_server/tools/_agent_os_engine.py +947 -0
  73. package/mcp_server/tools/_composition_engine.py +1530 -0
  74. package/mcp_server/tools/_conductor.py +199 -0
  75. package/mcp_server/tools/_conductor_budgets.py +222 -0
  76. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  77. package/mcp_server/tools/_form_engine.py +416 -0
  78. package/mcp_server/tools/_motif_engine.py +351 -0
  79. package/mcp_server/tools/_planner_engine.py +516 -0
  80. package/mcp_server/tools/_research_engine.py +542 -0
  81. package/mcp_server/tools/_research_provider.py +185 -0
  82. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  83. package/mcp_server/tools/agent_os.py +448 -0
  84. package/mcp_server/tools/analyzer.py +18 -0
  85. package/mcp_server/tools/automation.py +25 -10
  86. package/mcp_server/tools/composition.py +645 -0
  87. package/mcp_server/tools/devices.py +15 -1
  88. package/mcp_server/tools/midi_io.py +3 -1
  89. package/mcp_server/tools/motif.py +104 -0
  90. package/mcp_server/tools/planner.py +144 -0
  91. package/mcp_server/tools/research.py +223 -0
  92. package/mcp_server/tools/tracks.py +21 -6
  93. package/mcp_server/tools/transport.py +10 -2
  94. package/mcp_server/transition_engine/__init__.py +6 -0
  95. package/mcp_server/transition_engine/archetypes.py +167 -0
  96. package/mcp_server/transition_engine/critics.py +340 -0
  97. package/mcp_server/transition_engine/models.py +90 -0
  98. package/mcp_server/transition_engine/tools.py +291 -0
  99. package/mcp_server/translation_engine/__init__.py +5 -0
  100. package/mcp_server/translation_engine/critics.py +297 -0
  101. package/mcp_server/translation_engine/models.py +27 -0
  102. package/mcp_server/translation_engine/tools.py +108 -0
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +1 -1
  105. package/remote_script/LivePilot/arrangement.py +21 -3
  106. package/remote_script/LivePilot/clips.py +22 -6
  107. package/remote_script/LivePilot/notes.py +9 -1
  108. package/remote_script/LivePilot/server.py +6 -6
@@ -0,0 +1,645 @@
1
+ """Composition Engine V1 MCP tools — structural and musical intelligence.
2
+
3
+ 5 tools that connect the pure-computation engine (_composition_engine.py) to the
4
+ live Ableton session via the existing MCP infrastructure.
5
+
6
+ These tools power the composition intelligence layer:
7
+ analyze_composition — full structural analysis (sections, phrases, roles, issues)
8
+ get_section_graph — lightweight section inference only
9
+ get_phrase_grid — phrase boundaries for a section
10
+ plan_gesture — map musical intent to concrete automation plan
11
+ evaluate_composition_move — composition-specific keep/undo scoring
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Optional
18
+
19
+ from fastmcp import Context
20
+
21
+ from ..server import mcp
22
+ from ..memory.technique_store import TechniqueStore
23
+ from . import _composition_engine as engine
24
+
25
+ _memory_store = TechniqueStore()
26
+
27
+
28
+ def _get_ableton(ctx: Context):
29
+ return ctx.lifespan_context["ableton"]
30
+
31
+
32
+ def _parse_json_param(value, name: str) -> dict:
33
+ """Parse a dict, JSON string, or None parameter."""
34
+ if value is None:
35
+ return {}
36
+ if isinstance(value, str):
37
+ try:
38
+ return json.loads(value)
39
+ except json.JSONDecodeError as exc:
40
+ raise ValueError(f"Invalid JSON in {name}: {exc}") from exc
41
+ if isinstance(value, dict):
42
+ return value
43
+ raise ValueError(f"{name} must be a dict or JSON string")
44
+
45
+
46
+ def _build_clip_matrix(ableton, scene_count: int, track_count: int) -> list[list]:
47
+ """Build the clip matrix from scene_matrix data."""
48
+ try:
49
+ matrix_data = ableton.send_command("get_scene_matrix")
50
+ raw_matrix = matrix_data.get("matrix", [])
51
+ return raw_matrix
52
+ except Exception:
53
+ return [[] for _ in range(scene_count)]
54
+
55
+
56
+ # ── analyze_composition ───────────────────────────────────────────────
57
+
58
+
59
+ @mcp.tool()
60
+ def analyze_composition(ctx: Context) -> dict:
61
+ """Run full composition analysis on the current Ableton session.
62
+
63
+ Returns section graph, phrase grid, role graph, and issues from
64
+ form/section-identity/phrase critics. This is the "one call to
65
+ understand the arrangement structure."
66
+
67
+ Uses scene names + clip activity to infer sections, note data for
68
+ phrases, and track names + note patterns for role assignment.
69
+
70
+ The issues section contains actionable structural recommendations.
71
+ """
72
+ ableton = _get_ableton(ctx)
73
+
74
+ # 1. Get session info
75
+ session = ableton.send_command("get_session_info")
76
+ scenes = session.get("scenes", [])
77
+ tracks = session.get("tracks", [])
78
+ track_count = session.get("track_count", 0)
79
+
80
+ # 2. Get clip matrix for section inference
81
+ clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
82
+
83
+ # 3. Build section graph (from scenes)
84
+ sections = engine.build_section_graph_from_scenes(
85
+ scenes, clip_matrix, track_count,
86
+ )
87
+
88
+ # 4. Try arrangement clips as supplement
89
+ arr_clips = {}
90
+ for track in tracks:
91
+ try:
92
+ arr = ableton.send_command("get_arrangement_clips", {
93
+ "track_index": track["index"]
94
+ })
95
+ clips = arr.get("clips", [])
96
+ if clips:
97
+ arr_clips[track["index"]] = clips
98
+ except Exception:
99
+ pass
100
+
101
+ if not sections and arr_clips:
102
+ sections = engine.build_section_graph_from_arrangement(
103
+ arr_clips, track_count,
104
+ )
105
+
106
+ # 5. Get per-track info for role inference
107
+ track_data = []
108
+ for track in tracks:
109
+ try:
110
+ ti = ableton.send_command("get_track_info", {
111
+ "track_index": track["index"]
112
+ })
113
+ track_data.append(ti)
114
+ except Exception:
115
+ track_data.append({"index": track["index"], "name": track.get("name", ""),
116
+ "devices": []})
117
+
118
+ # 6. Get notes for phrase detection + role inference
119
+ notes_by_section_track: dict[str, dict[int, list]] = {}
120
+ all_notes_by_track: dict[int, list] = {}
121
+
122
+ for track in tracks:
123
+ t_idx = track["index"]
124
+ # Collect notes from all clips
125
+ track_notes = []
126
+ for s_idx in range(len(scenes)):
127
+ try:
128
+ result = ableton.send_command("get_notes", {
129
+ "track_index": t_idx, "clip_index": s_idx
130
+ })
131
+ notes = result.get("notes", [])
132
+ track_notes.extend(notes)
133
+ except Exception:
134
+ pass
135
+ all_notes_by_track[t_idx] = track_notes
136
+
137
+ # Map notes to sections
138
+ for section in sections:
139
+ notes_by_section_track[section.section_id] = {}
140
+ for t_idx in section.tracks_active:
141
+ notes_by_section_track[section.section_id][t_idx] = (
142
+ all_notes_by_track.get(t_idx, [])
143
+ )
144
+
145
+ # 7. Build phrase grid
146
+ all_phrases = []
147
+ for section in sections:
148
+ section_notes = {t: all_notes_by_track.get(t, []) for t in section.tracks_active}
149
+ phrases = engine.detect_phrases(section, section_notes)
150
+ all_phrases.extend(phrases)
151
+
152
+ # 8. Build role graph
153
+ roles = engine.build_role_graph(sections, track_data, notes_by_section_track)
154
+
155
+ # 9. Run critics
156
+ form_issues = engine.run_form_critic(sections)
157
+ identity_issues = engine.run_section_identity_critic(sections, roles)
158
+ phrase_issues = engine.run_phrase_critic(all_phrases)
159
+ all_issues = form_issues + identity_issues + phrase_issues
160
+
161
+ # 10. Assemble result
162
+ analysis = engine.CompositionAnalysis(
163
+ sections=sections,
164
+ phrases=all_phrases,
165
+ roles=roles,
166
+ issues=all_issues,
167
+ )
168
+ return analysis.to_dict()
169
+
170
+
171
+ # ── get_section_graph ─────────────────────────────────────────────────
172
+
173
+
174
+ @mcp.tool()
175
+ def get_section_graph(ctx: Context) -> dict:
176
+ """Get just the section graph — lightweight structural overview.
177
+
178
+ Infers sections from scene names and clip activity. Returns
179
+ section types, energy levels, density, and active tracks per section.
180
+ Faster than analyze_composition when you only need structure.
181
+ """
182
+ ableton = _get_ableton(ctx)
183
+ session = ableton.send_command("get_session_info")
184
+ scenes = session.get("scenes", [])
185
+ track_count = session.get("track_count", 0)
186
+
187
+ clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
188
+ sections = engine.build_section_graph_from_scenes(
189
+ scenes, clip_matrix, track_count,
190
+ )
191
+
192
+ return {
193
+ "sections": [s.to_dict() for s in sections],
194
+ "section_count": len(sections),
195
+ "has_energy_arc": _has_energy_arc(sections),
196
+ }
197
+
198
+
199
+ def _has_energy_arc(sections: list[engine.SectionNode]) -> bool:
200
+ if len(sections) < 2:
201
+ return False
202
+ energies = [s.energy for s in sections]
203
+ return (max(energies) - min(energies)) >= 0.15
204
+
205
+
206
+ # ── get_phrase_grid ───────────────────────────────────────────────────
207
+
208
+
209
+ @mcp.tool()
210
+ def get_phrase_grid(
211
+ ctx: Context,
212
+ section_index: int = 0,
213
+ ) -> dict:
214
+ """Get phrase boundaries for a specific section.
215
+
216
+ section_index: which section to analyze (0-based, from get_section_graph).
217
+ Returns phrase boundaries, cadence strengths, and note densities.
218
+ """
219
+ ableton = _get_ableton(ctx)
220
+ session = ableton.send_command("get_session_info")
221
+ scenes = session.get("scenes", [])
222
+ tracks = session.get("tracks", [])
223
+ track_count = session.get("track_count", 0)
224
+
225
+ clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
226
+ sections = engine.build_section_graph_from_scenes(
227
+ scenes, clip_matrix, track_count,
228
+ )
229
+
230
+ if section_index < 0 or section_index >= len(sections):
231
+ return {"error": f"section_index {section_index} out of range (0-{len(sections) - 1})"}
232
+
233
+ section = sections[section_index]
234
+
235
+ # Collect notes for active tracks — use the section's scene_index
236
+ # (which maps to the actual clip slot), not the section_index
237
+ # (which is a position in the section graph)
238
+ notes_by_track: dict[int, list] = {}
239
+ scene_idx = section.scene_index if hasattr(section, "scene_index") else section_index
240
+ for t_idx in section.tracks_active:
241
+ try:
242
+ result = ableton.send_command("get_notes", {
243
+ "track_index": t_idx,
244
+ "clip_index": scene_idx,
245
+ })
246
+ notes_by_track[t_idx] = result.get("notes", [])
247
+ except Exception:
248
+ notes_by_track[t_idx] = []
249
+
250
+ phrases = engine.detect_phrases(section, notes_by_track)
251
+ return {
252
+ "section": section.to_dict(),
253
+ "phrases": [p.to_dict() for p in phrases],
254
+ "phrase_count": len(phrases),
255
+ }
256
+
257
+
258
+ # ── plan_gesture ──────────────────────────────────────────────────────
259
+
260
+
261
+ @mcp.tool()
262
+ def plan_gesture(
263
+ ctx: Context,
264
+ intent: str,
265
+ target_tracks: list | str = "[]",
266
+ start_bar: int = 0,
267
+ duration_bars: int = 0,
268
+ foreground: bool = False,
269
+ ) -> dict:
270
+ """Plan a musical gesture — map abstract intent to concrete automation.
271
+
272
+ intent: reveal | conceal | handoff | inhale | release | lift | sink | punctuate | drift
273
+ target_tracks: list of track indices the gesture applies to
274
+ start_bar: where the gesture begins
275
+ duration_bars: how long (0 = use gesture default)
276
+ foreground: is this a focal point or background motion?
277
+
278
+ Returns a GesturePlan with: curve_family, parameter_hints, direction,
279
+ and timing — ready for use with apply_automation_shape.
280
+
281
+ Example: plan_gesture(intent="reveal", target_tracks=[6], start_bar=8)
282
+ → exponential curve on filter_cutoff, sweep up over 4 bars
283
+ """
284
+ # Parse intent
285
+ try:
286
+ gesture_intent = engine.GestureIntent(intent)
287
+ except ValueError:
288
+ valid = [g.value for g in engine.GestureIntent]
289
+ raise ValueError(f"Unknown intent '{intent}'. Valid: {valid}")
290
+
291
+ # Parse target_tracks
292
+ if isinstance(target_tracks, str):
293
+ try:
294
+ target_tracks = json.loads(target_tracks)
295
+ except json.JSONDecodeError:
296
+ target_tracks = []
297
+
298
+ duration = duration_bars if duration_bars > 0 else None
299
+ gesture = engine.plan_gesture(
300
+ intent=gesture_intent,
301
+ target_tracks=target_tracks,
302
+ start_bar=start_bar,
303
+ duration_bars=duration,
304
+ foreground=foreground,
305
+ )
306
+ return gesture.to_dict()
307
+
308
+
309
+ # ── evaluate_composition_move ─────────────────────────────────────────
310
+
311
+
312
+ @mcp.tool()
313
+ def evaluate_composition_move(
314
+ ctx: Context,
315
+ before_issues: list | str,
316
+ after_issues: list | str,
317
+ target_dimensions: dict | str = "{}",
318
+ protect: dict | str = "{}",
319
+ ) -> dict:
320
+ """Evaluate whether a composition move improved the arrangement.
321
+
322
+ Takes before/after issue lists (from analyze_composition) and compares
323
+ severity and count. Returns a score and keep/undo recommendation.
324
+
325
+ before_issues: issues list from analyze_composition BEFORE the move
326
+ after_issues: issues list from analyze_composition AFTER the move
327
+ target_dimensions: optional composition dimensions being targeted
328
+ protect: optional dimensions to preserve
329
+
330
+ Returns: {score, keep_change, issue_delta, severity_improvement, notes}
331
+ """
332
+ # Parse inputs
333
+ if isinstance(before_issues, str):
334
+ before_issues = json.loads(before_issues)
335
+ if isinstance(after_issues, str):
336
+ after_issues = json.loads(after_issues)
337
+
338
+ targets = _parse_json_param(target_dimensions, "target_dimensions")
339
+ prot = _parse_json_param(protect, "protect")
340
+
341
+ # Convert raw dicts back to CompositionIssue objects
342
+ before = [engine.CompositionIssue(**{k: v for k, v in i.items()
343
+ if k in ("issue_type", "critic", "severity", "confidence",
344
+ "scope", "recommended_moves", "evidence")})
345
+ for i in before_issues]
346
+ after = [engine.CompositionIssue(**{k: v for k, v in i.items()
347
+ if k in ("issue_type", "critic", "severity", "confidence",
348
+ "scope", "recommended_moves", "evidence")})
349
+ for i in after_issues]
350
+
351
+ return engine.evaluate_composition_move(before, after, targets, prot)
352
+
353
+
354
+ # ── get_harmony_field (Round 1) ───────────────────────────────────────
355
+
356
+
357
+ @mcp.tool()
358
+ def get_harmony_field(
359
+ ctx: Context,
360
+ section_index: int = 0,
361
+ ) -> dict:
362
+ """Analyze the harmonic content of a section — key, chords, voice-leading, tension.
363
+
364
+ Combines identify_scale, analyze_harmony, classify_progression, and
365
+ find_voice_leading_path into a single structured HarmonyField.
366
+
367
+ section_index: which section to analyze (0-based, from get_section_graph).
368
+ Returns: key, mode, chord_progression, voice_leading_quality, instability,
369
+ resolution_potential.
370
+ """
371
+ ableton = _get_ableton(ctx)
372
+ session = ableton.send_command("get_session_info")
373
+ scenes = session.get("scenes", [])
374
+ tracks = session.get("tracks", [])
375
+ track_count = session.get("track_count", 0)
376
+
377
+ clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
378
+ sections = engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
379
+
380
+ if section_index < 0 or section_index >= len(sections):
381
+ return {"error": f"section_index {section_index} out of range (0-{len(sections) - 1})"}
382
+
383
+ section = sections[section_index]
384
+
385
+ # Find a track with notes to analyze harmony
386
+ # Use theory engine functions directly instead of TCP calls to MCP tools
387
+ from . import _theory_engine as theory_engine
388
+ from . import _harmony_engine as harmony_engine
389
+
390
+ scale_info = None
391
+ harmony_analysis = None
392
+ progression_info = None
393
+ voice_leading_info = None
394
+
395
+ for t_idx in section.tracks_active:
396
+ try:
397
+ # Get notes via TCP (valid Remote Script command)
398
+ result = ableton.send_command("get_notes", {
399
+ "track_index": t_idx, "clip_index": section_index,
400
+ })
401
+ notes = result.get("notes", [])
402
+ if not notes:
403
+ continue
404
+
405
+ # identify_scale: run key detection directly
406
+ if not scale_info:
407
+ detected = theory_engine.detect_key(notes, mode_detection=True)
408
+ top = {
409
+ "key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
410
+ "confidence": detected["confidence"],
411
+ "mode": detected["mode"].replace("_", " "),
412
+ "mode_id": detected["mode"],
413
+ "tonic": detected["tonic_name"],
414
+ }
415
+ scale_info = {"top_match": top}
416
+
417
+ # analyze_harmony: chordify + roman numeral analysis directly
418
+ if not harmony_analysis:
419
+ key_info = theory_engine.detect_key(notes)
420
+ tonic = key_info["tonic"]
421
+ mode = key_info["mode"]
422
+ chord_groups = theory_engine.chordify(notes)
423
+ if chord_groups:
424
+ chords = []
425
+ for group in chord_groups:
426
+ pitches = group["pitches"]
427
+ pcs = group["pitch_classes"]
428
+ rn = theory_engine.roman_numeral(pcs, tonic, mode)
429
+ cn = theory_engine.chord_name(pitches)
430
+ chords.append({
431
+ "beat": group["beat"],
432
+ "duration": group["duration"],
433
+ "chord_name": cn,
434
+ "roman_numeral": rn["figure"],
435
+ "figure": rn["figure"],
436
+ "quality": rn["quality"],
437
+ })
438
+ if chords:
439
+ harmony_analysis = {
440
+ "key": f"{key_info['tonic_name']} {mode.replace('_', ' ')}",
441
+ "chords": chords,
442
+ }
443
+
444
+ # classify_progression directly
445
+ chord_names = [c["chord_name"] for c in chords if c.get("chord_name")]
446
+ if len(chord_names) >= 2:
447
+ try:
448
+ parsed = [harmony_engine.parse_chord(c) for c in chord_names[:8]]
449
+ transforms = harmony_engine.classify_transform_sequence(parsed)
450
+ pattern = "".join(transforms)
451
+ classification = "free neo-Riemannian progression"
452
+ clean = pattern.replace("?", "")
453
+ if len(clean) >= 2:
454
+ pair = clean[:2]
455
+ if pair in ("PL", "LP") and all(c in "PL" for c in clean):
456
+ classification = "hexatonic cycle fragment"
457
+ elif pair in ("PR", "RP") and all(c in "PR" for c in clean):
458
+ classification = "octatonic cycle fragment"
459
+ elif pair in ("LR", "RL") and all(c in "LR" for c in clean):
460
+ classification = "diatonic cycle fragment"
461
+ progression_info = {
462
+ "chords": chord_names[:8],
463
+ "transforms": transforms,
464
+ "pattern": pattern,
465
+ "classification": classification,
466
+ }
467
+ except Exception:
468
+ pass
469
+
470
+ # Populate voice_leading_info from chord groups
471
+ if harmony_analysis and not voice_leading_info:
472
+ try:
473
+ chord_groups_vl = theory_engine.chordify(notes)
474
+ if len(chord_groups_vl) >= 2:
475
+ all_vl_issues = []
476
+ for vi in range(1, min(len(chord_groups_vl), 9)):
477
+ prev_p = chord_groups_vl[vi - 1]["pitches"]
478
+ curr_p = chord_groups_vl[vi]["pitches"]
479
+ issues = theory_engine.check_voice_leading(prev_p, curr_p)
480
+ all_vl_issues.extend(issues)
481
+ voice_leading_info = {
482
+ "issues": all_vl_issues,
483
+ "issue_count": len(all_vl_issues),
484
+ "quality": "clean" if not all_vl_issues else "has_issues",
485
+ }
486
+ except Exception:
487
+ pass
488
+
489
+ if scale_info and harmony_analysis:
490
+ break
491
+ except Exception:
492
+ continue
493
+
494
+ hf = engine.build_harmony_field(
495
+ section_id=section.section_id,
496
+ harmony_analysis=harmony_analysis,
497
+ scale_info=scale_info,
498
+ progression_info=progression_info,
499
+ voice_leading_info=voice_leading_info,
500
+ )
501
+ result = hf.to_dict()
502
+ result["section_name"] = section.name
503
+ return result
504
+
505
+
506
+ # ── get_transition_analysis (Round 1) ─────────────────────────────────
507
+
508
+
509
+ @mcp.tool()
510
+ def get_transition_analysis(ctx: Context) -> dict:
511
+ """Analyze transition quality between all adjacent sections.
512
+
513
+ Checks for: hard cuts, missing pre-arrival subtraction, groove breaks,
514
+ harmonic non-sequiturs, and weak builds without role rotation.
515
+
516
+ Returns issues with recommended composition moves for each boundary.
517
+ """
518
+ ableton = _get_ableton(ctx)
519
+ session = ableton.send_command("get_session_info")
520
+ scenes = session.get("scenes", [])
521
+ tracks = session.get("tracks", [])
522
+ track_count = session.get("track_count", 0)
523
+
524
+ clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
525
+ sections = engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
526
+
527
+ if len(sections) < 2:
528
+ return {"issues": [], "note": "Need at least 2 sections for transition analysis"}
529
+
530
+ # Build role graph for transition critic
531
+ track_data = []
532
+ notes_map: dict[str, dict[int, list]] = {}
533
+ for track in tracks:
534
+ t_idx = track["index"]
535
+ try:
536
+ ti = ableton.send_command("get_track_info", {"track_index": t_idx})
537
+ track_data.append(ti)
538
+ except Exception:
539
+ track_data.append({"index": t_idx, "name": track.get("name", ""), "devices": []})
540
+
541
+ for section in sections:
542
+ notes_map[section.section_id] = {}
543
+ for t_idx in section.tracks_active:
544
+ notes_map[section.section_id][t_idx] = []
545
+
546
+ roles = engine.build_role_graph(sections, track_data, notes_map)
547
+
548
+ # Build harmony fields (lightweight — skip if tools fail)
549
+ harmony_fields = []
550
+ for i, section in enumerate(sections):
551
+ hf = engine.HarmonyField(section_id=section.section_id)
552
+ harmony_fields.append(hf)
553
+
554
+ issues = engine.run_transition_critic(sections, roles, harmony_fields)
555
+
556
+ return {
557
+ "transition_count": len(sections) - 1,
558
+ "issues": [i.to_dict() for i in issues],
559
+ "issue_count": len(issues),
560
+ }
561
+
562
+
563
+ # ── apply_gesture_template (Round 2) ──────────────────────────────────
564
+
565
+
566
+ @mcp.tool()
567
+ def apply_gesture_template(
568
+ ctx: Context,
569
+ template_name: str,
570
+ target_tracks: list | str = "[]",
571
+ anchor_bar: int = 0,
572
+ foreground: bool = False,
573
+ ) -> dict:
574
+ """Apply a compound gesture template — multiple coordinated automation gestures.
575
+
576
+ template_name: pre_arrival_vacuum | sectional_width_bloom | phrase_end_throw |
577
+ turnaround_accent | outro_decay_dissolve | bass_tuck_before_kick |
578
+ harmonic_tint_rise | response_echo | texture_drift_bed |
579
+ tension_ratchet | re_entry_spotlight
580
+ target_tracks: list of track indices
581
+ anchor_bar: reference point (section boundary bar number)
582
+ foreground: is this a focal point?
583
+
584
+ Returns: list of GesturePlans — execute each with apply_automation_shape.
585
+ """
586
+ if isinstance(target_tracks, str):
587
+ try:
588
+ target_tracks = json.loads(target_tracks)
589
+ except json.JSONDecodeError:
590
+ target_tracks = []
591
+
592
+ plans = engine.resolve_gesture_template(
593
+ template_name, target_tracks, anchor_bar, foreground,
594
+ )
595
+ return {
596
+ "template": template_name,
597
+ "description": engine.GESTURE_TEMPLATES[template_name]["description"],
598
+ "gesture_count": len(plans),
599
+ "gestures": [g.to_dict() for g in plans],
600
+ }
601
+
602
+
603
+ # ── get_section_outcomes (Round 2) ────────────────────────────────────
604
+
605
+
606
+ @mcp.tool()
607
+ def get_section_outcomes(
608
+ ctx: Context,
609
+ section_type: str = "",
610
+ limit: int = 50,
611
+ ) -> dict:
612
+ """Get composition move success rates grouped by section type.
613
+
614
+ Analyzes stored composition outcomes to answer: which moves work
615
+ best in which section types? Use before making structural changes
616
+ to learn from past sessions.
617
+
618
+ section_type: filter to a specific type (intro, verse, chorus, etc.)
619
+ Leave empty for all types.
620
+ """
621
+ # Fetch composition outcomes directly from TechniqueStore
622
+ try:
623
+ techniques = _memory_store.list_techniques(
624
+ type_filter="composition_outcome", sort_by="updated_at", limit=limit,
625
+ )
626
+ except Exception:
627
+ techniques = []
628
+
629
+ outcomes = []
630
+ for t in techniques:
631
+ try:
632
+ full = _memory_store.get(t["id"])
633
+ payload = full.get("payload", {})
634
+ if isinstance(payload, dict):
635
+ outcomes.append(payload)
636
+ except Exception:
637
+ pass
638
+
639
+ result = engine.analyze_section_outcomes(outcomes)
640
+
641
+ if section_type and section_type in result.get("outcomes_by_section", {}):
642
+ result["filtered_section"] = section_type
643
+ result["section_moves"] = result["outcomes_by_section"][section_type]
644
+
645
+ return result
@@ -145,11 +145,25 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
145
145
  if match is None:
146
146
  match = devices[-1]
147
147
 
148
+ # get_track_info returns device summaries without a parameters list,
149
+ # so use the 'parameter_count' field if present, otherwise fetch
150
+ # the actual device info for an accurate count.
151
+ param_count = match.get("parameter_count", len(match.get("parameters", [])))
152
+ if param_count == 0 and match.get("index") is not None:
153
+ try:
154
+ full_info = _get_ableton(ctx).send_command("get_device_info", {
155
+ "track_index": int(track_index),
156
+ "device_index": int(match["index"]),
157
+ })
158
+ param_count = full_info.get("parameter_count", 0)
159
+ except Exception:
160
+ pass
161
+
148
162
  device_info = _annotate_device_info({
149
163
  "name": match.get("name"),
150
164
  "class_name": match.get("class_name"),
151
165
  "is_active": match.get("is_active"),
152
- "parameter_count": len(match.get("parameters", [])),
166
+ "parameter_count": param_count,
153
167
  })
154
168
 
155
169
  merged = dict(annotated)
@@ -62,7 +62,9 @@ def _safe_output_path(directory: Path, filename: str) -> Path:
62
62
  if not safe_name:
63
63
  raise ValueError(f"Invalid filename: {filename!r}")
64
64
  out = (directory / safe_name).resolve()
65
- if not str(out).startswith(str(directory.resolve())):
65
+ # Use os.sep suffix to prevent prefix collisions (e.g., /foo/bar vs /foo/barbaz)
66
+ parent = str(directory.resolve()) + os.sep
67
+ if not (str(out) + os.sep).startswith(parent):
66
68
  raise ValueError(f"Filename escapes output directory: {filename!r}")
67
69
  return out
68
70