livepilot 1.12.2 → 1.13.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 (34) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +3 -3
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/branches/__init__.py +32 -0
  7. package/mcp_server/branches/types.py +230 -0
  8. package/mcp_server/composer/__init__.py +10 -1
  9. package/mcp_server/composer/branch_producer.py +229 -0
  10. package/mcp_server/evaluation/policy.py +129 -2
  11. package/mcp_server/experiment/engine.py +47 -11
  12. package/mcp_server/experiment/models.py +72 -7
  13. package/mcp_server/experiment/tools.py +231 -35
  14. package/mcp_server/memory/taste_graph.py +84 -11
  15. package/mcp_server/persistence/taste_store.py +21 -5
  16. package/mcp_server/runtime/session_kernel.py +46 -0
  17. package/mcp_server/runtime/tools.py +29 -3
  18. package/mcp_server/synthesis_brain/__init__.py +53 -0
  19. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  20. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  21. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  22. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  23. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  24. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  25. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  26. package/mcp_server/synthesis_brain/engine.py +91 -0
  27. package/mcp_server/synthesis_brain/models.py +121 -0
  28. package/mcp_server/synthesis_brain/timbre.py +194 -0
  29. package/mcp_server/tools/_conductor.py +144 -0
  30. package/mcp_server/wonder_mode/engine.py +324 -0
  31. package/mcp_server/wonder_mode/tools.py +153 -1
  32. package/package.json +2 -2
  33. package/remote_script/LivePilot/__init__.py +1 -1
  34. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.0 — Branch-native architecture (April 20 2026)
4
+
5
+ Twelve-PR migration from "match request → pick move → compile move" to
6
+ "understand intent → generate branches → compile branches → compare".
7
+ The planning layer opens up — Wonder, Preview Studio, Experiment, and
8
+ the new synthesis_brain all share one BranchSeed + CompiledBranch
9
+ contract. Move-first is still available as a targeted flow; branch-native
10
+ is the canonical exploratory path.
11
+
12
+ **No tool count change** (still 398). **Domain count 51 → 52**
13
+ (added `synthesis_brain`). **+175 new tests** across 9 new files
14
+ (2206 → 2387 passing, 1 skipped, 0 failures).
15
+
16
+ ### New substrate (additive, non-breaking)
17
+
18
+ - **`mcp_server/branches/`** (PR1) — shared `BranchSeed` and
19
+ `CompiledBranch` types with `seed_from_move_id` / `freeform_seed` /
20
+ `analytical_seed` factories. `BranchSeed` sources:
21
+ `semantic_move` / `freeform` / `synthesis` / `composer` / `technique`.
22
+ - **SessionKernel creative controls** (PR2) — `freshness`,
23
+ `creativity_profile`, `sacred_elements`, `synth_hints` added as
24
+ optional fields on `SessionKernel` and `get_session_kernel`. Legacy
25
+ callers see zero behavior change.
26
+ - **ExperimentBranch compat shim** (PR3) — `move_id` now optional;
27
+ new `ExperimentBranch.from_seed()` classmethod and
28
+ `create_experiment_from_seeds(seeds=[...], compiled_plans=[...])`
29
+ entry point. Legacy `create_experiment(move_ids=...)` keeps working
30
+ and internally delegates via `seed_from_move_id`.
31
+ - **Creative conductor fork** (PR4) — `classify_request_creative()`
32
+ alongside `classify_request()`. Adds producer selection
33
+ (`branch_sources`, `seed_hints`) based on request content + kernel state.
34
+ - **`interesting_but_failed` branch status** (PR7) — new
35
+ `classify_branch_outcome()` in `evaluation/policy.py`. Exploration
36
+ mode downgrades score / measurable-delta failures to
37
+ `interesting_but_failed`; protection violations still force undo
38
+ (safety invariant).
39
+ - **Per-goal-mode novelty bands** (PR8) — TasteGraph's single
40
+ `novelty_band` is now a view over `novelty_bands["improve"]`; the
41
+ `explore` band lets surprise-me branch generation disconnect from
42
+ conservative improve-mode history. `bypass_taste_in_generation` flag
43
+ makes `rank_moves` return uniform scores.
44
+
45
+ ### Branch-native producers
46
+
47
+ - **Wonder branch assembler** (PR6) — `generate_branch_seeds()` emits
48
+ seeds from four sources: semantic_move, technique (session memory),
49
+ sacred-element inversion (freshness-gated), and corpus hints.
50
+ `enter_wonder_mode` now surfaces `branch_seeds` alongside variants.
51
+ - **`synthesis_brain/` subsystem** (PR9, PR10) — native-synth-aware
52
+ branch production with adapters for Wavetable, Operator, Analog,
53
+ Drift, Meld. `analyze_synth_patch()` / `propose_synth_branches()`
54
+ callable from Python; `extract_timbre_fingerprint()` builds a
55
+ TimbralFingerprint from 8-band spectrum + optional FluCoMa
56
+ descriptors. No MCP tools yet — next release will wire dedicated
57
+ tools and do the tool-count metadata sweep in one pass.
58
+ - **Composer branch producer** (PR11) — `propose_composer_branches()`
59
+ emits N distinct compositional hypotheses from one prompt via three
60
+ strategies (canonical / energy_shift / layer_contrast), gated on
61
+ freshness. Each branch ships a pre-compiled scaffolding plan.
62
+
63
+ ### Docs refactor
64
+
65
+ - **Skills + command guides thinned** (PR5) — `livepilot-core/SKILL.md`
66
+ now presents two peer flows (Flow A targeted / Flow B exploratory)
67
+ instead of one recipe-first pipeline. `arrange` / `beat` / `mix` /
68
+ `sounddesign` commands each add a short Branch-Native section.
69
+ - **Branch status vocabulary** documented including
70
+ `interesting_but_failed` retention semantics.
71
+
72
+ ### Migration notes for callers
73
+
74
+ - All additions are optional-param / new-function shaped. Any code
75
+ reading `branch.move_id` keeps working because `ExperimentBranch`
76
+ mirrors `seed.move_id` there. Any code calling
77
+ `create_experiment(move_ids=...)` keeps its exact behavior.
78
+ - If you have persistent state on disk (`~/.livepilot/taste.json`):
79
+ v1.13 migrates `novelty_band` (flat float) to `novelty_bands` (dict)
80
+ on first read. Old clients reading the file still see the flat field.
81
+ - Tests added across 9 new files — no existing test needed editing
82
+ beyond `test_experiment_engine.py` (which gains PR3 coverage but
83
+ keeps every pre-PR3 test passing).
84
+
3
85
  ## 1.12.2 — Post-release audit reliability fixes (April 18 2026)
