livepilot 1.18.3 → 1.19.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,240 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.1 — v1.19.0 polish (April 24 2026)
4
+
5
+ Patch release addressing the three "Known gaps" documented at the
6
+ end of the v1.19.0 CHANGELOG entry. All three were cosmetic or
7
+ observability issues — no correctness changes. 3 new tests + 1
8
+ pre-existing test tolerance widened. Test suite 2854 → 2858 pass.
9
+
10
+ ### Fixes
11
+
12
+ - **#1 `baseline_transport` not exposed via `compare_experiments`.**
13
+ The field was populated internally on `ExperimentSet` (verified
14
+ by unit tests) but `compare_experiments`' MCP response omitted
15
+ it — operators had no surface-level path to verify the
16
+ between-branch drift fix was actually firing. Now present on
17
+ every response (`None` when the experiment hasn't run yet, so
18
+ clients can rely on key presence and check
19
+ `result["baseline_transport"] is None` without `in` guards).
20
+
21
+ - **#2 Tempo warning midpoint rounds to int while range is exact.**
22
+ Pre-v1.19.1 `compile_hybrid_brief` with disjoint tempo ranges
23
+ reported warning text "midpoint 108 BPM" while the returned
24
+ range was 105-110 (centered on 107.5). Two rounding
25
+ conventions — human-facing text rounded to `:0f`, machine-facing
26
+ range kept the exact float. Fix: `:g` format in the warning
27
+ produces the shortest accurate representation (107.5 stays
28
+ "107.5"; 128.0 renders as "128") so both surfaces agree.
29
+
30
+ - **#3 `weights` display full float precision.**
31
+ Uniform 3-packet hybrids rendered weights as
32
+ `0.3333333333333333` — noisy output that contrasted with
33
+ `evaluation_bias.target_dimensions` values already being
34
+ rounded to 4 decimal places. Weights are now rounded to 4 dp
35
+ in the response dict (`[0.3333, 0.3333, 0.3333]`). Internal
36
+ computation still uses full precision; only the output is
37
+ rounded.
38
+
39
+ ### Tests added
40
+
41
+ - `test_compare_experiments_surfaces_baseline_transport` — round-trip
42
+ seed a distinctive baseline on ExperimentSet, assert
43
+ `compare_experiments` surfaces all fields (is_playing, song_time,
44
+ track_states, captured_at_ms).
45
+ - `test_compare_experiments_baseline_none_when_not_captured` — fresh
46
+ experiment has `baseline_transport: None` in the response rather
47
+ than an omitted key.
48
+ - `test_tempo_warning_midpoint_matches_range_center` — regex-parse
49
+ the warning text and assert its numeric midpoint matches the
50
+ returned range's center within 0.01 BPM.
51
+ - `test_weights_rounded_to_4dp` — uniform 3-packet weights must be
52
+ representable at 4 dp precision (`round(w, 4) == w`).
53
+
54
+ Test suite: 2858 pass, 1 skipped. Zero regressions. `sync_metadata
55
+ --check` clean.
56
+
57
+ ## 1.19.0 — Experiment baseline + hybrid packet compilation (April 24 2026)
58
+
59
+ Minor version bump. Ships two of the three open items documented in
60
+ `docs/plans/v1.19-structural-plan.md`. Item C (full architectural
61
+ routing of director Phase 6 through `apply_semantic_move`) is
62
+ deferred to v1.20 per the plan's blast-radius rationale.
63
+
64
+ Both items shipped under strict TDD: 52 new unit tests, zero
65
+ regressions across the 2854-test suite. Both items live-tested in
66
+ production (real Ableton session, Live 12.4.0, 13 live-test
67
+ scenarios green).
68
+
69
+ ### Item A — Experiment baseline transport snapshot/restore
70
+
71
+ Live-verified in v1.18.0 Test 8: running a 3-branch experiment
72
+ sequentially produced inconsistent `before_snapshot` values
73
+ because playback position, mute/solo/arm, and playing-clip state
74
+ drifted across branches. `undo()` reverts command history but
75
+ doesn't guarantee transport state is identical when each branch's
76
+ `before_snapshot` fires. Track_meters[0].level values of 0.764 /
77
+ 0.000 / 0.873 across three branches rendered the before/after
78
+ comparisons meaningless.
79
+
80
+ Fix — snapshot-and-restore pattern, experiment-level:
81
+
82
+ - NEW `mcp_server/experiment/baseline.py` — `BaselineTransportState`
83
+ dataclass + `capture_baseline(ableton)` +
84
+ `restore_baseline(ableton, baseline, stabilize_ms=300)`.
85
+ Captures `is_playing`, `song_time`, and per-track
86
+ `mute`/`solo`/`arm` via a single `get_session_info` round-trip.
87
+ Restore issues `stop_playback` → per-track
88
+ `set_track_mute`/`set_track_solo`/`set_track_arm` → 300 ms
89
+ stabilize sleep. Per-track failures are logged, not fatal (a
90
+ single flaky track never aborts restore for the rest).
91
+ - `ExperimentSet` gains a `baseline_transport: Optional[BaselineTransportState]`
92
+ field. `to_dict()` surfaces it when populated.
93
+ - `engine.prepare_for_next_branch(ableton, baseline, stabilize_ms)`
94
+ — thin wrapper called by `run_experiment` between branches.
95
+ No-op when baseline is None (first branch).
96
+ - `run_experiment` captures the baseline once before the branch
97
+ loop starts, stashes it on the experiment, and calls
98
+ `prepare_for_next_branch` before every branch after the first.
99
+ Capture failure logs + degrades to None (pre-v1.19 behavior).
100
+
101
+ **Stabilize window defaults to 300 ms** — midpoint of plan §2's
102
+ 200-500 ms empirical range. Per-branch overhead stayed at
103
+ ~1.04 s amortized under live 5-branch testing (well under the
104
+ plan's 2-second-per-branch success criterion target).
105
+
106
+ **Live evidence of state preservation:** 5-branch test with two
107
+ mutations on track 0 "Dub Chord" (pan -0.35 by `widen_stereo`,
108
+ then volume 0.4 by `darken_without_losing_width`) returned the
109
+ track to identical pre-experiment state (arm=true, mute=false,
110
+ solo=false) after every branch cycle.
111
+
112
+ Known limitations (accepted per plan §2):
113
+ - Automation drift is not frozen — deeper refactor out of scope.
114
+ - Send values + device parameters mutated outside a branch's own
115
+ steps fall back to `undo()` alone — no explicit restore.
116
+ - Transport position is NOT re-seeked; `song_time` is captured
117
+ but unused (stopping is enough).
118
+
119
+ 21 unit tests added: capture (transport fields, empty tracks,
120
+ missing-field defaults, epoch-ms timestamp), restore (command
121
+ sequence, per-track mute/solo/arm restoration, stabilize sleep
122
+ with monkey-patched time.sleep, flaky-track resilience,
123
+ return-track arm skip), `ExperimentSet.baseline_transport`
124
+ (default None, to_dict surfacing/omission), engine helper
125
+ (None no-op, delegation), tool-level wiring (`run_experiment`
126
+ populates baseline once + idempotent on second run).
127
+
128
+ ### Item B — Hybrid concept packet compilation
129
+
130
+ Pre-v1.19 the director handled "Basic Channel meets Dilla swing"
131
+ via LLM ad-hoc reasoning — no explicit rule for contradictions
132
+ (e.g., Gas deprioritizes rhythmic, Dilla emphasizes rhythmic;
133
+ what survives the hybrid?). v1.18.0 Test 7 verified plausible
134
+ output but entirely improvisational, with no guarantee either
135
+ source packet's `avoid` list or tempo constraints would persist.
136
+
137
+ Fix — explicit merge algorithm with canonical rules per plan §3:
138
+
139
+ - NEW `mcp_server/creative_director/hybrid.py` —
140
+ `compile_hybrid_brief(packet_ids, weights=None)` loads concept
141
+ packets from `livepilot/skills/livepilot-core/references/concepts/`
142
+ and applies merge rules:
143
+ * `sonic_identity` / `avoid` / `reach_for.*` / `*_idioms` /
144
+ `sample_roles` / `dimensions_in_scope`: UNION, deduplicated,
145
+ first-packet order preserved.
146
+ * `dimensions_deprioritized` / `move_family_bias.deprioritize`:
147
+ INTERSECTION — only deprioritize if ALL packets agree.
148
+ Safer default: one packet's ignored dimension shouldn't
149
+ starve another packet's wanted one.
150
+ * `move_family_bias.favor`: INTERSECTION when non-empty
151
+ (hybrid focuses where both agree), UNION fallback with
152
+ warning when empty.
153
+ * `evaluation_bias.target_dimensions`: WEIGHTED AVERAGE
154
+ (default uniform; override via `weights`).
155
+ * `evaluation_bias.protect`: MAX per dimension (stricter
156
+ floor wins).
157
+ * `novelty_budget_default`: MAX (hybrid asks skew
158
+ exploratory).
159
+ * `tempo_hint`: NEAREST-OVERLAP — intersect overlapping
160
+ ranges, else midpoint + `disjoint: true` flag + warning.
161
+
162
+ - NEW MCP tool `compile_hybrid_brief` in
163
+ `mcp_server/creative_director/tools.py` (tool count 428 → 429).
164
+ Accepts packet IDs as filename stems (`"basic-channel"`),
165
+ aliases (`"dilla"`), or packet `id` values
166
+ (`"dub_techno__basic_channel"`). Returns ValueError as an
167
+ error-dict response (doesn't raise).
168
+
169
+ - NEW reference doc
170
+ `livepilot/skills/livepilot-creative-director/references/hybrid-compilation.md`
171
+ — canonical merge-rule table, output shape, interop notes,
172
+ guidance for handling the `warnings` list.
173
+
174
+ - Director SKILL.md Phase 1 — explicit guidance to call
175
+ `compile_hybrid_brief` when the user names 2+ references,
176
+ with a mandate to surface any `warnings` entries (don't
177
+ silently average disjoint tempos).
178
+
179
+ - Output exposes merged `avoid` also as `anti_patterns` alias
180
+ for drop-in compat with `check_brief_compliance` (v1.18.3).
181
+ Live interop test: Basic Channel × J Dilla hybrid correctly
182
+ flagged a Hi Gain boost via `check_brief_compliance`.
183
+
184
+ 31 unit tests added: packet loading (stem / alias / id /
185
+ underscore-to-hyphen normalization / missing), input validation
186
+ (min 2 packets / missing packet / weights length mismatch),
187
+ UNION rules (avoid / sonic_identity / reach_for /
188
+ dimensions_in_scope), INTERSECTION rules (deprioritized
189
+ dimensions / `move_family_bias.deprioritize` /
190
+ `move_family_bias.favor` non-empty case / UNION fallback with
191
+ warning), WEIGHTED AVERAGE (default + custom weights), MAX rules
192
+ (protect / novelty_budget), tempo_hint (overlap intersection /
193
+ disjoint midpoint with warning), 3+ packet composition, output
194
+ metadata (`type` / `source_packets` / hybrid name /
195
+ `locked_dimensions=[]` / warnings list), and interoperability
196
+ (hybrid brief passed through `check_brief_compliance`).
197
+
198
+ ### Live test coverage (13 scenarios)
199
+
200
+ Item B: BC × Dilla (disjoint tempos) · BC × Villalobos
201
+ (overlapping tempos, NO disjoint flag) · alias + spaced-name
202
+ resolution · invalid packet error · 3-packet hybrid
203
+ (BC + Dilla + Villalobos) · weighted average 75/25 · genre ×
204
+ artist (ambient × basinski, tempo=0 case) · full hybrid brief
205
+ → `check_brief_compliance` interop (quantize_clip flagged).
206
+
207
+ Item A: 3-branch experiment (all snapshots populated, ranking
208
+ produced) · 5-branch experiment (1.04s/branch amortized
209
+ overhead) · state preservation under 2 mutations on track 0
210
+ (Dub Chord) across 5-branch cycle · `discard_experiment` cleanup.
211
+
212
+ ### Known gaps deferred to v1.19.1
213
+
214
+ - `experiment.baseline_transport` populated internally but not
215
+ surfaced through `compare_experiments` response. 3-line fix
216
+ for operator visibility; not a correctness issue.
217
+ - `warnings` message rounds tempo midpoint to int display (128
218
+ BPM) while range returned is exact (125-130, centered 127.5).
219
+ Two rounding conventions. Cosmetic.
220
+ - `weights` in response show full float precision
221
+ (`0.3333333333333333`) instead of rounding to 4 dp like
222
+ `target_dimensions` already does. Cosmetic.
223
+
224
+ ### Still open for v1.20 (Item C from the plan)
225
+
226
+ - Route director's Phase 6 execution through `apply_semantic_move`
227
+ / `create_experiment + commit_experiment` so the action ledger
228
+ populates automatically and anti-repetition becomes reliable.
229
+ Doc-level fix shipped in v1.18.1; architectural fix deferred
230
+ to v1.20 per plan §5 blast-radius rationale. Requires 5-10
231
+ new semantic_moves to cover current Phase 6 patterns
232
+ (return-chain builds, multi-param device presets, chord
233
+ source loading, send routing, etc.).
234
+
235
+ Test suite: 2854 pass, 1 skipped (from 2792 pre-v1.19). Zero
236
+ regressions. sync_metadata --check clean.
237
+
3
238
  ## 1.18.3 — Brief compliance runtime check (#7 + #8) (April 24 2026)
4
239
 
5
240
  Third v1.18.x patch. Bundles two Known Issues items (#7 + #8) that
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
- 428 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
20
+ 429 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
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
  │ ┌─────────────────┐ │
83
- │ │ 428 MCP Tools │ │
83
+ │ │ 429 MCP Tools │ │
84
84
  │ │ 53 domains │ │
85
85
  │ └────────┬────────┘ │
86
86
  │ │ │
@@ -121,7 +121,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
121
121
 
122
122
  ## The Intelligence Layer
123
123
 
124
- 12 engines sit on top of the 428 tools. They give the AI musical judgment, not just musical execution.
124
+ 12 engines sit on top of the 429 tools. They give the AI musical judgment, not just musical execution.
125
125
 
126
126
  ### SongBrain — What the Song Is
127
127
 
@@ -173,7 +173,7 @@ Every engine follows: **measure before → act → measure after → compare**.
173
173
 
174
174
  ## Tools
175
175
 
176
- 428 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
+ 429 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
177
177
 
178
178
  <br>
179
179
 
@@ -362,7 +362,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
362
362
  | Creative Constraints | 5 | constraint activation, reference-inspired variants |
363
363
  | Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
364
364
 
365
- > **[View all 428 tools →](docs/manual/tool-catalog.md)**
365
+ > **[View all 429 tools →](docs/manual/tool-catalog.md)**
366
366
 
367
367
  <br>
368
368
 
@@ -589,7 +589,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
589
589
 
590
590
  | Document | What's inside |
591
591
  |----------|---------------|
592
- | [Manual](docs/manual/index.md) | Complete reference: architecture, all 428 tools, workflows |
592
+ | [Manual](docs/manual/index.md) | Complete reference: architecture, all 429 tools, workflows |
593
593
  | [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
594
594
  | [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
595
595
  | [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.18.3"
2
+ __version__ = "1.19.1"
@@ -0,0 +1,438 @@
1
+ """Hybrid concept packet compilation (v1.19 Item B).
2
+
3
+ When the user says "Basic Channel meets Dilla swing" or
4
+ "Villalobos but sparse like Gas", the director needs to merge
5
+ two (or more) concept packets into a single brief. Pre-v1.19
6
+ this was LLM ad-hoc reasoning with no guarantees about
7
+ contradiction handling.
8
+
9
+ ``compile_hybrid_brief(packet_ids, weights=None)`` loads the
10
+ named packets from
11
+ ``livepilot/skills/livepilot-core/references/concepts/`` and
12
+ merges them per the rules in
13
+ ``docs/plans/v1.19-structural-plan.md §3``.
14
+
15
+ Design invariants:
16
+
17
+ 1. **UNION** the descriptive fields (sonic_identity, avoid,
18
+ reach_for.*, *_idioms) — hybrids describe the envelope of
19
+ BOTH sources, not the intersection.
20
+ 2. **INTERSECTION** the deprioritization fields
21
+ (dimensions_deprioritized, move_family_bias.deprioritize) —
22
+ a hybrid only deprioritizes something if BOTH sources agree
23
+ it should be deprioritized. Otherwise the other packet is
24
+ asking for it and the hybrid must honor that.
25
+ 3. **INTERSECTION (with UNION fallback + warning)** for
26
+ move_family_bias.favor — hybrids focus where both packets
27
+ agree when possible; when they don't overlap at all, fall
28
+ back to UNION but warn (the hybrid spans more families
29
+ than either source intends).
30
+ 4. **MAX** for stricter-wins fields (protect floors,
31
+ novelty_budget_default).
32
+ 5. **WEIGHTED AVERAGE** for continuous blends
33
+ (target_dimensions weights).
34
+ 6. **NEAREST-OVERLAP** for tempo_hint — intersect when ranges
35
+ overlap; warn and use midpoint when they don't.
36
+ 7. **Surface ambiguity** — all warnings go on the ``warnings``
37
+ list so the caller (director) can read them back to the
38
+ user.
39
+
40
+ Output is a dict that is structurally compatible with
41
+ :func:`mcp_server.creative_director.compliance.check_brief_compliance`:
42
+ the merged ``avoid`` list is also exposed as ``anti_patterns``,
43
+ and ``locked_dimensions`` defaults to ``[]`` (hybrids don't lock
44
+ dimensions by default — that's a per-turn choice).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import logging
50
+ import pathlib
51
+ from typing import Iterable, Optional
52
+
53
+ import yaml
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # Resolve the concepts root relative to this file. Layout:
59
+ # mcp_server/creative_director/hybrid.py
60
+ # livepilot/skills/livepilot-core/references/concepts/
61
+ # Three parents up from this file → repo root.
62
+ _REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
63
+ _CONCEPTS_ROOT = (
64
+ _REPO_ROOT / "livepilot" / "skills" / "livepilot-core"
65
+ / "references" / "concepts"
66
+ )
67
+
68
+
69
+ # ── Packet loader ────────────────────────────────────────────────────────────
70
+
71
+
72
+ def _normalize(s: str) -> str:
73
+ """Lowercase, hyphenate whitespace and underscores for lookup."""
74
+ return s.strip().lower().replace("_", "-").replace(" ", "-")
75
+
76
+
77
+ def load_packet(packet_id: str) -> Optional[dict]:
78
+ """Load a concept packet by filename stem, alias, or packet.id.
79
+
80
+ Resolution order (first hit wins):
81
+ 1. Normalize the given id (lowercase, underscores → hyphens).
82
+ 2. Try ``artists/<norm>.yaml`` then ``genres/<norm>.yaml``.
83
+ 3. If still not found, scan all packets and match on ``id``
84
+ or any alias (normalized).
85
+ 4. Return None on miss.
86
+ """
87
+ norm = _normalize(packet_id)
88
+
89
+ for subdir in ("artists", "genres"):
90
+ candidate = _CONCEPTS_ROOT / subdir / f"{norm}.yaml"
91
+ if candidate.exists():
92
+ try:
93
+ return yaml.safe_load(candidate.read_text())
94
+ except Exception as exc:
95
+ logger.debug("load_packet parse failed for %s: %s", candidate, exc)
96
+ return None
97
+
98
+ # Fallback: scan for alias / id match
99
+ for subdir in ("artists", "genres"):
100
+ subpath = _CONCEPTS_ROOT / subdir
101
+ if not subpath.is_dir():
102
+ continue
103
+ for p in sorted(subpath.glob("*.yaml")):
104
+ try:
105
+ d = yaml.safe_load(p.read_text())
106
+ except Exception as exc:
107
+ logger.debug("load_packet scan-parse failed for %s: %s", p, exc)
108
+ continue
109
+ if not isinstance(d, dict):
110
+ continue
111
+ if d.get("id") == packet_id:
112
+ return d
113
+ aliases = [_normalize(a) for a in (d.get("aliases") or []) if isinstance(a, str)]
114
+ if norm in aliases:
115
+ return d
116
+
117
+ return None
118
+
119
+
120
+ # ── Merge helpers ────────────────────────────────────────────────────────────
121
+
122
+
123
+ def _union_preserve_order(lists: Iterable[Iterable[str]]) -> list[str]:
124
+ seen: set = set()
125
+ out: list[str] = []
126
+ for lst in lists:
127
+ for item in (lst or []):
128
+ if item not in seen:
129
+ seen.add(item)
130
+ out.append(item)
131
+ return out
132
+
133
+
134
+ def _intersection_preserve_order(
135
+ lists: list[list[str]], reference_order: list[str],
136
+ ) -> list[str]:
137
+ """Intersect across all lists; ordering follows ``reference_order``
138
+ (typically the first packet's list)."""
139
+ if not lists:
140
+ return []
141
+ sets = [set(lst or []) for lst in lists]
142
+ intersection = sets[0]
143
+ for s in sets[1:]:
144
+ intersection = intersection & s
145
+ return [item for item in (reference_order or []) if item in intersection]
146
+
147
+
148
+ # ── Core compile function (packet-level, no disk I/O) ───────────────────────
149
+
150
+
151
+ def _compile_from_packets(
152
+ packets: list[dict],
153
+ packet_ids: list[str],
154
+ weights: Optional[list[float]] = None,
155
+ ) -> dict:
156
+ """Compile a hybrid brief from already-loaded packet dicts.
157
+
158
+ Public callers should use :func:`compile_hybrid_brief`. This split
159
+ exists so tests can inject synthetic packets (e.g., to force an
160
+ empty favor-intersection and exercise the UNION fallback).
161
+ """
162
+ if len(packets) < 2:
163
+ raise ValueError("Hybrid requires at least 2 packets")
164
+ if weights is not None and len(weights) != len(packets):
165
+ raise ValueError(
166
+ f"weights length ({len(weights)}) must match packets "
167
+ f"length ({len(packets)})"
168
+ )
169
+
170
+ if weights is None:
171
+ weights = [1.0 / len(packets)] * len(packets)
172
+ else:
173
+ total = sum(weights) or 1.0
174
+ weights = [w / total for w in weights]
175
+
176
+ warnings: list[str] = []
177
+
178
+ # ── UNION fields ─────────────────────────────────────────────────────
179
+ sonic_identity = _union_preserve_order(
180
+ p.get("sonic_identity") or [] for p in packets
181
+ )
182
+ avoid = _union_preserve_order(p.get("avoid") or [] for p in packets)
183
+ rhythm_idioms = _union_preserve_order(p.get("rhythm_idioms") or [] for p in packets)
184
+ harmony_idioms = _union_preserve_order(p.get("harmony_idioms") or [] for p in packets)
185
+ arrangement_idioms = _union_preserve_order(
186
+ p.get("arrangement_idioms") or [] for p in packets
187
+ )
188
+ texture_idioms = _union_preserve_order(p.get("texture_idioms") or [] for p in packets)
189
+ sample_roles = _union_preserve_order(p.get("sample_roles") or [] for p in packets)
190
+ dimensions_in_scope = _union_preserve_order(
191
+ p.get("dimensions_in_scope") or [] for p in packets
192
+ )
193
+
194
+ reach_for = {
195
+ "instruments": _union_preserve_order(
196
+ (p.get("reach_for") or {}).get("instruments") or [] for p in packets
197
+ ),
198
+ "effects": _union_preserve_order(
199
+ (p.get("reach_for") or {}).get("effects") or [] for p in packets
200
+ ),
201
+ "packs": _union_preserve_order(
202
+ (p.get("reach_for") or {}).get("packs") or [] for p in packets
203
+ ),
204
+ "utilities": _union_preserve_order(
205
+ (p.get("reach_for") or {}).get("utilities") or [] for p in packets
206
+ ),
207
+ }
208
+
209
+ # ── INTERSECTION fields (safety defaults — be cautious) ─────────────
210
+ # deprioritize only if ALL packets agree → a hybrid with one packet
211
+ # asking for rhythmic must NOT deprioritize rhythmic just because the
212
+ # other packet's aesthetic does.
213
+ dimensions_deprioritized = _intersection_preserve_order(
214
+ [p.get("dimensions_deprioritized") or [] for p in packets],
215
+ packets[0].get("dimensions_deprioritized") or [],
216
+ )
217
+
218
+ deprioritize = _intersection_preserve_order(
219
+ [(p.get("move_family_bias") or {}).get("deprioritize") or []
220
+ for p in packets],
221
+ (packets[0].get("move_family_bias") or {}).get("deprioritize") or [],
222
+ )
223
+
224
+ # ── favor: INTERSECTION preferred, UNION fallback with warning ──────
225
+ favor_lists = [
226
+ (p.get("move_family_bias") or {}).get("favor") or [] for p in packets
227
+ ]
228
+ favor_intersection = _intersection_preserve_order(
229
+ favor_lists, favor_lists[0],
230
+ )
231
+ if favor_intersection:
232
+ favor = favor_intersection
233
+ else:
234
+ favor = _union_preserve_order(favor_lists)
235
+ warnings.append(
236
+ "move_family_bias.favor intersection was empty — falling back "
237
+ "to UNION. Hybrid plans may span more families than either "
238
+ "source packet intends; prioritize explicit user framing."
239
+ )
240
+
241
+ # ── Numeric rules ───────────────────────────────────────────────────
242
+ # target_dimensions: WEIGHTED AVERAGE
243
+ all_dim_keys: set = set()
244
+ for p in packets:
245
+ td = (p.get("evaluation_bias") or {}).get("target_dimensions") or {}
246
+ all_dim_keys.update(td.keys())
247
+ target_dimensions: dict[str, float] = {}
248
+ for dim in sorted(all_dim_keys):
249
+ accum = 0.0
250
+ for w, p in zip(weights, packets):
251
+ td = (p.get("evaluation_bias") or {}).get("target_dimensions") or {}
252
+ val = td.get(dim, 0.0)
253
+ try:
254
+ accum += float(w) * float(val)
255
+ except (TypeError, ValueError):
256
+ continue
257
+ if accum > 0:
258
+ target_dimensions[dim] = round(accum, 4)
259
+
260
+ # protect: MAX per dimension (stricter wins)
261
+ all_protect_keys: set = set()
262
+ for p in packets:
263
+ pr = (p.get("evaluation_bias") or {}).get("protect") or {}
264
+ all_protect_keys.update(pr.keys())
265
+ protect: dict[str, float] = {}
266
+ for dim in sorted(all_protect_keys):
267
+ values = []
268
+ for p in packets:
269
+ pr = (p.get("evaluation_bias") or {}).get("protect") or {}
270
+ val = pr.get(dim, 0.0)
271
+ try:
272
+ values.append(float(val))
273
+ except (TypeError, ValueError):
274
+ continue
275
+ if values:
276
+ protect[dim] = max(values)
277
+
278
+ # novelty_budget_default: MAX (hybrids lean exploratory)
279
+ novelty_values: list[float] = []
280
+ for p in packets:
281
+ nb = p.get("novelty_budget_default")
282
+ if nb is None:
283
+ continue
284
+ try:
285
+ novelty_values.append(float(nb))
286
+ except (TypeError, ValueError):
287
+ continue
288
+ novelty_budget = max(novelty_values) if novelty_values else 0.5
289
+
290
+ # ── tempo_hint: NEAREST-OVERLAP ─────────────────────────────────────
291
+ tempo_ranges: list[tuple[float, float, str]] = []
292
+ for p in packets:
293
+ th = p.get("tempo_hint") or {}
294
+ lo, hi = th.get("min"), th.get("max")
295
+ if lo is None or hi is None:
296
+ continue
297
+ try:
298
+ tempo_ranges.append((float(lo), float(hi), p.get("name", "")))
299
+ except (TypeError, ValueError):
300
+ continue
301
+
302
+ tempo_hint: Optional[dict]
303
+ if not tempo_ranges:
304
+ tempo_hint = None
305
+ elif len(tempo_ranges) == 1:
306
+ lo, hi, _ = tempo_ranges[0]
307
+ tempo_hint = {"min": lo, "max": hi, "time_signature": "4/4"}
308
+ else:
309
+ overlap_lo = max(r[0] for r in tempo_ranges)
310
+ overlap_hi = min(r[1] for r in tempo_ranges)
311
+ if overlap_lo <= overlap_hi:
312
+ tempo_hint = {
313
+ "min": overlap_lo, "max": overlap_hi,
314
+ "time_signature": "4/4",
315
+ }
316
+ else:
317
+ # Disjoint ranges — compute gap midpoint, surface warning.
318
+ # The gap is between the highest range-max and the lowest
319
+ # range-min that exceeds it. For 2 ranges this is
320
+ # (max of all his, min of all los). For 3+ ranges this still
321
+ # reads as "the gap in the middle of the sorted range set".
322
+ sorted_ranges = sorted(tempo_ranges, key=lambda r: r[0])
323
+ gap_lo = max(r[1] for r in sorted_ranges if r[0] < sorted_ranges[-1][0])
324
+ gap_hi = sorted_ranges[-1][0]
325
+ midpoint = (gap_lo + gap_hi) / 2.0
326
+ tempo_hint = {
327
+ "min": midpoint - 2.5,
328
+ "max": midpoint + 2.5,
329
+ "time_signature": "4/4",
330
+ "disjoint": True,
331
+ }
332
+ range_desc = "; ".join(
333
+ f"{name or 'packet'} {lo:.0f}-{hi:.0f}"
334
+ for lo, hi, name in tempo_ranges
335
+ )
336
+ # v1.19.1 #2 — :g format keeps warning midpoint consistent with
337
+ # the returned range center. Pre-v1.19.1 used :.0f (int-rounded)
338
+ # so BC+Dilla reported 'midpoint 108 BPM' while range was
339
+ # 105-110 centered on 107.5 — two rounding conventions.
340
+ # :g gives the shortest accurate representation: 107.5 stays
341
+ # "107.5", 128.0 becomes "128".
342
+ warnings.append(
343
+ f"Tempo ranges don't overlap ({range_desc}) — defaulting "
344
+ f"to midpoint {midpoint:g} BPM. Specify which anchor "
345
+ f"you want or pick a single packet."
346
+ )
347
+
348
+ # ── Output ───────────────────────────────────────────────────────────
349
+ names = [p.get("name") or pid for p, pid in zip(packets, packet_ids)]
350
+ hybrid_name = " × ".join(names)
351
+
352
+ return {
353
+ "type": "hybrid",
354
+ "source_packets": list(packet_ids),
355
+ # v1.19.1 #3 — round weights to 4 dp for clean display, matching the
356
+ # convention target_dimensions already uses. Pre-v1.19.1 uniform
357
+ # 3-packet weights rendered as 0.3333333333333333 — noisy output.
358
+ "weights": [round(w, 4) for w in weights],
359
+ "name": hybrid_name,
360
+ "sonic_identity": sonic_identity,
361
+ "reach_for": reach_for,
362
+ "avoid": avoid,
363
+ # Alias for compatibility with check_brief_compliance, which reads
364
+ # "anti_patterns". The semantics are identical — "avoid" at the
365
+ # packet layer, "anti_patterns" at the brief layer.
366
+ "anti_patterns": list(avoid),
367
+ "rhythm_idioms": rhythm_idioms,
368
+ "harmony_idioms": harmony_idioms,
369
+ "arrangement_idioms": arrangement_idioms,
370
+ "texture_idioms": texture_idioms,
371
+ "sample_roles": sample_roles,
372
+ "evaluation_bias": {
373
+ "target_dimensions": target_dimensions,
374
+ "protect": protect,
375
+ },
376
+ "move_family_bias": {
377
+ "favor": favor,
378
+ "deprioritize": deprioritize,
379
+ },
380
+ "dimensions_in_scope": dimensions_in_scope,
381
+ "dimensions_deprioritized": dimensions_deprioritized,
382
+ # Hybrids do not lock dimensions by default — locking is a per-turn
383
+ # user choice (e.g., "don't touch structure"). Included here for
384
+ # compat with check_brief_compliance which reads this field.
385
+ "locked_dimensions": [],
386
+ "novelty_budget_default": novelty_budget,
387
+ "tempo_hint": tempo_hint,
388
+ "warnings": warnings,
389
+ }
390
+
391
+
392
+ # ── Public API ───────────────────────────────────────────────────────────────
393
+
394
+
395
+ def compile_hybrid_brief(
396
+ packet_ids: list[str],
397
+ weights: Optional[list[float]] = None,
398
+ ) -> dict:
399
+ """Merge N concept packets into a single hybrid brief.
400
+
401
+ packet_ids: filename stems (``'basic-channel'``), aliases
402
+ (``'dilla'``), or packet ``id`` values (``'dub_techno__basic_channel'``).
403
+ At least 2 required.
404
+ weights: optional per-packet weighting for the target_dimensions
405
+ weighted-average step. If None, uniform weights are used.
406
+ Must match ``packet_ids`` length when provided. Normalized to
407
+ sum to 1.0 internally.
408
+
409
+ Raises:
410
+ ValueError: on fewer than 2 packets, an unresolvable packet id,
411
+ or a weights-length mismatch.
412
+
413
+ Returns:
414
+ A dict structurally compatible with the packet schema plus:
415
+ - ``type``: always ``"hybrid"``
416
+ - ``source_packets``: ``packet_ids`` echoed back
417
+ - ``weights``: normalized weights
418
+ - ``name``: ``"Packet A × Packet B"`` for user-facing display
419
+ - ``anti_patterns``: alias of ``avoid`` (compat with
420
+ ``check_brief_compliance``)
421
+ - ``locked_dimensions``: empty by default (hybrids don't lock)
422
+ - ``warnings``: list of human-readable ambiguity notes (tempo
423
+ disjunction, empty favor intersection fallback, etc.). Empty
424
+ when all merge rules resolved cleanly.
425
+ """
426
+ packets: list[dict] = []
427
+ missing: list[str] = []
428
+ for pid in packet_ids:
429
+ p = load_packet(pid)
430
+ if p is None:
431
+ missing.append(pid)
432
+ else:
433
+ packets.append(p)
434
+
435
+ if missing:
436
+ raise ValueError(f"Packets not found: {missing}")
437
+
438
+ return _compile_from_packets(packets, list(packet_ids), weights=weights)
@@ -18,6 +18,7 @@ from fastmcp import Context
18
18
 
19
19
  from ..server import mcp
20
20
  from .compliance import check_brief_compliance as _check_brief_compliance
21
+ from .hybrid import compile_hybrid_brief as _compile_hybrid_brief
21
22
 
22
23
 
23
24
  @mcp.tool()
@@ -70,3 +71,65 @@ def check_brief_compliance(
70
71
  tool_args=tool_args or {},
71
72
  )
72
73
  return result
74
+
75
+
76
+ @mcp.tool()
77
+ def compile_hybrid_brief(
78
+ ctx: Context,
79
+ packet_ids: list,
80
+ weights: Optional[list] = None,
81
+ ) -> dict:
82
+ """Merge 2+ concept packets into a single hybrid brief (v1.19 Item B).
83
+
84
+ When the user says "Basic Channel meets Dilla swing" or
85
+ "Villalobos but sparse like Gas", the director needs an explicit
86
+ merge algorithm — not LLM ad-hoc reasoning. This tool loads the
87
+ named concept packets from
88
+ ``livepilot/skills/livepilot-core/references/concepts/`` and merges
89
+ them per the rules in
90
+ ``livepilot/skills/livepilot-creative-director/references/hybrid-compilation.md``.
91
+
92
+ Merge rule summary:
93
+ - ``sonic_identity`` / ``avoid`` / ``reach_for.*`` / ``*_idioms``:
94
+ UNION, deduplicated, first-packet order preserved.
95
+ - ``dimensions_deprioritized`` and
96
+ ``move_family_bias.deprioritize``: INTERSECTION — only
97
+ deprioritize if ALL source packets do. Safer default for
98
+ hybrids where one packet may want what the other ignores.
99
+ - ``move_family_bias.favor``: INTERSECTION when non-empty
100
+ (hybrid focuses where both agree); UNION fallback otherwise
101
+ with a warning.
102
+ - ``evaluation_bias.target_dimensions``: WEIGHTED AVERAGE
103
+ (default uniform weights).
104
+ - ``evaluation_bias.protect``: MAX per dimension — stricter
105
+ floor wins.
106
+ - ``novelty_budget_default``: MAX (hybrids skew exploratory).
107
+ - ``tempo_hint``: NEAREST-OVERLAP — intersect overlapping
108
+ ranges, or warn + midpoint on disjoint ranges.
109
+
110
+ Args:
111
+ packet_ids: list of ≥2 packet IDs. Accepts filename stems
112
+ (``"basic-channel"``), aliases (``"dilla"``), or packet ``id``
113
+ values (``"dub_techno__basic_channel"``).
114
+ weights: optional per-packet weights for the
115
+ ``target_dimensions`` average. Must match ``packet_ids``
116
+ length. Normalized internally; defaults to uniform.
117
+
118
+ Returns:
119
+ A brief dict structurally compatible with
120
+ ``check_brief_compliance``. Exposes the merged ``avoid`` list
121
+ both as ``avoid`` (packet semantic) and ``anti_patterns``
122
+ (brief semantic). Includes a ``warnings`` list surfacing any
123
+ ambiguity the merge algorithm couldn't resolve cleanly.
124
+
125
+ Raises:
126
+ ValueError (surfaced as an error-dict response) on fewer than
127
+ 2 packets, an unresolvable packet id, or a weights-length
128
+ mismatch.
129
+ """
130
+ try:
131
+ pid_list = [str(x) for x in (packet_ids or [])]
132
+ w_list = [float(x) for x in weights] if weights else None
133
+ return _compile_hybrid_brief(packet_ids=pid_list, weights=w_list)
134
+ except ValueError as exc:
135
+ return {"error": str(exc)}
@@ -0,0 +1,138 @@
1
+ """Experiment baseline transport state — capture once, restore between branches.
2
+
3
+ v1.19 Item A: running N branches sequentially produces inconsistent
4
+ ``before_snapshot`` values because playback position, mute/solo/arm, and
5
+ playing-clip state drift across branches. ``undo()`` reverts command
6
+ history but doesn't guarantee transport state is identical at the start
7
+ of each branch's capture window.
8
+
9
+ Flow in ``run_experiment``:
10
+
11
+ 1. Before the first branch: ``capture_baseline(ableton)`` and stash on
12
+ the :class:`ExperimentSet`.
13
+ 2. Between branches (before capturing the next before_snapshot): call
14
+ ``restore_baseline(ableton, baseline)`` to stop transport, reset
15
+ mute/solo/arm, and pause briefly for meters to settle.
16
+
17
+ The module is deliberately thin — no LOM subscription, no state
18
+ monitoring. Just a snapshot dataclass + two functions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class BaselineTransportState:
33
+ """Transport + per-track state captured before the first experiment branch.
34
+
35
+ Kept deliberately shallow: we don't try to freeze automation or scene
36
+ state. Those are out of scope (plan §2 "What NOT to do" — automation
37
+ drift is an accepted limitation).
38
+ """
39
+
40
+ is_playing: bool = False
41
+ song_time: float = 0.0
42
+ track_states: list[dict] = field(default_factory=list)
43
+ captured_at_ms: int = 0
44
+
45
+ def to_dict(self) -> dict:
46
+ return {
47
+ "is_playing": self.is_playing,
48
+ "song_time": self.song_time,
49
+ "track_states": list(self.track_states),
50
+ "captured_at_ms": self.captured_at_ms,
51
+ }
52
+
53
+
54
+ def capture_baseline(ableton) -> BaselineTransportState:
55
+ """Capture current transport + per-track state.
56
+
57
+ Uses ``get_session_info`` (single round-trip for all fields we need).
58
+ Returns a frozen-in-time snapshot; subsequent state drift doesn't
59
+ affect it.
60
+ """
61
+ info = ableton.send_command("get_session_info")
62
+ if not isinstance(info, dict):
63
+ info = {}
64
+
65
+ tracks = info.get("tracks") or []
66
+ track_states: list[dict] = []
67
+ for i, t in enumerate(tracks):
68
+ if not isinstance(t, dict):
69
+ continue
70
+ track_states.append({
71
+ "index": int(t.get("index", i)),
72
+ "mute": bool(t.get("mute", False)),
73
+ "solo": bool(t.get("solo", False)),
74
+ "arm": bool(t.get("arm", False)),
75
+ })
76
+
77
+ return BaselineTransportState(
78
+ is_playing=bool(info.get("is_playing", False)),
79
+ song_time=float(info.get("current_song_time", 0.0) or 0.0),
80
+ track_states=track_states,
81
+ captured_at_ms=int(time.time() * 1000),
82
+ )
83
+
84
+
85
+ def restore_baseline(
86
+ ableton,
87
+ baseline: BaselineTransportState,
88
+ stabilize_ms: int = 300,
89
+ ) -> None:
90
+ """Restore transport + per-track state to the captured baseline.
91
+
92
+ Sequence:
93
+ 1. ``stop_playback`` (halt transport — also stops any live clips)
94
+ 2. For each track: ``set_track_mute`` / ``set_track_solo`` /
95
+ ``set_track_arm`` (best-effort; per-track failure is logged,
96
+ not fatal — a single flaky track should never abort restore
97
+ for the rest).
98
+ 3. Sleep ``stabilize_ms`` milliseconds so meters settle before the
99
+ next ``before_snapshot`` reads them. Pass ``0`` in tests.
100
+
101
+ We deliberately do NOT seek the transport to ``baseline.song_time``.
102
+ Returning from stopped transport is enough — re-seeking a stopped
103
+ transport is equivalent to not moving, and on a playing transport it
104
+ introduces its own stutter artefact. If a future branch needs timeline
105
+ position consistency, add a ``jump_to_time`` call here.
106
+
107
+ Return-track arms are skipped — ``set_track_arm`` on a negative index
108
+ raises (return tracks aren't armable in Live).
109
+ """
110
+ try:
111
+ ableton.send_command("stop_playback")
112
+ except Exception as exc:
113
+ logger.debug("restore_baseline stop_playback failed: %s", exc)
114
+
115
+ for ts in baseline.track_states:
116
+ idx = ts.get("index", -1)
117
+ try:
118
+ ableton.send_command("set_track_mute", {
119
+ "track_index": idx, "mute": bool(ts.get("mute", False)),
120
+ })
121
+ except Exception as exc:
122
+ logger.debug("restore_baseline set_track_mute(%s) failed: %s", idx, exc)
123
+ try:
124
+ ableton.send_command("set_track_solo", {
125
+ "track_index": idx, "solo": bool(ts.get("solo", False)),
126
+ })
127
+ except Exception as exc:
128
+ logger.debug("restore_baseline set_track_solo(%s) failed: %s", idx, exc)
129
+ if idx >= 0:
130
+ try:
131
+ ableton.send_command("set_track_arm", {
132
+ "track_index": idx, "arm": bool(ts.get("arm", False)),
133
+ })
134
+ except Exception as exc:
135
+ logger.debug("restore_baseline set_track_arm(%s) failed: %s", idx, exc)
136
+
137
+ if stabilize_ms > 0:
138
+ time.sleep(stabilize_ms / 1000.0)
@@ -445,3 +445,23 @@ def discard_experiment(experiment_id: str) -> dict:
445
445
  exp.status = "discarded"
446
446
 
447
447
  return {"discarded": True, "experiment_id": experiment_id}
448
+
449
+
450
+ # ── v1.19 Item A — between-branch baseline restore ───────────────────────────
451
+
452
+
453
+ def prepare_for_next_branch(ableton, baseline, stabilize_ms: int = 300) -> None:
454
+ """Restore baseline transport state before capturing the next branch.
455
+
456
+ Called by ``run_experiment`` between branches so each branch's
457
+ ``before_snapshot`` reads from identical starting conditions. No-op
458
+ when ``baseline`` is None (first branch — the baseline was just
459
+ captured, no drift to correct).
460
+
461
+ Thin wrapper around ``baseline.restore_baseline``; exists so the
462
+ MCP tool body stays small and the wiring is testable in isolation.
463
+ """
464
+ if baseline is None:
465
+ return
466
+ from .baseline import restore_baseline
467
+ restore_baseline(ableton, baseline, stabilize_ms=stabilize_ms)
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
19
19
  from typing import Any, Optional
20
20
 
21
21
  from ..branches import BranchSeed
22
+ from .baseline import BaselineTransportState
22
23
 
23
24
 
24
25
  @dataclass
@@ -249,6 +250,10 @@ class ExperimentSet:
249
250
  status: str = "open" # open | evaluated | committed | discarded
250
251
  winner_branch_id: Optional[str] = None
251
252
  created_at_ms: int = 0
253
+ # v1.19 Item A — transport state captured before the first branch runs
254
+ # and used to restore identical starting conditions between branches.
255
+ # See mcp_server.experiment.baseline for the snapshot / restore pair.
256
+ baseline_transport: Optional[BaselineTransportState] = None
252
257
 
253
258
  @property
254
259
  def branch_count(self) -> int:
@@ -290,7 +295,7 @@ class ExperimentSet:
290
295
  return _branch_rank_key(branch)
291
296
 
292
297
  def to_dict(self) -> dict:
293
- return {
298
+ d = {
294
299
  "experiment_id": self.experiment_id,
295
300
  "request_text": self.request_text,
296
301
  "status": self.status,
@@ -302,3 +307,6 @@ class ExperimentSet:
302
307
  for b in self.ranked_branches()
303
308
  ],
304
309
  }
310
+ if self.baseline_transport is not None:
311
+ d["baseline_transport"] = self.baseline_transport.to_dict()
312
+ return d
@@ -343,11 +343,33 @@ async def run_experiment(
343
343
  # Import compiler
344
344
  from ..semantic_moves import registry, compiler
345
345
 
346
+ # v1.19 Item A — capture baseline transport state BEFORE any branch runs.
347
+ # Each branch's before_snapshot is only comparable if it starts from the
348
+ # same reference state. Without this, live testing (v1.18.0 Test 8) showed
349
+ # 3 branches produce wildly inconsistent before_snapshot.track_meters[0].level
350
+ # values — clip stopped mid-experiment between branches.
351
+ if experiment.baseline_transport is None:
352
+ from .baseline import capture_baseline
353
+ try:
354
+ experiment.baseline_transport = capture_baseline(ableton)
355
+ except Exception as exc:
356
+ logger.debug("baseline capture failed: %s", exc)
357
+ experiment.baseline_transport = None
358
+
346
359
  results = []
360
+ pending_seen = 0
347
361
  for branch in experiment.branches:
348
362
  if branch.status != "pending":
349
363
  continue
350
364
 
365
+ # Between branches (not before the first), restore the baseline so
366
+ # the next before_snapshot reads from the same reference state.
367
+ if pending_seen > 0:
368
+ engine.prepare_for_next_branch(
369
+ ableton, experiment.baseline_transport, stabilize_ms=300,
370
+ )
371
+ pending_seen += 1
372
+
351
373
  # PR3: respect a pre-existing compiled_plan on the branch (freeform /
352
374
  # synthesis / composer producers bring their own). Only compile from
353
375
  # move_id when the branch arrived without a plan — which requires a
@@ -579,10 +601,21 @@ def compare_experiments(
579
601
  "evaluation": b.evaluation,
580
602
  }
581
603
 
604
+ # v1.19.1 #1 — surface baseline_transport for operator observability.
605
+ # Always present in the response (None when not captured) so clients
606
+ # can `result["baseline_transport"] is None` instead of checking for
607
+ # key presence first. Populated during run_experiment's first pass.
608
+ baseline_dict = (
609
+ experiment.baseline_transport.to_dict()
610
+ if experiment.baseline_transport is not None
611
+ else None
612
+ )
613
+
582
614
  return {
583
615
  "experiment_id": experiment_id,
584
616
  "request": experiment.request_text,
585
617
  "branch_count": experiment.branch_count,
618
+ "baseline_transport": baseline_dict,
586
619
  "ranking": [
587
620
  {
588
621
  "rank": i + 1,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.18.3",
3
+ "version": "1.19.1",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 428 tools, 53 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 429 tools, 53 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.18.3"
8
+ __version__ = "1.19.1"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.dreamrec/livepilot",
4
- "description": "428-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "429-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
5
5
  "repository": {
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.18.3",
9
+ "version": "1.19.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.18.3",
14
+ "version": "1.19.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }