livepilot 1.10.9 → 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 (61) hide show
  1. package/CHANGELOG.md +327 -0
  2. package/README.md +7 -7
  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/m4l_bridge.py +488 -13
  15. package/mcp_server/memory/taste_graph.py +84 -11
  16. package/mcp_server/persistence/taste_store.py +21 -5
  17. package/mcp_server/runtime/execution_router.py +7 -0
  18. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  19. package/mcp_server/runtime/remote_commands.py +54 -0
  20. package/mcp_server/runtime/session_kernel.py +46 -0
  21. package/mcp_server/runtime/tools.py +29 -3
  22. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  23. package/mcp_server/server.py +11 -3
  24. package/mcp_server/synthesis_brain/__init__.py +53 -0
  25. package/mcp_server/synthesis_brain/adapters/__init__.py +34 -0
  26. package/mcp_server/synthesis_brain/adapters/analog.py +167 -0
  27. package/mcp_server/synthesis_brain/adapters/base.py +86 -0
  28. package/mcp_server/synthesis_brain/adapters/drift.py +166 -0
  29. package/mcp_server/synthesis_brain/adapters/meld.py +151 -0
  30. package/mcp_server/synthesis_brain/adapters/operator.py +169 -0
  31. package/mcp_server/synthesis_brain/adapters/wavetable.py +228 -0
  32. package/mcp_server/synthesis_brain/engine.py +91 -0
  33. package/mcp_server/synthesis_brain/models.py +121 -0
  34. package/mcp_server/synthesis_brain/timbre.py +194 -0
  35. package/mcp_server/tools/_conductor.py +144 -0
  36. package/mcp_server/tools/analyzer.py +187 -7
  37. package/mcp_server/tools/clips.py +65 -0
  38. package/mcp_server/tools/devices.py +517 -5
  39. package/mcp_server/tools/diagnostics.py +42 -0
  40. package/mcp_server/tools/follow_actions.py +202 -0
  41. package/mcp_server/tools/grooves.py +142 -0
  42. package/mcp_server/tools/miditool.py +280 -0
  43. package/mcp_server/tools/scales.py +126 -0
  44. package/mcp_server/tools/take_lanes.py +135 -0
  45. package/mcp_server/tools/tracks.py +46 -3
  46. package/mcp_server/tools/transport.py +62 -1
  47. package/mcp_server/wonder_mode/engine.py +324 -0
  48. package/mcp_server/wonder_mode/tools.py +153 -1
  49. package/package.json +2 -2
  50. package/remote_script/LivePilot/__init__.py +8 -4
  51. package/remote_script/LivePilot/clips.py +62 -0
  52. package/remote_script/LivePilot/devices.py +444 -0
  53. package/remote_script/LivePilot/diagnostics.py +52 -1
  54. package/remote_script/LivePilot/follow_actions.py +235 -0
  55. package/remote_script/LivePilot/grooves.py +185 -0
  56. package/remote_script/LivePilot/scales.py +138 -0
  57. package/remote_script/LivePilot/take_lanes.py +175 -0
  58. package/remote_script/LivePilot/tracks.py +59 -1
  59. package/remote_script/LivePilot/transport.py +90 -1
  60. package/remote_script/LivePilot/version_detect.py +9 -0
  61. package/server.json +3 -3
@@ -16,6 +16,7 @@ from typing import Optional
16
16
  from fastmcp import Context
17
17
 
18
18
  from ..server import mcp
19
+ from ..branches import BranchSeed
19
20
  from . import engine
20
21
  from .models import BranchSnapshot
21
22
  import logging
@@ -63,18 +64,71 @@ def create_experiment(
63
64
  request_text: str,
64
65
  move_ids: Optional[list] = None,
65
66
  limit: int = 3,
67
+ seeds: Optional[list] = None,
68
+ compiled_plans: Optional[list] = None,
66
69
  ) -> dict:
67
70
  """Create an experiment set to compare multiple approaches.
68
71
 
69
- If move_ids is provided, creates one branch per move.
70
- Otherwise, uses propose_next_best_move to find candidates.
72
+ Three input modes (in priority order):
71
73
 
72
- request_text: what the user wants (e.g., "make this punchier")
73
- move_ids: specific moves to try (e.g., ["make_punchier", "tighten_low_end"])
74
- limit: max branches when auto-proposing (default 3)
74
+ 1. seeds (PR3+): a list of BranchSeed dicts. Each seed becomes one branch.
75
+ compiled_plans (optional parallel list) attaches pre-compiled plans
76
+ for freeform / synthesis / composer producers. Seed dict shape:
77
+ {seed_id, source, move_id, hypothesis, protected_qualities,
78
+ affected_scope, distinctness_reason, risk_label, novelty_label,
79
+ analytical_only}
80
+ Missing fields default per BranchSeed. This is the canonical path
81
+ for producers that have already done their own selection work.
82
+
83
+ 2. move_ids: legacy path — one semantic_move seed per move_id.
84
+ Unchanged behavior; internally delegates to the seeds path.
85
+
86
+ 3. Auto-proposal: neither seeds nor move_ids provided. Scans the semantic
87
+ move registry by keyword overlap with request_text and takes the top
88
+ ``limit`` moves (default 3).
75
89
 
76
90
  Returns: experiment set with branch IDs ready for run_experiment.
77
91
  """