4
86
 
5
87
  Six issues surfaced by an immediate post-v1.12.0 deep audit (parallel
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. 51 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
20
+ 398 tools. 52 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
21
21
  </p>
22
22
 
23
23
  <br>
@@ -80,7 +80,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
80
80
  │ ▼ │
81
81
  │ ┌─────────────────┐ │
82
82
  │ │ 398 MCP Tools │ │
83
- │ │ 51 domains │ │
83
+ │ │ 52 domains │ │
84
84
  │ └────────┬────────┘ │
85
85
  │ │ │
86
86
  │ Remote Script ──┤── TCP 9878 │
@@ -172,7 +172,7 @@ Every engine follows: **measure before → act → measure after → compare**.
172
172
 
173
173
  ## Tools
174
174
 
175
- 398 tools across 51 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
175
+ 398 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
176
 
177
177
  <br>
178
178
 
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.12.2"});
98
+ send_response({"ok": true, "version": "1.13.0"});
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.12.2"
2
+ __version__ = "1.13.0"
@@ -0,0 +1,32 @@
1
+ """Branch-native types — the shared substrate for Wonder, Preview Studio,
2
+ Experiment, and future producers (synthesis_brain, composer).
3
+
4
+ A BranchSeed is a producer-emitted creative intent (pre-compilation).
5
+ A CompiledBranch pairs a seed with a concrete plan ready for the execution
6
+ router. compiled_plan=None ⇒ analytical-only (directional suggestion).
7
+
8
+ This module is pure types + factory helpers. No I/O, no side effects, no
9
+ imports from other mcp_server subsystems.
10
+ """
11
+
12
+ from .types import (
13
+ BranchSeed,
14
+ CompiledBranch,
15
+ BranchSource,
16
+ RiskLabel,
17
+ NoveltyLabel,
18
+ seed_from_move_id,
19
+ freeform_seed,
20
+ analytical_seed,
21
+ )
22
+
23
+ __all__ = [
24
+ "BranchSeed",
25
+ "CompiledBranch",
26
+ "BranchSource",
27
+ "RiskLabel",
28
+ "NoveltyLabel",
29
+ "seed_from_move_id",
30
+ "freeform_seed",
31
+ "analytical_seed",
32
+ ]
@@ -0,0 +1,230 @@
1
+ """Branch-native types.
2
+
3
+ Design (see docs/specs/2026-03-17-livepilot-design.md and the branch-native
4
+ migration plan):
5
+
6
+ - Producers (Wonder, synthesis_brain, composer, technique memory) emit
7
+ BranchSeed objects expressing creative intent. A seed carries a hypothesis,
8
+ a source label, a distinctness reason, and novelty/risk labels — but no
9
+ executable plan yet.
10
+ - A compiler turns a seed into a CompiledBranch by attaching a plan. For
11
+ source="semantic_move" seeds, the existing semantic_moves.compiler is used.
12
+ For freeform/synthesis/composer seeds, the producer supplies the plan.
13
+ - CompiledBranch is the canonical post-compilation shape. PreviewVariant
14
+ and ExperimentBranch will migrate to thin wrappers over CompiledBranch
15
+ in later PRs; this PR only introduces the types.
16
+
17
+ compiled_plan=None means analytical_only — the branch is a directional
18
+ suggestion with no executable path. This case already exists in Wonder
19
+ (build_analytical_variant); the branch-native schema promotes it to a
20
+ first-class concept across the whole system.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ from dataclasses import asdict, dataclass, field
27
+ from typing import Literal, Optional
28
+
29
+
30
+ BranchSource = Literal[
31
+ "semantic_move", # compiled via semantic_moves.compiler
32
+ "freeform", # producer supplies the compiled plan directly
33
+ "synthesis", # from synthesis_brain (PR9+)
34
+ "composer", # from composer branch-native path (PR11)
35
+ "technique", # from technique memory replay
36
+ ]
37
+ RiskLabel = Literal["low", "medium", "high"]
38
+ NoveltyLabel = Literal["safe", "strong", "unexpected"]
39
+
40
+
41
+ @dataclass
42
+ class BranchSeed:
43
+ """Pre-compilation creative intent.
44
+
45
+ Distinctness between branches is checked at seed layer — two seeds with
46
+ the same (source, hypothesis_hash, affected_scope_hash) are not distinct.
47
+
48
+ Fields:
49
+ seed_id: stable identifier. For semantic_move seeds, derived from move_id.
50
+ source: producer category.
51
+ move_id: populated when source="semantic_move"; empty otherwise.
52
+ hypothesis: one-line human-readable prediction of what the branch does.
53
+ protected_qualities: dimension names the producer promises not to regress.
54
+ affected_scope: {track_indices, device_paths, section_ids, clip_slots}.
55
+ distinctness_reason: why this seed is different from siblings in a set.
56
+ risk_label: execution safety tier.
57
+ novelty_label: creative novelty tier — maps to the safe/strong/unexpected UX triptych.
58
+ analytical_only: true ⇒ no plan will be compiled; branch is directional only.
59
+ """
60
+
61
+ seed_id: str
62
+ source: BranchSource
63
+ move_id: str = ""
64
+ hypothesis: str = ""
65
+ protected_qualities: list[str] = field(default_factory=list)
66
+ affected_scope: dict = field(default_factory=dict)
67
+ distinctness_reason: str = ""
68
+ risk_label: RiskLabel = "low"
69
+ novelty_label: NoveltyLabel = "strong"
70
+ analytical_only: bool = False
71
+
72
+ def to_dict(self) -> dict:
73
+ return asdict(self)
74
+
75
+
76
+ @dataclass
77
+ class CompiledBranch:
78
+ """Post-compilation branch — supersedes the move_id-locked ExperimentBranch.
79
+
80
+ Fields:
81
+ branch_id: stable identifier scoped to a branch set.
82
+ seed: the originating BranchSeed (carries source, hypothesis, move_id).
83
+ compiled_plan: execution-router-ready plan dict, or None for analytical-only.
84
+ execution_log: per-step results after run/commit; [{tool, backend, ok, error, result}].
85
+ before_snapshot / after_snapshot: captured session state dicts.
86
+ evaluation: evaluator result dict; shape determined by the evaluator.
87
+ score: composite quality score (0-1).
88
+ status: pending | running | evaluated | committed | discarded
89
+ | interesting_but_failed ← PR7 will gate on this
90
+ created_at_ms / executed_at_ms: timestamps.
91
+ """
92
+
93
+ branch_id: str
94
+ seed: BranchSeed
95
+ compiled_plan: Optional[dict] = None
96
+ execution_log: list = field(default_factory=list)
97
+ before_snapshot: Optional[dict] = None
98
+ after_snapshot: Optional[dict] = None
99
+ evaluation: Optional[dict] = None
100
+ score: float = 0.0
101
+ status: str = "pending"
102
+ created_at_ms: int = 0
103
+ executed_at_ms: int = 0
104
+
105
+ @property
106
+ def move_id(self) -> str:
107
+ """Back-compat convenience — delegates to seed.move_id.
108
+
109
+ Lets callers that currently read branch.move_id keep working once
110
+ ExperimentBranch migrates to wrap CompiledBranch.
111
+ """
112
+ return self.seed.move_id
113
+
114
+ @property
115
+ def analytical_only(self) -> bool:
116
+ """A branch is analytical when the seed says so OR when no plan exists."""
117
+ return self.seed.analytical_only or self.compiled_plan is None
118
+
119
+ def to_dict(self) -> dict:
120
+ d = {
121
+ "branch_id": self.branch_id,
122
+ "seed": self.seed.to_dict(),
123
+ "move_id": self.move_id,
124
+ "score": self.score,
125
+ "status": self.status,
126
+ "analytical_only": self.analytical_only,
127
+ "created_at_ms": self.created_at_ms,
128
+ }
129
+ if self.compiled_plan:
130
+ d["step_count"] = self.compiled_plan.get("step_count", 0)
131
+ d["summary"] = self.compiled_plan.get("summary", "")
132
+ if self.before_snapshot is not None:
133
+ d["before_snapshot"] = self.before_snapshot
134
+ if self.after_snapshot is not None:
135
+ d["after_snapshot"] = self.after_snapshot
136
+ if self.evaluation is not None:
137
+ d["evaluation"] = self.evaluation
138
+ if self.execution_log:
139
+ d["execution_log"] = self.execution_log
140
+ d["steps_ok"] = sum(1 for e in self.execution_log if e.get("ok"))
141
+ d["steps_failed"] = sum(1 for e in self.execution_log if not e.get("ok"))
142
+ return d
143
+
144
+
145
+ # ── Factory helpers ──────────────────────────────────────────────────────
146
+
147
+ def _stable_seed_id(prefix: str, *parts: str) -> str:
148
+ """Deterministic seed_id from parts — no timestamps, stable across runs."""
149
+ seed = "|".join(str(p) for p in parts)
150
+ return f"{prefix}_" + hashlib.sha256(seed.encode()).hexdigest()[:10]
151
+
152
+
153
+ def seed_from_move_id(
154
+ move_id: str,
155
+ seed_id: str = "",
156
+ hypothesis: str = "",
157
+ novelty_label: NoveltyLabel = "strong",
158
+ risk_label: RiskLabel = "low",
159
+ protected_qualities: Optional[list[str]] = None,
160
+ distinctness_reason: str = "",
161
+ ) -> BranchSeed:
162
+ """Build a semantic_move seed — the baseline producer path.
163
+
164
+ Mirrors how wonder_mode.engine.discover_moves already hands moves to
165
+ build_variant. In later PRs, the Wonder distinctness selector will emit
166
+ BranchSeed directly instead of move dicts.
167
+ """
168
+ if not seed_id:
169
+ seed_id = _stable_seed_id("seed", "move", move_id, novelty_label)
170
+ return BranchSeed(
171
+ seed_id=seed_id,
172
+ source="semantic_move",
173
+ move_id=move_id,
174
+ hypothesis=hypothesis or f"Apply {move_id}",
175
+ novelty_label=novelty_label,
176
+ risk_label=risk_label,
177
+ protected_qualities=protected_qualities or [],
178
+ distinctness_reason=distinctness_reason,
179
+ )
180
+
181
+
182
+ def freeform_seed(
183
+ seed_id: str,
184
+ hypothesis: str,
185
+ affected_scope: Optional[dict] = None,
186
+ protected_qualities: Optional[list[str]] = None,
187
+ distinctness_reason: str = "",
188
+ novelty_label: NoveltyLabel = "strong",
189
+ risk_label: RiskLabel = "medium",
190
+ source: BranchSource = "freeform",
191
+ ) -> BranchSeed:
192
+ """Build a freeform seed — producer has a concrete hypothesis without a move.
193
+
194
+ The compiled plan is attached downstream by the producer; this seed
195
+ carries intent only. Used by synthesis_brain, composer, and any
196
+ producer that doesn't go through semantic_moves.compiler.
197
+ """
198
+ return BranchSeed(
199
+ seed_id=seed_id,
200
+ source=source,
201
+ hypothesis=hypothesis,
202
+ affected_scope=affected_scope or {},
203
+ protected_qualities=protected_qualities or [],
204
+ distinctness_reason=distinctness_reason,
205
+ novelty_label=novelty_label,
206
+ risk_label=risk_label,
207
+ )
208
+
209
+
210
+ def analytical_seed(
211
+ seed_id: str,
212
+ hypothesis: str,
213
+ source: BranchSource = "freeform",
214
+ protected_qualities: Optional[list[str]] = None,
215
+ ) -> BranchSeed:
216
+ """Build an analytical-only seed — no plan will be compiled.
217
+
218
+ Used when a producer has a directional suggestion but no executable path:
219
+ - Wonder fallback when no move matches (already present as
220
+ build_analytical_variant in wonder_mode.engine).
221
+ - Synthesis brain on opaque devices where parameter state can be read
222
+ but safe mutations cannot be proposed yet.
223
+ """
224
+ return BranchSeed(
225
+ seed_id=seed_id,
226
+ source=source,
227
+ hypothesis=hypothesis,
228
+ protected_qualities=protected_qualities or [],
229
+ analytical_only=True,
230
+ )
@@ -1 +1,10 @@
1
- """Composer Engine — auto-composition from text prompts via Splice + Sample Engine."""
1
+ """Composer Engine — auto-composition from text prompts via Splice + Sample Engine.
2
+
3
+ PR11 adds branch_producer.propose_composer_branches() for emitting
4
+ multiple section-hypothesis BranchSeeds alongside the existing
5
+ single-plan compose() entry point.
6
+ """
7
+
8
+ from .branch_producer import propose_composer_branches
9
+
10
+ __all__ = ["propose_composer_branches"]
@@ -0,0 +1,229 @@
1
+ """Composer branch producer — emit section-hypothesis BranchSeeds.
2
+
3
+ PR11 adds a branch-native entry point alongside the existing compose()
4
+ pipeline. Instead of a single deterministic layer plan, callers can
5
+ request N distinct compositional hypotheses and audition them via
6
+ create_experiment(seeds=..., compiled_plans=...).
7
+
8
+ Design:
9
+ A composer branch is a CompositionIntent + variant_strategy. Three
10
+ canned strategies are shipped in PR11:
11
+
12
+ "canonical" — intent unchanged, layer plan uses genre defaults
13
+ "energy_shift" — intent.energy inverted around 0.5 (dense ⇄ sparse)
14
+ "layer_contrast" — one role swapped in the layer plan (e.g. bass
15
+ role replaced with pad-anchor, or percussion
16
+ stripped to emphasize melodic content)
17
+
18
+ Seeds carry source="composer". Each branch produces a pre-compiled
19
+ plan through the existing ComposerEngine.compose() pipeline so
20
+ run_experiment respects the plans without re-compiling. Later PRs
21
+ can add more strategies (key-shift, section-reorder, tempo-halftime).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ from typing import Optional
28
+
29
+ from ..branches import BranchSeed, freeform_seed
30
+ from .prompt_parser import parse_prompt, CompositionIntent
31
+ from .layer_planner import plan_layers, plan_sections
32
+
33
+
34
+ # Strategy registry — each function takes an intent and returns (modified
35
+ # intent, distinctness_reason, novelty_label, risk_label).
36
+ def _strategy_canonical(intent: CompositionIntent):
37
+ return (
38
+ intent,
39
+ "baseline composition with genre defaults",
40
+ "safe",
41
+ "low",
42
+ )
43
+
44
+
45
+ def _strategy_energy_shift(intent: CompositionIntent):
46
+ new = CompositionIntent(
47
+ genre=intent.genre,
48
+ sub_genre=intent.sub_genre,
49
+ mood=intent.mood,
50
+ tempo=intent.tempo,
51
+ key=intent.key,
52
+ descriptors=list(intent.descriptors),
53
+ explicit_elements=list(intent.explicit_elements),
54
+ energy=round(1.0 - intent.energy, 2),
55
+ layer_count=intent.layer_count,
56
+ duration_bars=intent.duration_bars,
57
+ )
58
+ direction = "denser" if new.energy > intent.energy else "sparser"
59
+ return (
60
+ new,
61
+ f"energy shifted from {intent.energy:.1f} → {new.energy:.1f} ({direction})",
62
+ "strong",
63
+ "low",
64
+ )
65
+
66
+
67
+ def _strategy_layer_contrast(intent: CompositionIntent):
68
+ new = CompositionIntent(
69
+ genre=intent.genre,
70
+ sub_genre=intent.sub_genre,
71
+ mood=intent.mood,
72
+ tempo=intent.tempo,
73
+ key=intent.key,
74
+ descriptors=list(intent.descriptors),
75
+ # Force the layer planner to drop "bass" as an anchor role by adding
76
+ # "pad" explicitly to explicit_elements and not asking for a bass.
77
+ explicit_elements=list(intent.explicit_elements) + ["pad_anchor", "no_bass"],
78
+ energy=intent.energy,
79
+ layer_count=intent.layer_count,
80
+ duration_bars=intent.duration_bars,
81
+ )
82
+ return (
83
+ new,
84
+ "layer contrast — pad anchor instead of bass line",
85
+ "unexpected",
86
+ "medium",
87
+ )
88
+
89
+
90
+ _STRATEGIES = [
91
+ ("canonical", _strategy_canonical),
92
+ ("energy_shift", _strategy_energy_shift),
93
+ ("layer_contrast", _strategy_layer_contrast),
94
+ ]
95
+
96
+
97
+ def _short_id(prefix: str, key: str) -> str:
98
+ h = hashlib.sha256(f"{prefix}:{key}".encode()).hexdigest()[:10]
99
+ return f"{prefix}_{h}"
100
+
101
+
102
+ def propose_composer_branches(
103
+ request_text: str,
104
+ kernel: Optional[dict] = None,
105
+ count: int = 2,
106
+ search_roots: Optional[list] = None,
107
+ ) -> list[tuple[BranchSeed, dict]]:
108
+ """Emit composer-source branch seeds with pre-compiled plans.
109
+
110
+ request_text: the natural-language composition prompt.
111
+ kernel: optional SessionKernel dict — reads ``freshness`` to gate
112
+ whether high-novelty strategies (layer_contrast) are included.
113
+ count: desired number of branches (clamped to 1..len(_STRATEGIES)).
114
+ search_roots: optional list of directory paths for sample resolution,
115
+ threaded to ComposerEngine.compose().
116
+
117
+ Returns a list of (BranchSeed, compiled_plan_dict) tuples. Each plan
118
+ is a dict with {"steps": [...], "step_count": N, "summary": "..."}
119
+ compatible with run_experiment.
120
+ """
121
+ kernel = kernel or {}
122
+ freshness = float(kernel.get("freshness", 0.5) or 0.5)
123
+
124
+ intent = parse_prompt(request_text)
125
+
126
+ # Gate high-novelty strategies on freshness.
127
+ if freshness < 0.4:
128
+ strategies = [_STRATEGIES[0]] # canonical only
129
+ elif freshness < 0.7:
130
+ strategies = _STRATEGIES[:2] # canonical + energy_shift
131
+ else:
132
+ strategies = _STRATEGIES # all three
133
+
134
+ count = max(1, min(count, len(strategies)))
135
+ results: list[tuple[BranchSeed, dict]] = []
136
+
137
+ for name, strategy_fn in strategies[:count]:
138
+ try:
139
+ variant_intent, reason, novelty, risk = strategy_fn(intent)
140
+ plan = _build_section_hypothesis_plan(variant_intent, name)
141
+
142
+ seed = freeform_seed(
143
+ seed_id=_short_id(f"cmp_{name}", request_text),
144
+ hypothesis=f"Composer branch ({name}): {reason}",
145
+ source="composer",
146
+ novelty_label=novelty,
147
+ risk_label=risk,
148
+ distinctness_reason=reason,
149
+ )
150
+ results.append((seed, plan))
151
+ except Exception as exc:
152
+ # Don't let one strategy's failure kill the rest.
153
+ import logging
154
+ logging.getLogger(__name__).warning(
155
+ "composer strategy %s failed: %s", name, exc
156
+ )
157
+ continue
158
+
159
+ return results
160
+
161
+
162
+ def _build_section_hypothesis_plan(intent: CompositionIntent, strategy_name: str) -> dict:
163
+ """Build a lightweight, executable plan from an intent.
164
+
165
+ Uses the synchronous planning primitives (plan_layers, plan_sections)
166
+ to generate a scaffolding plan: set_tempo + create_midi_track per layer
167
+ with sensible names and colors. Sample resolution is deferred —
168
+ callers that want samples loaded should either hand the branch to
169
+ commit_experiment after auditioning, or re-run ComposerEngine.compose()
170
+ on the winning intent.
171
+
172
+ Returns a dict with {"steps", "step_count", "summary"}.
173
+ """
174
+ layers = plan_layers(intent)
175
+ sections = plan_sections(intent)
176
+
177
+ steps: list[dict] = []
178
+
179
+ # Step 1: tempo — only when intent.tempo is set. Remote transport
180
+ # handler takes "tempo" (not "bpm") — see transport.py:set_tempo.
181
+ if intent.tempo and intent.tempo > 0:
182
+ steps.append({
183
+ "tool": "set_tempo",
184
+ "params": {"tempo": float(intent.tempo)},
185
+ })
186
+
187
+ # Step 2: one create_midi_track per layer role — the skeleton every
188
+ # subsequent composition step builds on.
189
+ for idx, layer in enumerate(layers):
190
+ name = getattr(layer, "role", f"layer_{idx}")
191
+ steps.append({
192
+ "tool": "create_midi_track",
193
+ "params": {"name": str(name)},
194
+ })
195
+
196
+ # Step 3: one create_scene + set_scene_name per section. Remote
197
+ # create_scene handler only accepts "index" — see scenes.py:create_scene.
198
+ # Section labels land via set_scene_name after creation. step_id +
199
+ # $from_step binding resolves the new scene index so parallel branches
200
+ # with different section counts don't step on each other.
201
+ for s_idx, section in enumerate(sections):
202
+ if isinstance(section, dict):
203
+ sec_name = section.get("name", f"Section {s_idx + 1}")
204
+ else:
205
+ sec_name = f"Section {s_idx + 1}"
206
+ create_step_id = f"create_scene_{s_idx}"
207
+ steps.append({
208
+ "tool": "create_scene",
209
+ "step_id": create_step_id,
210
+ "params": {"index": -1}, # -1 ⇒ append at end
211
+ })
212
+ steps.append({
213
+ "tool": "set_scene_name",
214
+ "params": {
215
+ "scene_index": {"$from_step": create_step_id, "path": "index"},
216
+ "name": str(sec_name),
217
+ },
218
+ })
219
+
220
+ summary = (
221
+ f"{strategy_name}: {intent.genre or 'auto-genre'} @ "
222
+ f"{intent.tempo or 'auto-tempo'} bpm, energy {intent.energy:.1f} — "
223
+ f"{len(layers)} layers, {len(sections)} sections"
224
+ )
225
+ return {
226
+ "steps": steps,
227
+ "step_count": len(steps),
228
+ "summary": summary,
229
+ }