livepilot 1.10.4 → 1.10.6

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 (74) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +148 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +6 -6
  6. package/livepilot/.Codex-plugin/plugin.json +2 -2
  7. package/livepilot/.claude-plugin/plugin.json +2 -2
  8. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  9. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  10. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  11. package/livepilot/skills/livepilot-release/SKILL.md +5 -5
  12. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  13. package/m4l_device/livepilot_bridge.js +12 -1
  14. package/manifest.json +3 -3
  15. package/mcp_server/__init__.py +1 -1
  16. package/mcp_server/composer/sample_resolver.py +10 -6
  17. package/mcp_server/composer/tools.py +10 -6
  18. package/mcp_server/connection.py +6 -1
  19. package/mcp_server/creative_constraints/tools.py +9 -8
  20. package/mcp_server/experiment/engine.py +9 -5
  21. package/mcp_server/experiment/tools.py +9 -9
  22. package/mcp_server/hook_hunter/tools.py +14 -9
  23. package/mcp_server/m4l_bridge.py +11 -0
  24. package/mcp_server/memory/taste_graph.py +7 -2
  25. package/mcp_server/mix_engine/tools.py +8 -3
  26. package/mcp_server/musical_intelligence/tools.py +15 -10
  27. package/mcp_server/performance_engine/tools.py +6 -2
  28. package/mcp_server/preview_studio/tools.py +21 -15
  29. package/mcp_server/project_brain/tools.py +18 -10
  30. package/mcp_server/reference_engine/tools.py +7 -5
  31. package/mcp_server/runtime/capability_probe.py +10 -4
  32. package/mcp_server/runtime/tools.py +8 -2
  33. package/mcp_server/sample_engine/tools.py +394 -33
  34. package/mcp_server/semantic_moves/tools.py +5 -1
  35. package/mcp_server/server.py +10 -9
  36. package/mcp_server/services/motif_service.py +9 -3
  37. package/mcp_server/session_continuity/tools.py +7 -3
  38. package/mcp_server/session_continuity/tracker.py +9 -8
  39. package/mcp_server/song_brain/tools.py +17 -12
  40. package/mcp_server/splice_client/client.py +19 -6
  41. package/mcp_server/stuckness_detector/tools.py +8 -5
  42. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  43. package/mcp_server/tools/_agent_os_engine/critics.py +134 -0
  44. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  45. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  46. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  47. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  48. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  49. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  50. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  51. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  52. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  53. package/mcp_server/tools/_composition_engine/harmony.py +70 -0
  54. package/mcp_server/tools/_composition_engine/models.py +193 -0
  55. package/mcp_server/tools/_composition_engine/sections.py +371 -0
  56. package/mcp_server/tools/_perception_engine.py +18 -11
  57. package/mcp_server/tools/agent_os.py +23 -15
  58. package/mcp_server/tools/analyzer.py +166 -7
  59. package/mcp_server/tools/automation.py +6 -1
  60. package/mcp_server/tools/composition.py +25 -16
  61. package/mcp_server/tools/devices.py +10 -6
  62. package/mcp_server/tools/motif.py +7 -2
  63. package/mcp_server/tools/planner.py +6 -2
  64. package/mcp_server/tools/research.py +13 -10
  65. package/mcp_server/transition_engine/tools.py +6 -1
  66. package/mcp_server/translation_engine/tools.py +8 -6
  67. package/mcp_server/wonder_mode/engine.py +8 -3
  68. package/mcp_server/wonder_mode/tools.py +29 -21
  69. package/package.json +2 -2
  70. package/remote_script/LivePilot/__init__.py +1 -1
  71. package/requirements.txt +6 -0
  72. package/livepilot.mcpb +0 -0
  73. package/mcp_server/tools/_agent_os_engine.py +0 -947
  74. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -1,16 +1,20 @@
