livepilot 1.9.21 → 1.9.23

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 (110) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +47 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +47 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -14
  12. package/livepilot/commands/beat.md +68 -21
  13. package/livepilot/commands/evaluate.md +23 -13
  14. package/livepilot/commands/mix.md +35 -11
  15. package/livepilot/commands/perform.md +31 -19
  16. package/livepilot/commands/sounddesign.md +38 -17
  17. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  18. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  19. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  20. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  21. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  22. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  23. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  24. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  25. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  26. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  27. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  28. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  29. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  30. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  31. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  32. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  33. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  34. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  35. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  36. package/livepilot/skills/livepilot-release/SKILL.md +15 -15
  37. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  38. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  39. package/livepilot.mcpb +0 -0
  40. package/m4l_device/livepilot_bridge.js +1 -1
  41. package/manifest.json +91 -0
  42. package/mcp_server/__init__.py +1 -1
  43. package/mcp_server/creative_constraints/__init__.py +6 -0
  44. package/mcp_server/creative_constraints/engine.py +277 -0
  45. package/mcp_server/creative_constraints/models.py +75 -0
  46. package/mcp_server/creative_constraints/tools.py +341 -0
  47. package/mcp_server/experiment/__init__.py +6 -0
  48. package/mcp_server/experiment/engine.py +213 -0
  49. package/mcp_server/experiment/models.py +120 -0
  50. package/mcp_server/experiment/tools.py +263 -0
  51. package/mcp_server/hook_hunter/__init__.py +5 -0
  52. package/mcp_server/hook_hunter/analyzer.py +342 -0
  53. package/mcp_server/hook_hunter/models.py +57 -0
  54. package/mcp_server/hook_hunter/tools.py +586 -0
  55. package/mcp_server/memory/taste_graph.py +261 -0
  56. package/mcp_server/memory/tools.py +88 -0
  57. package/mcp_server/mix_engine/critics.py +2 -2
  58. package/mcp_server/mix_engine/models.py +1 -1
  59. package/mcp_server/mix_engine/state_builder.py +2 -2
  60. package/mcp_server/musical_intelligence/__init__.py +8 -0
  61. package/mcp_server/musical_intelligence/detectors.py +421 -0
  62. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  63. package/mcp_server/musical_intelligence/tools.py +221 -0
  64. package/mcp_server/preview_studio/__init__.py +5 -0
  65. package/mcp_server/preview_studio/engine.py +280 -0
  66. package/mcp_server/preview_studio/models.py +73 -0
  67. package/mcp_server/preview_studio/tools.py +423 -0
  68. package/mcp_server/runtime/session_kernel.py +96 -0
  69. package/mcp_server/runtime/tools.py +90 -1
  70. package/mcp_server/semantic_moves/__init__.py +13 -0
  71. package/mcp_server/semantic_moves/compiler.py +116 -0
  72. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  73. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  74. package/mcp_server/semantic_moves/models.py +46 -0
  75. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  76. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  77. package/mcp_server/semantic_moves/registry.py +32 -0
  78. package/mcp_server/semantic_moves/resolvers.py +126 -0
  79. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  80. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  81. package/mcp_server/semantic_moves/tools.py +204 -0
  82. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  83. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  84. package/mcp_server/server.py +10 -0
  85. package/mcp_server/session_continuity/__init__.py +6 -0
  86. package/mcp_server/session_continuity/models.py +86 -0
  87. package/mcp_server/session_continuity/tools.py +230 -0
  88. package/mcp_server/session_continuity/tracker.py +235 -0
  89. package/mcp_server/song_brain/__init__.py +6 -0
  90. package/mcp_server/song_brain/builder.py +477 -0
  91. package/mcp_server/song_brain/models.py +132 -0
  92. package/mcp_server/song_brain/tools.py +294 -0
  93. package/mcp_server/stuckness_detector/__init__.py +5 -0
  94. package/mcp_server/stuckness_detector/detector.py +400 -0
  95. package/mcp_server/stuckness_detector/models.py +66 -0
  96. package/mcp_server/stuckness_detector/tools.py +195 -0
  97. package/mcp_server/tools/_conductor.py +104 -6
  98. package/mcp_server/tools/analyzer.py +1 -1
  99. package/mcp_server/tools/devices.py +34 -0
  100. package/mcp_server/wonder_mode/__init__.py +6 -0
  101. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  102. package/mcp_server/wonder_mode/engine.py +493 -0
  103. package/mcp_server/wonder_mode/session.py +114 -0
  104. package/mcp_server/wonder_mode/tools.py +285 -0
  105. package/package.json +2 -2
  106. package/remote_script/LivePilot/__init__.py +1 -1
  107. package/remote_script/LivePilot/browser.py +4 -1
  108. package/remote_script/LivePilot/devices.py +29 -0
  109. package/remote_script/LivePilot/tracks.py +11 -4
  110. package/scripts/generate_tool_catalog.py +131 -0
