livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -20,7 +20,7 @@ Plan-aware gating (see `project_splice_subscription_model.md`):
20
20
  (100/day) rather than credits. `can_download_sample()` checks both
21
21
  the daily quota AND the credit floor, choosing the right budget for
22
22
  the user's actual plan.
23
- - Free samples (`Sample.IsPremium == False` or `Price == 0`) bypass
23
+ - Free samples (`Sample.IsPremium == False`) bypass
24
24
  gating entirely — they're free under any plan.
25
25
  """
26
26
 
@@ -156,6 +156,14 @@ class SpliceGRPCClient:
156
156
  self._quota = quota_tracker or get_tracker()
157
157
  # Cached plan state — refreshed on every explicit get_credits() call.
158
158
  self._cached_credits: Optional[SpliceCredits] = None
159
+ # Connection-health state. `connected=True` only proves the local
160
+ # gRPC channel object constructed — it does NOT prove Splice
161
+ # desktop is reachable. Real failures surface on the first RPC.
162
+ # `degraded` records that transition so the next call knows to
163
+ # retry `connect()` once instead of trusting a stale `connected`
164
+ # flag forever (see `_mark_degraded` / `_ensure_connected`).
165
+ self.degraded = False
166
+ self.last_error: Optional[str] = None
159
167
 
160
168
  @property
161
169
  def available(self) -> bool:
@@ -191,11 +199,16 @@ class SpliceGRPCClient:
191
199
  self.stub = self._pb2_grpc.AppStub(self.channel)
192
200
  self._port = port
193
201
  self.connected = True
202
+ # A fresh successful connect clears any prior degradation —
203
+ # this is the "one reconnect attempt" landing.
204
+ self.degraded = False
205
+ self.last_error = None
194
206
  logger.info(f"Connected to Splice gRPC on port {port}")
195
207
  return True
196
208
  except Exception as exc:
197
209
  logger.warning(f"Failed to connect to Splice: {exc}")
198
210
  self.connected = False
211
+ self.last_error = str(exc)
199
212
  return False
200
213
 
201
214
  async def disconnect(self):
@@ -206,6 +219,38 @@ class SpliceGRPCClient:
206
219
  self.stub = None
207
220
  self.connected = False
208
221
 
222
+ def _mark_degraded(self, exc: Exception) -> None:
223
+ """Record an RPC failure and drop the (falsely optimistic) `connected` flag.
224
+
225
+ `connect()` only proves the local channel object constructed, not
226
+ that Splice desktop is actually listening — that only surfaces on
227
+ the first real RPC. Without this, `connected` stayed True forever
228
+ after a Splice restart and every wrapper kept returning empty
229
+ defaults indistinguishable from genuine zero-result state for the
230
+ rest of the process lifetime. Callers can inspect `self.degraded`
231
+ / `self.last_error` to tell "connection is unhealthy" apart from
232
+ "legitimately found nothing".
233
+ """
234
+ self.degraded = True
235
+ self.last_error = str(exc)
236
+ self.connected = False
237
+
238
+ async def _ensure_connected(self) -> bool:
239
+ """True if the client is (or becomes) usable for an RPC call.
240
+
241
+ Retries `connect()` exactly once after a prior degradation —
242
+ cheap, since `connect()` only re-reads port.conf/cert and
243
+ rebuilds the local channel object, it doesn't itself probe the
244
+ network. A client that was never connected (and never marked
245
+ degraded) returns False immediately, matching the historical
246
+ "not connected → return default" behavior.
247
+ """
248
+ if self.connected:
249
+ return True
250
+ if not getattr(self, "degraded", False):
251
+ return False
252
+ return await self.connect()
253
+
209
254
  # ── Search ──────────────────────────────────────────────────────
210
255
 
211
256
  async def search_samples(
@@ -237,7 +282,7 @@ class SpliceGRPCClient:
237
282
  composition where role-correctness matters more than text-match
238
283
  on free-form query strings (BUG-FULL-MODE-9, 2026-05-01).
239
284
  """
240
- if not self.connected:
285
+ if not await self._ensure_connected():
241
286
  return SpliceSearchResult()
242
287
 
243
288
  pb2 = self._pb2
@@ -267,6 +312,7 @@ class SpliceGRPCClient:
267
312
  return self._parse_search_response(response)
268
313
  except Exception as exc:
269
314
  logger.warning(f"Splice search failed: {exc}")
315
+ self._mark_degraded(exc)
270
316
  return SpliceSearchResult()
271
317
 
