livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -26,11 +26,57 @@ def _get_ableton(ctx: Context):
26
26
  return ctx.lifespan_context["ableton"]
27
27
 
28
28
 
29
+ def _resolve_section_template(sections):
30
+ """Build the (SectionType, energy, density, bars) template for the planner.
31
+
32
+ Honors a caller-supplied ``sections`` list (entries shaped as
33
+ ``[type, energy, density, bars]`` or the dict form); otherwise falls back
34
+ to a generic genre-neutral arc that starts INTRO and ends OUTRO. The v1.24
35
+ refactor removed the built-in per-genre STYLE_TEMPLATES
36
+ (vocabulary-not-form) — the framework supplies only a neutral default
37
+ skeleton so plan_arrangement always returns a plan instead of crashing on
38
+ the now-mandatory section_template argument.
39
+ """
40
+ ST = comp_engine.SectionType
41
+ if sections:
42
+ template = []
43
+ for entry in sections:
44
+ if isinstance(entry, dict):
45
+ stype = entry.get("type") or entry.get("section_type") or "verse"
46
+ energy = float(entry.get("energy", entry.get("energy_target", 0.5)))
47
+ density = float(entry.get("density", entry.get("density_target", 0.5)))
48
+ bars = max(1, int(entry.get("bars", 8)))
49
+ else:
50
+ stype, energy, density, bars = entry
51
+ # Clamp bars>=1: a 0/negative bar count makes the engine's
52
+ # template_bars sum 0 -> ZeroDivisionError on scale_factor.
53
+ energy, density, bars = float(energy), float(density), max(1, int(bars))
54
+ if not isinstance(stype, ST):
55
+ try:
56
+ stype = ST(str(stype).lower())
57
+ except ValueError:
58
+ stype = ST.VERSE
59
+ template.append((stype, energy, density, bars))
60
+ if template:
61
+ return template
62
+ # Generic genre-neutral default arc (INTRO … OUTRO).
63
+ return [
64
+ (ST.INTRO, 0.3, 0.3, 8),
65
+ (ST.BUILD, 0.6, 0.6, 8),
66
+ (ST.DROP, 1.0, 0.9, 16),
67
+ (ST.BREAKDOWN, 0.5, 0.4, 8),
68
+ (ST.BUILD, 0.7, 0.7, 8),
69
+ (ST.DROP, 1.0, 1.0, 16),
70
+ (ST.OUTRO, 0.3, 0.2, 8),
71
+ ]
72
+
73
+
29
74
  @mcp.tool()
30
75
  def plan_arrangement(
31
76
  ctx: Context,
32
77
  target_bars: int = 128,
33
78
  style: str = "electronic",
79
+ sections: Optional[list] = None,
34
80
  ) -> dict:
35
81
  """Transform the current loop/session into a full arrangement blueprint.
36
82
 
@@ -41,14 +87,22 @@ def plan_arrangement(
41
87
  - Orchestration plan (which tracks play in which sections)
42
88
 
43
89
  target_bars: desired total arrangement length (default: 128 bars)
44
- style: electronic | hiphop | pop | ambient | techno
90
+ style: free-text style label (e.g. "electronic", "ambient") recorded as a
91
+ hint on the result. The framework no longer hardcodes genre→form
92
+ templates (vocabulary-not-form, v1.24); supply explicit form via
93
+ `sections` if desired.
94
+ sections: optional explicit form — a list of
95
+ [section_type, energy_target, density_target, bars] entries (or the
96
+ dict form {"type","energy","density","bars"}). When omitted a generic
97
+ genre-neutral arc (INTRO…OUTRO) is used so the tool always returns a
98
+ plan.
45
99
 
46
100
  Returns: full ArrangementPlan with actionable section-by-section instructions.
47
101
  """
48
- if style not in planner_engine.VALID_STYLES:
49
- return {"error": f"Unknown style '{style}'. Valid: {sorted(planner_engine.VALID_STYLES)}"}
50
-
51
102
  ableton = _get_ableton(ctx)
103
+ # Capture the caller's requested form before the local `sections` (the
104
+ # built section graph) shadows the parameter name below.
105
+ requested_sections = sections
52
106
 
