livepilot 1.23.6 → 1.24.0

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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/server.json +3 -3
@@ -15,12 +15,17 @@ from fastmcp import Context
15
15
 
16
16
  from ..server import mcp
17
17
  from .prompt_parser import parse_prompt
18
- from .engine import ComposerEngine
18
+ from .full.engine import ComposerEngine
19
+ from . import fast as fast_compose
20
+ from .fast.apply import apply_fast_plan
21
+ from .full.apply import apply_full_plan
19
22
  import logging
23
+ import time
20
24
 
21
25
  logger = logging.getLogger(__name__)
22
26
 
23
-
27
+ # Backward-compatible alias — tests import _apply_fast_plan from this module.
28
+ _apply_fast_plan = apply_fast_plan
24
29
 
25
30
  # Singleton engine — stateless, safe to reuse
26
31
  _engine = ComposerEngine()
@@ -82,50 +87,533 @@ async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int,
82
87
  return max_credits, credits_remaining, warnings
83
88
 
84
89
 
90
+ def _build_fast_brief(
91
+ ctx: Context, intent, bars: int, reference: str | None = None,
92
+ ) -> dict:
93
+ """Phase-1 fast mode (2026-05-01 redesign per user feedback).
94
+
95
+ Returns a CREATIVE BRIEF for the agent to read and design a layer plan
96
+ around. Does fresh-project cleanup (analyzer load + default-track
97
+ delete) and tempo set up front so the agent can focus on creative
98
+ content. Does NOT generate any patterns or load any instruments — the
99
+ agent picks instruments from instruments_by_role and writes notes
100
+ inline, then submits the plan to compose_fast_apply.
101
+
102
+ `reference` (Tier 2): when set (e.g. "Ricardo Villalobos"), the brief
103
+ includes artist-specific search queries the agent fires against the
104
+ Ableton Knowledge MCP to design USING that artist's techniques.
105
+ """
106
+ started = time.time()
107
+ ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
108
+ if ableton is None:
109
+ return {"error": "Ableton connection not available", "phase": "brief"}
110
+
111
+ # Pre-flight: read the session
112
+ session = ableton.send_command("get_session_info", {})
113
+ starting_track_count = int(session.get("track_count", 0))
114
+
115
+ # Fresh-project detection + cleanup. Identify default tracks; load the
116
+ # analyzer on master proactively; queue defaults for deletion. We
117
+ # delete BEFORE returning the brief so the agent's apply-step lands
118
+ # cleanly without leftover MIDI 1 / Audio 1 tracks.
119
+ fresh_project = False
120
+ fresh_actions: list[str] = []
121
+ if fast_compose.detect_fresh_project(session):
122
+ candidates: list[int] = []
123
+ for i in range(starting_track_count):
124
+ try:
125
+ ti = ableton.send_command("get_track_info", {"track_index": i})
126
+ if fast_compose.track_is_empty(ti):
127
+ candidates.append(i)
128
+ except Exception as exc:
129
+ logger.debug("fast: fresh-check get_track_info(%s) failed: %s", i, exc)
130
+ if len(candidates) == starting_track_count and starting_track_count > 0:
131
+ fresh_project = True
132
+ fresh_actions.append(f"detected_fresh_project_{starting_track_count}_default_tracks")
133
+ # Load analyzer on master proactively
134
+ try:
135
+ from ..tools.analyzer import ensure_analyzer_on_master as _ensure_analyzer
136
+ _ensure_analyzer(ctx)
137
+ fresh_actions.append("analyzer_loaded_on_master")
138
+ except Exception as exc:
139
+ logger.debug("fast: ensure_analyzer_on_master failed: %s", exc)
140
+ # Delete defaults in reverse order. We can't delete the LAST
141
+ # track (Ableton requires ≥1), so leave one default in place;
142
+ # the agent's compose_fast_apply will add new tracks first,
143
+ # then we'll prune the leftover survivor in apply.
144
+ deletable = sorted(candidates, reverse=True)[:-1] # leave 1
145
+ deleted_count = 0
146
+ for idx in deletable:
147
+ try:
148
+ ableton.send_command("delete_track", {"track_index": idx})
149
+ deleted_count += 1
150
+ except Exception as exc:
151
+ logger.debug("fast: delete_track(%s) failed: %s", idx, exc)
152
+ if deleted_count:
153
+ fresh_actions.append(f"deleted_{deleted_count}_default_tracks")
154
+
155
+ # Set tempo proactively (so the agent's plan plays at the right BPM)
156
+ tempo_set = False
157
+ if intent.tempo and intent.tempo > 0:
158
+ try:
159
+ ableton.send_command("set_tempo", {"tempo": float(intent.tempo)})
160
+ tempo_set = True
161
+ except Exception as exc:
162
+ logger.debug("fast: set_tempo failed: %s", exc)
163
+
164
+ # Atlas access for instrument candidates
165
+ atlas_obj = None
166
+ try:
167
+ from ..atlas import tools as atlas_module
168
+ atlas_obj = atlas_module._get_atlas()
169
+ except Exception as exc:
170
+ logger.debug("fast: atlas access failed: %s", exc)
171
+
172
+ # Anti-repeat: read all currently-loaded device names from the session
173
+ # so the brief picker can bias candidates AWAY from already-used devices.
174
+ # User feedback 2026-05-01: Tree Tone always wins for pad → boring.
175
+ post_cleanup_session = ableton.send_command("get_session_info", {})
176
+ loaded_device_names: set[str] = set()
177
+ track_count_after_cleanup = int(post_cleanup_session.get("track_count", 0))
178
+ for i in range(track_count_after_cleanup):
179
+ try:
180
+ ti = ableton.send_command("get_track_info", {"track_index": i})
181
+ for dev in (ti.get("devices") or []):
182
+ n = dev.get("name") or ""
183
+ if n:
184
+ loaded_device_names.add(n)
185
+ except Exception as exc:
186
+ logger.debug("fast: anti-repeat read failed for track %s: %s", i, exc)
187
+
188
+ fresh_state = {
189
+ "detected": fresh_project,
190
+ "actions_taken": fresh_actions,
191
+ "tempo_set": tempo_set,
192
+ "starting_track_count_after_cleanup": track_count_after_cleanup,
193
+ }
194
+
195
+ brief = fast_compose.build_creative_brief(
196
+ intent=intent,
197
+ atlas=atlas_obj,
198
+ fresh_project_state=fresh_state,
199
+ bars=bars,
200
+ reference=reference,
201
+ exclude_loaded_device_names=loaded_device_names,
202
+ )
203
+ brief["duration_ms"] = int((time.time() - started) * 1000)
204
+ return brief
205
+
206
+
85
207
  @mcp.tool()