272
318
  def _parse_search_response(self, response) -> SpliceSearchResult:
@@ -317,7 +363,7 @@ class SpliceGRPCClient:
317
363
  When not known we do NOT fetch the sample — that would waste
318
364
  a SampleInfo round-trip. Unknown samples default to paid.
319
365
  """
320
- if not self.connected:
366
+ if not await self._ensure_connected():
321
367
  return DownloadDecision(
322
368
  allowed=False,
323
369
  reason="Splice desktop app not reachable",
@@ -325,12 +371,17 @@ class SpliceGRPCClient:
325
371
  gating_mode="blocked",
326
372
  )
327
373
 
374
+ logger.debug(
375
+ "decide_download: file_hash=%s sample=%s",
376
+ file_hash, sample.filename if sample else "<unknown>",
377
+ )
378
+
328
379
  # Fast path: free samples bypass every gate.
329
380
  if sample is not None and sample.is_free:
330
381
  return DownloadDecision(
331
382
  allowed=True,
332
383
  reason=(
333
- "Sample is free (Price=0 or !IsPremium) — no credit or "
384
+ "Sample is free (not premium) — no credit or "
334
385
  "quota cost under any plan."
335
386
  ),
336
387
  plan_kind=(
@@ -346,32 +397,46 @@ class SpliceGRPCClient:
346
397
  plan = credits.plan_kind
347
398
 
348
399
  if plan == PlanKind.ABLETON_LIVE:
349
- quota = self._quota.summary()
350
- if quota["at_limit"]:
400
+ # `check_budget` reads would_exceed/near_limit/at_limit from a
401
+ # SINGLE lock-protected snapshot (see quota.py) instead of the
402
+ # stale `summary()["at_limit"]` check, which never consulted
403
+ # the would_exceed/near_limit predicates that already existed
404
+ # for exactly this purpose. Note this is still a check-then-act
405
+ # against the eventual `record_download()` call — that gap is
406
+ # inherent (the actual download is a network round-trip away)
407
+ # and this is a client-side warning, not a hard server limit.
408
+ budget = self._quota.check_budget(additional=1)
409
+ if budget["would_exceed"]:
351
410
  return DownloadDecision(
352
411
  allowed=False,
353
412
  reason=(
354
- f"Daily quota hit ({quota['used_today']}/"
355
- f"{quota['daily_limit']}). Resets at UTC midnight."
413
+ f"Daily quota hit ({budget['used_today']}/"
414
+ f"{budget['daily_limit']}). Resets at UTC midnight."
356
415
  ),
357
416
  plan_kind=plan,
358
417
  gating_mode="daily_quota",
359
418
  credits_remaining=credits.credits,
360
- quota_used=quota["used_today"],
361
- quota_remaining=quota["remaining_today"],
419
+ quota_used=budget["used_today"],
420
+ quota_remaining=budget["remaining_today"],
421
+ )
422
+ reason = (
423
+ f"Ableton Live plan: {budget['remaining_today']} of "
424
+ f"{budget['daily_limit']} daily samples remain. Download "
425
+ "will NOT deplete your 80 Splice.com credits."
426
+ )
427
+ if budget["near_limit"]:
428
+ reason += (
429
+ " Approaching the daily cap — consider "
430
+ "splice_preview_sample for further auditions today."
362
431
  )
363
432
  return DownloadDecision(
364
433
  allowed=True,
365
- reason=(
366
- f"Ableton Live plan: {quota['remaining_today']} of "
367
- f"{quota['daily_limit']} daily samples remain. Download "
368
- "will NOT deplete your 80 Splice.com credits."
369
- ),
434
+ reason=reason,
370
435
  plan_kind=plan,
371
436
  gating_mode="daily_quota",
372
437
  credits_remaining=credits.credits,
373
- quota_used=quota["used_today"],
374
- quota_remaining=quota["remaining_today"],
438
+ quota_used=budget["used_today"],
439
+ quota_remaining=budget["remaining_today"],
375
440
  )
376
441
 
377
442
  # Credit-metered plans (SOUNDS_PLUS, CREATOR, CREATOR_PLUS, UNKNOWN).
@@ -418,14 +483,15 @@ class SpliceGRPCClient:
418
483
  imperative "go download it" path; the decision is repeated here
419
484
  defensively because a future caller might forget to gate.
420
485
  """
421
- if not self.connected:
486
+ if not await self._ensure_connected():
422
487
  return None
423
488
 
424
489
  decision = await self.decide_download(file_hash, sample=sample)