92
+ # ── Mode 1: seeds provided ──────────────────────────────────────────
93
+ if seeds:
94
+ rehydrated: list[BranchSeed] = []
95
+ for i, s in enumerate(seeds):
96
+ if isinstance(s, BranchSeed):
97
+ rehydrated.append(s)
98
+ elif isinstance(s, dict):
99
+ try:
100
+ rehydrated.append(BranchSeed(**s))
101
+ except TypeError as exc:
102
+ return {"error": f"seeds[{i}] invalid: {exc}"}
103
+ else:
104
+ return {
105
+ "error": (
106
+ f"seeds[{i}] must be dict or BranchSeed, "
107
+ f"got {type(s).__name__}"
108
+ )
109
+ }
110
+
111
+ if compiled_plans is not None and len(compiled_plans) != len(rehydrated):
112
+ return {
113
+ "error": (
114
+ f"compiled_plans length ({len(compiled_plans)}) must match "
115
+ f"seeds length ({len(rehydrated)})"
116
+ )
117
+ }
118
+
119
+ ableton = _get_ableton(ctx)
120
+ ableton.send_command("get_session_info")
121
+ kernel_id = f"kern_{int(time.time())}"
122
+
123
+ experiment = engine.create_experiment_from_seeds(
124
+ request_text=request_text,
125
+ seeds=rehydrated,
126
+ kernel_id=kernel_id,
127
+ compiled_plans=compiled_plans,
128
+ )
129
+ return experiment.to_dict()
130
+
131
+ # ── Mode 2/3: legacy move_ids path ──────────────────────────────────
78
132
  if not move_ids:
79
133
  # Auto-propose moves from the registry
80
134
  from ..semantic_moves import registry