86
208
  async def compose(
87
209
  ctx: Context,
88
210
  prompt: str,
211
+ mode: str = "full",
212
+ bars: int = 4,
213
+ target_scene: Optional[int] = None,
214
+ seed_scene_index: int = 0,
89
215
  max_credits: int = 50,
90
216
  dry_run: bool = False,
217
+ reference: Optional[str] = None,
91
218
  ) -> dict:
92
- """Plan a full multi-layer composition from a text prompt.
219
+ """Plan, brief, or execute a multi-layer composition from a text prompt
220
+ or an existing seed loop.
221
+
222
+ Three modes:
223
+
224
+ ``mode="full"`` (default) — plan-only. Parses prompt into genre/mood/
225
+ tempo/key, plans layers using role templates, returns an executable
226
+ plan of tool calls for the agent to step through. This is the rich
227
+ composition path.
228
+
229
+ ``mode="fast"`` — **LLM-creative two-phase flow** (2026-05-01 redesign):
230
+ Phase 1 (this call): returns a CREATIVE BRIEF with parsed intent,
231
+ atlas-filtered instrument candidates per role, scale-pitch context,
232
+ genre creative guidance. Does NOT generate any musical content.
233
+ Pre-flight handles fresh-project detection, analyzer load, default-
234
+ track cleanup, and tempo set so the agent can focus on creativity.
235
+
236
+ Phase 2 (agent's job): read the brief, pick instruments creatively
237
+ from instruments_by_role, design MIDI notes inline (genuinely fresh
238
+ per call, not from templates), submit a complete plan to
239
+ ``compose_fast_apply``.
240
+
241
+ Phase 3 (compose_fast_apply): server-side execute the plan — create
242
+ tracks, load instruments, populate clips with the agent's notes,
243
+ fire scene.
244
+
245
+ ``mode="develop"`` — extend an existing 8-bar loop into a fuller
246
+ arrangement. Reads the seed at seed_scene_index (default 0),
247
+ builds a brief with identity + vocabulary, returns it. Agent
248
+ designs the variant set, calls develop_apply.
249
+
250
+ prompt: "dark minimal techno 128bpm" / "downtempo lo-fi Cm" / "trap"
251
+ mode: "full" | "fast" | "develop"
252
+ bars: clip length in bars (fast mode only — default 4)
253
+ target_scene: scene index to populate (full mode legacy param; fast
254
+ mode now lets the agent pick via compose_fast_apply)
255
+ seed_scene_index: scene to read as the seed (develop mode only,
256
+ default 0)
257
+ max_credits: max Splice credits budget for full-mode plans (default 50)
258
+ dry_run: full-mode only — skip credit checks
259
+
260
+ Fast mode returns: a brief dict with creative context. Call
261
+ compose_fast_apply with your designed plan to actually create tracks.
262
+ Develop mode returns: a brief dict with seed_state + design_targets.
263
+ Call develop_apply with your designed variant plan.
264
+ Full mode returns the existing plan dict.
265
+ """
266
+ intent = parse_prompt(prompt)
93
267
 
94
- Parses the prompt into genre/mood/tempo/key, plans layers using role
95
- templates, and compiles an executable plan of tool calls. Does NOT
96
- execute returns the plan for the agent to step through.
268
+ if mode == "develop":
269
+ from .develop.seed_introspector import introspect_seed
270
+ from .develop.brief_builder import build_develop_brief
271
+ seed = introspect_seed(ctx, scene_index=seed_scene_index)
272
+ if seed.get("error"):
273
+ return {"status": "error", "error": seed["error"], "phase": "introspect_seed"}
274
+ brief = build_develop_brief(ctx, seed, prompt_directive=prompt or None)
275
+ brief["prompt"] = prompt
276
+ return brief
277
+
278
+ if mode == "fast":
279
+ brief = _build_fast_brief(
280
+ ctx, intent, bars=int(bars), reference=reference,
281
+ )
282
+ brief["prompt"] = prompt
283
+ return brief
284
+
285
+ # mode == "full" — v1.24 LLM-creative two-phase flow
286
+ # Phase 1: return a FullBrief vocabulary so the agent can design form +
287
+ # variants + events. Phase 3: agent submits the designed plan to
288
+ # compose_full_apply → apply_full_plan_v2.
289
+ # The old deterministic engine path (step_plan) is deprecated
290
+ # (BUG-FULL-MODE-18: flat single-pattern arrangements).
291
+ from .full.brief_builder import build_full_brief
292
+ brief = build_full_brief(ctx, prompt=prompt, seed_state=None)
293
+ brief["prompt"] = prompt
294
+ return brief
97
295
 
98
- prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
99
- max_credits: maximum Splice credits budget for the plan (default 50, 0 = downloaded only)
100
- dry_run: if True, return the plan without credit checks
101
296
 
102
- Returns a compiled plan with step-by-step tool calls. The agent
103
- executes each step by calling the referenced tools in sequence.
297
+ @mcp.tool()
298
+ async def compose_fast_apply(ctx: Context, plan: dict) -> dict:
299
+ """Phase-3 of the LLM-creative fast mode (2026-05-01).
300
+
301
+ Receives a complete layer plan designed by the agent (informed by
302
+ the brief returned from ``compose(mode="fast")``) and bulk-executes
303
+ it server-side: creates MIDI tracks, loads instruments by URI,
304
+ creates clips, populates them with the agent's notes, fires the
305
+ scene. ALL underlying TCP commands run in this single call so the
306
+ agent doesn't pay round-trip cost between create_track / load /
307
+ clip / notes.
308
+
309
+ Plan shape:
310
+ {
311
+ "layers": [
312
+ {
313
+ "role": "kick" | "snare" | "hat" | "perc" | "clap" | "bass" |
314
+ "pad" | "lead" | "atmos" | "vox" | "fx",
315
+ "uri": "atlas URI from brief.instruments_by_role[role]",
316
+ "track_name": "optional display name (defaults to ROLE)",
317
+ "notes": [
318
+ {"pitch": int 0-127, "start_time": float beats from clip start,
319
+ "duration": float beats, "velocity": int 0-127},
320
+ ...
321
+ ],
322
+ # Phase B (2026-05-01): native-device effect chain on this
323
+ # track, applied AFTER the instrument loads. Each entry
324
+ # inserts one device (insert_device — 12.3+ API) and
325
+ # optionally sets a few of its parameters.
326
+ # Brief.creative_guidance.effect_chain_hints[role] is a
327
+ # canonical starting point, but the agent should adapt
328
+ # values to fit the prompt's mood (subtler in ambient,
329
+ # heavier in trap, etc.).
330
+ "effects": [
331
+ {"device": "Saturator", "params": {"Drive": 0.4}},
332
+ {"device": "EQ Eight", "params": {}},
333
+ ...
334
+ ],
335
+ # Phase B: track sends. return_name is case-insensitive;
336
+ # if no return matches, the entry is skipped (no fail).
337
+ "sends": [
338
+ {"return_name": "A-Reverb", "value": 0.25},
339
+ {"send_index": 1, "value": 0.10},
340
+ ...
341
+ ]
342
+ },
343
+ ...
344
+ ],
345
+ "scene_index": int or null (auto-pick first empty if null),
346
+ "bars": int (clip length, default 4),
347
+ "tempo": int or null (skip if already set in brief)
348
+ }
349
+
350
+ The agent should design notes creatively per call — don't reuse a
351
+ template. Variation is the whole point of this two-phase flow.
352
+
353
+ Returns: tracks_created, scene_fired, per-layer load+note status,
354
+ effects_loaded + sends_set totals, plus techniques_used aggregating
355
+ each layer's applied_technique (Tier-1C) so the user sees per-layer
356
+ provenance: what producer-voice snippet from which Ableton tutorial
357
+ informed each layer's design.
104
358
  """
105
- intent = parse_prompt(prompt)
359
+ return await apply_fast_plan(ctx, plan)
106
360
 
107
- splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
108
- search_roots = _get_search_roots(ctx)
109
361
 
110
- max_credits, credits_remaining, warnings = await _credit_safety_prelude(splice_client, max_credits)
362
+ @mcp.tool()
363
+ def consult_ableton_knowledge(
364
+ ctx: Context,
365
+ question: str,
366
+ session_context: Optional[dict] = None,
367
+ ) -> dict:
368
+ """Tier-3: Ableton Knowledge consultation orchestrator.
369
+
370
+ Takes a free-text production question + optional session context,
371
+ returns a structured consultation plan: which Ableton Knowledge MCP
372
+ tools to fire (search_transcripts / search_live_manual / search_videos /
373
+ search_knowledge_base), with what queries, in what order, plus a
374
+ synthesis template for the agent to combine the results into a
375
+ direct answer for the user.
376
+
377
+ The agent runs the recommended searches inline, synthesizes per the
378
+ template, and surfaces sources alongside the answer.
379
+
380
+ Examples:
381
+ consult_ableton_knowledge("how do I make my kick punchier?")
382
+ → plan: [search_live_manual("Saturator"), search_transcripts("kick punch"),
383
+ search_videos("kick design tutorial")] + synthesis template
384
+ consult_ableton_knowledge("what's the difference between Operator and Wavetable?")
385
+ → plan: [search_live_manual("Operator"), search_live_manual("Wavetable"),
386
+ search_transcripts("Operator vs Wavetable")]
387
+
388
+ session_context (optional): {
389
+ "current_genre": "techno",
390
+ "current_key": "Am",
391
+ "tracks": [{role, instrument}, ...]
392
+ } — informs query specificity (e.g. "kick punch" becomes "techno kick punch").
111
393
 
112
- result = await _engine.compose(
113
- intent,
114
- dry_run=dry_run,
115
- max_credits=max_credits,
116
- search_roots=search_roots,
117
- splice_client=splice_client,
394
+ Returns:
395
+ {
396
+ "question": str,
397
+ "intent_classification": "sound_design" | "arrangement" | "mixing" | "device" | "general",
398
+ "search_plan": [{tool, query, why}, ...],
399
+ "synthesis_template": str,
400
+ "expected_response_shape": dict,
401
+ }
402
+ """
403
+ q = (question or "").strip()
404
+ if not q:
405
+ return {
406
+ "error": "question is empty",
407
+ "question": question,
408
+ }
409
+
410
+ sc = session_context or {}
411
+ genre = (sc.get("current_genre") or "").strip().lower()
412
+
413
+ # Lightweight intent classification — keyword-based, deliberately
414
+ # simple so this tool is fast and predictable.
415
+ q_lower = q.lower()
416
+ intent_class = _classify_consultation_intent(q_lower)
417
+
418
+ # Build a search plan keyed off intent + question keywords + genre context
419
+ plan = _build_consultation_plan(q, q_lower, intent_class, genre)
420
+
421
+ synthesis_template = (
422
+ "After firing the searches in `search_plan`, synthesize a 2-3 paragraph answer that:\n"
423
+ "1. Directly answers the question (lead with the answer, not history).\n"
424
+ "2. Cites 1-2 specific snippets from the search results inline.\n"
425
+ "3. Lists the source URLs/sections alongside the answer.\n"
426
+ "4. If session_context is given, tailor the answer to that genre/key/setup.\n"
427
+ "5. Suggest 1 concrete next experiment the user could try in their session."
118
428
  )
119
- result.warnings.extend(warnings)
120
429
 
121
- output = result.to_dict()
122
- output["prompt"] = prompt
430
+ return {
431
+ "question": q,
432
+ "intent_classification": intent_class,
433
+ "session_context_used": bool(session_context),
434
+ "genre_context": genre or None,
435
+ "search_plan": plan,
436
+ "synthesis_template": synthesis_template,
437
+ "expected_response_shape": {
438
+ "answer": "2-3 paragraph synthesis",
439
+ "sources": [{"title": "...", "url": "...", "snippet": "..."}],
440
+ "next_experiment": "concrete suggestion",
441
+ },
442
+ "next_step": (
443
+ "Run each search in `search_plan` in order via the Ableton Knowledge "
444
+ "MCP tools. Synthesize per `synthesis_template`. Surface sources to "
445
+ "the user. If session_context was given, tailor the answer to it."
446
+ ),
447
+ }
123
448
 
124
- if credits_remaining is not None:
125
- output["credits_remaining"] = credits_remaining
126
- output["credits_budget"] = max_credits
127
449
 
128
- return output
450
+ # Lightweight keyword classifier for the consultation tool. Deliberately
451
+ # simple — Ableton Knowledge MCP does the heavy lifting; this just routes
452
+ # the question to the right starting tool set.
453
+ _CONSULTATION_INTENT_KEYWORDS: dict[str, tuple[str, ...]] = {
454
+ "sound_design": (
455
+ "kick", "snare", "hat", "drum", "bass", "pad", "lead", "808",
456
+ "saturate", "saturation", "compress", "sidechain", "punch",
457
+ "warm", "bright", "thick", "thin",
458
+ ),
459
+ "device": (
460
+ "operator", "wavetable", "drift", "analog", "simpler", "sampler",
461
+ "auto filter", "echo", "reverb", "compressor", "saturator",
462
+ "eq eight", "frequency shifter", "redux", "corpus", "tension",
463
+ "max for live", "m4l",
464
+ ),
465
+ "arrangement": (
466
+ "arrangement", "structure", "verse", "chorus", "drop", "build",
467
+ "transition", "intro", "outro", "section",
468
+ ),
469
+ "mixing": (
470
+ "mix", "balance", "level", "pan", "stereo", "width", "loud",
471
+ "loudness", "lufs", "master", "mastering", "headroom",
472
+ ),
473
+ "rhythm": (
474
+ "swing", "groove", "humanize", "shuffle", "syncopat", "polyrhythm",
475
+ "ghost note",
476
+ ),
477
+ "harmony": (
478
+ "chord", "progression", "scale", "mode", "minor", "major",
479
+ "voice lead", "voicing",
480
+ ),
481
+ }
482
+
483
+
484
+ def _classify_consultation_intent(q_lower: str) -> str:
485
+ """Return the most likely intent class for a consultation question.
486
+
487
+ Uses whole-word matching for single-word keywords to avoid the
488
+ classic substring-trap (e.g. "hat" matching inside "what"). Multi-
489
+ word keywords like "auto filter" use plain substring match because
490
+ whitespace already provides word boundaries.
491
+ """
492
+ import re
493
+ scores: dict[str, int] = {}
494
+ words = set(re.findall(r"\b[\w-]+\b", q_lower))
495
+ for cls, keywords in _CONSULTATION_INTENT_KEYWORDS.items():
496
+ for kw in keywords:
497
+ kw_lower = kw.lower()
498
+ if " " in kw_lower:
499
+ # Multi-word keyword: substring match is safe
500
+ if kw_lower in q_lower:
501
+ scores[cls] = scores.get(cls, 0) + 1
502
+ else:
503
+ # Single-word keyword: whole-word match avoids "hat" in "what"
504
+ if kw_lower in words:
505
+ scores[cls] = scores.get(cls, 0) + 1
506
+ if not scores:
507
+ return "general"
508
+ return max(scores.items(), key=lambda kv: kv[1])[0]
509
+
510
+
511
+ def _build_consultation_plan(
512
+ q: str, q_lower: str, intent_class: str, genre: str,
513
+ ) -> list[dict]:
514
+ """Build the ordered search plan based on intent classification.
515
+
516
+ Returns a list of {tool, query, why} entries the agent fires in
517
+ sequence to gather evidence before synthesizing.
518
+ """
519
+ plan: list[dict] = []
520
+ genre_prefix = f"{genre} " if genre else ""
521
+
522
+ if intent_class == "device":
523
+ plan.append({
524
+ "tool": "search_live_manual",
525
+ "query": q,
526
+ "why": "device-specific question — official manual is authoritative",
527
+ })
528
+ plan.append({
529
+ "tool": "search_videos",
530
+ "query": q,
531
+ "why": "Ableton's tutorial videos cover device usage in depth",
532
+ })
533
+ plan.append({
534
+ "tool": "search_transcripts",
535
+ "query": q,
536
+ "why": "transcript semantic search may surface specific use cases",
537
+ })
538
+ elif intent_class == "sound_design":
539
+ plan.append({
540
+ "tool": "search_transcripts",
541
+ "query": f"{genre_prefix}{q}",
542
+ "why": "producer-voice technique snippets are the most useful here",
543
+ })
544
+ plan.append({
545
+ "tool": "search_videos",
546
+ "query": f"{genre_prefix}{q} tutorial",
547
+ "why": "tutorial videos for hands-on technique",
548
+ })
549
+ plan.append({
550
+ "tool": "search_knowledge_base",
551
+ "query": q,
552
+ "why": "support articles often have step-by-step recipes",
553
+ })
554
+ elif intent_class == "arrangement":
555
+ plan.append({
556
+ "tool": "search_transcripts",
557
+ "query": f"{genre_prefix}{q} arrangement",
558
+ "why": "arrangement is often discussed in long-form video content",
559
+ })
560
+ plan.append({
561
+ "tool": "search_videos",
562
+ "query": f"{genre_prefix}arrangement structure",
563
+ "why": "arrangement-specific tutorials",
564
+ })
565
+ elif intent_class == "mixing":
566
+ plan.append({
567
+ "tool": "search_transcripts",
568
+ "query": q,
569
+ "why": "mixing techniques live in producer videos",
570
+ })
571
+ plan.append({
572
+ "tool": "search_live_manual",
573
+ "query": q,
574
+ "why": "manual covers Live's mixing tools (EQ Eight, Compressor, etc.)",
575
+ })
576
+ plan.append({
577
+ "tool": "search_knowledge_base",
578
+ "query": q,
579
+ "why": "support articles have mixing tips",
580
+ })
581
+ elif intent_class == "rhythm":
582
+ plan.append({
583
+ "tool": "search_transcripts",
584
+ "query": f"{genre_prefix}{q}",
585
+ "why": "groove/rhythm techniques are best from producer voices",
586
+ })
587
+ plan.append({
588
+ "tool": "search_live_manual",
589
+ "query": "groove pool",
590
+ "why": "Live has a Groove Pool — manual is authoritative",
591
+ })
592
+ elif intent_class == "harmony":
593
+ plan.append({
594
+ "tool": "search_transcripts",
595
+ "query": f"{genre_prefix}{q}",
596
+ "why": "harmonic techniques from real producer examples",
597
+ })
598
+ plan.append({
599
+ "tool": "search_videos",
600
+ "query": "music theory chord progression",
601
+ "why": "Ableton's theory-adjacent tutorials",
602
+ })
603
+ else:
604
+ # General fallback
605
+ plan.append({
606
+ "tool": "search_transcripts",
607
+ "query": q,
608
+ "why": "general semantic search across producer voice content",
609
+ })
610
+ plan.append({
611
+ "tool": "search_knowledge_base",
612
+ "query": q,
613
+ "why": "support articles for direct answers",
614
+ })
615
+
616
+ return plan
129
617
 
130
618
 
131
619
  @mcp.tool()
@@ -270,3 +758,113 @@ def propose_composer_branches(
270
758
  "seeds": seeds,
271
759
  "compiled_plans": plans,
272
760
  }
761
+
762
+
763
+ # ── Creative chop-mode helpers (2026-05-01) ───────────────────────
764
+ #
765
+ # Auto-warping every loop to project tempo is production-safe but kills
766
+ # the creative latitude of intentional tempo mismatch (J Dilla / Madlib /
767
+ # IDM territory — a 90-bpm loop in a 122-bpm project produces interesting
768
+ # rhythmic chopping when the source/project ratio is musically meaningful).
769
+ #
770
+ # These helpers + the `warp_strategy` parameter on compose_full_apply
771
+ # give the user three modes:
772
+ # "always" (default): warp every loop — production-safe.
773
+ # "smart": warp tonal layers (pad/bass/lead/vocal) always;
774
+ # leave drum/perc loops un-warped IF source/project
775
+ # ratio is in the magic set ±2%. Detected ratios:
776
+ # 0.5 (half-time), 0.667 (2:3), 0.75 (3:4 cross),
777
+ # 0.8 (4:5), 1.25 (5:4), 1.333 (4:3), 1.5 (3:2),
778
+ # 2.0 (double-time).
779
+ # "chop": never warp — full creative chopping mode.
780
+
781
+ # Backward-compatible aliases — tests import these from this module.
782
+ _apply_full_plan = apply_full_plan
783
+ from .full.apply import ( # noqa: E402
784
+ _resolve_from_step,
785
+ _extract_bpm_from_filename,
786
+ _is_meaningful_ratio,
787
+ )
788
+
789
+ @mcp.tool()
790
+ async def compose_full_apply(
791
+ ctx: Context,
792
+ plan: dict,
793
+ ) -> dict:
794
+ """Phase-3 of full mode (v1.24 LLM-creative): execute the agent-designed plan.
795
+
796
+ compose(mode="full") returns a FullBrief with genre/artist vocabulary,
797
+ the 42-event structural lexicon, and atlas instrument candidates. The
798
+ agent reads the brief, designs the song's form (section sequence, bar
799
+ counts, drop placement, variant per track per section), and submits
800
+ that designed plan here.
801
+
802
+ See mcp_server.composer.full.apply.apply_full_plan_v2 for the full
803
+ plan shape. Required fields: form (list of section dicts), tracks
804
+ (list of track specs with variants + arrangement_clips).
805
+
806
+ Replaces the deterministic engine path (BUG-FULL-MODE-18 fix):
807
+ the old flow tiled one source clip across all sections; the new flow
808
+ emits one source clip per variant so each section can have a genuinely
809
+ different pattern.
810
+ """
811
+ from .full.apply import apply_full_plan_v2
812
+ return await apply_full_plan_v2(ctx, plan)
813
+
814
+
815
+ # ── v1.24 develop mode ─────────────────────────────────────────────
816
+
817
+
818
+ @mcp.tool()
819
+ async def analyze_loop_for_extension(
820
+ ctx: Context,
821
+ scene_index: int = 0,
822
+ ) -> dict:
823
+ """Read-only analyzer for develop mode — returns SeedState for a scene.
824
+
825
+ Inspects the scene's clips, classifies each track as sample_trigger
826
+ or midi_riff, infers role from track name, and reports key/tempo/
827
+ time signature. The agent uses this BEFORE calling compose(mode='develop')
828
+ to verify the loop is extendable, OR as a standalone diagnostic.
829
+
830
+ Returns: dict per mcp_server.composer.develop.seed_introspector.introspect_seed.
831
+ No writes to the session.
832
+ """
833
+ from .develop.seed_introspector import introspect_seed
834
+ return introspect_seed(ctx, scene_index=scene_index)
835
+
836
+
837
+ @mcp.tool()
838
+ async def develop_apply(
839
+ ctx: Context,
840
+ plan: dict,
841
+ ) -> dict:
842
+ """Phase-3 develop mode: server-side execute the agent's variant plan.
843
+
844
+ Receives a plan with the agent-designed variant set:
845
+ {
846
+ "scope": "develop",
847
+ "clip_length_beats": float (default 4.0),
848
+ "tempo": float (optional override),
849
+ "variants": [
850
+ {
851
+ "track_index": int,
852
+ "scene_index": int,
853
+ "name": str,
854
+ "notes": [{"pitch": int, "start_time": float, "duration": float,
855
+ "velocity": int}, ...]
856
+ "sample_uri": str (optional — for sample-trigger swaps)
857
+ },
858
+ ...
859
+ ]
860
+ }
861
+
862
+ The agent decides variant count, names, scenes, MIDI per call — no
863
+ fixed taxonomy. Empty notes list creates an empty clip (drum-dropout
864
+ pattern).
865
+
866
+ Returns: status, clips_created, scenes_populated, sample_swaps,
867
+ preflight result, postflight result, errors list.
868
+ """
869
+ from .develop.apply import apply_develop_plan
870
+ return await apply_develop_plan(ctx, plan)