53
107
  # 1. Get session info
54
108
  session = ableton.send_command("get_session_info")
@@ -85,11 +139,19 @@ def plan_arrangement(
85
139
  # 5. Analyze loop identity
86
140
  loop_identity = planner_engine.analyze_loop_identity(roles, sections)
87
141
 
88
- # 6. Plan arrangement
142
+ # 6. Plan arrangement. The v1.24 refactor made section_template mandatory
143
+ # (STYLE_TEMPLATES removed); supply the caller's form or a neutral default.
144
+ # Malformed `sections` (wrong arity, non-numeric energy/density) would raise
145
+ # a raw ValueError/TypeError; convert to the file's structured-error form.
146
+ try:
147
+ section_template = _resolve_section_template(requested_sections)
148
+ except (ValueError, TypeError) as exc:
149
+ return {"error": f"Invalid sections entry: {exc}", "code": "INVALID_PARAM"}
89
150
  plan = planner_engine.plan_arrangement_from_loop(
90
151
  loop_identity,
91
152
  target_duration_bars=target_bars,
92
153
  style=style,
154
+ section_template=section_template,
93
155
  )
94
156
 
95
157
  # Add section-level sample role hints
@@ -97,7 +159,7 @@ def plan_arrangement(
97
159
 
98
160
  result = plan.to_dict()
99
161
  result["loop_identity"] = loop_identity.to_dict()
100
- result["available_styles"] = sorted(planner_engine.VALID_STYLES)
162
+ result["style"] = style
101
163
  return result
102
164
 
103
165
  # ── transform_section (Round 4) ─────────────────────────────────────
@@ -132,8 +132,11 @@ def get_emotional_arc(ctx: Context) -> dict:
132
132
  Use when asking "how busy is this section?"
133
133
 
134
134
  2. `get_emotional_arc.tension` (this tool)
135
- → narrative-arc signal weighted by harmonic instability,
136
- section placement, and payoff/contrast. Use when asking
135
+ → narrative-arc signal weighted by harmonic instability
136
+ (derived per section from key-detection confidence low
137
+ confidence reads as unstable — plus a bump when the mode
138
+ shifts from the previous section), section placement, and
139
+ payoff/contrast. Use when asking
137
140
  "where does the song want to go emotionally?" — tension
138
141
  can be HIGH in a sparse-but-anticipatory section (low
139
142
  density) and LOW in a busy-but-resolved section (high
@@ -191,6 +194,21 @@ def get_emotional_arc(ctx: Context) -> dict:
191
194
  continue
192
195
  harmony_fields.append(hf)
193
196
 
197
+ # Derive a real instability signal per section — cheapest honest proxy
198
+ # available from what the loop above already computed:
199
+ # 1. Key-detection confidence: low confidence (weak/ambiguous tonal
200
+ # center) reads as harmonically unstable. No key detected at all
201
+ # (no notes found) falls back to a neutral 0.3, matching the
202
+ # tension-curve fallback below.
203
+ # 2. Mode change vs the previous section: a shift from e.g. major to
204
+ # minor (or vice versa) is itself a destabilizing event, so it adds
205
+ # a bump on top of the confidence-derived base.
206
+ for hf in harmony_fields:
207
+ hf.instability = round(max(0.0, min(1.0, 1.0 - hf.confidence)), 3) if hf.key else 0.3
208
+ for prev_hf, curr_hf in zip(harmony_fields, harmony_fields[1:]):
209
+ if prev_hf.mode and curr_hf.mode and prev_hf.mode != curr_hf.mode:
210
+ curr_hf.instability = round(min(1.0, curr_hf.instability + 0.15), 3)
211
+
194
212
  issues = engine.run_emotional_arc_critic(sections, harmony_fields)
195
213
 
196
214
  # Build tension curve for visualization
@@ -75,7 +75,7 @@ def analyze_harmony(
75
75
  """
76
76
  notes = _get_clip_notes(ctx, track_index, clip_index)
77
77
  if not notes:
78
- return {"error": "No notes in clip", "suggestion": "Add notes first"}
78
+ return {"error": "No notes in clip", "code": "STATE_ERROR", "suggestion": "Add notes first"}
79
79
 
80
80
  key_info = _detect_or_parse_key(notes, key_hint=key)
81
81
  tonic = key_info["tonic"]
@@ -146,7 +146,7 @@ def suggest_next_chord(
146
146
  """
147
147
  notes = _get_clip_notes(ctx, track_index, clip_index)
148
148
  if not notes:
149
- return {"error": "No notes in clip"}
149
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
150
150
 
151
151
  key_info = _detect_or_parse_key(notes, key_hint=key)
152
152
  tonic = key_info["tonic"]
@@ -154,7 +154,7 @@ def suggest_next_chord(
154
154
 
155
155
  chord_groups = engine.chordify(notes)
156
156
  if not chord_groups:
157
- return {"error": "No chords detected in clip"}
157
+ return {"error": "No chords detected in clip", "code": "STATE_ERROR"}
158
158
 
159
159
  # Analyze last chord
160
160
  last_group = chord_groups[-1]
@@ -298,7 +298,7 @@ def detect_theory_issues(
298
298
  """
299
299
  notes = _get_clip_notes(ctx, track_index, clip_index)
300
300
  if not notes:
301
- return {"error": "No notes in clip"}
301
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
302
302
 
303
303
  key_info = _detect_or_parse_key(notes, key_hint=key)
304
304
  tonic = key_info["tonic"]
@@ -406,7 +406,7 @@ def identify_scale(
406
406
  """
407
407
  notes = _get_clip_notes(ctx, track_index, clip_index)
408
408
  if not notes:
409
- return {"error": "No notes in clip"}
409
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
410
410
 
411
411
  detected = engine.detect_key(notes, mode_detection=True)
412
412
 
@@ -481,7 +481,7 @@ def harmonize_melody(
481
481
  """
482
482
  notes = _get_clip_notes(ctx, track_index, clip_index)
483
483
  if not notes:
484
- return {"error": "No notes in clip"}
484
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
485
485
 
486
486
  melody = sorted(
487
487
  [n for n in notes if not n.get("mute", False)],
@@ -655,7 +655,7 @@ def generate_countermelody(
655
655
 
656
656
  notes = _get_clip_notes(ctx, track_index, clip_index)
657
657
  if not notes:
658
- return {"error": "No notes in clip"}
658
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
659
659
 
660
660
  melody = sorted(
661
661
  [n for n in notes if not n.get("mute", False)],
@@ -668,7 +668,7 @@ def generate_countermelody(
668
668
  # Build pool of scale pitches in range
669
669
  pool = [p for p in range(range_low, range_high + 1) if p % 12 in scale_pcs]
670
670
  if not pool:
671
- return {"error": "No scale pitches in given range"}
671
+ return {"error": "No scale pitches in given range", "code": "STATE_ERROR"}
672
672
 
673
673
  # Consonant intervals (semitones mod 12): P1, m3, M3, P4, P5, m6, M6, P8
674
674
  consonant = {0, 3, 4, 5, 7, 8, 9}
@@ -790,14 +790,14 @@ def transpose_smart(
790
790
  """
791
791
  notes = _get_clip_notes(ctx, track_index, clip_index)
792
792
  if not notes:
793
- return {"error": "No notes in clip"}
793
+ return {"error": "No notes in clip", "code": "STATE_ERROR"}
794
794
 
795
795
  source_key = engine.detect_key(notes)
796
796
 
797
797
  try:
798
798
  target = engine.parse_key(target_key)
799
799
  except ValueError:
800
- return {"error": f"Invalid target key: {target_key}"}
800
+ return {"error": f"Invalid target key: {target_key}", "code": "INVALID_PARAM"}
801
801
 
802
802
  source_tonic = source_key["tonic"]
803
803
  target_tonic = target["tonic"]
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  from typing import Optional
9
10
 
10
11
  from fastmcp import Context
@@ -142,7 +143,9 @@ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -
142
143
  the bridge once per audio clip and can add noticeable latency on
143
144
  large sessions.
144
145
  """
145
- result = _get_ableton(ctx).send_command("get_session_diagnostics")
146
+ result = await asyncio.to_thread(
147
+ _get_ableton(ctx).send_command, "get_session_diagnostics"
148
+ )
146
149
 
147
150
  if not check_clip_keys:
148
151
  return result
@@ -155,7 +158,9 @@ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -
155
158
  from .clips import check_clip_key_consistency # local import to avoid cycles
156
159
 
157
160
  audio_mismatches: list[dict] = []
158
- session_info = _get_ableton(ctx).send_command("get_session_info")
161
+ session_info = await asyncio.to_thread(
162
+ _get_ableton(ctx).send_command, "get_session_info"
163
+ )
159
164
  tracks = (session_info or {}).get("tracks", []) if isinstance(session_info, dict) else []
160
165
  for track in tracks:
161
166
  t_idx = track.get("index")
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from dataclasses import asdict, dataclass, field
12
12
 
13
+ from .archetypes import _SECTION_PAIR_PREFERENCES
13
14
  from .models import TransitionBoundary, TransitionPlan, TransitionScore
14
15
 
15
16
 
@@ -275,7 +276,18 @@ def run_gesture_fit_critic(
275
276
  # honor it explicitly so archetypes documented as universal don't get
276
277
  # flagged as mismatched on specific pairs like intro→build.
277
278
  WILDCARDS = {"any_section_change", "any"}
278
- if any(uc in WILDCARDS for uc in archetype.use_cases):
279
+ pair = (boundary.from_type, boundary.to_type)
280
+ preferred_for_pair = _SECTION_PAIR_PREFERENCES.get(pair, [])
281
+ if archetype.name in preferred_for_pair:
282
+ # The engine deliberately selects archetypes from this preference list
283
+ # via select_archetype(). Several preferred archetypes describe their
284
+ # use_cases with semantic labels (e.g. harmonic_suspend ->
285
+ # "chord_progression_pivot") that share no substring with section-type
286
+ # names, so the substring heuristic below would otherwise flag the
287
+ # engine's own chosen archetype as a mismatch. Treat membership in the
288
+ # preference map as an authoritative match.
289
+ use_case_match = True
290
+ elif any(uc in WILDCARDS for uc in archetype.use_cases):
279
291
  use_case_match = True
280
292
  else:
281
293
  pair_tags = {
@@ -733,11 +733,40 @@ def corpus_trim_plugin_identity(
733
733
  research_priority : tag value: "low" / "medium" / "skip". Default "low".
734
734
  """
735
735
  if not plugin_id:
736
- return {"error": "plugin_id is required", "status": "error"}
736
+ return {
737
+ "error": "plugin_id is required",
738
+ "code": "INVALID_PARAM",
739
+ "status": "error",
740
+ }
741
+ if not re.match(r"^[a-z0-9-]+$", plugin_id):
742
+ return {
743
+ "error": (
744
+ f"plugin_id '{plugin_id}' contains invalid characters; "
745
+ "must match ^[a-z0-9-]+$"
746
+ ),
747
+ "code": "INVALID_PARAM",
748
+ "status": "error",
749
+ }
750
+ inventory_path = DEFAULT_OUTPUT_ROOT / "plugins" / "_inventory.json"
751
+ if not inventory_path.exists():
752
+ return {
753
+ "error": "No inventory; run corpus_detect_plugins first.",
754
+ "code": "INVALID_PARAM",
755
+ "status": "error",
756
+ }
757
+ inventory = json.loads(inventory_path.read_text(encoding="utf-8"))
758
+ known_ids = {p.get("plugin_id") for p in inventory.get("plugins", [])}
759
+ if plugin_id not in known_ids:
760
+ return {
761
+ "error": f"plugin_id '{plugin_id}' not in inventory.",
762
+ "code": "INVALID_PARAM",
763
+ "status": "error",
764
+ }
737
765
  yaml_path = DEFAULT_OUTPUT_ROOT / "plugins" / plugin_id / "identity.yaml"
738
766
  if not yaml_path.exists():
739
767
  return {
740
768
  "error": f"No identity.yaml for plugin_id '{plugin_id}'",
769
+ "code": "NOT_FOUND",
741
770
  "status": "error",
742
771
  }
743
772
  import yaml as _yaml
@@ -271,9 +271,12 @@ def build_variant(
271
271
  if sacred and identity_effect == "preserves":
272
272
  why += f". Preserves {sacred[0].get('description', 'sacred elements')}"
273
273
 
274
- # Compile through semantic compiler if kernel available
274
+ # Compile through semantic compiler if kernel available. A variant is
275
+ # analytical-only when it has no compiled plan OR the compiled plan is
276
+ # non-executable (0 steps / requires seed_args) — LIVE#9: previously a
277
+ # non-executable compiled plan was mislabeled analytical_only=False.
275
278
  compiled = _compile_variant_plan(move_dict, kernel)
276
- analytical = compiled is None
279
+ analytical = compiled is None or not compiled.get("executable", False)
277
280
 
278
281
  return {
279
282
  "variant_id": variant_id,
@@ -597,6 +600,56 @@ def _get_corpus_hints(request_text: str, diagnosis: dict | None) -> dict | None:
597
600
  # ── Pipeline orchestrator ────────────────────────────────────────
598
601
 
599
602
 
603
+ def _pick_recommended(ranked: list[dict]) -> str:
604
+ """Pick the recommended variant_id from a ranked list.
605
+
606
+ Prefer the highest-ranked EXECUTABLE variant (analytical_only is False) so
607
+ callers that auto-apply `recommended` never get handed a non-executable /
608
+ analytical-only shell when a real move exists (P2-30 / LIVE#9). Falls back
609
+ to the top-ranked variant when none are executable.
610
+ """
611
+ if not ranked:
612
+ return ""
613
+ for v in ranked:
614
+ if not v.get("analytical_only", False):
615
+ return v["variant_id"]
616
+ return ranked[0]["variant_id"]
617
+
618
+
619
+ def _pick_boldest_executable(ranked: list[dict]) -> Optional[dict]:
620
+ """Pick the highest-novelty EXECUTABLE variant as a second recommendation.
621
+
622
+ `_pick_recommended` deliberately biases toward the top-ranked variant,
623
+ which is itself weighted toward taste/identity/coherence fit (see
624
+ `_select_weights`) — by construction the "recommended" slot is rarely
625
+ the boldest option, even for a request that explicitly asked to be
626
+ surprised (novelty only gets a minority weight in every weight
627
+ profile). This surfaces the boldest EXECUTABLE alternative alongside
628
+ it so a "surprise me" caller isn't structurally steered away from
629
+ genuine novelty. Analytical-only shells are excluded (nothing to run).
630
+ Ties on novelty_level are broken by rank (prefer the better-ranked of
631
+ equally-novel variants). Returns None when no executable variant exists
632
+ (e.g. an all-analytical cold-start set).
633
+ """
634
+ executable = [v for v in ranked if not v.get("analytical_only", False)]
635
+ if not executable:
636
+ return None
637
+ boldest = max(
638
+ executable,
639
+ key=lambda v: (v.get("novelty_level", 0.0), -v.get("rank", 10 ** 9)),
640
+ )
641
+ novelty = boldest.get("novelty_level", 0.0)
642
+ return {
643
+ "variant_id": boldest.get("variant_id", ""),
644
+ "why": (
645
+ f"Highest-novelty executable alternative (novelty {novelty:.2f}) "
646
+ f"— the top recommendation favors taste/identity fit over "
647
+ f"novelty by design, so this is the boldest option that can "
648
+ f"actually be run."
649
+ ),
650
+ }
651
+
652
+
600
653
  def generate_wonder_variants(
601
654
  request_text: str,
602
655
  diagnosis: dict | None = None,
@@ -651,7 +704,10 @@ def generate_wonder_variants(
651
704
  v["corpus_hints"] = corpus_hints
652
705
  variants.append(v)
653
706
 
654
- executable_count = len(variants)
707
+ # move_based_count = how many real moves matched (pre-padding). Drives the
708
+ # padding gate AND variant_count_actual (move-match semantics callers/tests
709
+ # depend on). Executability is a SEPARATE axis computed after ranking.
710
+ move_based_count = len(variants)
655
711
 
656
712
  # v1.18.2 #10 fix: when NO executable moves matched, seed from the
657
713
  # cold-start distinct-starting-points set instead of padding with
@@ -663,7 +719,7 @@ def generate_wonder_variants(
663
719
  # the generic analytical fallback because we don't want to mix real
664
720
  # move-based variants with architecture-first seeds — that would
665
721
  # confuse the presentation.
666
- if executable_count == 0:
722
+ if move_based_count == 0:
667
723
  while len(variants) < 3:
668
724
  idx = len(variants)
669
725
  seed = _COLD_START_SEEDS[idx]
@@ -698,8 +754,13 @@ def generate_wonder_variants(
698
754
  taste_evidence=taste_evidence,
699
755
  )
700
756
 
757
+ # LIVE#9 / P2-30: degraded_reason must reflect ACTUAL executability, not the
758
+ # move-match count. A move-based variant whose compiled plan is
759
+ # non-executable (0 steps / requires seed_args) is analytical_only=True and
760
+ # must NOT be presented as a full match.
761
+ real_executable = sum(1 for v in ranked if not v.get("analytical_only", False))
701
762
  degraded_reason = ""
702
- if executable_count == 0:
763
+ if move_based_count == 0:
703
764
  # v1.18.2 #10: cold-start path — distinct starting-point seeds
704
765
  # rather than identical-generic padding.
705
766
  degraded_reason = (
@@ -707,18 +768,30 @@ def generate_wonder_variants(
707
768
  "from distinct starting-point families (device_creation × 2 "
708
769
  "+ mix-architecture-first)"
709
770
  )
710
- elif executable_count == 1:
711
- degraded_reason = "Only 1 distinct executable move found"
771
+ elif real_executable < 3:
772
+ # Partial/degraded: fewer than 3 variants are actually executable; the
773
+ # remainder are analytical/non-executable fallbacks. Surface it so a
774
+ # degraded set is not presented as a full match.
775
+ fallback_count = 3 - real_executable
776
+ degraded_reason = (
777
+ f"Only {real_executable} of 3 variants are executable; the "
778
+ f"remaining {fallback_count} are analytical/non-executable fallbacks"
779
+ )
712
780
 
713
781
  return {
714
782
  "mode": "wonder",
715
783
  "request": request_text,
716
784
  "variants": ranked,
717
- "recommended": ranked[0]["variant_id"] if ranked else "",
785
+ "recommended": _pick_recommended(ranked),
786
+ # Additive second slot (does not change `recommended`'s semantics):
787
+ # the highest-novelty EXECUTABLE variant, surfaced alongside the
788
+ # safe/taste-weighted recommendation. See _pick_boldest_executable.
789
+ "boldest_executable": _pick_boldest_executable(ranked),
718
790
  "taste_evidence": taste_evidence,
719
791
  "identity_confidence": song_brain.get("identity_confidence", 0.0),
720
792
  "move_count_matched": len(moves),
721
- "variant_count_actual": executable_count,
793
+ "variant_count_actual": move_based_count,
794
+ "executable_count": real_executable,
722
795
  "degraded_reason": degraded_reason,
723
796
  }
724
797
 
@@ -9,6 +9,7 @@ SongBrain, action ledger, and creative threads.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import threading
12
13
  from dataclasses import asdict, dataclass, field
13
14
  from typing import Optional
14
15
 
@@ -63,6 +64,13 @@ class WonderSession:
63
64
 
64
65
  status: str = "diagnosing" # diagnosing, variants_ready, previewing, resolved
65
66
 
67
+ # Session-identity guard — see preview_studio.models.compute_session_fingerprint.
68
+ # Stamped at enter_wonder_mode time from the session_info already fetched
69
+ # for diagnosis; carried onto any PreviewSet built from this session's
70
+ # variants so a later commit can detect a stale topology. Empty string
71
+ # means "no signal" (older sessions, or built without a live session).
72
+ session_fingerprint: str = ""
73
+
66
74
  # Valid state transitions
67
75
  _VALID_TRANSITIONS: dict = field(default_factory=lambda: {
68
76
  "diagnosing": {"variants_ready"},
@@ -87,28 +95,42 @@ class WonderSession:
87
95
 
88
96
 
89
97
  # ── In-memory store ───────────────────────────────────────────────
98
+ #
99
+ # _wonder_sessions is mutated from both threadpooled sync tools
100
+ # (enter_wonder_mode) and event-loop async tools (commit_preview_variant,
101
+ # via _find_wonder_session_by_preview) — without a lock, two concurrent
102
+ # store_wonder_session calls can race in the check-then-evict loop below:
103
+ # both read the same `oldest_key`, both pop it, and a naive `del` on the
104
+ # second call raises KeyError. One module-level lock around the
105
+ # get/store/find critical sections closes that race.
90
106
 
91
107
  _wonder_sessions: dict[str, WonderSession] = {}
108
+ _wonder_sessions_lock = threading.Lock()
92
109
 
93
110
 
94
111
  def store_wonder_session(ws: WonderSession) -> None:
95
112
  """Store a WonderSession with FIFO eviction at capacity."""
96
- _wonder_sessions[ws.session_id] = ws
97
- while len(_wonder_sessions) > _MAX_WONDER_SESSIONS:
98
- oldest_key = next(iter(_wonder_sessions))
99
- evicted = _wonder_sessions.pop(oldest_key)
100
- if evicted.outcome == "pending":
101
- evicted.outcome = "abandoned"
113
+ with _wonder_sessions_lock:
114
+ _wonder_sessions[ws.session_id] = ws
115
+ while len(_wonder_sessions) > _MAX_WONDER_SESSIONS:
116
+ oldest_key = next(iter(_wonder_sessions), None)
117
+ if oldest_key is None:
118
+ break
119
+ evicted = _wonder_sessions.pop(oldest_key, None)
120
+ if evicted is not None and evicted.outcome == "pending":
121
+ evicted.outcome = "abandoned"
102
122
 
103
123
 
104
124
  def get_wonder_session(session_id: str) -> Optional[WonderSession]:
105
125
  """Retrieve a WonderSession by ID."""
106
- return _wonder_sessions.get(session_id)
126
+ with _wonder_sessions_lock:
127
+ return _wonder_sessions.get(session_id)
107
128
 
108
129
 
109
130
  def find_session_by_preview_set(set_id: str) -> Optional[WonderSession]:
110
131
  """Find a WonderSession linked to a preview set ID."""
111
- for ws in _wonder_sessions.values():
112
- if ws.preview_set_id == set_id:
113
- return ws
132
+ with _wonder_sessions_lock:
133
+ for ws in _wonder_sessions.values():
134
+ if ws.preview_set_id == set_id:
135
+ return ws
114
136
  return None
@@ -12,6 +12,7 @@ import logging
12
12
  from fastmcp import Context
13
13
 
14
14
  from ..server import mcp
15
+ from ..preview_studio.models import compute_session_fingerprint
15
16
  from . import engine
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -375,6 +376,11 @@ def enter_wonder_mode(
375
376
  variant_count_actual=result.get("variant_count_actual", 0),
376
377
  degraded_reason=result.get("degraded_reason", ""),
377
378
  status="diagnosing", # will transition below
379
+ # Stamped from the session_info already fetched above for diagnosis —
380
+ # no extra round-trip. Lets a later commit_preview_variant detect
381
+ # that the session's track topology changed since these variants'
382
+ # compiled_plan indices were built.
383
+ session_fingerprint=compute_session_fingerprint(session_info),
378
384
  )
379
385
  ws.transition_to("variants_ready")
380
386
 
@@ -456,7 +462,14 @@ def rank_wonder_variants(
456
462
 
457
463
  return {
458
464
  "rankings": ranked,
459
- "recommended": ranked[0]["variant_id"] if ranked else "",
465
+ # Prefer the highest-ranked EXECUTABLE variant (P2-30/LIVE#9) — the same
466
+ # rule as generate_wonder_variants; ranked[0] alone could hand back a
467
+ # non-executable/analytical-only shell.
468
+ "recommended": engine._pick_recommended(ranked),
469
+ # Additive second slot: the highest-novelty EXECUTABLE variant, for
470
+ # callers who invoked Wonder Mode wanting genuine surprise rather
471
+ # than the safest taste-weighted pick.
472
+ "boldest_executable": engine._pick_boldest_executable(ranked),
460
473
  }
461
474
 
462
475
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.27.1",
3
+ "version": "1.27.3",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 467 tools, 56 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
6
6
  "author": "Pilot Studio",