@@ -119,19 +173,30 @@ def create_experiment(
119
173
  async def run_experiment(
120
174
  ctx: Context,
121
175
  experiment_id: str,
176
+ exploration_rules: bool = False,
122
177
  ) -> dict:
123
178
  """Run all pending branches in an experiment.
124
179
 
125
180
  For each branch:
126
181
  1. Compile the semantic move against current session
182
+ (skipped when branch.compiled_plan is already set — PR3+)
127
183
  2. Capture before state
128
- 3. Execute the compiled plan (through the async router — v1.10.3 truth)
184
+ 3. Execute the compiled plan (through the async router)
129
185
  4. Capture after state
130
186
  5. Undo all successful steps (revert to checkpoint)
131
- 6. Evaluate the branch
187
+ 6. Evaluate the branch and classify its outcome via evaluation.policy
132
188
  7. Record per-step results on branch.execution_log
133
189
 
134
190
  Branches run sequentially (Ableton has linear undo).
191
+
192
+ exploration_rules (PR7): when True, branches that fail technical gates
193
+ (score < 0.40, non-positive measurable delta) are classified as
194
+ "interesting_but_failed" instead of "failed" — they stay in the
195
+ experiment for audit but don't appear in the ranking. Protection
196
+ violations STILL force undo regardless of this flag — that's a safety
197
+ invariant, not a taste judgment.
198
+
199
+ Default False preserves pre-PR7 behavior exactly.
135
200
  """
136
201
  experiment = engine.get_experiment(experiment_id)
137
202
  if not experiment:
@@ -149,19 +214,54 @@ async def run_experiment(
149
214
  if branch.status != "pending":
150
215
  continue
151
216
 
152
- # Compile the move
153
- move = registry.get_move(branch.move_id)
154
- if not move:
155
- branch.status = "failed"
156
- branch.score = 0.0
157
- branch.evaluation = {"error": f"Move {branch.move_id} not found"}
158
- results.append(branch.to_dict())
159
- continue
160
-
161
- session_info = ableton.send_command("get_session_info")
162
- kernel = {"session_info": session_info, "mode": "explore"}
163
- plan = compiler.compile(move, kernel)
164
- compiled_dict = plan.to_dict()
217
+ # PR3: respect a pre-existing compiled_plan on the branch (freeform /
218
+ # synthesis / composer producers bring their own). Only compile from
219
+ # move_id when the branch arrived without a plan — which requires a
220
+ # semantic_move seed (or a legacy move-only branch).
221
+ compiled_dict = branch.compiled_plan
222
+
223
+ if compiled_dict is None:
224
+ # Analytical-only branches short-circuit — no plan to run.
225
+ # Marked with status="analytical" so ranked_branches()
226
+ # (which only surfaces "evaluated") excludes them, and
227
+ # commit_experiment refuses to re-apply them.
228
+ if branch.seed is not None and branch.seed.analytical_only:
229
+ branch.status = "analytical"
230
+ branch.score = 0.0
231
+ branch.evaluation = {
232
+ "score": 0.0,
233
+ "keep_change": False,
234
+ "status": "analytical",
235
+ "note": "analytical_only branch — no execution path",
236
+ }
237
+ results.append(branch.to_dict())
238
+ continue
239
+
240
+ if not branch.move_id:
241
+ branch.status = "failed"
242
+ branch.score = 0.0
243
+ branch.evaluation = {
244
+ "error": (
245
+ "Branch has no compiled_plan and no move_id — "
246
+ "freeform producers must pre-populate compiled_plan"
247
+ )
248
+ }
249
+ results.append(branch.to_dict())
250
+ continue
251
+
252
+ # Compile from semantic move
253
+ move = registry.get_move(branch.move_id)
254
+ if not move:
255
+ branch.status = "failed"
256
+ branch.score = 0.0
257
+ branch.evaluation = {"error": f"Move {branch.move_id} not found"}
258
+ results.append(branch.to_dict())
259
+ continue
260
+
261
+ session_info = ableton.send_command("get_session_info")
262
+ kernel = {"session_info": session_info, "mode": "explore"}
263
+ plan = compiler.compile(move, kernel)
264
+ compiled_dict = plan.to_dict()
165
265
 
166
266
  # Run the branch through the async router
167
267
  await engine.run_branch_async(
@@ -174,26 +274,78 @@ async def run_experiment(
174
274
  ctx=ctx,
175
275
  )
176
276
 
177
- # Evaluate
277
+ # Evaluate — score via the inline heuristic, then classify via
278
+ # evaluation.policy for a unified keep/undo/interesting_but_failed
279
+ # decision (PR7).
280
+ from ..evaluation.policy import classify_branch_outcome
281
+
178
282
  def eval_fn(before, after):
179
- # Simple heuristic evaluation when spectral data isn't available
283
+ # Simple heuristic evaluation when spectral data isn't available.
284
+ # protection_violated is rough — derived from whether any track
285
+ # went silent (signal lost on a track = protection violation).
180
286
  score = 0.5 # Neutral
287
+ protection_violated = False
288
+ lost_tracks = 0
289
+
181
290
  if before.get("track_meters") and after.get("track_meters"):
182
- # Check all tracks still alive
183
291
  before_alive = sum(1 for t in before["track_meters"] if t.get("level", 0) > 0)
184
292
  after_alive = sum(1 for t in after["track_meters"] if t.get("level", 0) > 0)
185
- if after_alive >= before_alive:
293
+ lost_tracks = max(0, before_alive - after_alive)
294
+ if lost_tracks == 0:
186
295
  score += 0.1
187
296
  else:
188
- score -= 0.2 # Lost a track
297
+ score -= 0.2
298
+ # A track going silent is a protection violation — always
299
+ # undo regardless of exploration mode.
300
+ protection_violated = True
189
301
 
190
302
  if before.get("spectrum") and after.get("spectrum"):
191
- # Spectral balance improvement heuristic
192
- score += 0.1 # Bonus for having spectral data
193
-
194
- return {"score": round(score, 3), "keep_change": score > 0.45}
303
+ score += 0.1 # presence-of-data bonus
304
+
305
+ score = round(score, 3)
306
+ outcome = classify_branch_outcome(
307
+ score=score,
308
+ protection_violated=protection_violated,
309
+ # Minimal hard-rule inputs — the heuristic doesn't compute
310
+ # measurable_count / goal_progress deltas. target_count=0 and
311
+ # measurable_count=0 lets rule 1 defer to score-only judgment.
312
+ measurable_count=0,
313
+ target_count=0,
314
+ goal_progress=0.0,
315
+ exploration_rules=exploration_rules,
316
+ )
317
+
318
+ return {
319
+ "score": outcome.score,
320
+ "keep_change": outcome.keep_change,
321
+ "status": outcome.status,
322
+ "failure_reasons": outcome.failure_reasons,
323
+ "note": outcome.note,
324
+ "lost_tracks": lost_tracks,
325
+ }
195
326
 
196
327
  engine.evaluate_branch(branch, eval_fn)
328
+
329
+ # Promote the classified status onto the branch. ranked_branches()
330
+ # only surfaces status="evaluated", so branches the classifier
331
+ # rejected ("undo") or retained for audit ("interesting_but_failed")
332
+ # are both correctly excluded from winner recommendations.
333
+ # Without this mapping, a branch the hard-rule classifier explicitly
334
+ # rejected could still win a ranking and be re-applied by commit.
335
+ if branch.evaluation and branch.evaluation.get("status"):
336
+ status = branch.evaluation["status"]
337
+ if status == "keep":
338
+ branch.status = "evaluated"
339
+ elif status == "interesting_but_failed":
340
+ branch.status = "interesting_but_failed"
341
+ elif status == "undo":
342
+ # Undo-classified branches had their steps rolled back by
343
+ # run_branch_async's undo pass; they must NOT be eligible
344
+ # winners. "rejected" is a terminal branch status distinct
345
+ # from "failed" (execution failed) and distinct from
346
+ # "interesting_but_failed" (exploration-mode retention).
347
+ branch.status = "rejected"
348
+
197
349
  results.append(branch.to_dict())
198
350
 
199
351
  return {
@@ -221,6 +373,30 @@ def compare_experiments(
221
373
  return {"error": f"Experiment {experiment_id} not found"}
222
374
 
223
375
  ranked = experiment.ranked_branches()
376
+
377
+ # Surface non-winning branch categories separately. None of these are
378
+ # candidates for commit — ranked_branches() filters them out — but the
379
+ # user sees what was tried.
380
+ interesting_failed = [
381
+ b for b in experiment.branches if b.status == "interesting_but_failed"
382
+ ]
383
+ rejected = [
384
+ b for b in experiment.branches if b.status == "rejected"
385
+ ]
386
+ analytical = [
387
+ b for b in experiment.branches if b.status == "analytical"
388
+ ]
389
+
390
+ def _audit_row(b):
391
+ return {
392
+ "branch_id": b.branch_id,
393
+ "name": b.name,
394
+ "move_id": b.move_id,
395
+ "score": b.score,
396
+ "summary": b.compiled_plan.get("summary", "") if b.compiled_plan else "",
397
+ "evaluation": b.evaluation,
398
+ }
399
+
224
400
  return {
225
401
  "experiment_id": experiment_id,
226
402
  "request": experiment.request_text,
@@ -228,16 +404,14 @@ def compare_experiments(
228
404
  "ranking": [
229
405
  {
230
406
  "rank": i + 1,
231
- "branch_id": b.branch_id,
232
- "name": b.name,
233
- "move_id": b.move_id,
234
- "score": b.score,
235
- "summary": b.compiled_plan.get("summary", "") if b.compiled_plan else "",
236
- "evaluation": b.evaluation,
407
+ **_audit_row(b),
237
408
  }
238
409
  for i, b in enumerate(ranked)
239
410
  ],
240
411
  "winner": ranked[0].to_dict() if ranked else None,
412
+ "interesting_but_failed": [_audit_row(b) for b in interesting_failed],
413
+ "rejected": [_audit_row(b) for b in rejected],
414
+ "analytical": [_audit_row(b) for b in analytical],
241
415
  }
242
416
 
243
417
 
@@ -258,6 +432,28 @@ async def commit_experiment(
258
432
  if not experiment:
259
433
  return {"error": f"Experiment {experiment_id} not found"}
260
434
 
435
+ # Refuse to commit branches the classifier rejected or that were
436
+ # analytical-only. Those statuses exist specifically so callers
437
+ # can't route them into re-application, and ranked_branches()
438
+ # already excludes them — so reaching commit with such a branch
439
+ # means the caller is bypassing the ranking layer.
440
+ target = experiment.get_branch(branch_id)
441
+ if target is None:
442
+ return {"error": f"Branch {branch_id} not found"}
443
+ if target.status in ("rejected", "analytical", "failed"):
444
+ return {
445
+ "error": (
446
+ f"Cannot commit branch with status '{target.status}'. "
447
+ f"'rejected' = hard-rule classifier rolled back; "
448
+ f"'analytical' = no executable plan; "
449
+ f"'failed' = zero steps applied successfully. "
450
+ f"Use compare_experiments to see eligible winners "
451
+ f"(only status='evaluated' branches are ranking candidates)."
452
+ ),
453
+ "branch_id": branch_id,
454
+ "branch_status": target.status,
455
+ }
456
+
261
457
  ableton = _get_ableton(ctx)
262
458
  bridge = ctx.lifespan_context.get("m4l")
263
459
  mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})