livepilot 1.27.2 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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:
|
|
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["
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
711
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -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.27.
|
|
8
|
+
__version__ = "1.27.3"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|