425
490
  if not decision.allowed:
426
491
  logger.warning(
427
- "Splice download refused: %s (plan=%s, mode=%s)",
428
- decision.reason, decision.plan_kind.value, decision.gating_mode,
492
+ "Splice download refused for %s: %s (plan=%s, mode=%s)",
493
+ file_hash, decision.reason, decision.plan_kind.value,
494
+ decision.gating_mode,
429
495
  )
430
496
  return None
431
497
 
@@ -440,6 +506,7 @@ class SpliceGRPCClient:
440
506
  local_path = await self._wait_for_download(file_hash, timeout)
441
507
  except Exception as exc:
442
508
  logger.warning(f"Splice download failed for {file_hash}: {exc}")
509
+ self._mark_degraded(exc)
443
510
  return None
444
511
 
445
512
  if local_path is None:
@@ -484,7 +551,7 @@ class SpliceGRPCClient:
484
551
 
485
552
  async def get_sample_info(self, file_hash: str) -> Optional[SpliceSample]:
486
553
  """Get metadata for a specific sample."""
487
- if not self.connected:
554
+ if not await self._ensure_connected():
488
555
  return None
489
556
 
490
557
  pb2 = self._pb2
@@ -496,13 +563,14 @@ class SpliceGRPCClient:
496
563
  return self._parse_sample(response.Sample)
497
564
  except Exception as exc:
498
565
  logger.warning(f"SampleInfo failed: {exc}")
566
+ self._mark_degraded(exc)
499
567
  return None
500
568
 
501
569
  # ── Credits + Plan ──────────────────────────────────────────────
502
570
 
503
571
  async def get_credits(self) -> SpliceCredits:
504
572
  """Get current credit balance, plan, and feature-flag map."""
505
- if not self.connected:
573
+ if not await self._ensure_connected():
506
574
  return SpliceCredits()
507
575
 
508
576
  pb2 = self._pb2
@@ -540,6 +608,7 @@ class SpliceGRPCClient:
540
608
  return creds
541
609
  except Exception as exc:
542
610
  logger.warning(f"Credit check failed: {exc}")
611
+ self._mark_degraded(exc)
543
612
  return SpliceCredits()
544
613
 
545
614
  async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
@@ -563,7 +632,7 @@ class SpliceGRPCClient:
563
632
 
564
633
  async def sync_sounds(self) -> bool:
565
634
  """Trigger a full Splice library sync."""
566
- if not self.connected:
635
+ if not await self._ensure_connected():
567
636
  return False
568
637
  pb2 = self._pb2
569
638
  try:
@@ -574,6 +643,7 @@ class SpliceGRPCClient:
574
643
  return True
575
644
  except Exception as exc:
576
645
  logger.debug("sync_sounds failed: %s", exc)
646
+ self._mark_degraded(exc)
577
647
  return False
578
648
 
579
649
  # ── Collections ─────────────────────────────────────────────────
@@ -609,7 +679,7 @@ class SpliceGRPCClient:
609
679
  self, page: int = 1, per_page: int = 50,
610
680
  ) -> tuple[int, list[SpliceCollection]]:
611
681
  """List the user's collections. Returns (total_count, collections)."""
612
- if not self.connected:
682
+ if not await self._ensure_connected():
613
683
  return 0, []
614
684
  pb2 = self._pb2
615
685
  try:
@@ -622,13 +692,14 @@ class SpliceGRPCClient:
622
692
  return total, collections
623
693
  except Exception as exc:
624
694
  logger.warning(f"CollectionsList failed: {exc}")
695
+ self._mark_degraded(exc)
625
696
  return 0, []
626
697
 
627
698
  async def collection_samples(
628
699
  self, uuid: str, page: int = 1, per_page: int = 50,
629
700
  ) -> tuple[int, list[SpliceSample]]:
630
701
  """List samples inside a collection. Returns (total_hits, samples)."""
631
- if not self.connected:
702
+ if not await self._ensure_connected():
632
703
  return 0, []
633
704
  pb2 = self._pb2
634
705
  try:
@@ -643,11 +714,14 @@ class SpliceGRPCClient:
643
714
  return total, samples
644
715
  except Exception as exc:
645
716
  logger.warning(f"CollectionListSamples failed: {exc}")
717
+ self._mark_degraded(exc)
646
718
  return 0, []
647
719
 
648
720
  async def add_to_collection(self, uuid: str, sample_hashes: list[str]) -> bool:
649
721
  """Add samples to a collection. Returns True on success."""
