livepilot 1.16.1 → 1.17.1

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 (52) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/README.md +16 -15
  3. package/installer/codex.js +14 -0
  4. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  5. package/m4l_device/livepilot_bridge.js +1 -1
  6. package/mcp_server/__init__.py +1 -1
  7. package/mcp_server/atlas/__init__.py +85 -0
  8. package/mcp_server/atlas/device_atlas.json +3183 -382
  9. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  10. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +17 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  22. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  23. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  24. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  25. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  26. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +38 -0
  27. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  28. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  30. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  33. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  34. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  35. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  36. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  37. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  38. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  40. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  41. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +17 -0
  42. package/mcp_server/atlas/enrichments/utility/performer.yaml +15 -0
  43. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +21 -0
  44. package/mcp_server/atlas/tools.py +291 -0
  45. package/mcp_server/m4l_bridge.py +19 -2
  46. package/mcp_server/sample_engine/tools.py +201 -128
  47. package/mcp_server/splice_client/http_bridge.py +319 -116
  48. package/mcp_server/tools/automation.py +168 -0
  49. package/package.json +2 -2
  50. package/remote_script/LivePilot/__init__.py +1 -1
  51. package/remote_script/LivePilot/arrangement.py +216 -1
  52. package/server.json +3 -3
@@ -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 = None
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 and getattr(grpc_client, "connected", False):
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 or not getattr(client, "connected", False):
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 = None
861
- try:
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 = None
974
- try:
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 = None
1100
- try:
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 = None
1190
- try:
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 = None
1480
- try:
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",
@@ -1496,25 +1501,31 @@ async def splice_describe_sound(
1496
1501
  bpm: Optional[int] = None,
1497
1502
  key: Optional[str] = None,
1498
1503
  limit: int = 20,
1504
+ rephrase: bool = True,
1499
1505
  ) -> dict:
1500
1506
  """Natural-language sample search — the Sounds Plugin's "Describe a Sound".
1501
1507
 
1502
1508
  Splice's AI matches free-form descriptions like "dark ambient pad with
1503
- shimmer" or "tight 90s house hi-hat" to catalog samples. This is NOT
1504
- on the local gRPC — the bridge proxies to api.splice.com using your
1505
- session token.
1509
+ shimmer" or "tight 90s house hi-hat" to catalog samples. Hits the
1510
+ GraphQL `SamplesSearch` operation on `surfaces-graphql.splice.com`
1511
+ with `semantic=1` + `rephrase=true` enabled.
1506
1512
 
1507
- **Status: scaffolding complete, endpoint pending real-traffic capture.**
1508
- Until `SPLICE_DESCRIBE_ENDPOINT` env var is set (or
1509
- `SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
1510
- ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
1513
+ **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1514
+ against Splice desktop 5.4.9 + Sounds Plugin.
1511
1515
 
1512
1516
  description: free-text prompt ("warm analog bass under 80bpm")
1513
1517
  bpm: optional BPM filter
1514
1518
  key: optional musical key ("Dm", "F#")
1515
1519
  limit: max results (default 20)
1520
+ rephrase: let Splice's ML rephrase the query for better matches
1521
+ (default True). Returned as `rephrased_query_string`.
1522
+
1523
+ Returns `{ok, query, samples[], total_hits, rephrased_query_string,
1524
+ tag_summary[], ...}`. Each sample has uuid/name/bpm/key/duration/
1525
+ instrument/tags/pack_name/files. Use the uuid with
1526
+ `splice_download_sample(uuid)` to pull the audio file.
1516
1527
  """
1517
- bridge, err = _build_http_bridge(ctx)
1528
+ bridge, err = await _build_http_bridge(ctx)
1518
1529
  if err:
1519
1530
  return err
1520
1531
  from ..splice_client.http_bridge import SpliceHTTPError