@@ -0,0 +1,75 @@
1
+ """Creative Constraints data models — pure dataclasses, zero I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Optional
7
+
8
+
9
+ CONSTRAINT_MODES = [
10
+ "use_loaded_devices_only",
11
+ "no_new_tracks",
12
+ "subtraction_only",
13
+ "arrangement_only",
14
+ "mood_shift_without_new_fx",
15
+ "make_it_stranger_but_keep_the_hook",
16
+ "club_translation_safe",
17
+ "performance_safe_creative",
18
+ ]
19
+
20
+
21
+ @dataclass
22
+ class ConstraintSet:
23
+ """A set of creative constraints for planning."""
24
+
25
+ constraints: list[str] = field(default_factory=list)
26
+ description: str = ""
27
+ reason: str = "" # why these constraints help
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+
33
+ @dataclass
34
+ class ReferencePrinciple:
35
+ """A distilled principle from a reference track."""
36
+
37
+ domain: str = "" # "arrangement", "texture", "density", "width", "payoff", "emotional"
38
+ principle: str = "" # human-readable principle
39
+ value: float = 0.0 # quantified where possible
40
+ applicability: float = 0.5 # 0-1 how applicable to current song
41
+ note: str = ""
42
+
43
+ def to_dict(self) -> dict:
44
+ return asdict(self)
45
+
46
+
47
+ @dataclass
48
+ class ReferenceDistillation:
49
+ """Distilled principles from a reference, ready to apply."""
50
+
51
+ reference_id: str = ""
52
+ reference_description: str = ""
53
+ principles: list[ReferencePrinciple] = field(default_factory=list)
54
+ emotional_posture: str = ""
55
+ density_motion: str = ""
56
+ arrangement_patience: str = ""
57
+ texture_treatment: str = ""
58
+ foreground_background: str = ""
59
+ width_strategy: str = ""
60
+ payoff_architecture: str = ""
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "reference_id": self.reference_id,
65
+ "reference_description": self.reference_description,
66
+ "principles": [p.to_dict() for p in self.principles],
67
+ "emotional_posture": self.emotional_posture,
68
+ "density_motion": self.density_motion,
69
+ "arrangement_patience": self.arrangement_patience,
70
+ "texture_treatment": self.texture_treatment,
71
+ "foreground_background": self.foreground_background,
72
+ "width_strategy": self.width_strategy,
73
+ "payoff_architecture": self.payoff_architecture,
74
+ "principle_count": len(self.principles),
75
+ }
@@ -0,0 +1,341 @@
1
+ """Creative Constraints MCP tools — 5 tools for constrained creativity
2
+ and reference distillation.
3
+
4
+ apply_creative_constraint_set — activate creative constraints
5
+ distill_reference_principles — learn principles from a reference
6
+ map_reference_principles_to_song — translate reference into current song
7
+ generate_constrained_variants — generate triptych variants under constraints
8
+ generate_reference_inspired_variants — variants from reference principles
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from fastmcp import Context
16
+
17
+ from ..server import mcp
18
+ from . import engine
19
+ from .models import CONSTRAINT_MODES
20
+
21
+
22
+ # Module-level cache for active constraints and distillations
23
+ _active_constraints: Optional[engine.ConstraintSet] = None
24
+ _cached_distillation: Optional[engine.ReferenceDistillation] = None
25
+
26
+
27
+ @mcp.tool()
28
+ def apply_creative_constraint_set(
29
+ ctx: Context,
30
+ constraints: list[str] | None = None,
31
+ ) -> dict:
32
+ """Apply creative constraints to focus suggestions.
33
+
34
+ Constraints modify planning and ranking, not just validation.
35
+ When stuck, try adding constraints instead of more unconstrained advice.
36
+
37
+ Available constraints:
38
+ - use_loaded_devices_only — only use what's already loaded
39
+ - no_new_tracks — work within existing tracks
40
+ - subtraction_only — only remove/reduce, no additions
41
+ - arrangement_only — only structural changes
42
+ - mood_shift_without_new_fx — shift mood with existing tools
43
+ - make_it_stranger_but_keep_the_hook — push novelty safely
44
+ - club_translation_safe — keep changes club/DJ-friendly
45
+ - performance_safe_creative — only live-safe changes
46
+
47
+ constraints: list of constraint names to activate
48
+ """
49
+ global _active_constraints
50
+
51
+ if not constraints:
52
+ return {
53
+ "error": "No constraints provided",
54
+ "available": CONSTRAINT_MODES,
55
+ }
56
+
57
+ cs = engine.build_constraint_set(constraints)
58
+ _active_constraints = cs
59
+
60
+ invalid = [c for c in constraints if c not in CONSTRAINT_MODES]
61
+ result = {
62
+ "active_constraints": cs.constraints,
63
+ "description": cs.description,
64
+ "reason": cs.reason,
65
+ }
66
+ if invalid:
67
+ result["invalid_constraints"] = invalid
68
+ result["available"] = CONSTRAINT_MODES
69
+
70
+ return result
71
+
72
+
73
+ @mcp.tool()
74
+ def distill_reference_principles(
75
+ ctx: Context,
76
+ reference_description: str = "",
77
+ style_name: str = "",
78
+ ) -> dict:
79
+ """Learn musical principles from a reference — not surface traits.
80
+
81
+ Extracts: emotional posture, density motion, arrangement patience,
82
+ texture treatment, width strategy, and payoff architecture.
83
+
84
+ Never outputs a plan that copies surface traits directly.
85
+ Always translates through the current song's identity.
86
+
87
+ reference_description: text description of the reference
88
+ style_name: optional style/genre name for style-based references
89
+ """
90
+ global _cached_distillation
91
+
92
+ if not reference_description.strip() and not style_name.strip():
93
+ return {"error": "Provide reference_description or style_name"}
94
+
95
+ # Build a reference profile from available data
96
+ reference_profile: dict = {}
97
+
98
+ # Try to get style tactics if style_name is provided
99
+ if style_name:
100
+ try:
101
+ from ..tools._research_engine import get_style_tactics
102
+ tactics = get_style_tactics(style_name)
103
+ if tactics:
104
+ reference_profile = {
105
+ "emotional_stance": tactics.get("emotional_stance", ""),
106
+ "density_arc": tactics.get("density_arc", []),
107
+ "section_pacing": tactics.get("section_pacing", []),
108
+ "width_depth": tactics.get("width_depth", {}),
109
+ "spectral_contour": tactics.get("spectral_contour", {}),
110
+ "groove_posture": tactics.get("groove_posture", {}),
111
+ "harmonic_character": tactics.get("harmonic_character", ""),
112
+ }
113
+ except Exception:
114
+ pass
115
+
116
+ # Try to get a reference profile from the reference engine
117
+ if not reference_profile:
118
+ try:
119
+ from ..reference_engine.profile_builder import build_style_reference_profile
120
+ profile = build_style_reference_profile(
121
+ style_name or reference_description
122
+ )
123
+ reference_profile = profile.to_dict()
124
+ except Exception:
125
+ # Fallback: build from description keywords
126
+ reference_profile = _profile_from_description(reference_description)
127
+
128
+ distillation = engine.distill_reference_principles(
129
+ reference_profile=reference_profile,
130
+ reference_description=reference_description or style_name,
131
+ )
132
+ _cached_distillation = distillation
133
+
134
+ return distillation.to_dict()
135
+
136
+
137
+ @mcp.tool()
138
+ def map_reference_principles_to_song(
139
+ ctx: Context,
140
+ ) -> dict:
141
+ """Map distilled reference principles to the current song.
142
+
143
+ Must call distill_reference_principles first. Translates each
144
+ principle through the song's identity, loaded tools, and hook.
145
+
146
+ Returns actionable mappings — how to apply each principle
147
+ while preserving the song's own character.
148
+ """
149
+ if _cached_distillation is None:
150
+ return {"error": "No reference distilled yet — call distill_reference_principles first"}
151
+
152
+ song_brain = _get_song_brain_dict()
153
+
154
+ mappings = engine.map_principles_to_song(song_brain, _cached_distillation)
155
+
156
+ return {
157
+ "reference": _cached_distillation.reference_description,
158
+ "mappings": mappings,
159
+ "mapping_count": len(mappings),
160
+ "note": "Principles are adapted to your song — not copied from the reference",
161
+ }
162
+
163
+
164
+ @mcp.tool()
165
+ def generate_constrained_variants(
166
+ ctx: Context,
167
+ request_text: str,
168
+ constraints: list[str] | None = None,
169
+ kernel_id: str = "",
170
+ ) -> dict:
171
+ """Generate creative variants under active constraints.
172
+
173
+ Combines constraint filtering with the Preview Studio's triptych.
174
+ Each variant respects the constraint set — e.g., "subtraction_only"
175
+ means no variant adds new elements.
176
+
177
+ request_text: what the user wants
178
+ constraints: list of constraint names to apply (or uses currently active)
179
+ kernel_id: optional session kernel reference
180
+ """
181
+ if not request_text.strip():
182
+ return {"error": "request_text cannot be empty"}
183
+
184
+ # Apply constraints
185
+ active = _active_constraints
186
+ if constraints:
187
+ active = engine.build_constraint_set(constraints)
188
+
189
+ if not active or not active.constraints:
190
+ return {
191
+ "error": "No constraints active — call apply_creative_constraint_set first or provide constraints",
192
+ "available": CONSTRAINT_MODES,
193
+ }
194
+
195
+ # Generate variants via preview studio
196
+ try:
197
+ from ..preview_studio import engine as ps_engine
198
+ song_brain = _get_song_brain_dict()
199
+ taste_graph = {}
200
+ try:
201
+ from ..memory.taste_graph import build_taste_graph
202
+ from ..memory.taste_memory import TasteMemoryStore
203
+ from ..memory.anti_memory import AntiMemoryStore
204
+ taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
205
+ anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
206
+ taste_graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
207
+ except Exception:
208
+ pass
209
+
210
+ ps = ps_engine.create_preview_set(
211
+ request_text=f"[Constrained: {', '.join(active.constraints)}] {request_text}",
212
+ kernel_id=kernel_id,
213
+ strategy="creative_triptych",
214
+ song_brain=song_brain,
215
+ taste_graph=taste_graph,
216
+ )
217
+
218
+ # Validate each variant's compiled_plan against constraints
219
+ for v in ps.variants:
220
+ v.what_preserved = f"{v.what_preserved} | Constraints: {', '.join(active.constraints)}"
221
+ if v.compiled_plan:
222
+ plan = {"steps": [
223
+ {"action": step.get("tool", ""), **step}
224
+ for step in v.compiled_plan
225
+ ]}
226
+ validation = engine.validate_plan_against_constraints(plan, active)
227
+ if not validation["valid"]:
228
+ v.compiled_plan = None
229
+ v.what_changed = f"[FILTERED] {v.what_changed} — violates {', '.join(active.constraints)}"
230
+
231
+ return {
232
+ "preview_set": ps.to_dict(),
233
+ "constraints_applied": active.constraints,
234
+ "note": "Variants with violating plans have been filtered",
235
+ }
236
+ except Exception as e:
237
+ return {"error": f"Failed to generate constrained variants: {e}"}
238
+
239
+
240
+ @mcp.tool()
241
+ def generate_reference_inspired_variants(
242
+ ctx: Context,
243
+ request_text: str = "",
244
+ kernel_id: str = "",
245
+ ) -> dict:
246
+ """Generate creative variants inspired by a distilled reference.
247
+
248
+ Requires a prior call to distill_reference_principles.
249
+ Uses the distilled principles (not surface traits) to shape
250
+ each variant through the current song's identity.
251
+
252
+ request_text: optional extra context for what the user wants
253
+ kernel_id: optional session kernel reference
254
+ """
255
+ if _cached_distillation is None:
256
+ return {"error": "No reference distilled yet — call distill_reference_principles first"}
257
+
258
+ # Build request text from reference principles
259
+ principles_text = ", ".join(
260
+ p.principle for p in _cached_distillation.principles[:3]
261
+ )
262
+ full_request = (
263
+ f"Inspired by: {_cached_distillation.reference_description}. "
264
+ f"Key principles: {principles_text}. "
265
+ f"{request_text}"
266
+ ).strip()
267
+
268
+ # Generate variants via preview studio
269
+ try:
270
+ from ..preview_studio import engine as ps_engine
271
+ song_brain = _get_song_brain_dict()
272
+
273
+ ps = ps_engine.create_preview_set(
274
+ request_text=full_request,
275
+ kernel_id=kernel_id,
276
+ strategy="creative_triptych",
277
+ song_brain=song_brain,
278
+ )
279
+
280
+ # Annotate variants with reference info
281
+ for v in ps.variants:
282
+ v.why_it_matters = (
283
+ f"Reference-inspired: {_cached_distillation.reference_description}. "
284
+ f"{v.why_it_matters}"
285
+ )
286
+
287
+ return {
288
+ "preview_set": ps.to_dict(),
289
+ "reference": _cached_distillation.reference_description,
290
+ "principles_applied": [p.to_dict() for p in _cached_distillation.principles[:5]],
291
+ "note": "Variants are shaped by reference principles, not surface imitation",
292
+ }
293
+ except Exception as e:
294
+ return {"error": f"Failed to generate reference-inspired variants: {e}"}
295
+
296
+
297
+ # ── Helpers ───────────────────────────────────────────────────────
298
+
299
+
300
+ def _get_song_brain_dict() -> dict:
301
+ try:
302
+ from ..song_brain.tools import _current_brain
303
+ if _current_brain is not None:
304
+ return _current_brain.to_dict()
305
+ except Exception as _e:
306
+ if __debug__:
307
+ import sys
308
+ print(f"LivePilot: SongBrain unavailable in creative_constraints: {_e}", file=sys.stderr)
309
+ return {}
310
+
311
+
312
+ def _profile_from_description(description: str) -> dict:
313
+ """Build a rough reference profile from text description."""
314
+ desc_lower = description.lower()
315
+
316
+ emotional_map = {
317
+ "dark": "tense",
318
+ "bright": "euphoric",
319
+ "sad": "melancholic",
320
+ "aggressive": "aggressive",
321
+ "dreamy": "dreamy",
322
+ "chill": "relaxed",
323
+ "intense": "aggressive",
324
+ "minimal": "restrained",
325
+ }
326
+
327
+ emotional = ""
328
+ for keyword, stance in emotional_map.items():
329
+ if keyword in desc_lower:
330
+ emotional = stance
331
+ break
332
+
333
+ return {
334
+ "emotional_stance": emotional,
335
+ "density_arc": [],
336
+ "section_pacing": [],
337
+ "width_depth": {},
338
+ "spectral_contour": {},
339
+ "groove_posture": {},
340
+ "harmonic_character": "",
341
+ }
@@ -0,0 +1,6 @@
1
+ """Experiment engine — branch-based creative search.
2
+
3
+ Experiments let the system try multiple approaches to a musical problem,
4
+ evaluate each one, and present ranked winners. Branches are virtual —
5
+ they use Ableton's undo system to revert between trials.
6
+ """
@@ -0,0 +1,213 @@
1
+ """Experiment engine — runs branches sequentially using Ableton's undo system.
2
+
3
+ The engine manages the lifecycle: create branches from semantic moves,
4
+ run each one (apply → capture → undo), evaluate, rank, and commit the winner.
5
+
6
+ Critical constraint: Ableton has linear undo. Experiments MUST run sequentially:
7
+ 1. Capture before state
8
+ 2. Apply semantic move (compiled plan)
9
+ 3. Capture after state
10
+ 4. Undo all changes back to the checkpoint
11
+ 5. Repeat for next branch
12
+ 6. When winner is chosen, re-apply that branch's moves permanently
13
+
14
+ All I/O happens through the AbletonConnection passed to run methods.
15
+ The engine itself is pure orchestration logic.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import time
22
+ from typing import Optional
23
+
24
+ from .models import ExperimentSet, ExperimentBranch, BranchSnapshot
25
+
26
+
27
+ # ── In-memory experiment store ───────────────────────────────────────────────
28
+
29
+ _EXPERIMENTS: dict[str, ExperimentSet] = {}
30
+
31
+
32
+ def _gen_id(prefix: str, seed: str) -> str:
33
+ """Generate a short deterministic ID."""
34
+ h = hashlib.sha256(f"{prefix}:{seed}:{time.time()}".encode()).hexdigest()[:8]
35
+ return f"{prefix}_{h}"
36
+
37
+
38
+ # ── Create experiments ───────────────────────────────────────────────────────
39
+
40
+ def create_experiment(
41
+ request_text: str,
42
+ move_ids: list[str],
43
+ kernel_id: str = "",
44
+ ) -> ExperimentSet:
45
+ """Create an experiment set with branches for each semantic move.
46
+
47
+ Does NOT execute anything — just creates the branch structures.
48
+ Call run_experiment() to actually trial each branch.
49
+ """
50
+ exp_id = _gen_id("exp", request_text)
51
+ now = int(time.time() * 1000)
52
+
53
+ branches = []
54
+ for i, move_id in enumerate(move_ids):
55
+ branch = ExperimentBranch(
56
+ branch_id=_gen_id("br", f"{move_id}_{i}"),
57
+ name=f"Branch {i+1}: {move_id}",
58
+ move_id=move_id,
59
+ source_kernel_id=kernel_id,
60
+ status="pending",
61
+ created_at_ms=now,
62
+ )
63
+ branches.append(branch)
64
+
65
+ experiment = ExperimentSet(
66
+ experiment_id=exp_id,
67
+ request_text=request_text,
68
+ branches=branches,
69
+ status="open",
70
+ created_at_ms=now,
71
+ )
72
+
73
+ _EXPERIMENTS[exp_id] = experiment
74
+ return experiment
75
+
76
+
77
+ def get_experiment(experiment_id: str) -> Optional[ExperimentSet]:
78
+ """Get an experiment by ID."""
79
+ return _EXPERIMENTS.get(experiment_id)
80
+
81
+
82
+ def list_experiments() -> list[dict]:
83
+ """List all experiment sets."""
84
+ return [exp.to_dict() for exp in _EXPERIMENTS.values()]
85
+
86
+
87
+ # ── Run experiments (requires Ableton connection) ────────────────────────────
88
+
89
+ def run_branch(
90
+ branch: ExperimentBranch,
91
+ ableton, # AbletonConnection
92
+ compiled_plan: dict,
93
+ capture_fn, # function() -> BranchSnapshot
94
+ ) -> ExperimentBranch:
95
+ """Run a single branch experiment.
96
+
97
+ 1. Capture before state
98
+ 2. Execute compiled plan steps
99
+ 3. Capture after state
100
+ 4. Undo all changes
101
+
102
+ The branch is updated in-place with snapshots and status.
103
+ """
104
+ branch.status = "running"
105
+ branch.compiled_plan = compiled_plan
106
+
107
+ # 1. Capture before
108
+ branch.before_snapshot = capture_fn()
109
+
110
+ # 2. Execute plan steps
111
+ steps_executed = 0
112
+ for step in compiled_plan.get("steps", []):
113
+ tool = step.get("tool", "")
114
+ params = step.get("params", {})
115
+ if not tool:
116
+ continue
117
+ # Skip read-only verification steps
118
+ if tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
119
+ continue
120
+ try:
121
+ ableton.send_command(tool, params)
122
+ steps_executed += 1
123
+ except Exception:
124
+ pass # Best effort — continue with remaining steps
125
+
126
+ branch.executed_at_ms = int(time.time() * 1000)
127
+
128
+ # 3. Capture after
129
+ branch.after_snapshot = capture_fn()
130
+
131
+ # 4. Undo all changes back to checkpoint
132
+ for _ in range(steps_executed):
133
+ try:
134
+ ableton.send_command("undo", {})
135
+ except Exception:
136
+ break
137
+
138
+ branch.status = "evaluated"
139
+ return branch
140
+
141
+
142
+ def evaluate_branch(
143
+ branch: ExperimentBranch,
144
+ evaluate_fn, # function(before, after) -> dict with "score", "keep_change"
145
+ ) -> ExperimentBranch:
146
+ """Score a branch using the evaluation fabric."""
147
+ if not branch.before_snapshot or not branch.after_snapshot:
148
+ branch.evaluation = {"error": "Missing snapshots"}
149
+ branch.score = 0.0
150
+ return branch
151
+
152
+ result = evaluate_fn(
153
+ branch.before_snapshot.to_dict(),
154
+ branch.after_snapshot.to_dict(),
155
+ )
156
+ branch.evaluation = result
157
+ branch.score = result.get("score", 0.0)
158
+ return branch
159
+
160
+
161
+ # ── Commit / discard ─────────────────────────────────────────────────────────
162
+
163
+ def commit_branch(
164
+ experiment: ExperimentSet,
165
+ branch_id: str,
166
+ ableton,
167
+ ) -> dict:
168
+ """Re-apply the winning branch's moves permanently."""
169
+ branch = experiment.get_branch(branch_id)
170
+ if not branch:
171
+ return {"error": f"Branch {branch_id} not found"}
172
+
173
+ if not branch.compiled_plan:
174
+ return {"error": "Branch has no compiled plan"}
175
+
176
+ # Re-execute the plan (this time without undoing)
177
+ executed = []
178
+ for step in branch.compiled_plan.get("steps", []):
179
+ tool = step.get("tool", "")
180
+ params = step.get("params", {})
181
+ if not tool or tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
182
+ continue
183
+ try:
184
+ result = ableton.send_command(tool, params)
185
+ executed.append({"tool": tool, "ok": True})
186
+ except Exception as exc:
187
+ executed.append({"tool": tool, "ok": False, "error": str(exc)})
188
+
189
+ branch.status = "committed"
190
+ experiment.winner_branch_id = branch_id
191
+ experiment.status = "committed"
192
+
193
+ return {
194
+ "committed": True,
195
+ "branch_id": branch_id,
196
+ "branch_name": branch.name,
197
+ "steps_executed": len(executed),
198
+ "score": branch.score,
199
+ }
200
+
201
+
202
+ def discard_experiment(experiment_id: str) -> dict:
203
+ """Discard an entire experiment set."""
204
+ exp = _EXPERIMENTS.get(experiment_id)
205
+ if not exp:
206
+ return {"error": f"Experiment {experiment_id} not found"}
207
+
208
+ for branch in exp.branches:
209
+ if branch.status not in ("committed", "discarded"):
210
+ branch.status = "discarded"
211
+ exp.status = "discarded"
212
+
213
+ return {"discarded": True, "experiment_id": experiment_id}