650
- if not self.connected or not sample_hashes:
722
+ if not sample_hashes:
723
+ return False
724
+ if not await self._ensure_connected():
651
725
  return False
652
726
  pb2 = self._pb2
653
727
  try:
@@ -658,13 +732,16 @@ class SpliceGRPCClient:
658
732
  return True
659
733
  except Exception as exc:
660
734
  logger.warning(f"CollectionAddItems failed: {exc}")
735
+ self._mark_degraded(exc)
661
736
  return False
662
737
 
663
738
  async def remove_from_collection(
664
739
  self, uuid: str, sample_hashes: list[str],
665
740
  ) -> bool:
666
741
  """Remove samples from a collection."""
667
- if not self.connected or not sample_hashes:
742
+ if not sample_hashes:
743
+ return False
744
+ if not await self._ensure_connected():
668
745
  return False
669
746
  pb2 = self._pb2
670
747
  try:
@@ -675,11 +752,12 @@ class SpliceGRPCClient:
675
752
  return True
676
753
  except Exception as exc:
677
754
  logger.warning(f"CollectionDeleteItems failed: {exc}")
755
+ self._mark_degraded(exc)
678
756
  return False
679
757
 
680
758
  async def create_collection(self, name: str) -> Optional[SpliceCollection]:
681
759
  """Create a new user collection. Returns the new Collection or None."""
682
- if not self.connected:
760
+ if not await self._ensure_connected():
683
761
  return None
684
762
  pb2 = self._pb2
685
763
  try:
@@ -690,6 +768,7 @@ class SpliceGRPCClient:
690
768
  return self._parse_collection(response.Collection)
691
769
  except Exception as exc:
692
770
  logger.warning(f"CollectionAdd failed: {exc}")
771
+ self._mark_degraded(exc)
693
772
  return None
694
773
 
695
774
  # ── Packs ───────────────────────────────────────────────────────
@@ -712,7 +791,7 @@ class SpliceGRPCClient:
712
791
  Returns (pack, error) — `error` is a user-readable string when the
713
792
  lookup didn't find a match.
714
793
  """
715
- if not self.connected:
794
+ if not await self._ensure_connected():
716
795
  return None, "Splice gRPC not connected"
717
796
  pb2 = self._pb2
718
797
  target = pack_uuid.strip()
@@ -751,6 +830,7 @@ class SpliceGRPCClient:
751
830
  except Exception as exc:
752
831
  msg = f"ListSamplePacks gRPC call failed: {type(exc).__name__}: {exc}"
753
832
  logger.warning(msg)
833
+ self._mark_degraded(exc)
754
834
  return None, msg
755
835
  return None, (
756
836
  f"Pack '{target}' not found in the user's library. "
@@ -787,7 +867,7 @@ class SpliceGRPCClient:
787
867
  sort_order: str = "",
788
868
  ) -> tuple[int, list[SplicePreset]]:
789
869
  """List presets the user has purchased/owns."""
790
- if not self.connected:
870
+ if not await self._ensure_connected():
791
871
  return 0, []
792
872
  pb2 = self._pb2
793
873
  try:
@@ -803,13 +883,14 @@ class SpliceGRPCClient:
803
883
  return total, presets
804
884
  except Exception as exc:
805
885
  logger.warning(f"PresetsListPurchased failed: {exc}")
886
+ self._mark_degraded(exc)
806
887
  return 0, []
807
888
 
808
889
  async def get_preset_info(
809
890
  self, uuid: str = "", file_hash: str = "", plugin_name: str = "",
810
891
  ) -> Optional[dict]:
811
892
  """Fetch metadata for a single preset."""
812
- if not self.connected:
893
+ if not await self._ensure_connected():
813
894
  return None
814
895
  pb2 = self._pb2
815
896
  try:
@@ -826,11 +907,12 @@ class SpliceGRPCClient:
826
907
  }
827
908
  except Exception as exc:
828
909
  logger.warning(f"PresetInfo failed: {exc}")
910
+ self._mark_degraded(exc)
829
911
  return None
830
912
 
831
913
  async def download_preset(self, uuid: str) -> bool:
832
914
  """Trigger a preset download (uses credits)."""
833
- if not self.connected:
915
+ if not await self._ensure_connected():
834
916
  return False
835
917
  pb2 = self._pb2
836
918
  try:
@@ -841,13 +923,14 @@ class SpliceGRPCClient:
841
923
  return True
842
924
  except Exception as exc:
843
925
  logger.warning(f"PresetDownload failed: {exc}")
926
+ self._mark_degraded(exc)
844
927
  return False
845
928
 
846
929
  # ── Convert to WAV ──────────────────────────────────────────────
847
930
 
848
931
  async def convert_to_wav(self, path: str) -> Optional[dict]:
849
932
  """Convert an audio file to PCM WAV via Splice's converter."""