@@ -1524,95 +1535,157 @@ async def splice_describe_sound(
1524
1535
  result = await bridge.describe_sound(
1525
1536
  description=description.strip(),
1526
1537
  bpm=bpm, key=key, limit=int(limit),
1538
+ rephrase=bool(rephrase),
1527
1539
  )
1528
1540
  except SpliceHTTPError as exc:
1529
1541
  return exc.to_dict()
1530
1542
  except Exception as exc:
1531
1543
  return {"ok": False, "error": f"describe_sound failed: {exc}"}
1532
- return {"ok": True, "query": description, **(result if isinstance(result, dict) else {"raw": result})}
1544
+ # Don't expose the full GraphQL `raw` dict in the user-facing response
1545
+ # unless they asked — it adds ~270KB noise per call. Keep it for
1546
+ # power users via an explicit future flag.
1547
+ out = dict(result) if isinstance(result, dict) else {"raw": result}
1548
+ out.pop("raw", None)
1549
+ return {"ok": True, "query": description, **out}
1533
1550
 
1534
1551
 
1535
1552
  @mcp.tool()
1536
1553
  async def splice_generate_variation(
1537
1554
  ctx: Context,
1538
- file_hash: str,
1539
- target_key: Optional[str] = None,
1540
- target_bpm: Optional[int] = None,
1541
- count: int = 1,
1555
+ uuid: str,
1556
+ is_legacy: bool = True,
1542
1557
  ) -> dict:
1543
- """Generate AI variations of a Splice sample — the Sounds Plugin's "Variations".
1544
-
1545
- Splice's AI produces unique re-keyed / re-tempo'd versions of any
1546
- sample. Costs additional credits per variation (on top of the base
1547
- license). NOT on the local gRPC bridged via api.splice.com.
1548
-
1549
- **Status: scaffolding complete, endpoint pending real-traffic capture.**
1550
- Until `SPLICE_VARIATION_ENDPOINT` env var is set (or
1551
- `SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
1552
- ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
1553
-
1554
- file_hash: sample identifier (from search results)
1555
- target_key: desired key (e.g. "Am")
1556
- target_bpm: desired tempo
1557
- count: number of variations to generate (1-5)
1558
-
1559
- WARNING: this WILL spend credits when the endpoint is live.
1560
- Consider previewing the source sample with splice_preview_sample first.
1558
+ """Find catalog samples similar to a given Splice sample — the "Variations" feature.
1559
+
1560
+ Splice's right-click "Variations" menu item surfaces other catalog
1561
+ samples with similar sonic character. The GraphQL operation name
1562
+ is `AssetSimilarSoundsQuery`. Up to 10 results per call. No credit
1563
+ cost (this is a recommender lookup, not AI audio synthesis — the
1564
+ original naming in the handoff was aspirational).
1565
+
1566
+ **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1567
+ against Splice desktop v5.4.9.
1568
+
1569
+ uuid: source sample's catalog uuid (from `splice_describe_sound`
1570
+ results or any other Splice metadata call)
1571
+ is_legacy: match how Splice's own client sets it — default True is
1572
+ correct for all mainstream catalog samples; set False only
1573
+ if working with post-catalog-v2 assets
1574
+
1575
+ Returns `{ok, uuid, similar_samples[], count}`. Each entry has the
1576
+ same flat shape as a describe_sound sample (uuid/name/bpm/key/
1577
+ duration/tags/pack_name/files). Use the uuid of any result with
1578
+ `splice_download_sample()` to pull the audio.
1561
1579
  """
1562
- bridge, err = _build_http_bridge(ctx)
1580
+ bridge, err = await _build_http_bridge(ctx)
1563
1581
  if err:
1564
1582
  return err
1565
1583
  from ..splice_client.http_bridge import SpliceHTTPError
1566
- if not file_hash or not file_hash.strip():
1567
- return {"ok": False, "error": "file_hash is required"}
1568
- if count < 1 or count > 5:
1569
- return {"ok": False, "error": "count must be 1-5"}
1584
+ if not uuid or not uuid.strip():
1585
+ return {"ok": False, "error": "uuid is required"}
1570
1586
  try:
1571
1587
  result = await bridge.generate_variation(
1572
- file_hash=file_hash.strip(),
1573
- target_key=target_key,
1574
- target_bpm=target_bpm,
1575
- count=int(count),
1588
+ uuid=uuid.strip(),
1589
+ is_legacy=bool(is_legacy),
1576
1590
  )
1577
1591
  except SpliceHTTPError as exc:
1578
1592
  return exc.to_dict()
1579
1593
  except Exception as exc:
1580
1594
  return {"ok": False, "error": f"generate_variation failed: {exc}"}
1581
- return {"ok": True, "file_hash": file_hash, **(result if isinstance(result, dict) else {"raw": result})}
1595
+ out = dict(result) if isinstance(result, dict) else {"raw": result}
1596
+ out.pop("raw", None) # drop verbose debug payload
1597
+ return {"ok": True, "uuid": uuid, **out}
1582
1598
 
1583
1599
 
1584
- @mcp.tool()
1585
- async def splice_search_with_sound(
1586
- ctx: Context,
1587
- audio_path: str,
1588
- limit: int = 20,
1589
- ) -> dict:
1590
- """Reference-audio search — the Sounds Plugin's "Search with Sound".
1600
+ # NOTE: splice_search_with_sound was removed 2026-04-22 — user does this
1601
+ # in-Splice manually. If someone wants to resurrect it, the capture recipe
1602
+ # is still at docs/2026-04-22-splice-https-capture-recipe.md.
1603
+
1591
1604
 
