livepilot 1.13.0 → 1.14.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,200 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.1 — reload_handlers workflow + device/mixing fixes (April 21 2026)
4
+
5
+ Patch release that lands the post-1.14.0 audit work: one new diagnostics
6
+ tool, three bug fixes, and a new plugin-sync verification script.
7
+
8
+ **Tool count**: 402 → **403** (added `reload_handlers`).
9
+ **Domain count**: unchanged at 52.
10
+ **Tests**: 2467 → **2485 passing** (+18 new), 0 regressions.
11
+
12
+ ### New tool: `reload_handlers`
13
+
14
+ - Replaces the manual "toggle Control Surface in Live → Preferences →
15
+ Link/MIDI" step that every Remote Script edit required. New workflow:
16
+ after `node installer/install.js`, call `reload_handlers` via the MCP
17
+ tool. The Remote Script side uses `pkgutil` + `importlib.reload()` to
18
+ re-fire all `@register` decorators in place in <1s, without dropping
19
+ the MCP TCP connection on port 9878.
20
+ - Ships with a pkgutil-based module-discovery helper in
21
+ `remote_script/LivePilot/__init__.py`, so new handler modules added to
22
+ `remote_script/LivePilot/` are picked up automatically on reload.
23
+ - Exception: the very first bootstrap (no prior `LivePilot.*` in
24
+ `sys.modules`) still needs one full Ableton restart. After that,
25
+ `reload_handlers` works forever.
26
+ - Domain: `diagnostics`. Added to `docs/manual/tool-catalog.md` to keep
27
+ the CI skill-contract test green.
28
+
29
+ ### Bug fixes
30
+
31
+ - **`find_and_load_device` duplicate loads** — the tool was no-oping
32
+ only on exact name match; changed to also treat cases where the
33
+ target device is already the tail of the chain as a no-op. Prevents
34
+ the "load Simpler, load Simpler, load Simpler" cascade when the MCP
35
+ server retries a loader.
36
+ - **`get_device_parameters` "Invalid display value"** — certain Live
37
+ parameters (especially plugin wrappers on AU/VST) raise
38
+ `RuntimeError("Invalid display value")` when their
39
+ `str_for_value()` is queried before the parameter has settled. The
40
+ handler now swallows that specific error and returns the raw float
41
+ instead of 500-ing the whole request.
42
+ - **Sidechain LOM reopen (BUG-A3 redux)** — Compressor2 moved its
43
+ sidechain block into a nested property in a recent Live update, so
44
+ `compressor_set_sidechain` lost the ability to toggle. The handler
45
+ now probes the LOM surface at tool-call time and falls back to the
46
+ flat path when the nested one isn't exposed.
47
+ - **Mixing `channel` lazy-get** — channel objects were resolved eagerly
48
+ at import time, breaking in edge cases where the Song came up before
49
+ the mixer. Now resolved on first use.
50
+
51
+ ### New: plugin-sync verification
52
+
53
+ - `scripts/verify_plugin_sync.py` — catches the v1.14.0 regression
54
+ class where `.mcp.json` went missing from
55
+ `~/.claude/plugins/cache/dreamrec-LivePilot/livepilot/$VERSION/`. All
56
+ four sync targets (active plugin dir, cache version dir, marketplace
57
+ snapshot, `installed_plugins.json`) are now verified by one command.
58
+
59
+ ---
60
+
61
+ ## 1.14.0 — Branch-native v2: producer context, synth intelligence, render verify (April 20 2026)
62
+
63
+ Five-PR follow-up to v1.13.0 that closes the loops the first pass left
64
+ open. Producer context flows through the branch lifecycle via a versioned
65
+ `producer_payload`; synthesis adapters decode algorithm topology instead
66
+ of always targeting the same operator; composer winners commit the full
67
+ resolved plan instead of the audition scaffold; render-verify captures
68
+ audio before/after each branch and feeds spectral movement into the
69
+ hard-rule classifier; and four dedicated MCP tools expose the new
70
+ producers to the LLM.
71
+
72
+ **Tool count**: 398 → **402** (added `analyze_synth_patch`,
73
+ `propose_synth_branches`, `extract_timbre_fingerprint`,
74
+ `propose_composer_branches`). **Domain count**: unchanged at 52.
75
+ **Tests**: 2409 → **2467 passing** (+58 new), 0 regressions.
76
+
77
+ ### New substrate
78
+
79
+ - **`producer_payload: dict` on `BranchSeed`** (PR1) — versioned
80
+ opaque dict producers populate with regeneration / provenance /
81
+ winner-escalation context. Always carries `schema_version` (default 1)
82
+ so older payloads don't break newer readers. Lives on the seed, not
83
+ `compiled_plan`, so analytical-only branches can carry context too.
84
+ Canonical shapes documented per producer (synthesis / composer /
85
+ semantic_move / freeform / technique).
86
+
87
+ - **`BranchSnapshot` gains render-based fields** (PR4) —
88
+ `capture_path`, `loudness`, `spectral_shape`, `fingerprint`. Populated
89
+ only when `run_experiment(render_verify=True)` opts in. Pre-v2
90
+ consumers see no shape change when render-verify is off.
91
+
92
+ ### Synthesis adapters get topology awareness (PR2)
93
+
94
+ - **Wavetable**: position→region classification (sub / mid / bright /
95
+ complex) drives shift direction. Target brightness biases the chosen
96
+ target region — not just freshness scaling.
97
+
98
+ - **Operator**: static `_ALGO_TOPOLOGY` table maps all 11 algorithms
99
+ to their carrier/modulator roles. Targeting picks the modulator with
100
+ the highest Level for the ratio shift; additive algorithms (5, 9)
101
+ fall back to the dominant carrier. No more "always Osc B".
102
+
103
+ - **Analog / Drift / Meld**: single fixed proposers become strategy
104
+ registries. Gates honor `role_hint` ("bass" skips `detune_warmth`,
105
+ "pad" skips `filter_pluck`, silent engines skip `engine_mix_shift`)
106
+ and target fingerprint dimensions (`target.brightness` picks
107
+ `filter_sweep_open` vs `filter_sweep_close`).
108
+
109
+ ### Composer winner escalation (PR3)
110
+
111
+ - Composer seeds now carry their `CompositionIntent` in
112
+ `producer_payload` at emit time. On `commit_experiment`,
113
+ `escalate_composer_branch` rehydrates the intent and runs the full
114
+ `ComposerEngine.compose()` pipeline — Splice / filesystem / browser
115
+ sample resolution — then swaps the scaffold plan for the resolved one
116
+ BEFORE `commit_branch_async` runs it through the async router. The
117
+ scaffold is preserved on `branch.evaluation` for audit.
118
+
119
+ - Graceful fallback when compose yields zero executable layers:
120
+ commit runs the scaffold instead of erroring. User gets tracks +
121
+ scenes they can populate manually, with `composer_escalation.error`
122
+ explaining why escalation couldn't complete.
123
+
124
+ - **Latency note**: composer winner commit now takes 10-30s (Splice +
125
+ filesystem resolution) vs ~0.5s pre-v2. Documented; a progress
126
+ callback version is future work.
127
+
128
+ ### Render-verify + classifier wiring (PR4)
129
+
130
+ - `run_experiment(render_verify=False, render_duration_seconds=2.0)` —
131
+ opt-in per-branch audio capture → offline loudness + spectrum
132
+ analysis → `TimbralFingerprint` extraction. ~2 * duration seconds +
133
+ ~1-2s analysis overhead per branch; default off preserves speed.
134
+
135
+ - New `derive_goal_progress_from_fingerprint(diff, target?)` turns
136
+ TimbralFingerprint diffs into `(goal_progress, measurable_count)`.
137
+ Dimensions below 0.02 epsilon are dropped as noise. With a target:
138
+ sign(target) * diff gives signed progress. Without: magnitude-only
139
+ contribution (branch moved = measurable).
140
+
141
+ - `classify_branch_outcome` accepts `fingerprint_diff` + `timbral_target`
142
+ kwargs. When set and caller didn't supply their own measurable inputs,
143
+ the classifier derives them from the diff. Caller-supplied values
144
+ still take precedence (back-compat). Protection violations still
145
+ trump fingerprint evidence — safety invariant preserved.
146
+
147
+ - `compare_experiments` automatically surfaces `fingerprint_diff` +
148
+ `fingerprint_before` + `fingerprint_after` on each branch's
149
+ `evaluation` dict via the existing pass-through.
150
+
151
+ ### Four new MCP tools (PR5, 398 → 402)
152
+
153
+ - **`analyze_synth_patch(track_index, device_index, role_hint="")`** —
154
+ SynthProfile for any supported native synth. Fetches parameter state
155
+ + display values, hands to the adapter. Opaque fallback for
156
+ non-supported devices.
157
+
158
+ - **`propose_synth_branches(track_index, device_index, target?,
159
+ freshness?, role_hint?)`** — algorithm/topology-aware branch seeds
160
+ with pre-compiled plans. Feeds directly to
161
+ `create_experiment(seeds=..., compiled_plans=...)`.
162
+
163
+ - **`extract_timbre_fingerprint(spectrum?, loudness?, spectral_shape?)`**
164
+ — pure transform from analysis dicts to 9-dimensional
165
+ `TimbralFingerprint`. For callers that already have analysis data.
166
+
167
+ - **`propose_composer_branches(request_text, count=2, freshness=0.65)`**
168
+ — N compositional hypotheses (canonical / energy_shift /
169
+ layer_contrast) with producer_payload-captured intents for
170
+ winner-commit escalation.
171
+
172
+ ### Migration notes for callers
173
+
174
+ - All additions are optional-param / new-function shaped. Pre-v2
175
+ callers see no behavior change unless they opt in.
176
+ - Pre-v2 serialized branches deserialize fine: `producer_payload`
177
+ defaults to `{"schema_version": 1}` when absent.
178
+ - `create_experiment(move_ids=...)` identical behavior.
179
+ - `enter_wonder_mode` response shape stable; `branch_seeds` /
180
+ `compiled_plans_by_seed_id` still additive.
181
+ - `run_experiment(render_verify=False)` default matches v1.13.0
182
+ behavior exactly.
183
+
184
+ ### Known limitations
185
+
186
+ See [`docs/manual/branch-native-migration.md`](docs/manual/branch-native-migration.md#known-limitations)
187
+ for the full list. Headlines:
188
+
189
+ - Wavetable region classification is a coarse heuristic on raw
190
+ `Osc 1 Pos`. Future work: render-based per-wavetable mapping.
191
+ - Composer commit latency (10-30s) with no progress callback yet.
192
+ - Render-verify requires the M4L analyzer bridge + LivePilot_Analyzer
193
+ on master; silently degrades to fast-path when either is missing.
194
+ - End-to-end render-verify path (capture_audio + bridge) is hardware-
195
+ dependent and not unit-tested; wiring is covered via classifier +
196
+ fingerprint derivation tests.
197
+
3
198
  ## 1.13.0 — Branch-native architecture (April 20 2026)
4
199
 
5
200
  Twelve-PR migration from "match request → pick move → compile move" to
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  <p align="center">
19
19
  An agentic production system for Ableton Live 12.<br>
20
- 398 tools. 52 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
20
+ 403 tools. 52 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
21
21
  </p>
22
22
 
23
23
  <br>
@@ -79,7 +79,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
79
79
  │ └─────────────────┼──────────────────┘ │
80
80
  │ ▼ │
81
81
  │ ┌─────────────────┐ │
82
- │ │ 398 MCP Tools │ │
82
+ │ │ 403 MCP Tools │ │
83
83
  │ │ 52 domains │ │
84
84
  │ └────────┬────────┘ │
85
85
  │ │ │
@@ -120,7 +120,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
120
120
 
121
121
  ## The Intelligence Layer
122
122
 
123
- 12 engines sit on top of the 398 tools. They give the AI musical judgment, not just musical execution.
123
+ 12 engines sit on top of the 403 tools. They give the AI musical judgment, not just musical execution.
124
124
 
125
125
  ### SongBrain — What the Song Is
126
126
 
@@ -172,7 +172,7 @@ Every engine follows: **measure before → act → measure after → compare**.
172
172
 
173
173
  ## Tools
174
174
 
175
- 398 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
175
+ 403 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
176
 
177
177
  <br>
178
178
 
@@ -360,7 +360,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
360
360
  | Creative Constraints | 5 | constraint activation, reference-inspired variants |
361
361
  | Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
362
362
 
363
- > **[View all 398 tools →](docs/manual/tool-catalog.md)**
363
+ > **[View all 403 tools →](docs/manual/tool-catalog.md)**
364
364
 
365
365
  <br>
366
366
 
@@ -587,7 +587,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
587
587
 
588
588
  | Document | What's inside |
589
589
  |----------|---------------|
590
- | [Manual](docs/manual/index.md) | Complete reference: architecture, all 398 tools, workflows |
590
+ | [Manual](docs/manual/index.md) | Complete reference: architecture, all 403 tools, workflows |
591
591
  | [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
592
592
  | [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
593
593
  | [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
Binary file
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.13.0"});
98
+ send_response({"ok": true, "version": "1.14.1"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.13.0"
2
+ __version__ = "1.14.1"
@@ -15,6 +15,7 @@ from .types import (
15
15
  BranchSource,
16
16
  RiskLabel,
17
17
  NoveltyLabel,
18
+ PRODUCER_PAYLOAD_SCHEMA_VERSION,
18
19
  seed_from_move_id,
19
20
  freeform_seed,
20
21
  analytical_seed,
@@ -26,6 +27,7 @@ __all__ = [
26
27
  "BranchSource",
27
28
  "RiskLabel",
28
29
  "NoveltyLabel",
30
+ "PRODUCER_PAYLOAD_SCHEMA_VERSION",
29
31
  "seed_from_move_id",
30
32
  "freeform_seed",
31
33
  "analytical_seed",
@@ -38,6 +38,9 @@ RiskLabel = Literal["low", "medium", "high"]
38
38
  NoveltyLabel = Literal["safe", "strong", "unexpected"]
39
39
 
40
40
 
41
+ PRODUCER_PAYLOAD_SCHEMA_VERSION = 1
42
+
43
+
41
44
  @dataclass
42
45
  class BranchSeed:
43
46
  """Pre-compilation creative intent.
@@ -52,10 +55,32 @@ class BranchSeed:
52
55
  hypothesis: one-line human-readable prediction of what the branch does.
53
56
  protected_qualities: dimension names the producer promises not to regress.
54
57
  affected_scope: {track_indices, device_paths, section_ids, clip_slots}.
58
+ Session-scope information: which tracks/devices/sections the branch
59
+ affects. Distinct from ``producer_payload`` which carries regeneration
60
+ context.
55
61
  distinctness_reason: why this seed is different from siblings in a set.
56
62
  risk_label: execution safety tier.
57
63
  novelty_label: creative novelty tier — maps to the safe/strong/unexpected UX triptych.
58
64
  analytical_only: true ⇒ no plan will be compiled; branch is directional only.
65
+ producer_payload: free-form dict the emitting producer populates with
66
+ whatever it needs for regeneration / provenance / winner escalation.
67
+ Consumers must check ``schema_version`` (defaults to 1) before reading
68
+ specific keys so older serialized branches don't break newer readers.
69
+
70
+ Canonical shapes per producer:
71
+ semantic_move: {schema_version: 1} (empty — move_id is the key)
72
+ freeform / technique: {schema_version: 1} (optional)
73
+ synthesis:
74
+ {schema_version: 1,
75
+ device_name: "Wavetable",
76
+ track_index: int, device_index: int,
77
+ strategy: "osc_position_shift" | "voice_width_variant" | ...,
78
+ topology_hint: {...}} (used by PR4 render-verify to re-target)
79
+ composer:
80
+ {schema_version: 1,
81
+ strategy: "canonical" | "energy_shift" | "layer_contrast",
82
+ intent: {<CompositionIntent.to_dict()>}} (used by PR3 to
83
+ rehydrate for winner-commit escalation)
59
84
  """
60
85
 
61
86
  seed_id: str
@@ -68,9 +93,20 @@ class BranchSeed:
68
93
  risk_label: RiskLabel = "low"
69
94
  novelty_label: NoveltyLabel = "strong"
70
95
  analytical_only: bool = False
96
+ producer_payload: dict = field(
97
+ default_factory=lambda: {"schema_version": PRODUCER_PAYLOAD_SCHEMA_VERSION}
98
+ )
71
99
 
72
100
  def to_dict(self) -> dict:
73
- return asdict(self)
101
+ d = asdict(self)
102
+ # Guarantee schema_version in the payload even if the dict was
103
+ # mutated to empty by a caller.
104
+ payload = d.get("producer_payload") or {}
105
+ if "schema_version" not in payload:
106
+ payload = dict(payload)
107
+ payload["schema_version"] = PRODUCER_PAYLOAD_SCHEMA_VERSION
108
+ d["producer_payload"] = payload
109
+ return d
74
110
 
75
111
 
76
112
  @dataclass
@@ -158,6 +194,7 @@ def seed_from_move_id(
158
194
  risk_label: RiskLabel = "low",
159
195
  protected_qualities: Optional[list[str]] = None,
160
196
  distinctness_reason: str = "",
197
+ producer_payload: Optional[dict] = None,
161
198
  ) -> BranchSeed:
162
199
  """Build a semantic_move seed — the baseline producer path.
163
200
 
@@ -176,6 +213,7 @@ def seed_from_move_id(
176
213
  risk_label=risk_label,
177
214
  protected_qualities=protected_qualities or [],
178
215
  distinctness_reason=distinctness_reason,
216
+ producer_payload=_normalize_payload(producer_payload),
179
217
  )
180
218
 
181
219
 
@@ -188,6 +226,7 @@ def freeform_seed(
188
226
  novelty_label: NoveltyLabel = "strong",
189
227
  risk_label: RiskLabel = "medium",
190
228
  source: BranchSource = "freeform",
229
+ producer_payload: Optional[dict] = None,
191
230
  ) -> BranchSeed:
192
231
  """Build a freeform seed — producer has a concrete hypothesis without a move.
193
232
 
@@ -204,6 +243,7 @@ def freeform_seed(
204
243
  distinctness_reason=distinctness_reason,
205
244
  novelty_label=novelty_label,
206
245
  risk_label=risk_label,
246
+ producer_payload=_normalize_payload(producer_payload),
207
247
  )
208
248
 
209
249
 
@@ -212,6 +252,7 @@ def analytical_seed(
212
252
  hypothesis: str,
213
253
  source: BranchSource = "freeform",
214
254
  protected_qualities: Optional[list[str]] = None,
255
+ producer_payload: Optional[dict] = None,
215
256
  ) -> BranchSeed:
216
257
  """Build an analytical-only seed — no plan will be compiled.
217
258
 
@@ -227,4 +268,19 @@ def analytical_seed(
227
268
  hypothesis=hypothesis,
228
269
  protected_qualities=protected_qualities or [],
229
270
  analytical_only=True,
271
+ producer_payload=_normalize_payload(producer_payload),
230
272
  )
273
+
274
+
275
+ def _normalize_payload(payload: Optional[dict]) -> dict:
276
+ """Ensure a producer_payload always has schema_version set.
277
+
278
+ Producers that don't care about versioning pass None / empty dict and
279
+ get the default schema_version=PRODUCER_PAYLOAD_SCHEMA_VERSION back.
280
+ Producers that carry their own version number have it preserved.
281
+ """
282
+ if not payload:
283
+ return {"schema_version": PRODUCER_PAYLOAD_SCHEMA_VERSION}
284
+ out = dict(payload)
285
+ out.setdefault("schema_version", PRODUCER_PAYLOAD_SCHEMA_VERSION)
286
+ return out
@@ -5,6 +5,6 @@ multiple section-hypothesis BranchSeeds alongside the existing
5
5
  single-plan compose() entry point.
6
6
  """
7
7
 
8
- from .branch_producer import propose_composer_branches
8
+ from .branch_producer import propose_composer_branches, escalate_composer_branch
9
9
 
10
- __all__ = ["propose_composer_branches"]
10
+ __all__ = ["propose_composer_branches", "escalate_composer_branch"]
@@ -29,6 +29,7 @@ from typing import Optional
29
29
  from ..branches import BranchSeed, freeform_seed
30
30
  from .prompt_parser import parse_prompt, CompositionIntent
31
31
  from .layer_planner import plan_layers, plan_sections
32
+ from .engine import ComposerEngine, CompositionResult
32
33
 
33
34
 
34
35
  # Strategy registry — each function takes an intent and returns (modified
@@ -146,6 +147,15 @@ def propose_composer_branches(
146
147
  novelty_label=novelty,
147
148
  risk_label=risk,
148
149
  distinctness_reason=reason,
150
+ # PR3 — carry the variant intent + strategy so commit_experiment
151
+ # can rehydrate and run the full ComposerEngine.compose()
152
+ # pipeline on the winner instead of committing the scaffold.
153
+ producer_payload={
154
+ "strategy": name,
155
+ "intent": variant_intent.to_dict(),
156
+ "request_text": request_text,
157
+ "reason": reason,
158
+ },
149
159
  )
150
160
  results.append((seed, plan))
151
161
  except Exception as exc:
@@ -159,6 +169,116 @@ def propose_composer_branches(
159
169
  return results
160
170
 
161
171
 
172
+ async def escalate_composer_branch(
173
+ producer_payload: dict,
174
+ search_roots: Optional[list] = None,
175
+ splice_client: object = None,
176
+ browser_client: object = None,
177
+ max_credits: int = 10,
178
+ ) -> dict:
179
+ """Run the full ComposerEngine.compose() pipeline on a committed
180
+ composer branch, using the CompositionIntent captured in the seed's
181
+ producer_payload at emit time.
182
+
183
+ Returns a dict with:
184
+ ok: bool
185
+ plan: list of executable steps (the full resolved plan, not the
186
+ scaffolding the branch was auditioned with)
187
+ step_count: int
188
+ layer_count: int
189
+ resolved_samples: dict (role → local_path)
190
+ warnings: list (unresolved layers, missing samples, etc.)
191
+ error: str (when ok=False)
192
+
193
+ When ok=False, callers should fall back to committing the scaffold
194
+ plan instead of dropping the branch — the scaffolding is still
195
+ useful as a track/scene skeleton the user can populate manually.
196
+
197
+ This function is async because ComposerEngine.compose() is async
198
+ (it awaits Splice / filesystem sample resolution).
199
+ """
200
+ import logging
201
+ logger = logging.getLogger(__name__)
202
+
203
+ schema_version = producer_payload.get("schema_version") if producer_payload else None
204
+ intent_dict = (producer_payload or {}).get("intent")
205
+
206
+ if not intent_dict:
207
+ return {
208
+ "ok": False,
209
+ "error": (
210
+ "Composer branch producer_payload missing 'intent'. "
211
+ "This branch was likely emitted before PR3/v2 and cannot "
212
+ "be escalated — commit the scaffold plan instead."
213
+ ),
214
+ }
215
+
216
+ # Rehydrate CompositionIntent from the payload dict. Tolerate unknown
217
+ # keys by only pulling the fields CompositionIntent understands — older
218
+ # schemas may have fewer fields, newer may have more.
219
+ try:
220
+ intent_fields = {
221
+ k: v for k, v in intent_dict.items()
222
+ if k in (
223
+ "genre", "sub_genre", "mood", "tempo", "key",
224
+ "descriptors", "explicit_elements", "energy",
225
+ "layer_count", "duration_bars",
226
+ )
227
+ }
228
+ intent = CompositionIntent(**intent_fields)
229
+ except Exception as exc:
230
+ return {
231
+ "ok": False,
232
+ "error": (
233
+ f"Failed to rehydrate CompositionIntent from producer_payload "
234
+ f"(schema_version={schema_version}): {exc}"
235
+ ),
236
+ }
237
+
238
+ engine = ComposerEngine()
239
+ try:
240
+ result: CompositionResult = await engine.compose(
241
+ intent=intent,
242
+ dry_run=False,
243
+ max_credits=max_credits,
244
+ search_roots=search_roots or [],
245
+ splice_client=splice_client,
246
+ browser_client=browser_client,
247
+ )
248
+ except Exception as exc:
249
+ return {
250
+ "ok": False,
251
+ "error": f"ComposerEngine.compose() raised: {exc}",
252
+ }
253
+
254
+ # Fallback when no layers resolved — explicit signal so callers can
255
+ # fall back to the scaffold instead of silently shipping an empty
256
+ # plan.
257
+ if not result.plan or len(result.layers) == 0:
258
+ return {
259
+ "ok": False,
260
+ "error": (
261
+ "ComposerEngine.compose() produced zero executable layers. "
262
+ "Sample resolution likely failed — check Splice credits, "
263
+ "filesystem roots, or browser connectivity. Falling back "
264
+ "to scaffold commit is the correct action."
265
+ ),
266
+ "warnings": list(result.warnings),
267
+ "resolved_samples": dict(result.resolved_samples),
268
+ }
269
+
270
+ return {
271
+ "ok": True,
272
+ "plan": list(result.plan),
273
+ "step_count": len(result.plan),
274
+ "layer_count": len(result.layers),
275
+ "resolved_samples": dict(result.resolved_samples),
276
+ "credits_estimated": result.credits_estimated,
277
+ "warnings": list(result.warnings),
278
+ "intent_used": intent.to_dict(),
279
+ }
280
+
281
+
162
282
  def _build_section_hypothesis_plan(intent: CompositionIntent, strategy_name: str) -> dict:
163
283
  """Build a lightweight, executable plan from an intent.
164
284
 
@@ -1,8 +1,10 @@
1
- """Composer Engine MCP tools — 3 tools for auto-composition.
1
+ """Composer Engine MCP tools — 4 tools for auto-composition.
2
2
 
3
3
  compose: full multi-layer composition from text prompt
4
4
  augment_with_samples: add layers to existing session
5
5
  get_composition_plan: dry run preview
6
+ propose_composer_branches (PR5/v2): multi-strategy branch hypotheses for
7
+ exploratory workflows (feeds create_experiment(seeds=...))
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -213,3 +215,58 @@ async def get_composition_plan(
213
215
  "then step through each tool call in sequence."
214
216
  )
215
217
  return plan
218
+
219
+
220
+ @mcp.tool()
221
+ def propose_composer_branches(
222
+ ctx: Context,
223
+ request_text: str,
224
+ count: int = 2,
225
+ freshness: float = 0.65,
226
+ ) -> dict:
227
+ """Emit N distinct compositional hypotheses for a single prompt (PR5/v2).
228
+
229
+ Branch-native companion to compose(): instead of one deterministic
230
+ layer plan, produces up to ``count`` BranchSeeds with different
231
+ strategic angles the user can audition via create_experiment +
232
+ run_experiment. Each seed carries a pre-compiled scaffolding plan
233
+ (set_tempo + create_midi_track per layer + create_scene per section)
234
+ that gets escalated to a fully resolved plan by commit_experiment
235
+ when the winning branch is chosen.
236
+
237
+ Strategies (gated on freshness):
238
+ canonical — intent unchanged, genre defaults
239
+ (shipped at every freshness level)
240
+ energy_shift — intent.energy inverted around 0.5
241
+ (freshness >= 0.4)
242
+ layer_contrast — one role swapped (pad-anchor instead of bass)
243
+ (freshness >= 0.7)
244
+
245
+ Returns:
246
+ {
247
+ "request_text": str,
248
+ "branch_count": int,
249
+ "seeds": [BranchSeed.to_dict(), ...],
250
+ "compiled_plans": [plan_dict, ...] (parallel to seeds; scaffold),
251
+ }
252
+
253
+ Each seed's producer_payload carries {strategy, intent,
254
+ request_text, reason} so commit_experiment can rehydrate the
255
+ CompositionIntent and run the full ComposerEngine.compose() for
256
+ the winner.
257
+ """
258
+ from .branch_producer import propose_composer_branches as _propose
259
+
260
+ pairs = _propose(
261
+ request_text=request_text,
262
+ kernel={"freshness": float(freshness)},
263
+ count=int(count),
264
+ )
265
+ seeds = [s.to_dict() for s, _ in pairs]
266
+ plans = [p for _, p in pairs]
267
+ return {
268
+ "request_text": request_text,
269
+ "branch_count": len(seeds),
270
+ "seeds": seeds,
271
+ "compiled_plans": plans,
272
+ }