850
- if not self.connected:
933
+ if not await self._ensure_connected():
851
934
  return None
852
935
  pb2 = self._pb2
853
936
  try:
@@ -864,6 +947,7 @@ class SpliceGRPCClient:
864
947
  }
865
948
  except Exception as exc:
866
949
  logger.warning(f"ConvertToWav failed: {exc}")
950
+ self._mark_degraded(exc)
867
951
  return None
868
952
 
869
953
  # ── Connection Helpers ──────────────────────────────────────────
@@ -500,7 +500,8 @@ class SpliceHTTPBridge:
500
500
 
501
501
  loop = asyncio.get_running_loop()
502
502
  last_err = None
503
- for attempt in range(1 + max(0, self.config.max_retries)):
503
+ max_attempts = 1 + max(0, self.config.max_retries)
504
+ for attempt in range(max_attempts):
504
505
  try:
505
506
  return await loop.run_in_executor(
506
507
  None,
@@ -509,10 +510,21 @@ class SpliceHTTPBridge:
509
510
  )
510
511
  except SpliceHTTPError as exc:
511
512
  last_err = exc
512
- # Retry only on 5xx / network. 4xx is terminal.
513
+ # Retry only on 5xx / network. 4xx is terminal, and so is a
514
+ # response-decode failure (DECODE_ERROR) — a malformed body
515
+ # will fail to parse identically on every retry, so retrying
516
+ # just burns the backoff delay for no benefit. Both
517
+ # DECODE_ERROR and NETWORK_ERROR carry status_code=0, which
518
+ # would otherwise slip past the "< 500 is terminal" check
519
+ # below and retry forever on a deterministic parse failure.
520
+ if exc.code == "DECODE_ERROR":
521
+ raise
513
522
  if exc.status_code and exc.status_code < 500:
514
523
  raise
515
- await asyncio.sleep(min(2 ** attempt, 5))
524
+ # Only sleep between attempts — not after the final one, which
525
+ # is about to raise `last_err` and return control to the caller.
526
+ if attempt < max_attempts - 1:
527
+ await asyncio.sleep(min(2 ** attempt, 5))
516
528
  assert last_err is not None
517
529
  raise last_err
518
530
 
@@ -146,6 +146,34 @@ class DailyQuotaTracker:
146
146
  used, _ = self.current()
147
147
  return used >= self.warn_threshold
148
148
 
149
+ def check_budget(self, additional: int = 1) -> dict:
150
+ """Atomic snapshot combining would_exceed/near_limit/at_limit.
151
+
152
+ `would_exceed()` and `near_limit()` each independently call
153
+ `current()`, which reads the on-disk state under its own lock
154
+ acquisition. That's safe for either predicate alone, but a
155
+ caller (like `decide_download`) that wants BOTH answers for one
156
+ decision could otherwise observe two different moments if a
157
+ concurrent `record_download()` lands between the two reads. This
158
+ takes a single lock and derives every predicate from the same
159
+ `used` count.
160
+
161
+ Returns a dict compatible in spirit with `summary()` but with an
162
+ explicit `would_exceed` for the caller's `additional` count.
163
+ """
164
+ with self._lock:
165
+ state = self._load()
166
+ used = state.counts.get(_today_utc(), 0)
167
+ remaining = max(0, self.daily_limit - used)
168
+ return {
169
+ "used_today": used,
170
+ "remaining_today": remaining,
171
+ "daily_limit": self.daily_limit,
172
+ "would_exceed": (used + additional) > self.daily_limit,
173
+ "near_limit": used >= self.warn_threshold,
174
+ "at_limit": used >= self.daily_limit,
175
+ }
176
+
149
177
  # ── Mutations ─────────────────────────────────────────────────────
150
178
 
151
179
  def record_download(self, file_hash: str, filename: str = "") -> dict:
@@ -190,7 +190,10 @@ def _state_signals_to_signal_list(state: dict) -> list[StucknessSignal]:
190
190
 
191
191
  def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
192
192
  """Check for repeated undone moves (kept=False in ledger entries)."""
