livepilot 1.9.22 → 1.9.24
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/.claude-plugin/marketplace.json +3 -3
- package/.mcpbignore +40 -0
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +84 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +141 -72
- package/bin/livepilot.js +135 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
- package/livepilot/commands/arrange.md +42 -23
- package/livepilot/commands/mix.md +34 -19
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -25
- package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +60 -4
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
- package/livepilot/skills/livepilot-core/references/overview.md +4 -4
- package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
- package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
- package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
- package/livepilot/skills/livepilot-release/SKILL.md +29 -15
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
- package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
- package/livepilot.mcpb +0 -0
- package/manifest.json +91 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_constraints/__init__.py +6 -0
- package/mcp_server/creative_constraints/engine.py +277 -0
- package/mcp_server/creative_constraints/models.py +75 -0
- package/mcp_server/creative_constraints/tools.py +341 -0
- package/mcp_server/experiment/__init__.py +6 -0
- package/mcp_server/experiment/engine.py +213 -0
- package/mcp_server/experiment/models.py +120 -0
- package/mcp_server/experiment/tools.py +263 -0
- package/mcp_server/hook_hunter/__init__.py +5 -0
- package/mcp_server/hook_hunter/analyzer.py +365 -0
- package/mcp_server/hook_hunter/models.py +58 -0
- package/mcp_server/hook_hunter/tools.py +588 -0
- package/mcp_server/memory/taste_graph.py +328 -0
- package/mcp_server/memory/tools.py +99 -0
- package/mcp_server/mix_engine/critics.py +2 -2
- package/mcp_server/mix_engine/models.py +1 -1
- package/mcp_server/mix_engine/state_builder.py +2 -2
- package/mcp_server/musical_intelligence/__init__.py +8 -0
- package/mcp_server/musical_intelligence/detectors.py +434 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +224 -0
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/__init__.py +5 -0
- package/mcp_server/preview_studio/engine.py +280 -0
- package/mcp_server/preview_studio/models.py +74 -0
- package/mcp_server/preview_studio/tools.py +466 -0
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +118 -0
- package/mcp_server/runtime/execution_router.py +139 -0
- package/mcp_server/runtime/remote_commands.py +82 -0
- package/mcp_server/runtime/session_kernel.py +96 -0
- package/mcp_server/runtime/tools.py +90 -1
- package/mcp_server/semantic_moves/__init__.py +13 -0
- package/mcp_server/semantic_moves/compiler.py +116 -0
- package/mcp_server/semantic_moves/mix_compilers.py +291 -0
- package/mcp_server/semantic_moves/mix_moves.py +157 -0
- package/mcp_server/semantic_moves/models.py +46 -0
- package/mcp_server/semantic_moves/performance_compilers.py +208 -0
- package/mcp_server/semantic_moves/performance_moves.py +81 -0
- package/mcp_server/semantic_moves/registry.py +32 -0
- package/mcp_server/semantic_moves/resolvers.py +126 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
- package/mcp_server/semantic_moves/tools.py +205 -0
- package/mcp_server/semantic_moves/transition_compilers.py +222 -0
- package/mcp_server/semantic_moves/transition_moves.py +76 -0
- package/mcp_server/server.py +10 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/__init__.py +6 -0
- package/mcp_server/session_continuity/models.py +86 -0
- package/mcp_server/session_continuity/tools.py +230 -0
- package/mcp_server/session_continuity/tracker.py +263 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +504 -0
- package/mcp_server/song_brain/models.py +136 -0
- package/mcp_server/song_brain/tools.py +312 -0
- package/mcp_server/stuckness_detector/__init__.py +5 -0
- package/mcp_server/stuckness_detector/detector.py +400 -0
- package/mcp_server/stuckness_detector/models.py +66 -0
- package/mcp_server/stuckness_detector/tools.py +195 -0
- package/mcp_server/tools/_conductor.py +104 -6
- package/mcp_server/tools/analyzer.py +1 -1
- package/mcp_server/tools/devices.py +34 -0
- package/mcp_server/wonder_mode/__init__.py +6 -0
- package/mcp_server/wonder_mode/diagnosis.py +84 -0
- package/mcp_server/wonder_mode/engine.py +493 -0
- package/mcp_server/wonder_mode/session.py +114 -0
- package/mcp_server/wonder_mode/tools.py +290 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +4 -1
- package/remote_script/LivePilot/devices.py +29 -0
- package/remote_script/LivePilot/tracks.py +11 -4
- package/scripts/generate_tool_catalog.py +131 -0
- package/scripts/sync_metadata.py +132 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Preview Studio MCP tools — 5 tools for creative comparison.
|
|
2
|
+
|
|
3
|
+
create_preview_set — generate safe/strong/unexpected variants
|
|
4
|
+
compare_preview_variants — rank variants by taste + identity + impact
|
|
5
|
+
commit_preview_variant — apply the chosen variant
|
|
6
|
+
discard_preview_set — throw away all variants
|
|
7
|
+
render_preview_variant — render a short preview via undo system
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from fastmcp import Context
|
|
15
|
+
|
|
16
|
+
from ..server import mcp
|
|
17
|
+
from . import engine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_ableton(ctx: Context):
|
|
21
|
+
return ctx.lifespan_context["ableton"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _should_refuse_analytical(compiled_plan, wonder_linked: bool) -> bool:
|
|
25
|
+
"""Check if an analytical variant should be refused in Wonder context."""
|
|
26
|
+
return compiled_plan is None and wonder_linked
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_wonder_session_by_preview(set_id: str):
|
|
30
|
+
"""Find a WonderSession linked to this preview set."""
|
|
31
|
+
try:
|
|
32
|
+
from ..wonder_mode.session import find_session_by_preview_set
|
|
33
|
+
return find_session_by_preview_set(set_id)
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def create_preview_set(
|
|
40
|
+
ctx: Context,
|
|
41
|
+
request_text: str,
|
|
42
|
+
kernel_id: str = "",
|
|
43
|
+
strategy: str = "creative_triptych",
|
|
44
|
+
wonder_session_id: str = "",
|
|
45
|
+
) -> dict:
|
|
46
|
+
"""Create a preview set with multiple creative options.
|
|
47
|
+
|
|
48
|
+
Generates safe / strong / unexpected variants for comparison.
|
|
49
|
+
Each variant includes what it changes, why it matters, and what
|
|
50
|
+
it preserves from the song's identity.
|
|
51
|
+
|
|
52
|
+
request_text: what the user wants (e.g., "make this more magical")
|
|
53
|
+
kernel_id: optional session kernel reference
|
|
54
|
+
strategy: "creative_triptych" (default) or "binary"
|
|
55
|
+
wonder_session_id: optional — links to a WonderSession for lifecycle tracking
|
|
56
|
+
|
|
57
|
+
Returns: preview set with variant summaries.
|
|
58
|
+
"""
|
|
59
|
+
if not request_text.strip():
|
|
60
|
+
return {"error": "request_text cannot be empty"}
|
|
61
|
+
|
|
62
|
+
# Wonder-aware path: use variants from WonderSession
|
|
63
|
+
if wonder_session_id:
|
|
64
|
+
from ..wonder_mode.session import get_wonder_session
|
|
65
|
+
ws = get_wonder_session(wonder_session_id)
|
|
66
|
+
if not ws:
|
|
67
|
+
return {"error": f"Wonder session {wonder_session_id} not found"}
|
|
68
|
+
if not ws.variants:
|
|
69
|
+
return {"error": f"Wonder session {wonder_session_id} has no variants"}
|
|
70
|
+
|
|
71
|
+
from .models import PreviewVariant, PreviewSet
|
|
72
|
+
import time
|
|
73
|
+
|
|
74
|
+
# Filter to executable variants only
|
|
75
|
+
exec_variants = [v for v in ws.variants if not v.get("analytical_only")]
|
|
76
|
+
if not exec_variants:
|
|
77
|
+
return {"error": "No executable variants in Wonder session — all are analytical-only"}
|
|
78
|
+
|
|
79
|
+
now = int(time.time() * 1000)
|
|
80
|
+
preview_variants = []
|
|
81
|
+
for v in exec_variants:
|
|
82
|
+
preview_variants.append(PreviewVariant(
|
|
83
|
+
variant_id=v.get("variant_id", ""),
|
|
84
|
+
label=v.get("label", ""),
|
|
85
|
+
intent=v.get("intent", ""),
|
|
86
|
+
novelty_level=v.get("novelty_level", 0.5),
|
|
87
|
+
identity_effect=v.get("identity_effect", "preserves"),
|
|
88
|
+
what_changed=v.get("what_changed", ""),
|
|
89
|
+
what_preserved=v.get("what_preserved", ""),
|
|
90
|
+
why_it_matters=v.get("why_it_matters", ""),
|
|
91
|
+
move_id=v.get("move_id", ""),
|
|
92
|
+
compiled_plan=v.get("compiled_plan"),
|
|
93
|
+
taste_fit=v.get("taste_fit", 0.5),
|
|
94
|
+
score=v.get("score", 0.0),
|
|
95
|
+
summary=v.get("distinctness_reason", ""),
|
|
96
|
+
created_at_ms=now,
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
set_id = f"ps_wonder_{wonder_session_id[:12]}"
|
|
100
|
+
ps = PreviewSet(
|
|
101
|
+
set_id=set_id,
|
|
102
|
+
request_text=request_text,
|
|
103
|
+
strategy="wonder",
|
|
104
|
+
source_kernel_id=kernel_id,
|
|
105
|
+
variants=preview_variants,
|
|
106
|
+
created_at_ms=now,
|
|
107
|
+
)
|
|
108
|
+
engine.store_preview_set(ps)
|
|
109
|
+
|
|
110
|
+
# Update WonderSession
|
|
111
|
+
ws.preview_set_id = set_id
|
|
112
|
+
ws.transition_to("previewing")
|
|
113
|
+
|
|
114
|
+
return ps.to_dict()
|
|
115
|
+
|
|
116
|
+
# Get request-aware moves via propose_next_best_move logic
|
|
117
|
+
# instead of arbitrary registry order
|
|
118
|
+
available_moves = []
|
|
119
|
+
try:
|
|
120
|
+
from ..semantic_moves import registry
|
|
121
|
+
from ..semantic_moves.tools import propose_next_best_move as _propose
|
|
122
|
+
# Use the proposer's keyword+taste scoring to find relevant moves
|
|
123
|
+
request_lower = request_text.lower()
|
|
124
|
+
all_moves = list(registry._REGISTRY.values())
|
|
125
|
+
scored = []
|
|
126
|
+
for move in all_moves:
|
|
127
|
+
score = 0.0
|
|
128
|
+
move_words = set(move.move_id.replace("_", " ").split())
|
|
129
|
+
intent_words = set(move.intent.lower().split())
|
|
130
|
+
request_words = set(request_lower.split())
|
|
131
|
+
overlap = request_words & (move_words | intent_words)
|
|
132
|
+
score += len(overlap) * 0.3
|
|
133
|
+
for dim in move.targets:
|
|
134
|
+
if dim in request_lower:
|
|
135
|
+
score += 0.2
|
|
136
|
+
if score > 0:
|
|
137
|
+
scored.append((move.to_dict(), score))
|
|
138
|
+
scored.sort(key=lambda x: -x[1])
|
|
139
|
+
available_moves = [m for m, _ in scored[:3]]
|
|
140
|
+
# Fallback: if no keyword match, take top 3 from full registry
|
|
141
|
+
if not available_moves:
|
|
142
|
+
available_moves = registry.list_moves()[:3]
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# Get song brain if available
|
|
147
|
+
song_brain: dict = {}
|
|
148
|
+
try:
|
|
149
|
+
from ..song_brain.tools import _current_brain
|
|
150
|
+
if _current_brain is not None:
|
|
151
|
+
song_brain = _current_brain.to_dict()
|
|
152
|
+
except Exception as _e:
|
|
153
|
+
if __debug__:
|
|
154
|
+
import sys
|
|
155
|
+
print(f"LivePilot: SongBrain unavailable in preview_studio: {_e}", file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
# Get taste graph — session + persistent stores
|
|
158
|
+
taste_graph: dict = {}
|
|
159
|
+
try:
|
|
160
|
+
from ..memory.taste_graph import build_taste_graph
|
|
161
|
+
from ..memory.taste_memory import TasteMemoryStore
|
|
162
|
+
from ..memory.anti_memory import AntiMemoryStore
|
|
163
|
+
from ..persistence.taste_store import PersistentTasteStore
|
|
164
|
+
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
165
|
+
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
166
|
+
persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
|
|
167
|
+
graph = build_taste_graph(
|
|
168
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
169
|
+
persistent_store=persistent,
|
|
170
|
+
)
|
|
171
|
+
taste_graph = graph.to_dict()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
ps = engine.create_preview_set(
|
|
176
|
+
request_text=request_text,
|
|
177
|
+
kernel_id=kernel_id,
|
|
178
|
+
strategy=strategy,
|
|
179
|
+
available_moves=available_moves,
|
|
180
|
+
song_brain=song_brain,
|
|
181
|
+
taste_graph=taste_graph,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return ps.to_dict()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@mcp.tool()
|
|
188
|
+
def compare_preview_variants(
|
|
189
|
+
ctx: Context,
|
|
190
|
+
set_id: str,
|
|
191
|
+
taste_weight: float = 0.3,
|
|
192
|
+
novelty_weight: float = 0.2,
|
|
193
|
+
identity_weight: float = 0.5,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Compare and rank variants in a preview set.
|
|
196
|
+
|
|
197
|
+
Rankings combine taste fit, novelty balance, and identity preservation.
|
|
198
|
+
Returns ranked list with scores and a recommended pick.
|
|
199
|
+
|
|
200
|
+
set_id: the preview set to compare
|
|
201
|
+
taste_weight: how much to weight user taste fit (0-1)
|
|
202
|
+
novelty_weight: how much to weight novelty balance (0-1)
|
|
203
|
+
identity_weight: how much to weight identity preservation (0-1)
|
|
204
|
+
"""
|
|
205
|
+
ps = engine.get_preview_set(set_id)
|
|
206
|
+
if not ps:
|
|
207
|
+
return {"error": f"Preview set {set_id} not found"}
|
|
208
|
+
|
|
209
|
+
criteria = {
|
|
210
|
+
"taste_weight": taste_weight,
|
|
211
|
+
"novelty_weight": novelty_weight,
|
|
212
|
+
"identity_weight": identity_weight,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
comparison = engine.compare_variants(ps, criteria)
|
|
216
|
+
return comparison
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@mcp.tool()
|
|
220
|
+
def commit_preview_variant(
|
|
221
|
+
ctx: Context,
|
|
222
|
+
set_id: str,
|
|
223
|
+
variant_id: str,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""Commit the chosen variant from a preview set.
|
|
226
|
+
|
|
227
|
+
Marks the variant as committed and discards the others.
|
|
228
|
+
The caller should then apply the variant's compiled plan.
|
|
229
|
+
|
|
230
|
+
set_id: the preview set
|
|
231
|
+
variant_id: the chosen variant to commit
|
|
232
|
+
"""
|
|
233
|
+
ps = engine.get_preview_set(set_id)
|
|
234
|
+
if not ps:
|
|
235
|
+
return {"error": f"Preview set {set_id} not found"}
|
|
236
|
+
|
|
237
|
+
chosen = engine.commit_variant(ps, variant_id)
|
|
238
|
+
if not chosen:
|
|
239
|
+
available = [v.variant_id for v in ps.variants]
|
|
240
|
+
return {
|
|
241
|
+
"error": f"Variant {variant_id} not found in set {set_id}",
|
|
242
|
+
"available_variants": available,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
result = {
|
|
246
|
+
"committed": True,
|
|
247
|
+
"variant_id": chosen.variant_id,
|
|
248
|
+
"label": chosen.label,
|
|
249
|
+
"intent": chosen.intent,
|
|
250
|
+
"move_id": chosen.move_id,
|
|
251
|
+
"identity_effect": chosen.identity_effect,
|
|
252
|
+
"what_preserved": chosen.what_preserved,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Wonder lifecycle hooks
|
|
256
|
+
ws = _find_wonder_session_by_preview(set_id)
|
|
257
|
+
if ws:
|
|
258
|
+
ws.selected_variant_id = variant_id
|
|
259
|
+
ws.outcome = "committed"
|
|
260
|
+
ws.transition_to("resolved")
|
|
261
|
+
|
|
262
|
+
# Record accepted turn resolution
|
|
263
|
+
try:
|
|
264
|
+
from ..session_continuity.tracker import record_turn_resolution, resolve_thread
|
|
265
|
+
record_turn_resolution(
|
|
266
|
+
request_text=ws.request_text,
|
|
267
|
+
outcome="accepted",
|
|
268
|
+
move_applied=chosen.move_id,
|
|
269
|
+
identity_effect=chosen.identity_effect,
|
|
270
|
+
user_sentiment="liked",
|
|
271
|
+
)
|
|
272
|
+
if ws.creative_thread_id:
|
|
273
|
+
resolve_thread(ws.creative_thread_id)
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
# Update taste graph (with persistent backing)
|
|
278
|
+
try:
|
|
279
|
+
from ..memory.taste_graph import build_taste_graph
|
|
280
|
+
from ..memory.taste_memory import TasteMemoryStore
|
|
281
|
+
from ..memory.anti_memory import AntiMemoryStore
|
|
282
|
+
from ..persistence.taste_store import PersistentTasteStore
|
|
283
|
+
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
284
|
+
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
285
|
+
persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
|
|
286
|
+
graph = build_taste_graph(
|
|
287
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
288
|
+
persistent_store=persistent,
|
|
289
|
+
)
|
|
290
|
+
# Look up family from WonderSession's variant list
|
|
291
|
+
family = ""
|
|
292
|
+
for v in ws.variants:
|
|
293
|
+
if v.get("variant_id") == variant_id:
|
|
294
|
+
family = v.get("family", "")
|
|
295
|
+
break
|
|
296
|
+
if chosen.move_id and family:
|
|
297
|
+
graph.record_move_outcome(
|
|
298
|
+
move_id=chosen.move_id,
|
|
299
|
+
family=family,
|
|
300
|
+
kept=True,
|
|
301
|
+
)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
result["wonder_session_id"] = ws.session_id
|
|
306
|
+
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@mcp.tool()
|
|
311
|
+
def render_preview_variant(
|
|
312
|
+
ctx: Context,
|
|
313
|
+
set_id: str = "",
|
|
314
|
+
variant_id: str = "",
|
|
315
|
+
bars: int = 8,
|
|
316
|
+
) -> dict:
|
|
317
|
+
"""Render a short preview of a specific variant for evaluation.
|
|
318
|
+
|
|
319
|
+
Captures a snapshot of what the variant would sound like if applied,
|
|
320
|
+
without permanently changing the session. Uses Ableton's undo system
|
|
321
|
+
to revert after capture.
|
|
322
|
+
|
|
323
|
+
set_id: the preview set containing the variant
|
|
324
|
+
variant_id: which variant to render
|
|
325
|
+
bars: how many bars to capture (default 8)
|
|
326
|
+
|
|
327
|
+
Returns the variant's snapshot data and summary.
|
|
328
|
+
"""
|
|
329
|
+
ps = engine.get_preview_set(set_id)
|
|
330
|
+
if not ps:
|
|
331
|
+
return {"error": f"Preview set {set_id} not found"}
|
|
332
|
+
|
|
333
|
+
variant = None
|
|
334
|
+
for v in ps.variants:
|
|
335
|
+
if v.variant_id == variant_id:
|
|
336
|
+
variant = v
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if not variant:
|
|
340
|
+
available = [v.variant_id for v in ps.variants]
|
|
341
|
+
return {
|
|
342
|
+
"error": f"Variant {variant_id} not found in set {set_id}",
|
|
343
|
+
"available_variants": available,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Wonder-linked context: refuse analytical variants
|
|
347
|
+
wonder_linked = _find_wonder_session_by_preview(set_id) is not None
|
|
348
|
+
if _should_refuse_analytical(variant.compiled_plan, wonder_linked):
|
|
349
|
+
return {
|
|
350
|
+
"error": "This variant is analytical-only and cannot be previewed",
|
|
351
|
+
"variant_id": variant_id,
|
|
352
|
+
"analytical_only": True,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# If the variant has a compiled plan, we could apply-capture-undo.
|
|
356
|
+
# Without a compiled plan, return the variant's analytical preview.
|
|
357
|
+
if variant.compiled_plan:
|
|
358
|
+
ableton = _get_ableton(ctx)
|
|
359
|
+
# compiled_plan may be a list (from semantic moves) or a dict with "steps" key
|
|
360
|
+
plan = variant.compiled_plan
|
|
361
|
+
steps = plan if isinstance(plan, list) else plan.get("steps", [])
|
|
362
|
+
|
|
363
|
+
from ..runtime.execution_router import execute_plan_steps
|
|
364
|
+
|
|
365
|
+
applied_count = 0
|
|
366
|
+
try:
|
|
367
|
+
# Capture before state
|
|
368
|
+
before_info = ableton.send_command("get_session_info", {})
|
|
369
|
+
|
|
370
|
+
# Execute through unified router
|
|
371
|
+
exec_results = execute_plan_steps(steps, ableton=ableton, ctx=ctx)
|
|
372
|
+
applied_count = sum(1 for r in exec_results if r.ok)
|
|
373
|
+
|
|
374
|
+
# Capture after state
|
|
375
|
+
after_info = ableton.send_command("get_session_info", {})
|
|
376
|
+
except Exception as e:
|
|
377
|
+
return {"error": f"Render failed: {e}", "variant_id": variant_id}
|
|
378
|
+
finally:
|
|
379
|
+
# Undo all applied changes regardless of success/failure
|
|
380
|
+
for _ in range(applied_count):
|
|
381
|
+
try:
|
|
382
|
+
ableton.send_command("undo")
|
|
383
|
+
except Exception:
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
# Determine preview mode: audible (M4L available) or metadata-only
|
|
387
|
+
preview_mode = "metadata_only_preview"
|
|
388
|
+
spectral_before = None
|
|
389
|
+
spectral_after = None
|
|
390
|
+
|
|
391
|
+
# Try audible preview — capture spectrum via M4L spectral cache
|
|
392
|
+
try:
|
|
393
|
+
from ..m4l_bridge import SpectralCache
|
|
394
|
+
cache = ctx.lifespan_context.get("spectral_cache")
|
|
395
|
+
if cache and isinstance(cache, SpectralCache) and cache.has_data():
|
|
396
|
+
spectral_before = cache.get_snapshot()
|
|
397
|
+
# Play for the requested bar count
|
|
398
|
+
tempo = before_info.get("tempo", 120)
|
|
399
|
+
play_seconds = bars * (60.0 / tempo) * 4 # bars * beat_duration * 4 beats
|
|
400
|
+
ableton.send_command("start_playback", {})
|
|
401
|
+
import time as _time
|
|
402
|
+
_time.sleep(min(play_seconds, 8.0)) # cap at 8 seconds
|
|
403
|
+
spectral_after = cache.get_snapshot()
|
|
404
|
+
ableton.send_command("stop_playback", {})
|
|
405
|
+
preview_mode = "audible_preview"
|
|
406
|
+
except Exception:
|
|
407
|
+
pass # fall back to metadata_only
|
|
408
|
+
|
|
409
|
+
variant.status = "rendered"
|
|
410
|
+
variant.preview_mode = preview_mode
|
|
411
|
+
variant.render_ref = f"render_{variant_id}_{bars}bars"
|
|
412
|
+
|
|
413
|
+
result = {
|
|
414
|
+
"rendered": True,
|
|
415
|
+
"variant_id": variant_id,
|
|
416
|
+
"label": variant.label,
|
|
417
|
+
"bars": bars,
|
|
418
|
+
"preview_mode": preview_mode,
|
|
419
|
+
"before_summary": {"tempo": before_info.get("tempo"), "tracks": before_info.get("track_count")},
|
|
420
|
+
"after_summary": {"tempo": after_info.get("tempo"), "tracks": after_info.get("track_count")},
|
|
421
|
+
"identity_effect": variant.identity_effect,
|
|
422
|
+
"what_changed": variant.what_changed,
|
|
423
|
+
"what_preserved": variant.what_preserved,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if spectral_before and spectral_after:
|
|
427
|
+
result["spectral_comparison"] = {
|
|
428
|
+
"before": spectral_before,
|
|
429
|
+
"after": spectral_after,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return result
|
|
433
|
+
else:
|
|
434
|
+
# Analytical preview — no live render
|
|
435
|
+
variant.status = "rendered"
|
|
436
|
+
variant.preview_mode = "analytical_preview"
|
|
437
|
+
return {
|
|
438
|
+
"rendered": True,
|
|
439
|
+
"variant_id": variant_id,
|
|
440
|
+
"label": variant.label,
|
|
441
|
+
"bars": bars,
|
|
442
|
+
"preview_mode": "analytical_preview",
|
|
443
|
+
"intent": variant.intent,
|
|
444
|
+
"novelty_level": variant.novelty_level,
|
|
445
|
+
"identity_effect": variant.identity_effect,
|
|
446
|
+
"what_changed": variant.what_changed,
|
|
447
|
+
"what_preserved": variant.what_preserved,
|
|
448
|
+
"why_it_matters": variant.why_it_matters,
|
|
449
|
+
"note": "Analytical preview — no compiled plan available for live render",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@mcp.tool()
|
|
454
|
+
def discard_preview_set(
|
|
455
|
+
ctx: Context,
|
|
456
|
+
set_id: str,
|
|
457
|
+
) -> dict:
|
|
458
|
+
"""Discard an entire preview set and all its variants.
|
|
459
|
+
|
|
460
|
+
Use when the user doesn't want any of the options.
|
|
461
|
+
"""
|
|
462
|
+
success = engine.discard_set(set_id)
|
|
463
|
+
if not success:
|
|
464
|
+
return {"error": f"Preview set {set_id} not found"}
|
|
465
|
+
|
|
466
|
+
return {"discarded": True, "set_id": set_id}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Capability and degradation reporting for advanced tools.
|
|
2
|
+
|
|
3
|
+
Every advanced tool reports its operational state so callers know
|
|
4
|
+
what data was available, what was missing, and how much to trust
|
|
5
|
+
the result.
|
|
6
|
+
|
|
7
|
+
Levels:
|
|
8
|
+
full — all required data sources available
|
|
9
|
+
fallback — some data missing, result is degraded but useful
|
|
10
|
+
analytical_only — no live data, pure heuristic
|
|
11
|
+
unavailable — cannot operate at all
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CapabilityReport:
|
|
21
|
+
"""Operational state of an advanced tool invocation."""
|
|
22
|
+
|
|
23
|
+
level: str = "full" # full, fallback, analytical_only, unavailable
|
|
24
|
+
confidence: float = 1.0
|
|
25
|
+
available_sources: list[str] = field(default_factory=list)
|
|
26
|
+
missing_sources: list[str] = field(default_factory=list)
|
|
27
|
+
fallback_used: str = ""
|
|
28
|
+
reason: str = ""
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
d = {"capability": self.level, "confidence": round(self.confidence, 2)}
|
|
32
|
+
if self.missing_sources:
|
|
33
|
+
d["missing"] = self.missing_sources
|
|
34
|
+
if self.fallback_used:
|
|
35
|
+
d["fallback"] = self.fallback_used
|
|
36
|
+
if self.reason:
|
|
37
|
+
d["reason"] = self.reason
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_capability(
|
|
42
|
+
required: list[str],
|
|
43
|
+
available: dict[str, bool],
|
|
44
|
+
) -> CapabilityReport:
|
|
45
|
+
"""Build a capability report from required vs available data sources."""
|
|
46
|
+
missing = [r for r in required if not available.get(r, False)]
|
|
47
|
+
present = [r for r in required if available.get(r, False)]
|
|
48
|
+
|
|
49
|
+
if not missing:
|
|
50
|
+
return CapabilityReport(
|
|
51
|
+
level="full", confidence=1.0, available_sources=present,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if len(missing) == len(required):
|
|
55
|
+
return CapabilityReport(
|
|
56
|
+
level="analytical_only", confidence=0.2,
|
|
57
|
+
available_sources=[], missing_sources=missing,
|
|
58
|
+
reason="No required data sources available",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
ratio = len(present) / len(required)
|
|
62
|
+
return CapabilityReport(
|
|
63
|
+
level="fallback", confidence=round(ratio * 0.8, 2),
|
|
64
|
+
available_sources=present, missing_sources=missing,
|
|
65
|
+
fallback_used="degraded inference from partial data",
|
|
66
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Runtime capability probe — detects what's available at startup.
|
|
2
|
+
|
|
3
|
+
Reports capability tiers: Core Control, Analyzer-Enhanced,
|
|
4
|
+
Offline Analysis, Creative Intelligence, Persistent Memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def probe_capabilities(
|
|
15
|
+
ableton: Any = None,
|
|
16
|
+
ctx: Any = None,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""Probe runtime capabilities and return a structured report.
|
|
19
|
+
|
|
20
|
+
Can be called at startup or on demand via --doctor.
|
|
21
|
+
"""
|
|
22
|
+
report: dict[str, dict] = {}
|
|
23
|
+
|
|
24
|
+
# 1. Ableton reachability
|
|
25
|
+
ableton_ok = False
|
|
26
|
+
if ableton is not None:
|
|
27
|
+
try:
|
|
28
|
+
info = ableton.send_command("ping")
|
|
29
|
+
ableton_ok = info is not None
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
report["ableton"] = {
|
|
33
|
+
"status": "ok" if ableton_ok else "unavailable",
|
|
34
|
+
"detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# 2. Remote Script parity
|
|
38
|
+
from .remote_commands import REMOTE_COMMANDS
|
|
39
|
+
report["remote_script"] = {
|
|
40
|
+
"status": "ok",
|
|
41
|
+
"command_count": len(REMOTE_COMMANDS),
|
|
42
|
+
"detail": f"{len(REMOTE_COMMANDS)} registered commands",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# 3. M4L bridge
|
|
46
|
+
bridge_ok = False
|
|
47
|
+
if ctx is not None:
|
|
48
|
+
bridge = getattr(ctx, "lifespan_context", {}).get("m4l_bridge") if hasattr(ctx, "lifespan_context") else None
|
|
49
|
+
bridge_ok = bridge is not None
|
|
50
|
+
report["m4l_bridge"] = {
|
|
51
|
+
"status": "ok" if bridge_ok else "unavailable",
|
|
52
|
+
"detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 30 analyzer tools unavailable",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 4. Offline perception
|
|
56
|
+
numpy_ok = False
|
|
57
|
+
try:
|
|
58
|
+
import numpy # noqa: F401
|
|
59
|
+
numpy_ok = True
|
|
60
|
+
except ImportError:
|
|
61
|
+
pass
|
|
62
|
+
report["offline_perception"] = {
|
|
63
|
+
"status": "ok" if numpy_ok else "degraded",
|
|
64
|
+
"detail": "numpy available" if numpy_ok else "numpy not installed — offline analysis unavailable",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# 5. Persistence
|
|
68
|
+
livepilot_dir = Path.home() / ".livepilot"
|
|
69
|
+
persistence_ok = livepilot_dir.exists() and os.access(livepilot_dir, os.W_OK)
|
|
70
|
+
taste_exists = (livepilot_dir / "taste.json").exists()
|
|
71
|
+
techniques_exists = (livepilot_dir / "memory" / "techniques.json").exists()
|
|
72
|
+
report["persistence"] = {
|
|
73
|
+
"status": "ok" if persistence_ok else "unavailable",
|
|
74
|
+
"detail": f"~/.livepilot/ {'writable' if persistence_ok else 'not found'}",
|
|
75
|
+
"taste_store": taste_exists,
|
|
76
|
+
"technique_store": techniques_exists,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# 6. Capability tier — highest active tier
|
|
80
|
+
if ableton_ok and bridge_ok:
|
|
81
|
+
tier = "analyzer_enhanced"
|
|
82
|
+
elif ableton_ok:
|
|
83
|
+
tier = "core_control"
|
|
84
|
+
else:
|
|
85
|
+
tier = "creative_intelligence" # heuristic-only, no Ableton connection
|
|
86
|
+
|
|
87
|
+
report["tier"] = {
|
|
88
|
+
"active": tier,
|
|
89
|
+
"levels": {
|
|
90
|
+
"core_control": ableton_ok,
|
|
91
|
+
"analyzer_enhanced": ableton_ok and bridge_ok,
|
|
92
|
+
"offline_analysis": numpy_ok,
|
|
93
|
+
"creative_intelligence": True, # always available
|
|
94
|
+
"persistent_memory": persistence_ok,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return report
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_doctor_report(report: dict) -> str:
|
|
102
|
+
"""Format capability report for --doctor output."""
|
|
103
|
+
lines = ["LivePilot Capability Report", "=" * 40]
|
|
104
|
+
|
|
105
|
+
icons = {"ok": " PASS", "unavailable": " FAIL", "degraded": " WARN"}
|
|
106
|
+
|
|
107
|
+
for area in ["ableton", "remote_script", "m4l_bridge", "offline_perception", "persistence"]:
|
|
108
|
+
info = report.get(area, {})
|
|
109
|
+
status = info.get("status", "unknown")
|
|
110
|
+
icon = icons.get(status, " ????")
|
|
111
|
+
detail = info.get("detail", "")
|
|
112
|
+
lines.append(f"{icon} {area}: {detail}")
|
|
113
|
+
|
|
114
|
+
tier = report.get("tier", {}).get("active", "unknown")
|
|
115
|
+
lines.append("")
|
|
116
|
+
lines.append(f"Active tier: {tier}")
|
|
117
|
+
|
|
118
|
+
return "\n".join(lines)
|