livepilot 1.17.0 → 1.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -0
- package/README.md +8 -7
- package/installer/codex.js +14 -0
- package/m4l_device/BUILD_GUIDE.md +24 -20
- 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/m4l_bridge.py +2 -1
- package/mcp_server/preview_studio/engine.py +85 -11
- package/mcp_server/preview_studio/models.py +8 -0
- package/mcp_server/preview_studio/tools.py +98 -48
- package/mcp_server/runtime/capability_state.py +18 -0
- package/mcp_server/runtime/degradation.py +62 -0
- package/mcp_server/runtime/tools.py +53 -4
- package/mcp_server/sample_engine/tools.py +66 -65
- package/mcp_server/song_brain/tools.py +23 -0
- package/mcp_server/synthesis_brain/timbre.py +14 -8
- package/mcp_server/tools/_agent_os_engine/__init__.py +10 -0
- package/mcp_server/tools/_agent_os_engine/iteration.py +344 -0
- package/mcp_server/tools/agent_os.py +194 -3
- package/mcp_server/tools/analyzer.py +19 -6
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/requirements.txt +5 -5
- package/server.json +3 -3
|
@@ -270,7 +270,68 @@ async def commit_preview_variant(
|
|
|
270
270
|
if not ps:
|
|
271
271
|
return {"error": f"Preview set {set_id} not found"}
|
|
272
272
|
|
|
273
|
+
# Resolve the chosen variant WITHOUT mutating state yet. We have to
|
|
274
|
+
# short-circuit analytical-only / blocked picks BEFORE engine.commit_variant
|
|
275
|
+
# runs, otherwise `preview_set.status` gets flipped to "committed" and
|
|
276
|
+
# sibling variants get discarded even though nothing executed.
|
|
277
|
+
chosen = None
|
|
278
|
+
for v in ps.variants:
|
|
279
|
+
if v.variant_id == variant_id:
|
|
280
|
+
chosen = v
|
|
281
|
+
break
|
|
282
|
+
if not chosen:
|
|
283
|
+
available = [v.variant_id for v in ps.variants]
|
|
284
|
+
return {
|
|
285
|
+
"error": f"Variant {variant_id} not found in set {set_id}",
|
|
286
|
+
"available_variants": available,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# ── Truth-gap guard: refuse to "commit" a variant that can't execute ──
|
|
290
|
+
# If the variant was flagged blocked/failed upstream or lacks a
|
|
291
|
+
# compiled plan, the old code still marked preview_set.status='committed'
|
|
292
|
+
# and returned committed=False as a silent contradiction. Close that
|
|
293
|
+
# gap: return an honest no-op and leave state untouched so the caller
|
|
294
|
+
# can pick a different variant.
|
|
295
|
+
plan = chosen.compiled_plan
|
|
296
|
+
plan_is_empty = (
|
|
297
|
+
plan is None
|
|
298
|
+
or (isinstance(plan, list) and len(plan) == 0)
|
|
299
|
+
or (isinstance(plan, dict) and len(plan.get("steps") or []) == 0)
|
|
300
|
+
)
|
|
301
|
+
blocked = chosen.status in {"blocked", "failed"}
|
|
302
|
+
if plan_is_empty or blocked:
|
|
303
|
+
reason = "blocked" if blocked and plan_is_empty is False else "analytical_only"
|
|
304
|
+
return {
|
|
305
|
+
"committed": False,
|
|
306
|
+
"status": "analytical_only" if reason == "analytical_only" else "blocked",
|
|
307
|
+
"reason": reason,
|
|
308
|
+
"preview_set_id": set_id,
|
|
309
|
+
"variant_id": chosen.variant_id,
|
|
310
|
+
"label": chosen.label,
|
|
311
|
+
"intent": chosen.intent,
|
|
312
|
+
"move_id": chosen.move_id,
|
|
313
|
+
"identity_effect": chosen.identity_effect,
|
|
314
|
+
"what_preserved": chosen.what_preserved,
|
|
315
|
+
"message": (
|
|
316
|
+
"chose analytical variant; no session changes applied"
|
|
317
|
+
if reason == "analytical_only"
|
|
318
|
+
else "variant is blocked; no session changes applied"
|
|
319
|
+
),
|
|
320
|
+
"note": (
|
|
321
|
+
"Variant has no compiled plan (analytical-only). Preview set "
|
|
322
|
+
"was left in its pre-commit state so you can pick a different "
|
|
323
|
+
"variant."
|
|
324
|
+
if reason == "analytical_only"
|
|
325
|
+
else "Variant is blocked/failed. Preview set was left in its "
|
|
326
|
+
"pre-commit state so you can pick a different variant."
|
|
327
|
+
),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Only now do we flip state — the chosen variant has an executable plan.
|
|
273
331
|
chosen = engine.commit_variant(ps, variant_id)
|
|
332
|
+
# engine.commit_variant cannot return None here (we already verified
|
|
333
|
+
# the variant_id exists), but keep the defensive check for the type
|
|
334
|
+
# checker.
|
|
274
335
|
if not chosen:
|
|
275
336
|
available = [v.variant_id for v in ps.variants]
|
|
276
337
|
return {
|
|
@@ -289,55 +350,44 @@ async def commit_preview_variant(
|
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
# ── v1.10.3: actually execute the compiled plan ──
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
353
|
+
from ..runtime.execution_router import execute_plan_steps_async
|
|
354
|
+
plan = chosen.compiled_plan
|
|
355
|
+
steps = plan if isinstance(plan, list) else plan.get("steps", []) or []
|
|
356
|
+
ableton = _get_ableton(ctx)
|
|
357
|
+
bridge = ctx.lifespan_context.get("m4l")
|
|
358
|
+
mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
|
|
359
|
+
|
|
360
|
+
exec_results = await execute_plan_steps_async(
|
|
361
|
+
steps,
|
|
362
|
+
ableton=ableton,
|
|
363
|
+
bridge=bridge,
|
|
364
|
+
mcp_registry=mcp_registry,
|
|
365
|
+
ctx=ctx,
|
|
366
|
+
stop_on_failure=False,
|
|
367
|
+
)
|
|
368
|
+
log = [
|
|
369
|
+
{
|
|
370
|
+
"tool": r.tool,
|
|
371
|
+
"backend": r.backend,
|
|
372
|
+
"ok": r.ok,
|
|
373
|
+
**({"result": r.result} if r.ok else {"error": r.error}),
|
|
374
|
+
}
|
|
375
|
+
for r in exec_results
|
|
376
|
+
]
|
|
377
|
+
steps_ok = sum(1 for r in exec_results if r.ok)
|
|
378
|
+
steps_failed = len(exec_results) - steps_ok
|
|
379
|
+
|
|
380
|
+
result["execution_log"] = log
|
|
381
|
+
result["steps_ok"] = steps_ok
|
|
382
|
+
result["steps_failed"] = steps_failed
|
|
383
|
+
|
|
384
|
+
if steps_failed == 0 and steps_ok > 0:
|
|
385
|
+
result["status"] = "committed"
|
|
386
|
+
elif steps_ok > 0:
|
|
387
|
+
result["status"] = "committed_with_errors"
|
|
302
388
|
else:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
steps = plan if isinstance(plan, list) else plan.get("steps", []) or []
|
|
306
|
-
ableton = _get_ableton(ctx)
|
|
307
|
-
bridge = ctx.lifespan_context.get("m4l")
|
|
308
|
-
mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
|
|
309
|
-
|
|
310
|
-
exec_results = await execute_plan_steps_async(
|
|
311
|
-
steps,
|
|
312
|
-
ableton=ableton,
|
|
313
|
-
bridge=bridge,
|
|
314
|
-
mcp_registry=mcp_registry,
|
|
315
|
-
ctx=ctx,
|
|
316
|
-
stop_on_failure=False,
|
|
317
|
-
)
|
|
318
|
-
log = [
|
|
319
|
-
{
|
|
320
|
-
"tool": r.tool,
|
|
321
|
-
"backend": r.backend,
|
|
322
|
-
"ok": r.ok,
|
|
323
|
-
**({"result": r.result} if r.ok else {"error": r.error}),
|
|
324
|
-
}
|
|
325
|
-
for r in exec_results
|
|
326
|
-
]
|
|
327
|
-
steps_ok = sum(1 for r in exec_results if r.ok)
|
|
328
|
-
steps_failed = len(exec_results) - steps_ok
|
|
329
|
-
|
|
330
|
-
result["execution_log"] = log
|
|
331
|
-
result["steps_ok"] = steps_ok
|
|
332
|
-
result["steps_failed"] = steps_failed
|
|
333
|
-
|
|
334
|
-
if steps_failed == 0 and steps_ok > 0:
|
|
335
|
-
result["status"] = "committed"
|
|
336
|
-
elif steps_ok > 0:
|
|
337
|
-
result["status"] = "committed_with_errors"
|
|
338
|
-
else:
|
|
339
|
-
result["status"] = "failed"
|
|
340
|
-
result["committed"] = False
|
|
389
|
+
result["status"] = "failed"
|
|
390
|
+
result["committed"] = False
|
|
341
391
|
|
|
342
392
|
# Wonder lifecycle hooks
|
|
343
393
|
ws = _find_wonder_session_by_preview(set_id)
|
|
@@ -155,6 +155,9 @@ def build_capability_state(
|
|
|
155
155
|
)
|
|
156
156
|
|
|
157
157
|
# ── web ──────────────────────────────────────────────────────────
|
|
158
|
+
# Server-side outbound HTTP capability. True when the MCP host can
|
|
159
|
+
# reach an arbitrary public URL. Does NOT imply curated research
|
|
160
|
+
# corpora are installed — see the ``research`` domain below.
|
|
158
161
|
web_reasons: list[str] = []
|
|
159
162
|
if not web_ok:
|
|
160
163
|
web_reasons.append("web_unavailable")
|
|
@@ -166,6 +169,21 @@ def build_capability_state(
|
|
|
166
169
|
reasons=web_reasons,
|
|
167
170
|
)
|
|
168
171
|
|
|
172
|
+
# ── flucoma ──────────────────────────────────────────────────────
|
|
173
|
+
# Optional dependency (the ``flucoma`` Python package). Emitted
|
|
174
|
+
# unconditionally so consumers can distinguish "probed and missing"
|
|
175
|
+
# from "probe not run yet".
|
|
176
|
+
flucoma_reasons: list[str] = []
|
|
177
|
+
if not flucoma_ok:
|
|
178
|
+
flucoma_reasons.append("flucoma_not_installed")
|
|
179
|
+
domains["flucoma"] = CapabilityDomain(
|
|
180
|
+
name="flucoma",
|
|
181
|
+
available=flucoma_ok,
|
|
182
|
+
confidence=0.9 if flucoma_ok else 0.0,
|
|
183
|
+
mode="available" if flucoma_ok else "unavailable",
|
|
184
|
+
reasons=flucoma_reasons,
|
|
185
|
+
)
|
|
186
|
+
|
|
169
187
|
# ── research (composite) ────────────────────────────────────────
|
|
170
188
|
research_reasons: list[str] = []
|
|
171
189
|
research_sources = 0
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Explicit degradation signalling for engines that fall back to synthesized data.
|
|
2
|
+
|
|
3
|
+
Before PR-B, several engines silently substituted defaults when a data
|
|
4
|
+
source failed — ``song_brain`` injected ``tempo=120.0, track_count=0``
|
|
5
|
+
on session-fetch failure, and ``preview_studio`` compiled variants
|
|
6
|
+
against an empty-but-valid kernel when the caller didn't supply one.
|
|
7
|
+
Downstream consumers had no way to tell synthesized data from real
|
|
8
|
+
data, so polished outputs were returned as if they were real.
|
|
9
|
+
|
|
10
|
+
``DegradationInfo`` is the shared payload engines attach to their
|
|
11
|
+
responses whenever they substitute fallback values. Consumers can
|
|
12
|
+
inspect ``is_degraded``, ``reasons``, and ``substituted_fields`` to
|
|
13
|
+
decide whether to trust the response or re-try the operation.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
from mcp_server.runtime.degradation import DegradationInfo
|
|
18
|
+
|
|
19
|
+
deg = DegradationInfo()
|
|
20
|
+
try:
|
|
21
|
+
data = fetch_real_data()
|
|
22
|
+
except Exception:
|
|
23
|
+
data = FALLBACK_DATA
|
|
24
|
+
deg = DegradationInfo(
|
|
25
|
+
is_degraded=True,
|
|
26
|
+
reasons=["data_source_unreachable"],
|
|
27
|
+
substituted_fields=["tempo", "track_count"],
|
|
28
|
+
)
|
|
29
|
+
return {..., "degradation": deg.to_dict()}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DegradationInfo:
|
|
39
|
+
"""A structured signal that an engine substituted fallback data.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
is_degraded: True when any field in the response was substituted
|
|
43
|
+
with a synthesized/default value. False means the response
|
|
44
|
+
is fully backed by real data sources.
|
|
45
|
+
reasons: Short machine-readable tokens describing why degradation
|
|
46
|
+
happened (e.g., ``"session_fetch_failed"``,
|
|
47
|
+
``"empty_kernel_fallback"``). Intentionally open-ended — the
|
|
48
|
+
set grows as new fallback paths get flagged.
|
|
49
|
+
substituted_fields: Names of top-level response fields whose
|
|
50
|
+
values came from the fallback path, not the real source.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
is_degraded: bool = False
|
|
54
|
+
reasons: list[str] = field(default_factory=list)
|
|
55
|
+
substituted_fields: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"is_degraded": self.is_degraded,
|
|
60
|
+
"reasons": list(self.reasons),
|
|
61
|
+
"substituted_fields": list(self.substituted_fields),
|
|
62
|
+
}
|
|
@@ -7,6 +7,9 @@ Tools:
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import importlib.util
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.request
|
|
10
13
|
from typing import Optional
|
|
11
14
|
|
|
12
15
|
from fastmcp import Context
|
|
@@ -14,13 +17,55 @@ from fastmcp import Context
|
|
|
14
17
|
from ..server import mcp
|
|
15
18
|
from ..memory.technique_store import TechniqueStore
|
|
16
19
|
from .capability_state import build_capability_state
|
|
17
|
-
import logging
|
|
18
20
|
|
|
19
21
|
logger = logging.getLogger(__name__)
|
|
20
22
|
|
|
21
23
|
_memory_store = TechniqueStore()
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
# ── Capability probes ──────────────────────────────────────────────────
|
|
27
|
+
#
|
|
28
|
+
# These helpers are module-level so tests can monkeypatch them directly.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _probe_web(timeout: float = 0.5) -> bool:
|
|
32
|
+
"""Server-side outbound HTTP probe.
|
|
33
|
+
|
|
34
|
+
True when the MCP host can reach an arbitrary public URL. Does NOT
|
|
35
|
+
imply curated research corpora are installed — see the ``research``
|
|
36
|
+
domain for that.
|
|
37
|
+
|
|
38
|
+
Implementation: a ``timeout``-second HEAD request to
|
|
39
|
+
``https://api.github.com`` using stdlib ``urllib.request``. Any
|
|
40
|
+
exception (DNS failure, TLS error, socket timeout, proxy block,
|
|
41
|
+
non-2xx response) collapses to False so the probe is safe to call
|
|
42
|
+
from any code path.
|
|
43
|
+
"""
|
|
44
|
+
req = urllib.request.Request("https://api.github.com", method="HEAD")
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
+
status = getattr(resp, "status", None)
|
|
48
|
+
return status is not None and 200 <= status < 400
|
|
49
|
+
except Exception as exc: # noqa: BLE001 — swallow everything to False
|
|
50
|
+
logger.debug("_probe_web failed: %s", exc)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _probe_flucoma() -> bool:
|
|
55
|
+
"""Check whether the ``flucoma`` Python package is importable.
|
|
56
|
+
|
|
57
|
+
Uses ``importlib.util.find_spec`` so no import side-effects fire
|
|
58
|
+
(matching the pattern already used for optional capability probes
|
|
59
|
+
elsewhere in the codebase). Returns False if the package is missing
|
|
60
|
+
or if the spec lookup itself raises.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
return importlib.util.find_spec("flucoma") is not None
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
logger.debug("_probe_flucoma failed: %s", exc)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
24
69
|
@mcp.tool()
|
|
25
70
|
def get_capability_state(ctx: Context) -> dict:
|
|
26
71
|
"""Probe the runtime and return a capability state snapshot.
|
|
@@ -59,9 +104,13 @@ def get_capability_state(ctx: Context) -> dict:
|
|
|
59
104
|
logger.debug("get_capability_state failed: %s", exc)
|
|
60
105
|
memory_ok = False
|
|
61
106
|
|
|
62
|
-
# ── Web
|
|
63
|
-
|
|
64
|
-
|
|
107
|
+
# ── Web — actually probe outbound HTTP egress ───────────────────
|
|
108
|
+
# Scoped to server-side outbound HTTP reachability; does NOT imply
|
|
109
|
+
# a curated research corpus is installed (see ``research`` domain).
|
|
110
|
+
web_ok = _probe_web()
|
|
111
|
+
|
|
112
|
+
# ── FluCoMa — optional import via find_spec (no side effects) ───
|
|
113
|
+
flucoma_ok = _probe_flucoma()
|
|
65
114
|
|
|
66
115
|
state = build_capability_state(
|
|
67
116
|
session_ok=session_ok,
|
|
@@ -259,14 +259,10 @@ async def search_samples(
|
|
|
259
259
|
# Splice search — prefer gRPC online catalog when available, fall back
|
|
260
260
|
# to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
|
|
261
261
|
if source in (None, "splice"):
|
|
262
|
-
grpc_client =
|
|
263
|
-
try:
|
|
264
|
-
grpc_client = ctx.lifespan_context.get("splice_client")
|
|
265
|
-
except AttributeError:
|
|
266
|
-
grpc_client = None
|
|
262
|
+
grpc_client = await _ensure_splice_client_connected(ctx)
|
|
267
263
|
|
|
268
264
|
used_grpc = False
|
|
269
|
-
if grpc_client is not None
|
|
265
|
+
if grpc_client is not None:
|
|
270
266
|
try:
|
|
271
267
|
grpc_result = await grpc_client.search_samples(
|
|
272
268
|
query=query,
|
|
@@ -712,6 +708,43 @@ _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
|
|
|
712
708
|
_SPLICE_PREVIEW_CACHE = "~/Library/Caches/LivePilot/splice_previews"
|
|
713
709
|
|
|
714
710
|
|
|
711
|
+
def _get_splice_client_from_context(ctx: Context):
|
|
712
|
+
"""Return the shared Splice client from lifespan context when present."""
|
|
713
|
+
try:
|
|
714
|
+
return ctx.lifespan_context.get("splice_client")
|
|
715
|
+
except AttributeError:
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def _ensure_splice_client_connected(ctx: Context):
|
|
720
|
+
"""Reconnect the shared Splice client on demand.
|
|
721
|
+
|
|
722
|
+
The MCP server creates one long-lived client during startup. If that
|
|
723
|
+
first handshake races or Splice launches later, the old behavior kept
|
|
724
|
+
every tool stuck in a disconnected state until the whole MCP server
|
|
725
|
+
restarted. Re-check here so tool results reflect current desktop state.
|
|
726
|
+
"""
|
|
727
|
+
client = _get_splice_client_from_context(ctx)
|
|
728
|
+
if client is None:
|
|
729
|
+
return None
|
|
730
|
+
if getattr(client, "connected", False):
|
|
731
|
+
return client
|
|
732
|
+
|
|
733
|
+
connect = getattr(client, "connect", None)
|
|
734
|
+
if connect is None:
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
await connect()
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
logger.debug("Splice reconnect failed: %s", exc)
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
if getattr(client, "connected", False):
|
|
744
|
+
return client
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
|
|
715
748
|
@mcp.tool()
|
|
716
749
|
async def get_splice_credits(ctx: Context) -> dict:
|
|
717
750
|
"""Get the user's current Splice plan, credits, and daily sample quota.
|
|
@@ -745,15 +778,10 @@ async def get_splice_credits(ctx: Context) -> dict:
|
|
|
745
778
|
from ..splice_client.models import PlanKind
|
|
746
779
|
from ..splice_client.quota import get_tracker
|
|
747
780
|
|
|
748
|
-
client = None
|
|
749
|
-
try:
|
|
750
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
751
|
-
except AttributeError:
|
|
752
|
-
pass
|
|
753
|
-
|
|
754
781
|
quota_summary = get_tracker().summary()
|
|
782
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
755
783
|
|
|
756
|
-
if client is None
|
|
784
|
+
if client is None:
|
|
757
785
|
return {
|
|
758
786
|
"connected": False,
|
|
759
787
|
"username": "",
|
|
@@ -857,13 +885,8 @@ async def splice_catalog_hunt(
|
|
|
857
885
|
Each sample entry contains `file_hash` which you can pass to
|
|
858
886
|
`splice_download_sample` to trigger a download.
|
|
859
887
|
"""
|
|
860
|
-
client =
|
|
861
|
-
|
|
862
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
863
|
-
except AttributeError:
|
|
864
|
-
pass
|
|
865
|
-
|
|
866
|
-
if client is None or not getattr(client, "connected", False):
|
|
888
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
889
|
+
if client is None:
|
|
867
890
|
return {
|
|
868
891
|
"connected": False,
|
|
869
892
|
"error": "Splice gRPC not connected",
|
|
@@ -970,13 +993,8 @@ async def splice_download_sample(
|
|
|
970
993
|
"""
|
|
971
994
|
import shutil
|
|
972
995
|
|
|
973
|
-
client =
|
|
974
|
-
|
|
975
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
976
|
-
except AttributeError:
|
|
977
|
-
pass
|
|
978
|
-
|
|
979
|
-
if client is None or not getattr(client, "connected", False):
|
|
996
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
997
|
+
if client is None:
|
|
980
998
|
return {
|
|
981
999
|
"ok": False,
|
|
982
1000
|
"error": "Splice gRPC not connected",
|
|
@@ -1096,13 +1114,8 @@ async def splice_preview_sample(
|
|
|
1096
1114
|
import urllib.request
|
|
1097
1115
|
import urllib.error
|
|
1098
1116
|
|
|
1099
|
-
client =
|
|
1100
|
-
|
|
1101
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
1102
|
-
except AttributeError:
|
|
1103
|
-
pass
|
|
1104
|
-
|
|
1105
|
-
if client is None or not getattr(client, "connected", False):
|
|
1117
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
1118
|
+
if client is None:
|
|
1106
1119
|
return {"ok": False, "error": "Splice gRPC not connected"}
|
|
1107
1120
|
|
|
1108
1121
|
# Two-stage lookup: SampleInfo is the fast path but only returns
|
|
@@ -1184,14 +1197,10 @@ async def splice_preview_sample(
|
|
|
1184
1197
|
# ────────────────────────────────────────────────────────────────────────
|
|
1185
1198
|
|
|
1186
1199
|
|
|
1187
|
-
def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
|
|
1200
|
+
async def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
|
|
1188
1201
|
"""Fetch the Splice client from context, or return an error dict."""
|
|
1189
|
-
client =
|
|
1190
|
-
|
|
1191
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
1192
|
-
except AttributeError:
|
|
1193
|
-
pass
|
|
1194
|
-
if client is None or not getattr(client, "connected", False):
|
|
1202
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
1203
|
+
if client is None:
|
|
1195
1204
|
return None, {"ok": False, "error": "Splice gRPC not connected"}
|
|
1196
1205
|
return client, None
|
|
1197
1206
|
|
|
@@ -1215,7 +1224,7 @@ async def splice_list_collections(
|
|
|
1215
1224
|
],
|
|
1216
1225
|
}
|
|
1217
1226
|
"""
|
|
1218
|
-
client, err = _require_splice_client(ctx)
|
|
1227
|
+
client, err = await _require_splice_client(ctx)
|
|
1219
1228
|
if err:
|
|
1220
1229
|
return err
|
|
1221
1230
|
total, collections = await client.list_collections(
|
|
@@ -1244,7 +1253,7 @@ async def splice_search_in_collection(
|
|
|
1244
1253
|
`splice_catalog_hunt` — you can feed them straight into
|
|
1245
1254
|
`splice_preview_sample` or `splice_download_sample`.
|
|
1246
1255
|
"""
|
|
1247
|
-
client, err = _require_splice_client(ctx)
|
|
1256
|
+
client, err = await _require_splice_client(ctx)
|
|
1248
1257
|
if err:
|
|
1249
1258
|
return err
|
|
1250
1259
|
total, samples = await client.collection_samples(
|
|
@@ -1271,7 +1280,7 @@ async def splice_add_to_collection(
|
|
|
1271
1280
|
and web UI immediately. Use this to let LivePilot "save for later"
|
|
1272
1281
|
items it finds during composition work.
|
|
1273
1282
|
"""
|
|
1274
|
-
client, err = _require_splice_client(ctx)
|
|
1283
|
+
client, err = await _require_splice_client(ctx)
|
|
1275
1284
|
if err:
|
|
1276
1285
|
return err
|
|
1277
1286
|
if not file_hashes:
|
|
@@ -1289,7 +1298,7 @@ async def splice_remove_from_collection(
|
|
|
1289
1298
|
ctx: Context, collection_uuid: str, file_hashes: list[str],
|
|
1290
1299
|
) -> dict:
|
|
1291
1300
|
"""Remove one or more samples from a user Collection (server-side)."""
|
|
1292
|
-
client, err = _require_splice_client(ctx)
|
|
1301
|
+
client, err = await _require_splice_client(ctx)
|
|
1293
1302
|
if err:
|
|
1294
1303
|
return err
|
|
1295
1304
|
if not file_hashes:
|
|
@@ -1307,7 +1316,7 @@ async def splice_remove_from_collection(
|
|
|
1307
1316
|
@mcp.tool()
|
|
1308
1317
|
async def splice_create_collection(ctx: Context, name: str) -> dict:
|
|
1309
1318
|
"""Create a new user Collection. Returns the new UUID on success."""
|
|
1310
|
-
client, err = _require_splice_client(ctx)
|
|
1319
|
+
client, err = await _require_splice_client(ctx)
|
|
1311
1320
|
if err:
|
|
1312
1321
|
return err
|
|
1313
1322
|
name = (name or "").strip()
|
|
@@ -1347,7 +1356,7 @@ async def splice_list_presets(
|
|
|
1347
1356
|
],
|
|
1348
1357
|
}
|
|
1349
1358
|
"""
|
|
1350
|
-
client, err = _require_splice_client(ctx)
|
|
1359
|
+
client, err = await _require_splice_client(ctx)
|
|
1351
1360
|
if err:
|
|
1352
1361
|
return err
|
|
1353
1362
|
total, presets = await client.list_purchased_presets(
|
|
@@ -1371,7 +1380,7 @@ async def splice_preset_info(
|
|
|
1371
1380
|
plugin_name: str = "",
|
|
1372
1381
|
) -> dict:
|
|
1373
1382
|
"""Fetch metadata for a single preset (uuid, file_hash, or plugin_name)."""
|
|
1374
|
-
client, err = _require_splice_client(ctx)
|
|
1383
|
+
client, err = await _require_splice_client(ctx)
|
|
1375
1384
|
if err:
|
|
1376
1385
|
return err
|
|
1377
1386
|
if not (uuid or file_hash or plugin_name):
|
|
@@ -1394,7 +1403,7 @@ async def splice_download_preset(ctx: Context, uuid: str) -> dict:
|
|
|
1394
1403
|
"""
|
|
1395
1404
|
from ..splice_client.client import CREDIT_HARD_FLOOR
|
|
1396
1405
|
|
|
1397
|
-
client, err = _require_splice_client(ctx)
|
|
1406
|
+
client, err = await _require_splice_client(ctx)
|
|
1398
1407
|
if err:
|
|
1399
1408
|
return err
|
|
1400
1409
|
if not uuid:
|
|
@@ -1437,7 +1446,7 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
|
|
|
1437
1446
|
Useful for discovering related samples by pack, or surfacing pack-level
|
|
1438
1447
|
genre/provider info that search results omit.
|
|
1439
1448
|
"""
|
|
1440
|
-
client, err = _require_splice_client(ctx)
|
|
1449
|
+
client, err = await _require_splice_client(ctx)
|
|
1441
1450
|
if err:
|
|
1442
1451
|
return err
|
|
1443
1452
|
if not pack_uuid:
|
|
@@ -1469,19 +1478,15 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
|
|
|
1469
1478
|
# ────────────────────────────────────────────────────────────────────────
|
|
1470
1479
|
|
|
1471
1480
|
|
|
1472
|
-
def _build_http_bridge(ctx: Context):
|
|
1481
|
+
async def _build_http_bridge(ctx: Context):
|
|
1473
1482
|
"""Construct the HTTPS bridge with the current gRPC client attached.
|
|
1474
1483
|
|
|
1475
1484
|
Returns (bridge, err_dict). On success err_dict is None.
|
|
1476
1485
|
"""
|
|
1477
1486
|
from ..splice_client.http_bridge import SpliceHTTPBridge
|
|
1478
1487
|
|
|
1479
|
-
client =
|
|
1480
|
-
|
|
1481
|
-
client = ctx.lifespan_context.get("splice_client")
|
|
1482
|
-
except AttributeError:
|
|
1483
|
-
pass
|
|
1484
|
-
if client is None or not getattr(client, "connected", False):
|
|
1488
|
+
client = await _ensure_splice_client_connected(ctx)
|
|
1489
|
+
if client is None:
|
|
1485
1490
|
return None, {
|
|
1486
1491
|
"ok": False,
|
|
1487
1492
|
"error": "Splice gRPC not connected — session token unreachable",
|
|
@@ -1520,7 +1525,7 @@ async def splice_describe_sound(
|
|
|
1520
1525
|
instrument/tags/pack_name/files. Use the uuid with
|
|
1521
1526
|
`splice_download_sample(uuid)` to pull the audio file.
|
|
1522
1527
|
"""
|
|
1523
|
-
bridge, err = _build_http_bridge(ctx)
|
|
1528
|
+
bridge, err = await _build_http_bridge(ctx)
|
|
1524
1529
|
if err:
|
|
1525
1530
|
return err
|
|
1526
1531
|
from ..splice_client.http_bridge import SpliceHTTPError
|
|
@@ -1572,7 +1577,7 @@ async def splice_generate_variation(
|
|
|
1572
1577
|
duration/tags/pack_name/files). Use the uuid of any result with
|
|
1573
1578
|
`splice_download_sample()` to pull the audio.
|
|
1574
1579
|
"""
|
|
1575
|
-
bridge, err = _build_http_bridge(ctx)
|
|
1580
|
+
bridge, err = await _build_http_bridge(ctx)
|
|
1576
1581
|
if err:
|
|
1577
1582
|
return err
|
|
1578
1583
|
from ..splice_client.http_bridge import SpliceHTTPError
|
|
@@ -1631,12 +1636,8 @@ async def splice_http_diagnose(ctx: Context) -> dict:
|
|
|
1631
1636
|
# diagnostic is worse than no diagnostic.
|
|
1632
1637
|
session_token_available = False
|
|
1633
1638
|
session_token_error = None
|
|
1634
|
-
grpc_client =
|
|
1635
|
-
|
|
1636
|
-
grpc_client = ctx.lifespan_context.get("splice_client")
|
|
1637
|
-
except AttributeError:
|
|
1638
|
-
pass
|
|
1639
|
-
if grpc_client is None or not getattr(grpc_client, "connected", False):
|
|
1639
|
+
grpc_client = await _ensure_splice_client_connected(ctx)
|
|
1640
|
+
if grpc_client is None:
|
|
1640
1641
|
session_token_error = "Splice gRPC not connected"
|
|
1641
1642
|
else:
|
|
1642
1643
|
# Connection is up; confirm a token actually comes back.
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from fastmcp import Context
|
|
11
11
|
|
|
12
|
+
from ..runtime.degradation import DegradationInfo
|
|
12
13
|
from ..server import mcp
|
|
13
14
|
from . import builder
|
|
14
15
|
from .models import SongBrain
|
|
@@ -55,6 +56,12 @@ def _fetch_session_data(ctx: Context) -> dict:
|
|
|
55
56
|
- composition_analysis: from musical intelligence section inference
|
|
56
57
|
- role_graph: from semantic move resolvers (track role inference)
|
|
57
58
|
- recent_moves: from session-scoped action ledger
|
|
59
|
+
|
|
60
|
+
On session-fetch failure the fallback session_info shape is injected
|
|
61
|
+
(``tempo=120.0, track_count=0``) and a ``DegradationInfo`` is attached
|
|
62
|
+
under the ``_degradation`` key so callers can tell synthesized data
|
|
63
|
+
from real data. ``_fetch_session_data`` never raises — it always
|
|
64
|
+
returns a dict with the expected keys.
|
|
58
65
|
"""
|
|
59
66
|
ableton = _get_ableton(ctx)
|
|
60
67
|
data: dict = {
|
|
@@ -66,12 +73,19 @@ def _fetch_session_data(ctx: Context) -> dict:
|
|
|
66
73
|
"role_graph": {},
|
|
67
74
|
"recent_moves": [],
|
|
68
75
|
}
|
|
76
|
+
degradation = DegradationInfo()
|
|
69
77
|
|
|
70
78
|
try:
|
|
71
79
|
data["session_info"] = ableton.send_command("get_session_info", {})
|
|
72
80
|
except Exception as exc:
|
|
73
81
|
logger.debug("_fetch_session_data failed: %s", exc)
|
|
74
82
|
data["session_info"] = {"tempo": 120.0, "track_count": 0}
|
|
83
|
+
degradation.is_degraded = True
|
|
84
|
+
if "session_fetch_failed" not in degradation.reasons:
|
|
85
|
+
degradation.reasons.append("session_fetch_failed")
|
|
86
|
+
for fld in ("tempo", "track_count"):
|
|
87
|
+
if fld not in degradation.substituted_fields:
|
|
88
|
+
degradation.substituted_fields.append(fld)
|
|
75
89
|
|
|
76
90
|
try:
|
|
77
91
|
matrix = ableton.send_command("get_scene_matrix")
|
|
@@ -135,6 +149,10 @@ def _fetch_session_data(ctx: Context) -> dict:
|
|
|
135
149
|
except Exception as exc:
|
|
136
150
|
logger.debug("_fetch_session_data failed: %s", exc)
|
|
137
151
|
|
|
152
|
+
# Attach the degradation signal so build_song_brain can surface it.
|
|
153
|
+
# Under a reserved key (leading underscore) so it never collides with
|
|
154
|
+
# a real session data field.
|
|
155
|
+
data["_degradation"] = degradation
|
|
138
156
|
return data
|
|
139
157
|
|
|
140
158
|
|
|
@@ -180,10 +198,15 @@ def build_song_brain(ctx: Context) -> dict:
|
|
|
180
198
|
)
|
|
181
199
|
_set_brain(ctx, brain)
|
|
182
200
|
|
|
201
|
+
# Surface the degradation payload so callers can distinguish a
|
|
202
|
+
# tempo=120 / track_count=0 synthesized response from a real one.
|
|
203
|
+
degradation = data.get("_degradation") or DegradationInfo()
|
|
204
|
+
|
|
183
205
|
return {
|
|
184
206
|
**brain.to_dict(),
|
|
185
207
|
"summary": brain.summary,
|
|
186
208
|
"capability": cap.to_dict(),
|
|
209
|
+
"degradation": degradation.to_dict(),
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
|