1592
- Uploads a local audio file to Splice's AI and returns catalog samples
1593
- with similar character. Complements `splice_describe_sound` (text)
1594
- and `search_samples` (keyword).
1605
+ @mcp.tool()
1606
+ async def splice_http_diagnose(ctx: Context) -> dict:
1607
+ """Diagnose the Splice HTTPS bridge configuration and readiness.
1595
1608
 
1596
- **Status: scaffolding complete, wiring pending real-traffic capture
1597
- (multipart upload shape is the most uncertain part of the bridge).**
1598
- Until `SPLICE_SEARCH_WITH_SOUND_ENDPOINT` is set, returns a structured
1599
- NOT_YET_IMPLEMENTED error.
1609
+ Reports which endpoints are configured, whether a session token is
1610
+ reachable from the gRPC client, and what the next step is to unblock
1611
+ `splice_describe_sound` and `splice_generate_variation`.
1600
1612
 
1601
- audio_path: absolute path to a local audio file (.wav, .mp3, .flac)
1602
- limit: max results (default 20)
1613
+ Use this BEFORE calling either tool if you want a clear readout of
1614
+ "what's missing, and how do I fix it" instead of per-tool
1615
+ ENDPOINT_NOT_CONFIGURED errors.
1603
1616
  """
1604
- bridge, err = _build_http_bridge(ctx)
1605
- if err:
1606
- return err
1607
- from ..splice_client.http_bridge import SpliceHTTPError
1608
- if not audio_path or not os.path.isfile(audio_path):
1609
- return {"ok": False, "error": f"audio_path not found: {audio_path}"}
1610
- try:
1611
- result = await bridge.search_with_sound(
1612
- audio_path=audio_path, limit=int(limit),
1617
+ from ..splice_client.http_bridge import SpliceHTTPConfig
1618
+
1619
+ cfg = SpliceHTTPConfig.from_env()
1620
+ endpoints = {
1621
+ "describe": cfg.describe_endpoint,
1622
+ "variation": cfg.variation_endpoint,
1623
+ }
1624
+ verified = {
1625
+ "describe": cfg.describe_verified,
1626
+ "variation": cfg.variation_verified,
1627
+ }
1628
+ unverified = [name for name, ok in verified.items() if not ok]
1629
+ configured_count = sum(1 for v in endpoints.values() if v not in (None, ""))
1630
+
1631
+ # Try to read the session token via the gRPC client the SAME way
1632
+ # the real tools do — reach into ctx.lifespan_context["splice_client"]
1633
+ # and actually attempt a GetSession fetch. Walking a different
1634
+ # engine-nested path (earlier mistake) reported "token unavailable"
1635
+ # while the bridge's real request path succeeded — a misleading
1636
+ # diagnostic is worse than no diagnostic.
1637
+ session_token_available = False
1638
+ session_token_error = None
1639
+ grpc_client = await _ensure_splice_client_connected(ctx)
1640
+ if grpc_client is None:
1641
+ session_token_error = "Splice gRPC not connected"
1642
+ else:
1643
+ # Connection is up; confirm a token actually comes back.
1644
+ from ..splice_client.http_bridge import fetch_session_token
1645
+ try:
1646
+ token = await fetch_session_token(grpc_client)
1647
+ if token:
1648
+ session_token_available = True
1649
+ else:
1650
+ session_token_error = (
1651
+ "GetSession RPC returned no token — user may be "
1652
+ "logged out or gRPC schema drifted"
1653
+ )
1654
+ except Exception as exc:
1655
+ session_token_error = f"GetSession call failed: {exc}"
1656
+
1657
+ next_steps: list = []
1658
+ if "describe" in unverified:
1659
+ next_steps.append(
1660
+ "Describe endpoint unverified — reset config to defaults "
1661
+ "(delete ~/.livepilot/splice.json or unset env vars) so the "
1662
+ "captured surfaces-graphql.splice.com/graphql endpoint is used."
1613
1663
  )
1614
- except SpliceHTTPError as exc:
1615
- return exc.to_dict()
1616
- except Exception as exc:
1617
- return {"ok": False, "error": f"search_with_sound failed: {exc}"}
1618
- return {"ok": True, "audio_path": audio_path, **(result if isinstance(result, dict) else {"raw": result})}
1664
+ if "variation" in unverified:
1665
+ next_steps.append(
1666
+ "Variation GraphQL operation not yet captured. Right-click "
1667
+ "a Splice sample and click Variations with mitmproxy running. "
1668
+ "See docs/2026-04-22-splice-https-capture-recipe.md."
1669
+ )
1670
+ if not session_token_available:
1671
+ next_steps.append(
1672
+ "Splice desktop app is not reachable — the bridge reads the "
1673
+ "session token via gRPC GetSession RPC. Ensure the app is "
1674
+ "running and logged in."
1675
+ )
1676
+ if not next_steps:
1677
+ next_steps.append("Bridge fully ready — test with splice_describe_sound.")
1678
+
1679
+ return {
1680
+ "ok": True,
1681
+ "base_url": cfg.base_url,
1682
+ "endpoints": endpoints,
1683
+ "verified": verified,
1684
+ "configured_count": configured_count,
1685
+ "unverified_endpoints": unverified,
1686
+ "is_user_configured": cfg.is_user_configured,
1687
+ "session_token_available": session_token_available,
1688
+ "session_token_error": session_token_error,
1689
+ "next_steps": next_steps,
1690
+ "docs": "docs/2026-04-22-splice-https-capture-recipe.md",
1691
+ }