193
- recent = history[-20:] if len(history) > 20 else history
193
+ # `history` is NEWEST-FIRST (action ledger get_recent_moves), so the
194
+ # recency window is the FRONT of the list. `history[-N:]` would take the
195
+ # OLDEST N entries — analyzing stale history once the list exceeds N.
196
+ recent = history[:20]
194
197
  undo_count = sum(1 for a in recent if a.get("kept") is False)
195
198
 
196
199
  if undo_count >= 4:
@@ -204,9 +207,9 @@ def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
204
207
 
205
208
  def _check_local_tweaking(history: list[dict]) -> Optional[StucknessSignal]:
206
209
  """Check for many small parameter changes in one local area."""
207
- recent = history[-15:] if len(history) > 15 else history
210
+ recent = history[:15] # newest-first: front = most recent
208
211
  param_tools = {"set_device_parameter", "set_track_volume", "set_track_pan",
209
- "set_send_level", "set_clip_loop", "batch_set_parameters"}
212
+ "set_track_send", "set_clip_loop", "batch_set_parameters"}
210
213
  param_entries = []
211
214
  for entry in recent:
212
215
  tools_used = [a.get("tool", "") for a in entry.get("actions", [])]
@@ -232,7 +235,7 @@ def _check_loop_without_structure(
232
235
  history: list[dict], section_count: int
233
236
  ) -> Optional[StucknessSignal]:
234
237
  """Check for long work without structural changes."""
235
- recent = history[-30:] if len(history) > 30 else history
238
+ recent = history[:30] # newest-first: front = most recent
236
239
  structural_tools = {"create_clip", "delete_clip", "create_midi_track",
237
240
  "create_audio_track", "delete_track", "duplicate_clip"}
238
241
  structural = 0
@@ -260,7 +263,7 @@ def _check_loop_without_structure(
260
263
 
261
264
  def _check_repeated_requests(history: list[dict]) -> Optional[StucknessSignal]:
262
265
  """Check for repeated similar intents without acceptance."""
263
- recent = history[-10:] if len(history) > 10 else history
266
+ recent = history[:10] # newest-first: front = most recent
264
267
  intents = [a.get("intent", "").lower() for a in recent if a.get("intent")]
265
268
 
266
269
  if len(intents) >= 3:
@@ -10,9 +10,12 @@ add tuning-table variants and LFO-routing variants.
10
10
  from __future__ import annotations
11
11
 
12
12
  import hashlib
13
+ import logging
13
14
  from typing import Optional
14
15
 
15
16
  from ...branches import BranchSeed, freeform_seed
17
+
18
+ logger = logging.getLogger(__name__)
16
19
  from ..models import (
17
20
  SynthProfile,
18
21
  TimbralFingerprint,
@@ -110,6 +113,16 @@ class DriftAdapter:
110
113
  try:
111
114
  maybe = strategy_fn(profile, target, kernel, adapter=self)
112
115
  except Exception:
116
+ # Never let one strategy's crash kill the rest, but make the
117
+ # swallowed failure observable instead of silently degrading
118
+ # the branch set.
119
+ logger.warning(
120
+ "Drift strategy %s crashed on track %s device %s; skipping",
121
+ getattr(strategy_fn, "__name__", strategy_fn),
122
+ profile.track_index,
123
+ profile.device_index,
124
+ exc_info=True,
125
+ )
113
126
  continue
114
127
  if maybe is not None:
115
128
  results.append(maybe)
@@ -11,9 +11,12 @@ variants.
11
11
  from __future__ import annotations
12
12
 
13
13
  import hashlib
14
+ import logging
14
15
  from typing import Optional
15
16
 
16
17
  from ...branches import BranchSeed, freeform_seed
18
+
19
+ logger = logging.getLogger(__name__)
17
20
  from ..models import (
18
21
  SynthProfile,
19
22
  TimbralFingerprint,
@@ -107,6 +110,16 @@ class MeldAdapter:
107
110
  try:
108
111
  maybe = strategy_fn(profile, target, kernel, adapter=self)
109
112
  except Exception:
113
+ # Never let one strategy's crash kill the rest, but make the
114
+ # swallowed failure observable instead of silently degrading
115
+ # the branch set.
116
+ logger.warning(
117
+ "Meld strategy %s crashed on track %s device %s; skipping",
118
+ getattr(strategy_fn, "__name__", strategy_fn),
119
+ profile.track_index,
120
+ profile.device_index,
121
+ exc_info=True,
122
+ )
110
123
  continue
111
124
  if maybe is not None:
112
125
  results.append(maybe)