1
- """Sample Engine MCP tools — 7 intelligence-layer tools.
1
+ """Sample Engine MCP tools — intelligence-layer tools.
2
2
 
3
- No new Ableton communication these orchestrate existing tools
4
- through the analyzer, critics, planner, and technique library.
3
+ Wraps analyzer, critics, planner, technique library, and (as of v1.10.5)
4
+ direct Splice online catalog hunt/download via the gRPC client.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import logging
10
+ import os
9
11
  from typing import Optional
10
12
 
11
13
  from fastmcp import Context
12
14
 
13
15
  from ..server import mcp
16
+
17
+ logger = logging.getLogger(__name__)
14
18
  from .models import SampleProfile, SampleIntent, SampleFitReport
15
19
  from .analyzer import build_profile_from_filename
16
20
  from .critics import run_all_sample_critics
@@ -46,8 +50,8 @@ async def analyze_sample(
46
50
  )
47
51
  if not result.get("error"):
48
52
  file_path = result.get("file_path")
49
- except Exception:
50
- pass
53
+ except Exception as exc:
54
+ logger.warning("m4l get_clip_file_path failed: %s", exc)
51
55
 
52
56
  if file_path is None:
53
57
  return {"error": "Could not determine file path — provide file_path directly"}
@@ -96,7 +100,8 @@ def evaluate_sample_fit(
96
100
  name = track_info.get("name", "").lower()
97
101
  if name:
98
102
  existing_roles.append(name)
99
- except Exception:
103
+ except Exception as exc:
104
+ logger.debug("get_track_info(%d) skipped: %s", i, exc)
100
105
  continue
101
106
 
102
107
  # Detect key from MIDI tracks
@@ -118,12 +123,13 @@ def evaluate_sample_fit(
118
123
  mode_suffix = "m" if "minor" in mode else ""
119
124
  song_key = f"{key_result['tonic_name']}{mode_suffix}"
120
125
  break
121
- except Exception:
126
+ except Exception as exc:
127
+ logger.debug("key detection on track %d skipped: %s", i, exc)
122
128
  continue
123
129
  except ImportError:
124
130
  pass
125
- except Exception:
126
- pass
131
+ except Exception as exc:
132
+ logger.warning("session context for evaluate_sample_fit failed: %s", exc)
127
133
 
128
134
  critics = run_all_sample_critics(
129
135
  profile=profile,
@@ -155,7 +161,7 @@ def evaluate_sample_fit(
155
161
 
156
162
 
157
163
  @mcp.tool()
158
- def search_samples(
164
+ async def search_samples(
159
165
  ctx: Context,
160
166
  query: str,
161
167
  material_type: Optional[str] = None,
@@ -169,6 +175,12 @@ def search_samples(
169
175
  Searches all enabled sources in parallel and ranks results.
170
176
  Splice results include rich metadata (key, BPM, genre, tags, pack info).
171
177
 
178
+ When the Splice desktop app is running AND grpcio is installed, this
179
+ searches Splice's ONLINE catalog (19,690+ hits for a generic query)
180
+ and returns un-downloaded items alongside local files. When gRPC is
181
+ unavailable, it falls back to the local SQLite index and only returns
182
+ already-downloaded samples.
183
+
172
184
  query: search text like "dark vocal", "breakbeat", "foley metal"
173
185
  material_type: filter by type (vocal, drum_loop, texture, etc.)
174
186
  key: prefer samples in this key (e.g., "Cm", "F#")
@@ -188,21 +200,74 @@ def search_samples(
188
200
  except ValueError:
189
201
  pass
190
202
 
191
- # Splice search (richest metadata, searched first)
203
+ # Splice search prefer gRPC online catalog when available, fall back
204
+ # to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
192
205
  if source in (None, "splice"):
193
- splice = SpliceSource()
194
- if splice.enabled:
195
- splice_results = splice.search(
196
- query=query,
197
- max_results=max_results,
198
- key=key,
199
- bpm_min=bpm_min,
200
- bpm_max=bpm_max,
201
- )
202
- for candidate in splice_results:
203
- d = candidate.to_dict()
204
- d["source_priority"] = 1 # highest
205
- results.append(d)
206
+ grpc_client = None
207
+ try:
208
+ grpc_client = ctx.lifespan_context.get("splice_client")
209
+ except AttributeError:
210
+ grpc_client = None
211
+
212
+ used_grpc = False
213
+ if grpc_client is not None and getattr(grpc_client, "connected", False):
214
+ try:
215
+ grpc_result = await grpc_client.search_samples(
216
+ query=query,
217
+ key=(key or "").lower().rstrip("m") if key else "",
218
+ bpm_min=int(bpm_min) if bpm_min else 0,
219
+ bpm_max=int(bpm_max) if bpm_max else 0,
220
+ per_page=max_results,
221
+ page=1,
222
+ purchased_only=False,
223
+ )
224
+ for s in grpc_result.samples[:max_results]:
225
+ results.append({
226
+ "source": "splice",
227
+ "name": s.filename,
228
+ "file_path": s.local_path or None,
229
+ "uri": None,
230
+ "freesound_id": None,
231
+ "relevance_score": 0,
232
+ "source_priority": 1,
233
+ "splice_catalog": True,
234
+ "downloaded": bool(s.local_path),
235
+ "file_hash": s.file_hash,
236
+ "metadata": {
237
+ "key": s.audio_key,
238
+ "bpm": s.bpm,
239
+ "tags": ",".join(s.tags) if s.tags else "",
240
+ "genre": s.genre or None,
241
+ "sample_type": s.sample_type,
242
+ "material_type": "vocal" if "vocal" in (s.tags or []) else "unknown",
243
+ "pack": s.provider_name,
244
+ "pack_uuid": s.pack_uuid,
245
+ "duration": s.duration_ms / 1000.0 if s.duration_ms else 0.0,
246
+ "is_premium": s.is_premium,
247
+ "chord_type": s.chord_type,
248
+ },
249
+ })
250
+ used_grpc = True
251
+ except Exception as exc:
252
+ logger.warning("Splice gRPC search failed, falling back to SQL: %s", exc)
253
+ used_grpc = False
254
+
255
+ # Also query local index (if not already covered by gRPC) to surface
256
+ # downloaded-only samples that might not appear in catalog results.
257
+ if not used_grpc:
258
+ splice = SpliceSource()
259
+ if splice.enabled:
260
+ splice_results = splice.search(
261
+ query=query,
262
+ max_results=max_results,
263
+ key=key,
264
+ bpm_min=bpm_min,
265
+ bpm_max=bpm_max,
266
+ )
267
+ for candidate in splice_results:
268
+ d = candidate.to_dict()
269
+ d["source_priority"] = 1
270
+ results.append(d)
206
271
 
207
272
  # Browser search
208
273
  if source in (None, "browser"):
@@ -223,10 +288,11 @@ def search_samples(
223
288
  d = candidate.to_dict()
224
289
  d["source_priority"] = 2
225
290
  results.append(d)
226
- except Exception:
291
+ except Exception as exc:
292
+ logger.debug("browser search %s skipped: %s", category, exc)
227
293
  continue
228
- except Exception:
229
- pass
294
+ except Exception as exc:
295
+ logger.warning("browser search unavailable: %s", exc)
230
296
 
231
297
  # Filesystem search
232
298
  if source in (None, "filesystem"):
@@ -383,7 +449,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
383
449
  try:
384
450
  ableton = ctx.lifespan_context["ableton"]
385
451
  info = ableton.send_command("get_session_info", {})
386
- except Exception:
452
+ except Exception as exc:
453
+ logger.warning("get_sample_opportunities: Ableton not reachable: %s", exc)
387
454
  return {"opportunities": [], "note": "Cannot read session — Ableton not connected"}
388
455
 
389
456
  track_count = info.get("track_count", 0)
@@ -399,7 +466,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
399
466
  for d in devices:
400
467
  if d.get("class_name") in ("OriginalSimpler", "MultiSampler"):
401
468
  has_sampler = True
402
- except Exception:
469
+ except Exception as exc:
470
+ logger.debug("track scan idx=%d skipped: %s", i, exc)
403
471
  continue
404
472
 
405
473
  # No organic texture
@@ -483,8 +551,8 @@ def plan_slice_workflow(
483
551
  if ableton:
484
552
  info = ableton.send_command("get_session_info", {})
485
553
  tempo = float(info.get("tempo", 120.0))
486
- except Exception:
487
- pass
554
+ except Exception as exc:
555
+ logger.debug("plan_slice_workflow tempo fetch failed (using 120): %s", exc)
488
556
 
489
557
  # Read slice count from existing Simpler if track provided
490
558
  slice_count = 8 # Default transient slice count
@@ -497,8 +565,8 @@ def plan_slice_workflow(
497
565
  })
498
566
  if isinstance(slices, dict) and slices.get("slice_count"):
499
567
  slice_count = slices["slice_count"]
500
- except Exception:
501
- pass # Fall back to default
568
+ except Exception as exc:
569
+ logger.debug("get_simpler_slices failed (using default 8): %s", exc)
502
570
 
503
571
  # Build the plan
504
572
  plan = plan_slice_steps(
@@ -543,3 +611,296 @@ def plan_slice_workflow(
543
611
  plan["style_hint"] = style_hint
544
612
 
545
613
  return plan
614
+
615
+
616
+ # ── v1.10.5 Splice online catalog tools ───────────────────────────────────
617
+ #
618
+ # These expose the SpliceGRPCClient's catalog capabilities as first-class MCP
619
+ # tools so the agent can drive hunt→download→load without a standalone helper
620
+ # script. See docs/2026-04-14-bugs-discovered.md — P0-2.
621
+ #
622
+ # Prerequisites:
623
+ # - Splice desktop app running (port.conf present in ~/Library/Application
624
+ # Support/com.splice.Splice/)
625
+ # - grpcio and protobuf installed (added to requirements.txt in v1.10.5)
626
+ #
627
+ # Credit model (as of 2026-04-14):
628
+ # - Even with `SoundsStatus: subscribed`, the gRPC `DownloadSample` endpoint
629
+ # always decrements a monthly credit counter (default 100/month on most
630
+ # subscription plans).
631
+ # - The "unlimited downloads in Ableton" the Splice marketing references
632
+ # only applies to the Splice Sounds.vst3 plugin, which uses a different
633
+ # HTTPS API that these tools cannot drive.
634
+ # - `CREDIT_HARD_FLOOR = 5` in client.py reserves 5 credits as a safety
635
+ # margin — downloads will refuse below the floor.
636
+
637
+
638
+ _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
639
+
640
+
641
+ @mcp.tool()
642
+ async def get_splice_credits(ctx: Context) -> dict:
643
+ """Get the user's current Splice credit balance and subscription tier.
644
+
645
+ Returns: {
646
+ "connected": bool, # whether Splice desktop gRPC is reachable
647
+ "username": str,
648
+ "plan": str, # e.g. "subscribed", "free"
649
+ "credits_remaining": int,
650
+ "credit_floor": int, # safety reserve (typically 5)
651
+ "can_download": bool, # credits_remaining > credit_floor
652
+ }
653
+
654
+ Returns connected=False (with zero credits) when the Splice desktop app
655
+ isn't running or grpcio isn't installed.
656
+ """
657
+ from ..splice_client.client import CREDIT_HARD_FLOOR
658
+
659
+ client = None
660
+ try:
661
+ client = ctx.lifespan_context.get("splice_client")
662
+ except AttributeError:
663
+ pass
664
+
665
+ if client is None or not getattr(client, "connected", False):
666
+ return {
667
+ "connected": False,
668
+ "username": "",
669
+ "plan": "",
670
+ "credits_remaining": 0,
671
+ "credit_floor": CREDIT_HARD_FLOOR,
672
+ "can_download": False,
673
+ "hint": (
674
+ "Splice gRPC not connected. Ensure Splice desktop app is "
675
+ "running and grpcio+protobuf are installed in the LivePilot "
676
+ "venv (pip install grpcio protobuf)."
677
+ ),
678
+ }
679
+
680
+ try:
681
+ info = await client.get_credits()
682
+ except Exception as exc:
683
+ return {
684
+ "connected": False,
685
+ "error": f"get_credits failed: {exc}",
686
+ "credit_floor": CREDIT_HARD_FLOOR,
687
+ }
688
+
689
+ remaining = int(info.credits)
690
+ return {
691
+ "connected": True,
692
+ "username": info.username,
693
+ "plan": info.plan,
694
+ "credits_remaining": remaining,
695
+ "credit_floor": CREDIT_HARD_FLOOR,
696
+ "can_download": remaining > CREDIT_HARD_FLOOR,
697
+ }
698
+
699
+
700
+ @mcp.tool()
701
+ async def splice_catalog_hunt(
702
+ ctx: Context,
703
+ query: str,
704
+ bpm_min: int = 0,
705
+ bpm_max: int = 0,
706
+ key: str = "",
707
+ sample_type: str = "",
708
+ genre: str = "",
709
+ per_page: int = 10,
710
+ page: int = 1,
711
+ ) -> dict:
712
+ """Search Splice's ONLINE catalog via gRPC.
713
+
714
+ Unlike `search_samples` which can fall back to the local SQLite index,
715
+ this tool ONLY queries the online catalog — if Splice isn't connected
716
+ it returns an error instead of local-only results. Use this when you
717
+ specifically want fresh catalog content.
718
+
719
+ query: free-text search ("mellotron", "lofi chord", "soul vocal")
720
+ bpm_min: minimum BPM (0 = no lower bound)
721
+ bpm_max: maximum BPM (0 = no upper bound)
722
+ key: musical key (e.g. "cm", "f#", "a")
723
+ sample_type: "loop", "oneshot", or "" for any
724
+ genre: genre filter (e.g. "hip hop", "ambient")
725
+ per_page: results per page (1-50)
726
+ page: page number (1-indexed)
727
+
728
+ Returns: {
729
+ "connected": bool,
730
+ "total_hits": int, # total catalog matches
731
+ "samples": [...], # sample metadata with file_hash for download
732
+ }
733
+
734
+ Each sample entry contains `file_hash` which you can pass to
735
+ `splice_download_sample` to trigger a download.
736
+ """
737
+ client = None
738
+ try:
739
+ client = ctx.lifespan_context.get("splice_client")
740
+ except AttributeError:
741
+ pass
742
+
743
+ if client is None or not getattr(client, "connected", False):
744
+ return {
745
+ "connected": False,
746
+ "error": "Splice gRPC not connected",
747
+ "hint": (
748
+ "Ensure Splice desktop app is running. Also verify grpcio "
749
+ "and protobuf are installed: `pip install grpcio protobuf`."
750
+ ),
751
+ "samples": [],
752
+ "total_hits": 0,
753
+ }
754
+
755
+ try:
756
+ result = await client.search_samples(
757
+ query=query,
758
+ key=key.lower().rstrip("m") if key else "",
759
+ chord_type="minor" if key and key.lower().endswith("m") else "",
760
+ bpm_min=int(bpm_min),
761
+ bpm_max=int(bpm_max),
762
+ sample_type=sample_type,
763
+ genre=genre,
764
+ per_page=max(1, min(per_page, 50)),
765
+ page=max(1, int(page)),
766
+ purchased_only=False,
767
+ )
768
+ except Exception as exc:
769
+ return {
770
+ "connected": False,
771
+ "error": f"Splice search failed: {exc}",
772
+ "samples": [],
773
+ }
774
+
775
+ samples_out = []
776
+ for s in result.samples:
777
+ samples_out.append({
778
+ "file_hash": s.file_hash,
779
+ "filename": s.filename,
780
+ "key": s.audio_key,
781
+ "chord_type": s.chord_type,
782
+ "bpm": s.bpm,
783
+ "duration_sec": round((s.duration_ms or 0) / 1000.0, 2),
784
+ "genre": s.genre,
785
+ "sample_type": s.sample_type,
786
+ "tags": list(s.tags) if s.tags else [],
787
+ "pack": s.provider_name,
788
+ "pack_uuid": s.pack_uuid,
789
+ "is_premium": bool(s.is_premium),
790
+ "is_downloaded": bool(s.local_path),
791
+ "local_path": s.local_path or None,
792
+ "preview_url": s.preview_url,
793
+ })
794
+
795
+ return {
796
+ "connected": True,
797
+ "query": query,
798
+ "total_hits": result.total_hits,
799
+ "returned": len(samples_out),
800
+ "samples": samples_out,
801
+ "matching_tags": dict(result.matching_tags) if result.matching_tags else {},
802
+ }
803
+
804
+
805
+ @mcp.tool()
806
+ async def splice_download_sample(
807
+ ctx: Context,
808
+ file_hash: str,
809
+ copy_to_user_library: bool = True,
810
+ ) -> dict:
811
+ """Download a Splice sample by file_hash (costs 1 credit).
812
+
813
+ Use `splice_catalog_hunt` first to find samples and get their file_hash.
814
+ This tool will:
815
+ 1. Check credit balance against the safety floor (refuses if < 5)
816
+ 2. Trigger the download via the Splice desktop gRPC
817
+ 3. Poll until the file appears on disk (up to 30s)
818
+ 4. Optionally copy the file into `~/Music/Ableton/User Library/Samples/
819
+ Splice/` so Ableton's browser indexes it — this makes the sample
820
+ loadable via `load_browser_item` with a `query:UserLibrary#Samples:...`
821
+ URI.
822
+
823
+ Returns: {
824
+ "ok": bool,
825
+ "local_path": str, # Splice's own download path
826
+ "user_library_path": str, # if copy_to_user_library=True
827
+ "browser_uri": str, # ready for load_browser_item
828
+ "credits_remaining": int,
829
+ }
830
+
831
+ Note: even with an "unlimited" subscription, this gRPC path always
832
+ decrements credits (typically 100/month allotment). The unlimited
833
+ downloads inside Ableton's Splice Sounds VST3 use a different API
834
+ that LivePilot cannot drive programmatically yet.
835
+ """
836
+ import shutil
837
+
838
+ client = None
839
+ try:
840
+ client = ctx.lifespan_context.get("splice_client")
841
+ except AttributeError:
842
+ pass
843
+
844
+ if client is None or not getattr(client, "connected", False):
845
+ return {
846
+ "ok": False,
847
+ "error": "Splice gRPC not connected",
848
+ }
849
+
850
+ # Credit safety check
851
+ try:
852
+ can, remaining = await client.can_afford(1, budget=10)
853
+ except Exception as exc:
854
+ return {"ok": False, "error": f"Credit check failed: {exc}"}
855
+ if not can:
856
+ return {
857
+ "ok": False,
858
+ "error": (
859
+ f"Credit safety floor hit (remaining={remaining}, "
860
+ f"hard floor=5). Skipping download."
861
+ ),
862
+ "credits_remaining": remaining,
863
+ }
864
+
865
+ # Trigger download
866
+ try:
867
+ local_path = await client.download_sample(file_hash, timeout=30.0)
868
+ except Exception as exc:
869
+ return {"ok": False, "error": f"Download failed: {exc}"}
870
+
871
+ if not local_path:
872
+ return {
873
+ "ok": False,
874
+ "error": "Download did not complete within 30s timeout",
875
+ }
876
+
877
+ response: dict = {
878
+ "ok": True,
879
+ "local_path": local_path,
880
+ "filename": os.path.basename(local_path),
881
+ }
882
+
883
+ # Copy into User Library so Ableton's browser indexes it
884
+ if copy_to_user_library:
885
+ dest_dir = os.path.expanduser(_SPLICE_USER_LIB_DEST)
886
+ try:
887
+ os.makedirs(dest_dir, exist_ok=True)
888
+ dest_path = os.path.join(dest_dir, os.path.basename(local_path))
889
+ if not os.path.exists(dest_path):
890
+ shutil.copy2(local_path, dest_path)
891
+ response["user_library_path"] = dest_path
892
+ # URI format Ableton uses for user_library samples
893
+ response["browser_uri"] = (
894
+ f"query:UserLibrary#Samples:Splice:{os.path.basename(local_path)}"
895
+ )
896
+ except Exception as exc:
897
+ response["copy_warning"] = f"Failed to copy to User Library: {exc}"
898
+
899
+ # Post-credit count
900
+ try:
901
+ info = await client.get_credits()
902
+ response["credits_remaining"] = int(info.credits)
903
+ except Exception as exc:
904
+ logger.warning("post-download credit check failed: %s", exc)
905
+
906
+ return response
@@ -14,6 +14,9 @@ from fastmcp import Context
14
14
 
15
15
  from ..server import mcp
16
16
  from . import registry
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
17
20
 
18
21
 
19
22
  @mcp.tool()
@@ -80,7 +83,8 @@ def preview_semantic_move(
80
83
  info = ableton.send_command("get_session_info")
81
84
  if isinstance(info, dict):
82
85
  session_info = info
83
- except Exception:
86
+ except Exception as exc:
87
+ logger.debug("preview_semantic_move failed: %s", exc)
84
88
  session_info = {}
85
89
 
86
90
  state = build_capability_state(
@@ -44,9 +44,9 @@ def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
44
44
  """Check whether the analyzer device is currently on the master track."""
45
45
  try:
46
46
  track = ableton.send_command("get_master_track")
47
- except Exception:
47
+ except Exception as exc:
48
+ logger.debug("_master_has_livepilot_analyzer failed: %s", exc)
48
49
  return False
49
-
50
50
  devices = track.get("devices", []) if isinstance(track, dict) else []
51
51
  for device in devices:
52
52
  normalized = " ".join(
@@ -92,7 +92,8 @@ async def lifespan(server):
92
92
  splice_client = SpliceGRPCClient()
93
93
  try:
94
94
  await splice_client.connect()
95
- except Exception:
95
+ except Exception as exc:
96
+ logger.debug("lifespan failed: %s", exc)
96
97
  pass # client remains in disconnected state
97
98
 
98
99
  # Start UDP listener for incoming M4L spectral data (port 9880)
@@ -144,10 +145,8 @@ async def lifespan(server):
144
145
  ableton.disconnect()
145
146
  try:
146
147
  await splice_client.disconnect()
147
- except Exception:
148
- pass
149
-
150
-
148
+ except Exception as exc:
149
+ logger.debug("lifespan failed: %s", exc)
151
150
  mcp = FastMCP("LivePilot", lifespan=lifespan)
152
151
 
153
152
  # Import tool modules so they register with `mcp`
@@ -199,7 +198,9 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
199
198
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
200
199
  from .atlas import tools as atlas_tools # noqa: F401, E402
201
200
  from .composer import tools as composer_tools # noqa: F401, E402
201
+ import logging
202
202
 
203
+ logger = logging.getLogger(__name__)
203
204
 
204
205
  # ---------------------------------------------------------------------------
205
206
  # Schema coercion patch — accept strings for numeric parameters
@@ -213,6 +214,7 @@ from .composer import tools as composer_tools # noqa: F401, E40
213
214
  # "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
214
215
  # ---------------------------------------------------------------------------
215
216
 
217
+
216
218
  def _coerce_schema_property(prop: dict) -> None:
217
219
  """Widen a single JSON Schema property to also accept strings."""
218
220
  if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
@@ -253,6 +255,7 @@ def _get_all_tools():
253
255
  if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
254
256
  return list(mcp._local_provider._components.values())
255
257
  import sys
258
+
256
259
  print(
257
260
  "LivePilot: WARNING — could not access FastMCP tool registry, "
258
261
  "string-to-number schema coercion will not work",
@@ -273,7 +276,6 @@ def _patch_tool_schemas() -> None:
273
276
  if isinstance(definition, dict):
274
277
  _coerce_schema_property(definition)
275
278
 
276
-
277
279
  _patch_tool_schemas()
278
280
 
279
281
 
@@ -281,6 +283,5 @@ def main():
281
283
  """Run the MCP server over stdio."""
282
284
  mcp.run(transport="stdio")
283
285
 
284
-
285
286
  if __name__ == "__main__":
286
287
  main()
@@ -9,6 +9,9 @@ Pure computation — no I/O. Callers provide pre-fetched data.
9
9
  from __future__ import annotations
10
10
 
11
11
  from typing import Optional
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
12
15
 
13
16
 
14
17
  def get_motif_data(
@@ -28,13 +31,15 @@ def get_motif_data(
28
31
 
29
32
  try:
30
33
  from ..tools import _motif_engine as motif_engine
34
+
31
35
  motifs = motif_engine.detect_motifs(notes_by_track)
32
36
  return {
33
37
  "motifs": [m.to_dict() for m in motifs],
34
38
  "motif_count": len(motifs),
35
39
  "tracks_analyzed": len(notes_by_track),
36
40
  }
37
- except Exception:
41
+ except Exception as exc:
42
+ logger.debug("get_motif_data failed: %s", exc)
38
43
  return {"motifs": [], "motif_count": 0, "tracks_analyzed": 0}
39
44
 
40
45
 
@@ -60,8 +65,9 @@ def fetch_notes_from_ableton(ableton, tracks: list[dict], max_clips: int = 8) ->
60
65
  "clip_index": clip_idx,
61
66
  })
62
67
  track_notes.extend(result.get("notes", []))
63
- except Exception:
64
- pass
68
+ except Exception as exc:
69
+ logger.debug("fetch_notes_from_ableton failed: %s", exc)
70
+
65
71
  if track_notes:
66
72
  notes_by_track[t_idx] = track_notes
67
73
  return notes_by_track
@@ -15,6 +15,9 @@ from fastmcp import Context
15
15
 
16
16
  from ..server import mcp
17
17
  from . import tracker
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
18
21
 
19
22
 
20
23
  @mcp.tool()
@@ -200,7 +203,6 @@ def explain_preference_vs_identity(
200
203
  "note": "Identity has stronger weight inside a session (0.65 vs 0.35)",
201
204
  }
202
205
 
203
-
204
206
  # ── Helpers ───────────────────────────────────────────────────────
205
207
 
206
208
 
@@ -222,9 +224,11 @@ def _get_taste_graph(ctx: Context) -> dict:
222
224
  from ..memory.taste_graph import build_taste_graph
223
225
  from ..memory.taste_memory import TasteMemoryStore
224
226
  from ..memory.anti_memory import AntiMemoryStore
227
+
225
228
  taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
226
229
  anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
227
230
  return build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
228
- except Exception:
229
- pass
231
+ except Exception as exc:
232
+ logger.debug("_get_taste_graph failed: %s", exc)
233
+
230
234
  return {}