livepilot 1.17.1 → 1.17.2

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.
@@ -240,9 +240,9 @@ def evaluate_move(
240
240
  Takes before/after sonic snapshots and the active GoalVector.
241
241
  Returns a score and keep/undo recommendation.
242
242
 
243
- Snapshots should contain: spectrum (8-band dict), rms, peak.
244
- Get these from get_master_spectrum + get_master_rms before and after
245
- making changes.
243
+ Snapshots should contain: spectrum (9-band dict sub_low → air, or
244
+ 8-band from pre-v1.16 .amxd builds), rms, peak. Get these from
245
+ get_master_spectrum + get_master_rms before and after making changes.
246
246
 
247
247
  Hard rules enforce undo when:
248
248
  - No measurable improvement (delta <= 0)
@@ -471,3 +471,194 @@ def route_request(
471
471
 
472
472
  plan = conductor.classify_request(request)
473
473
  return plan.to_dict()
474
+
475
+
476
+ # ── iterate_toward_goal (closed evaluation loop) ──────────────────────
477
+
478
+
479
+ @mcp.tool()
480
+ async def iterate_toward_goal(
481
+ ctx: Context,
482
+ goal_vector: dict | str,
483
+ candidate_move_sets: list,
484
+ threshold: float = 0.70,
485
+ max_iterations: int = 3,
486
+ on_timeout: str = "commit_best",
487
+ render_verify: bool = False,
488
+ ) -> dict:
489
+ """Close the evaluation loop: run experiments until threshold or timeout.
490
+
491
+ Each iteration creates an experiment from one candidate_move_sets entry,
492
+ runs all branches (which auto-undo per-branch via the experiment engine),
493
+ and checks the top-ranked branch's score against the GoalVector. If score
494
+ >= threshold, commit that branch permanently and stop. Otherwise discard
495
+ the experiment and try the next candidate set. On timeout, commit the
496
+ best-so-far (on_timeout='commit_best') or commit nothing
497
+ (on_timeout='discard_on_timeout').
498
+
499
+ Args:
500
+ goal_vector: Compiled GoalVector dict (from compile_goal_vector) or
501
+ JSON string. Provides the scoring target passed through to the
502
+ evaluation scorer inside each run_experiment call.
503
+ candidate_move_sets: List of move_id lists — one per iteration.
504
+ Example: [["make_punchier", "widen_stereo"], ["tighten_low_end"]].
505
+ Iteration 0 tries the first list, iteration 1 the second, etc.
506
+ If shorter than max_iterations, iteration stops when exhausted.
507
+ threshold: Winner score required to commit early. 0.0–1.0. Default 0.70.
508
+ max_iterations: Hard cap on outer-loop iterations. Default 3.
509
+ on_timeout: "commit_best" (commit highest-scoring experiment at end)
510
+ or "discard_on_timeout" (no commit if threshold never met).
511
+ render_verify: When True each branch captures + analyzes audio
512
+ (~6s extra per branch). Default False.
513
+
514
+ Returns: IterationResult dict with status, iterations_run,
515
+ committed_experiment_id, committed_branch_id, final_score, steps,
516
+ reason.
517
+
518
+ Safety: Only commits when threshold_met OR (on_timeout='commit_best' AND
519
+ best-so-far exists). Never double-undoes — per-branch undo is handled
520
+ inside run_experiment; this tool only issues commit or discard.
521
+ """
522
+ import time as _time
523
+ from ..branches import seed_from_move_id
524
+ from ..experiment import engine as exp_engine
525
+ from ..experiment.tools import (
526
+ _capture_snapshot,
527
+ _capture_snapshot_with_render_verify,
528
+ )
529
+ from ..semantic_moves import registry, compiler
530
+ from ..evaluation.policy import classify_branch_outcome
531
+ from ._agent_os_engine import iterate_toward_goal_engine_async
532
+
533
+ gv_dict = _parse_json_param(goal_vector, "goal_vector")
534
+
535
+ if not isinstance(candidate_move_sets, list) or not all(
536
+ isinstance(s, list) and all(isinstance(m, str) for m in s)
537
+ for s in candidate_move_sets
538
+ ):
539
+ return {
540
+ "error": (
541
+ "candidate_move_sets must be a list of lists of move_id strings"
542
+ )
543
+ }
544
+
545
+ ableton = _get_ableton(ctx)
546
+ bridge = ctx.lifespan_context.get("m4l")
547
+ mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
548
+
549
+ # Pre-validate the GoalVector once — the eval_fn closure reuses this.
550
+ goal = engine.validate_goal_vector(
551
+ request_text=gv_dict.get("request_text", "iterate_toward_goal"),
552
+ targets=gv_dict.get("targets", {}),
553
+ protect=gv_dict.get("protect", {}),
554
+ mode=gv_dict.get("mode", "improve"),
555
+ aggression=float(gv_dict.get("aggression", 0.5)),
556
+ research_mode=gv_dict.get("research_mode", "none"),
557
+ )
558
+
559
+ # ── Callbacks wire the pure-logic engine to real experiment I/O ──
560
+
561
+ async def _create(move_ids: list[str]) -> str:
562
+ seeds = [seed_from_move_id(mid) for mid in move_ids]
563
+ kernel_id = f"iter_kern_{int(_time.time())}"
564
+ exp = exp_engine.create_experiment_from_seeds(
565
+ request_text=gv_dict.get("request_text", "iterate_toward_goal"),
566
+ seeds=seeds,
567
+ kernel_id=kernel_id,
568
+ )
569
+ return exp.experiment_id
570
+
571
+ async def _run(experiment_id: str):
572
+ experiment = exp_engine.get_experiment(experiment_id)
573
+ if experiment is None:
574
+ return None, 0.0
575
+
576
+ if render_verify:
577
+ capture_fn = lambda: _capture_snapshot_with_render_verify(ctx, 2.0)
578
+ else:
579
+ capture_fn = lambda: _capture_snapshot(ctx)
580
+
581
+ for branch in experiment.branches:
582
+ if branch.status != "pending":
583
+ continue
584
+
585
+ # Compile plan from semantic move when branch doesn't carry one
586
+ if branch.compiled_plan is None and branch.move_id:
587
+ move = registry.get_move(branch.move_id)
588
+ if move is None:
589
+ branch.status = "failed"
590
+ continue
591
+ session_info = ableton.send_command("get_session_info")
592
+ kernel = {"session_info": session_info, "mode": "explore"}
593
+ plan = compiler.compile(move, kernel)
594
+ branch.compiled_plan = plan.to_dict()
595
+
596
+ if branch.compiled_plan is None:
597
+ branch.status = "failed"
598
+ continue
599
+
600
+ await exp_engine.run_branch_async(
601
+ branch=branch,
602
+ ableton=ableton,
603
+ compiled_plan=branch.compiled_plan,
604
+ capture_fn=capture_fn,
605
+ bridge=bridge,
606
+ mcp_registry=mcp_registry,
607
+ ctx=ctx,
608
+ )
609
+
610
+ def eval_fn(before, after):
611
+ score_result = engine.compute_evaluation_score(goal, before, after)
612
+ outcome = classify_branch_outcome(
613
+ score=score_result.get("score", 0.0),
614
+ protection_violated=not score_result.get("keep_change", True)
615
+ and "protected" in " ".join(score_result.get("notes", [])).lower(),
616
+ measurable_count=0,
617
+ target_count=0,
618
+ goal_progress=score_result.get("goal_progress", 0.0),
619
+ exploration_rules=False,
620
+ )
621
+ return {
622
+ "score": outcome.score,
623
+ "keep_change": outcome.keep_change,
624
+ "status": outcome.status,
625
+ "note": outcome.note,
626
+ "dimension_changes": score_result.get("dimension_changes", {}),
627
+ }
628
+
629
+ exp_engine.evaluate_branch(branch, eval_fn)
630
+ if branch.evaluation and branch.evaluation.get("status") == "keep":
631
+ branch.status = "evaluated"
632
+ elif branch.evaluation and branch.evaluation.get("status") == "undo":
633
+ branch.status = "rejected"
634
+
635
+ ranked = experiment.ranked_branches()
636
+ if not ranked:
637
+ return None, 0.0
638
+ top = ranked[0]
639
+ return top.branch_id, float(top.score or 0.0)
640
+
641
+ async def _commit(experiment_id: str, branch_id: str) -> dict:
642
+ return await exp_engine.commit_branch_async(
643
+ exp_engine.get_experiment(experiment_id),
644
+ branch_id,
645
+ ableton,
646
+ bridge=bridge,
647
+ mcp_registry=mcp_registry,
648
+ ctx=ctx,
649
+ )
650
+
651
+ async def _discard(experiment_id: str) -> dict:
652
+ return exp_engine.discard_experiment(experiment_id)
653
+
654
+ result = await iterate_toward_goal_engine_async(
655
+ candidate_move_sets=candidate_move_sets,
656
+ threshold=float(threshold),
657
+ max_iterations=int(max_iterations),
658
+ create_experiment_fn=_create,
659
+ run_experiment_fn=_run,
660
+ commit_fn=_commit,
661
+ discard_fn=_discard,
662
+ on_timeout=on_timeout,
663
+ )
664
+ return result.to_dict()
@@ -214,11 +214,24 @@ async def get_master_spectrum(
214
214
  samples: int = 0,
215
215
  sub_detail: bool = False,
216
216
  ) -> dict:
217
- """Get 8-band frequency analysis of the master bus.
218
-
219
- Returns band energies: sub (20-60Hz), low (60-200Hz), low_mid (200-500Hz),
220
- mid (500-2kHz), high_mid (2-4kHz), high (4-8kHz), presence (8-12kHz),
221
- air (12-20kHz). Values 0.0-1.0.
217
+ """Get 9-band frequency analysis of the master bus.
218
+
219
+ Returns band energies (fffb~ center frequencies shown in parens):
220
+ sub_low 20-60 Hz (~35 Hz center) kick fundamentals, Villalobos subs
221
+ sub 60-120 Hz (~85 Hz) 808s, sub-bass body
222
+ low 120-250 Hz (~175 Hz) — bass body, warmth
223
+ low_mid 250-500 Hz (~350 Hz) — mud zone, male vocal lows
224
+ mid 500-1 kHz (~700 Hz) — vocal presence, snare body
225
+ high_mid 1-2 kHz (~1.4 kHz) — consonants, pick attack
226
+ high 2-4 kHz (~2.8 kHz) — presence, vocal intelligibility
227
+ presence 4-8 kHz (~5.6 kHz) — cymbal definition, air of breath
228
+ air 8-20 kHz (~12 kHz) — shimmer, sparkle
229
+ Values 0.0-1.0.
230
+
231
+ Older .amxd builds (pre-v1.16) emit the legacy 8-band layout without the
232
+ explicit `sub_low` split — the server auto-detects band count from the OSC
233
+ payload and picks the right name set. Re-freeze the Max device to get the
234
+ 9-band resolution.
222
235
 
223
236
  Also returns detected key/scale if enough audio has been analyzed.
224
237
  Requires LivePilot Analyzer on master track.
@@ -242,7 +255,7 @@ async def get_master_spectrum(
242
255
  Pass `sub_detail=True` to attach a `sub_detail` dict with three
243
256
  finer buckets: `sub_deep` (20-45 Hz), `sub_mid` (45-60 Hz),
244
257
  `sub_high` (60-80 Hz). Derived from the FluCoMa mel spectrum
245
- (40 bands) rather than the 8-band cache, so it requires FluCoMa
258
+ (40 bands) rather than the 9-band cache, so it requires FluCoMa
246
259
  to be active. When FluCoMa is unavailable, sub_detail is omitted
247
260
  with a `sub_detail_warning` field explaining why.
248
261
  """
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.17.1",
3
+ "version": "1.17.2",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 426 tools, 52 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 — 427 tools, 52 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.17.1"
8
+ __version__ = "1.17.2"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
package/requirements.txt CHANGED
@@ -2,18 +2,18 @@
2
2
  numpy>=1.24.0
3
3
  fastmcp>=3.0.0,<3.3.0 # pinned upper bound — _get_all_tools() accesses private internals
4
4
  midiutil>=1.2.1
5
- pretty_midi>=0.2.10
5
+ pretty_midi>=0.2.11
6
6
  # v1.8 Perception Layer (offline analysis)
7
- pyloudnorm>=0.1.0
8
- soundfile>=0.12.0
9
- scipy>=1.11.0
7
+ pyloudnorm>=0.2.0
8
+ soundfile>=0.13.1
9
+ scipy>=1.17.1
10
10
  mutagen>=1.47.0
11
11
  # v1.10.5 Splice online catalog integration — required, not optional.
12
12
  # Without these, SpliceGRPCClient silently disables itself and search_samples
13
13
  # falls back to the SQLite sounds.db which only returns locally downloaded
14
14
  # samples (see docs/2026-04-14-bugs-discovered.md — P0-2).
15
15
  grpcio>=1.60.0
16
- protobuf>=4.25.0
16
+ protobuf>=7.34.1
17
17
 
18
18
  # Development / testing (not required for runtime)
19
19
  # pip install pytest pytest-asyncio
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": "426-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "427-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.17.1",
9
+ "version": "1.17.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.17.1",
14
+ "version": "1.17.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }