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.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +3 -3
  4. package/CHANGELOG.md +84 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +141 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -23
  12. package/livepilot/commands/mix.md +34 -19
  13. package/livepilot/commands/perform.md +31 -19
  14. package/livepilot/commands/sounddesign.md +38 -25
  15. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  17. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  18. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  19. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  20. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  21. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  22. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  23. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  24. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  25. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  26. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  27. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  28. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  29. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  30. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  31. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  32. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  33. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  34. package/livepilot/skills/livepilot-release/SKILL.md +29 -15
  35. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  36. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  37. package/livepilot.mcpb +0 -0
  38. package/manifest.json +91 -0
  39. package/mcp_server/__init__.py +1 -1
  40. package/mcp_server/creative_constraints/__init__.py +6 -0
  41. package/mcp_server/creative_constraints/engine.py +277 -0
  42. package/mcp_server/creative_constraints/models.py +75 -0
  43. package/mcp_server/creative_constraints/tools.py +341 -0
  44. package/mcp_server/experiment/__init__.py +6 -0
  45. package/mcp_server/experiment/engine.py +213 -0
  46. package/mcp_server/experiment/models.py +120 -0
  47. package/mcp_server/experiment/tools.py +263 -0
  48. package/mcp_server/hook_hunter/__init__.py +5 -0
  49. package/mcp_server/hook_hunter/analyzer.py +365 -0
  50. package/mcp_server/hook_hunter/models.py +58 -0
  51. package/mcp_server/hook_hunter/tools.py +588 -0
  52. package/mcp_server/memory/taste_graph.py +328 -0
  53. package/mcp_server/memory/tools.py +99 -0
  54. package/mcp_server/mix_engine/critics.py +2 -2
  55. package/mcp_server/mix_engine/models.py +1 -1
  56. package/mcp_server/mix_engine/state_builder.py +2 -2
  57. package/mcp_server/musical_intelligence/__init__.py +8 -0
  58. package/mcp_server/musical_intelligence/detectors.py +434 -0
  59. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  60. package/mcp_server/musical_intelligence/tools.py +224 -0
  61. package/mcp_server/persistence/__init__.py +1 -0
  62. package/mcp_server/persistence/base_store.py +82 -0
  63. package/mcp_server/persistence/project_store.py +106 -0
  64. package/mcp_server/persistence/taste_store.py +122 -0
  65. package/mcp_server/preview_studio/__init__.py +5 -0
  66. package/mcp_server/preview_studio/engine.py +280 -0
  67. package/mcp_server/preview_studio/models.py +74 -0
  68. package/mcp_server/preview_studio/tools.py +466 -0
  69. package/mcp_server/runtime/capability.py +66 -0
  70. package/mcp_server/runtime/capability_probe.py +118 -0
  71. package/mcp_server/runtime/execution_router.py +139 -0
  72. package/mcp_server/runtime/remote_commands.py +82 -0
  73. package/mcp_server/runtime/session_kernel.py +96 -0
  74. package/mcp_server/runtime/tools.py +90 -1
  75. package/mcp_server/semantic_moves/__init__.py +13 -0
  76. package/mcp_server/semantic_moves/compiler.py +116 -0
  77. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  78. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  79. package/mcp_server/semantic_moves/models.py +46 -0
  80. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  81. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  82. package/mcp_server/semantic_moves/registry.py +32 -0
  83. package/mcp_server/semantic_moves/resolvers.py +126 -0
  84. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  85. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  86. package/mcp_server/semantic_moves/tools.py +205 -0
  87. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  88. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  89. package/mcp_server/server.py +10 -0
  90. package/mcp_server/services/__init__.py +1 -0
  91. package/mcp_server/services/motif_service.py +67 -0
  92. package/mcp_server/session_continuity/__init__.py +6 -0
  93. package/mcp_server/session_continuity/models.py +86 -0
  94. package/mcp_server/session_continuity/tools.py +230 -0
  95. package/mcp_server/session_continuity/tracker.py +263 -0
  96. package/mcp_server/song_brain/__init__.py +6 -0
  97. package/mcp_server/song_brain/builder.py +504 -0
  98. package/mcp_server/song_brain/models.py +136 -0
  99. package/mcp_server/song_brain/tools.py +312 -0
  100. package/mcp_server/stuckness_detector/__init__.py +5 -0
  101. package/mcp_server/stuckness_detector/detector.py +400 -0
  102. package/mcp_server/stuckness_detector/models.py +66 -0
  103. package/mcp_server/stuckness_detector/tools.py +195 -0
  104. package/mcp_server/tools/_conductor.py +104 -6
  105. package/mcp_server/tools/analyzer.py +1 -1
  106. package/mcp_server/tools/devices.py +34 -0
  107. package/mcp_server/wonder_mode/__init__.py +6 -0
  108. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  109. package/mcp_server/wonder_mode/engine.py +493 -0
  110. package/mcp_server/wonder_mode/session.py +114 -0
  111. package/mcp_server/wonder_mode/tools.py +290 -0
  112. package/package.json +2 -2
  113. package/remote_script/LivePilot/__init__.py +1 -1
  114. package/remote_script/LivePilot/browser.py +4 -1
  115. package/remote_script/LivePilot/devices.py +29 -0
  116. package/remote_script/LivePilot/tracks.py +11 -4
  117. package/scripts/generate_tool_catalog.py +131 -0
  118. 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)