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
@@ -23,6 +23,7 @@ OSC address convention:
23
23
  from __future__ import annotations
24
24
 
25
25
  import asyncio
26
+ import atexit
26
27
  import base64
27
28
  import json
28
29
  import random
@@ -30,6 +31,7 @@ import socket
30
31
  import struct
31
32
  import threading
32
33
  import time
34
+ import uuid
33
35
  from typing import Any, Callable, Optional
34
36
 
35
37
 
@@ -158,15 +160,21 @@ class MidiToolCache:
158
160
  self._context: Optional[dict] = None
159
161
  self._notes: Optional[list] = None
160
162
  self._last_seen = 0.0
163
+ self._request_time = 0.0
161
164
  self._connected = False
162
165
  self._target_tool: Optional[str] = None
163
166
  self._target_params: dict = {}
164
167
 
165
168
  def set_request(self, context: dict, notes: list) -> None:
166
169
  with self._lock:
170
+ now = time.monotonic()
167
171
  self._context = context
168
172
  self._notes = notes
169
- self._last_seen = time.monotonic()
173
+ self._last_seen = now
174
+ # Timestamp the request payload independently of _last_seen so a
175
+ # later mark_ready() heartbeat (which bumps _last_seen for
176
+ # is_connected) cannot resurrect an expired context/notes payload.
177
+ self._request_time = now
170
178
  self._connected = True
171
179
 
172
180
  def mark_ready(self) -> None:
@@ -179,7 +187,7 @@ class MidiToolCache:
179
187
  with self._lock:
180
188
  if self._context is None:
181
189
  return None
182
- age = time.monotonic() - self._last_seen
190
+ age = time.monotonic() - self._request_time
183
191
  if age > self._max_age:
184
192
  return None
185
193
  return dict(self._context)
@@ -188,7 +196,7 @@ class MidiToolCache:
188
196
  with self._lock:
189
197
  if self._notes is None:
190
198
  return None
191
- age = time.monotonic() - self._last_seen
199
+ age = time.monotonic() - self._request_time
192
200
  if age > self._max_age:
193
201
  return None
194
202
  return list(self._notes)
@@ -477,7 +485,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
477
485
  /rms f — RMS level
478
486
  /pitch f f — MIDI note, amplitude
479
487
  /response s — base64-encoded JSON (single packet)
480
- /response_chunk i i s — chunked response (index, total, data)
488
+ /response_chunk [s] i i s — chunked response. New builds prefix the
489
+ per-response request id so chunks of
490
+ different commands never share a bucket;
491
+ legacy builds omit it: (index, total, data)
481
492
  """
482
493
 
483
494
  # Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
@@ -499,8 +510,18 @@ class SpectralReceiver(asyncio.DatagramProtocol):
499
510
  self._chunk_id = 0
500
511
  self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
501
512
  self._response_callback: Optional[asyncio.Future] = None
513
+ self._response_request_id: Optional[str] = None
514
+ # Set True the first time a response arrives carrying a request id.
515
+ # Once the analyzer build is known to stamp ids, a response that
516
+ # arrives with NO id (or a mismatched one) while a future is live is a
517
+ # stale straggler and must be dropped — see _handle_response.
518
+ self._seen_request_id = False
502
519
  self._capture_future: Optional[asyncio.Future] = None
503
520
  self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
521
+ # Source-address allowlist: the M4L device always sends from loopback.
522
+ # Track which non-loopback senders we've already warned about so a
523
+ # spoofing/collision source doesn't spam stderr once per packet.
524
+ self._warned_sources: set[str] = set()
504
525
 
505
526
  def set_miditool_handler(self, handler: Optional[Callable[[str, dict, list], None]]) -> None:
506
527
  """Register a callback invoked on each /miditool/request packet.
@@ -514,7 +535,27 @@ class SpectralReceiver(asyncio.DatagramProtocol):
514
535
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
515
536
  self.transport = transport
516
537
 
538
+ # Any local process can send to this UDP port, so a stray/malicious
539
+ # sender could poison the spectral cache or spoof /response packets to
540
+ # resolve pending bridge futures. The M4L device always sends from
541
+ # loopback, so anything else is dropped. This is a source-address
542
+ # allowlist, not authentication — a full shared-token handshake between
543
+ # the .amxd and the bridge is a deeper fix, tracked separately.
544
+ _ALLOWED_HOSTS = ("127.0.0.1", "::1")
545
+
517
546
  def datagram_received(self, data: bytes, addr: tuple) -> None:
547
+ host = addr[0] if addr else None
548
+ if host not in self._ALLOWED_HOSTS:
549
+ if host not in self._warned_sources:
550
+ self._warned_sources.add(host)
551
+ import sys
552
+ print(
553
+ f"LivePilot: dropping UDP packet from non-loopback source "
554
+ f"{addr!r} on spectral bridge port (further packets from "
555
+ "this source will be dropped silently)",
556
+ file=sys.stderr,
557
+ )
558
+ return
518
559
  try:
519
560
  self._parse_osc(data)
520
561
  except Exception as exc:
@@ -653,7 +694,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
653
694
  self._handle_response(str(args[0]))
654
695
 
655
696
  elif address == "/response_chunk" and len(args) >= 3:
656
- self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
697
+ # New builds prefix the request id (string): (rid, index, total, data).
698
+ # Legacy builds send (index, total, data). Distinguish by the type
699
+ # of the first arg — OSC ints decode to int, the id to a str.
700
+ if len(args) >= 4 and isinstance(args[0], str):
701
+ self._handle_chunk(
702
+ int(args[1]), int(args[2]), str(args[3]),
703
+ request_id=str(args[0]),
704
+ )
705
+ else:
706
+ self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
657
707
 
658
708
  elif address == "/miditool/request" and len(args) >= 1:
659
709
  self._handle_miditool_request(str(args[0]))
@@ -665,24 +715,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
665
715
  def _handle_response(self, encoded: str) -> None:
666
716
  """Decode a single-packet base64 response.
667
717
 
668
- Resolves _response_callback exactly once, then clears it. Without the
669
- clear, a second late packet could overwrite a future belonging to a
670
- different in-flight command. The protocol has no request id yet
671
- (livepilot_bridge.js:666 emits bare /response), so correlation relies
672
- on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
673
- plus this one-shot clear.
718
+ Updated analyzer JS echoes ``_livepilot_request_id`` on every response
719
+ (single-packet here, and on the chunk header for chunked responses).
720
+ Correlation rules, once this device is known to stamp ids:
721
+ * a response whose id does NOT match the in-flight future is dropped;
722
+ * a response with NO id at all is also dropped — it is a stale
723
+ straggler (e.g. a batched read that outlived its timeout), not a
724
+ pre-request-id build.
725
+ Pre-request-id builds (which never stamp an id) stay supported: until
726
+ the first id is ever seen, no-id responses are accepted.
674
727
  """
675
728
  try:
676
729
  # URL-safe base64 decode (- and _ instead of + and /)
677
730
  padded = encoded + "=" * (-len(encoded) % 4)
678
731
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
679
732
  result = _normalize_bridge_payload(json.loads(decoded))
733
+ response_request_id = None
734
+ if isinstance(result, dict):
735
+ response_request_id = result.pop("_livepilot_request_id", None)
736
+
737
+ if response_request_id is not None:
738
+ # This analyzer build stamps request ids — remember it so a
739
+ # later no-id straggler is recognised as stale rather than
740
+ # mistaken for an old build that never sends ids.
741
+ self._seen_request_id = True
742
+
680
743
  cb = self._response_callback
744
+ expected_request_id = self._response_request_id
745
+ if cb and not cb.done() and expected_request_id is not None:
746
+ mismatched = (
747
+ response_request_id is not None
748
+ and str(response_request_id) != str(expected_request_id)
749
+ )
750
+ missing_but_expected = (
751
+ response_request_id is None and self._seen_request_id
752
+ )
753
+ if mismatched or missing_but_expected:
754
+ # Stale/uncorrelated reply — drop it and keep waiting for
755
+ # the response that actually matches this command.
756
+ return
757
+
681
758
  if cb and not cb.done():
682
759
  cb.set_result(result)
683
760
  # Clear regardless — either we consumed it, or it was already
684
761
  # done/abandoned. Future packets with no owner get dropped.
685
762
  self._response_callback = None
763
+ self._response_request_id = None
686
764
  except Exception as exc:
687
765
  import sys
688
766
  print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
@@ -691,7 +769,8 @@ class SpectralReceiver(asyncio.DatagramProtocol):
691
769
  """Decode a /miditool/request packet and dispatch to the configured handler.
692
770
 
693
771
  Payload: base64(JSON({request_id, context, notes})).
694
- Packet arrives on the UDP receive thread; the registered handler is
772
+ Packet arrives on the asyncio event-loop thread (SpectralReceiver is a
773
+ DatagramProtocol, not a separate thread); the registered handler is
695
774
  expected to schedule work on an event loop rather than block here.
696
775
  """
697
776
  try:
@@ -718,49 +797,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
718
797
  import sys
719
798
  print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
720
799
 
721
- def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
800
+ def _handle_chunk(
801
+ self,
802
+ index: int,
803
+ total: int,
804
+ encoded: str,
805
+ request_id: Optional[str] = None,
806
+ ) -> None:
722
807
  """Reassemble chunked responses.
723
808
 
724
- The previous implementation incremented ``_chunk_id`` only when
725
- ``index == 0`` and assumed the first chunk always arrived first.
726
- Under UDP reordering (rare on loopback but possible under system
727
- load), a chunk with ``index > 0`` arriving before ``index 0`` would
728
- be dropped into the PREVIOUS sequence's bucket silently corrupting
729
- that earlier response's payload.
730
-
731
- Until the wire protocol adds an explicit sequence id, the safer
732
- behavior is: if we see an out-of-order first-chunk (``index > 0``
733
- with no open bucket), start a fresh bucket but log a warning. That
734
- way we never poison a prior sequence, and the problem surfaces in
735
- logs if it happens.
809
+ New analyzer builds stamp the per-response request id on every
810
+ ``/response_chunk`` header, so chunks from different commands land in
811
+ SEPARATE buckets and can never be interleaved even if a stale chunk
812
+ from a timed-out command arrives mid-flight. Legacy builds omit the id
813
+ (``request_id`` None/""); they fall back to a single rolling bucket,
814
+ which is safe because ``send_command`` serialises on ``_cmd_lock``.
815
+
816
+ Hardening (v1.27.2): an index outside ``[0, total)`` is dropped a
817
+ duplicate or malformed packet must never count toward completion and
818
+ reassembly only fires once EVERY index ``0..total-1`` is present. The
819
+ old ``len(parts) == total`` check could KeyError on a duplicate or
820
+ out-of-range index, silently losing the response and forcing a full
821
+ timeout.
736
822
  """
737
- # Only one response is chunked at a time: send_command serialises on
738
- # _cmd_lock, so a single active reassembly bucket is sufficient and we
739
- # do NOT need the first packet to be index 0. Accepting chunks in any
740
- # order fixes the permanent-loss path where an index>0 chunk arriving
741
- # before index 0 (UDP loopback reordering under load) used to split
742
- # one response across two buckets that never completed.
743
- active = self._chunks.get(self._chunk_key)
744
- if active is None or active["total"] != total:
745
- # No bucket open, OR a chunk with a different `total` arrived,
746
- # meaning a new response started (e.g. the previous one timed out
747
- # without ever completing). Evict any stale partial and open fresh.
748
- if active is not None:
749
- self._chunks.pop(self._chunk_key, None)
750
- self._chunk_times.pop(self._chunk_key, None)
751
- self._chunk_id += 1
752
- self._chunk_key = str(self._chunk_id)
753
- self._chunks[self._chunk_key] = {"parts": {}, "total": total}
754
- self._chunk_times[self._chunk_key] = time.monotonic()
755
-
756
- key = self._chunk_key
757
- self._chunks[key]["parts"][index] = encoded
758
-
759
- if len(self._chunks[key]["parts"]) == total:
760
- # All chunks received reassemble
761
- full = ""
762
- for i in range(total):
763
- full += self._chunks[key]["parts"][i]
823
+ # Drop chunks that cannot belong to a well-formed response.
824
+ if total <= 0 or index < 0 or index >= total:
825
+ return
826
+
827
+ if request_id:
828
+ # Collision-free per-response bucket.
829
+ key = f"rid:{request_id}"
830
+ active = self._chunks.get(key)
831
+ if active is None or active["total"] != total:
832
+ self._chunks[key] = {"parts": {}, "total": total}
833
+ self._chunk_times[key] = time.monotonic()
834
+ self._chunk_key = key
835
+ else:
836
+ # Legacy single-active-bucket model (no id on the wire). A chunk
837
+ # with a different `total` means a new response started (e.g. the
838
+ # previous one timed out without ever completing) — evict + reopen.
839
+ active = self._chunks.get(self._chunk_key)
840
+ if active is None or active["total"] != total:
841
+ if active is not None:
842
+ self._chunks.pop(self._chunk_key, None)
843
+ self._chunk_times.pop(self._chunk_key, None)
844
+ self._chunk_id += 1
845
+ self._chunk_key = str(self._chunk_id)
846
+ self._chunks[self._chunk_key] = {"parts": {}, "total": total}
847
+ self._chunk_times[self._chunk_key] = time.monotonic()
848
+ key = self._chunk_key
849
+
850
+ parts = self._chunks[key]["parts"]
851
+ parts[index] = encoded
852
+
853
+ if len(parts) == total and all(i in parts for i in range(total)):
854
+ # Every chunk present — reassemble in order.
855
+ full = "".join(parts[i] for i in range(total))
764
856
  del self._chunks[key]
765
857
  self._chunk_times.pop(key, None)
766
858
  self._handle_response(full)
@@ -780,13 +872,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
780
872
  result = _normalize_bridge_payload(json.loads(decoded))
781
873
  if self._capture_future and not self._capture_future.done():
782
874
  self._capture_future.set_result(result)
875
+ # Clear the reference so a completed future isn't retained until the
876
+ # next capture, and so send_capture's done()-guarded cancel has no
877
+ # stale object to consider.
878
+ self._capture_future = None
783
879
  except Exception as exc:
784
880
  import sys
785
881
  print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
786
882
 
787
- def set_response_future(self, future: asyncio.Future) -> None:
883
+ def set_response_future(
884
+ self,
885
+ future: Optional[asyncio.Future],
886
+ request_id: Optional[str] = None,
887
+ ) -> None:
788
888
  """Set a future to be resolved with the next response."""
789
889
  self._response_callback = future
890
+ self._response_request_id = request_id if future is not None else None
790
891
 
791
892
  def set_capture_future(self, future: asyncio.Future) -> None:
792
893
  """Set a future to be resolved when a capture_complete OSC arrives."""
@@ -810,7 +911,14 @@ class M4LBridge:
810
911
  self.receiver = receiver
811
912
  self.miditool_cache = miditool_cache
812
913
  self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
914
+ # Non-blocking so a momentarily-full send buffer never stalls the
915
+ # asyncio event loop. The miditool response path sends from inside a
916
+ # DatagramProtocol callback that runs ON the loop thread — a blocking
917
+ # sendto there would freeze every pending coroutine. UDP loopback sends
918
+ # essentially never block; _safe_sendto guards the rare case anyway.
919
+ self._sock.setblocking(False)
813
920
  self._m4l_addr = ("127.0.0.1", 9881)
921
+ self._closed = False
814
922
  self._cmd_lock: Optional[asyncio.Lock] = None
815
923
  # BUG-audit-C1: send_capture uses _capture_future, which is
816
924
  # independent of _response_callback used by send_command.
@@ -825,13 +933,40 @@ class M4LBridge:
825
933
  if self.receiver.miditool_cache is None and miditool_cache is not None:
826
934
  self.receiver.miditool_cache = miditool_cache
827
935
 
936
+ # Best-effort: release this bridge's OUTGOING UDP 9881 send socket on a
937
+ # clean interpreter exit. NOTE: this is NOT the 9880 analyzer listener —
938
+ # that socket lives on the SpectralReceiver transport (created in the
939
+ # server lifespan) and is closed in the lifespan finally. The OS reclaims
940
+ # BOTH ports on any process death, INCLUDING the SIGTERM/SIGKILL the
941
+ # orphan-kill runbook uses (which bypasses atexit). The real defense
942
+ # against an orphan squatting 9880 is the §9b kill-orphan procedure +
943
+ # reconnect_bridge, not this handler.
944
+ atexit.register(self.close)
945
+
946
+ def _safe_sendto(self, data: bytes) -> None:
947
+ """Send a UDP packet without ever blocking the event loop.
948
+
949
+ The socket is non-blocking; if the OS send buffer is momentarily full
950
+ (vanishingly rare on loopback) sendto raises BlockingIOError. We drop
951
+ the packet rather than stall — the caller's timeout/retry handles it.
952
+ """
953
+ try:
954
+ self._sock.sendto(data, self._m4l_addr)
955
+ except BlockingIOError:
956
+ import sys
957
+ print(
958
+ "LivePilot: M4L UDP send buffer full — packet dropped",
959
+ file=sys.stderr,
960
+ )
961
+
828
962
  def _dispatch_miditool_request(
829
963
  self, request_id: str, context: dict, notes: list,
830
964
  ) -> None:
831
965
  """Handle a decoded /miditool/request: run the configured generator,
832
966
  send /miditool/response back with {request_id, notes}.
833
967
 
834
- Invoked from SpectralReceiver on the UDP receive thread. We do not
968
+ Invoked from SpectralReceiver on the asyncio event-loop thread
969
+ (it is a DatagramProtocol, not a separate UDP thread). We do not
835
970
  await anything here — generators are synchronous, pure Python.
836
971
  If no target is configured, pass notes through unchanged (identity).
837
972
  """
@@ -869,7 +1004,7 @@ class M4LBridge:
869
1004
  """
870
1005
  payload = {"tool_name": tool_name or "", "params": params or {}}
871
1006
  osc = self._build_osc("miditool/config", (json.dumps(payload),))
872
- self._sock.sendto(osc, self._m4l_addr)
1007
+ self._safe_sendto(osc)
873
1008
 
874
1009
  def send_miditool_response(self, request_id: str, notes: list) -> None:
875
1010
  """Send transformed notes back to the JS bridge.
@@ -879,7 +1014,7 @@ class M4LBridge:
879
1014
  """
880
1015
  payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
881
1016
  osc = self._build_osc("miditool/response", (json.dumps(payload),))
882
- self._sock.sendto(osc, self._m4l_addr)
1017
+ self._safe_sendto(osc)
883
1018
 
884
1019
  async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
885
1020
  """Send an OSC command to the M4L device and wait for the response."""
@@ -905,12 +1040,14 @@ class M4LBridge:
905
1040
  # Create a future for the response
906
1041
  loop = asyncio.get_running_loop()
907
1042
  future = loop.create_future()
908
- self.receiver.set_response_future(future)
1043
+ request_id = uuid.uuid4().hex
1044
+ self.receiver.set_response_future(future, request_id=request_id)
909
1045
 
910
1046
  # Build and send OSC message (no leading / — Max udpreceive
911
1047
  # passes messagename with / intact to JS, breaking dispatch)
912
- osc_data = self._build_osc(command, args)
913
- self._sock.sendto(osc_data, self._m4l_addr)
1048
+ request_arg = f"__livepilot_request_id:{request_id}"
1049
+ osc_data = self._build_osc(command, (*args, request_arg))
1050
+ self._safe_sendto(osc_data)
914
1051
 
915
1052
  # Wait for response with timeout
916
1053
  try:
@@ -958,7 +1095,7 @@ class M4LBridge:
958
1095
  self.receiver.set_capture_future(future)
959
1096
 
960
1097
  osc_data = self._build_osc(command, args)
961
- self._sock.sendto(osc_data, self._m4l_addr)
1098
+ self._safe_sendto(osc_data)
962
1099
 
963
1100
  try:
964
1101
  result = await asyncio.wait_for(future, timeout=timeout)
@@ -971,7 +1108,7 @@ class M4LBridge:
971
1108
  async def cancel_capture_future(self) -> None:
972
1109
  """Resolve any in-progress capture future with a stopped result.
973
1110
 
974
- Does NOT acquire _cmd_lock — send_capture holds it during recording.
1111
+ Does NOT acquire _capture_lock — send_capture holds it during recording.
975
1112
  Resolving (not cancelling) the future lets send_capture return a
976
1113
  clean partial-result dict instead of raising CancelledError.
977
1114
  """
@@ -1025,4 +1162,22 @@ class M4LBridge:
1025
1162
  return addr_bytes + tag_bytes + arg_data
1026
1163
 
1027
1164
  def close(self) -> None:
1028
- self._sock.close()
1165
+ """Close the outgoing UDP socket and mark this bridge as shut down.
1166
+
1167
+ Idempotent — safe to call multiple times (from the server lifespan
1168
+ finally block AND from the atexit handler registered at construction).
1169
+
1170
+ Releases this bridge's OUTGOING UDP 9881 send socket (M4LBridge._sock).
1171
+ This is NOT the 9880 analyzer listener — that is the SpectralReceiver
1172
+ transport, closed in the server lifespan finally. Called from both the
1173
+ lifespan shutdown and an atexit handler; on abrupt process death the OS
1174
+ reclaims the socket regardless (see the §9b orphan-kill runbook for the
1175
+ actual stale-9880 defense).
1176
+ """
1177
+ if self._closed:
1178
+ return
1179
+ self._closed = True
1180
+ try:
1181
+ self._sock.close()
1182
+ except OSError:
1183
+ pass
@@ -216,6 +216,49 @@ class TasteGraph:
216
216
  logger.debug("update_novelty failed: %s", exc)
217
217
  pass # persistence is best-effort
218
218
 
219
+ def record_anti_preference(self, dimension: str, direction: str) -> None:
220
+ """Record a dimension avoidance and persist it across restarts.
221
+
222
+ Mirrors record_move_outcome's write-back contract: updates the
223
+ in-memory dimension_avoidances map AND persists to disk so the
224
+ avoidance survives a server restart. build_taste_graph hydrates
225
+ anti_preferences from the same persistent store, so without this
226
+ write-back the persisted read branch would stay dead (P2-29).
227
+ """
228
+ now = int(time.time() * 1000)
229
+ self.dimension_avoidances[dimension] = direction
230
+ self.evidence_count += 1
231
+ self.last_updated_ms = now
232
+
233
+ # Write-back to persistent store
234
+ if self._persistent_store is not None:
235
+ try:
236
+ self._persistent_store.record_anti_preference(dimension, direction)
237
+ except Exception as exc:
238
+ logger.debug("record_anti_preference failed: %s", exc)
239
+ pass # persistence is best-effort
240
+
241
+ def record_dimension_weight(self, dimension: str, value: float) -> None:
242
+ """Set a dimension preference weight and persist it across restarts.
243
+
244
+ Mirrors record_move_outcome's write-back contract: updates the
245
+ in-memory dimension_weights map AND persists to disk so the weight
246
+ survives a server restart. build_taste_graph hydrates
247
+ dimension_weights from the same persistent store, so without this
248
+ write-back the persisted read branch would stay dead (P2-29).
249
+ """
250
+ now = int(time.time() * 1000)
251
+ self.dimension_weights[dimension] = value
252
+ self.last_updated_ms = now
253
+
254
+ # Write-back to persistent store
255
+ if self._persistent_store is not None:
256
+ try:
257
+ self._persistent_store.record_dimension_weight(dimension, value)
258
+ except Exception as exc:
259
+ logger.debug("record_dimension_weight failed: %s", exc)
260
+ pass # persistence is best-effort
261
+
219
262
  # ── Ranking ──────────────────────────────────────────────────────
220
263
 
221
264
  def rank_moves(
@@ -2,6 +2,7 @@
2
2
 
3
3
  import copy
4
4
  import json
5
+ import logging
5
6
  import os
6
7
  import threading
7
8
  import uuid
@@ -10,6 +11,9 @@ from pathlib import Path
10
11
  from typing import Any, Optional
11
12
 
12
13
 
14
+ logger = logging.getLogger(__name__)
15
+
16
+
13
17
  VALID_TYPES = frozenset(
14
18
  ["beat_pattern", "device_chain", "mix_template", "browser_pin", "preference",
15
19
  "outcome", "composition_outcome", "technique_card"]
@@ -74,9 +78,28 @@ class TechniqueStore:
74
78
  with open(self._file, "r") as f:
75
79
  self._data = json.load(f)
76
80
  self._loaded_sig = self._file_signature()
77
- except (json.JSONDecodeError, ValueError):
78
- corrupt = self._file.with_suffix(".json.corrupt")
79
- self._file.rename(corrupt)
81
+ except (json.JSONDecodeError, ValueError) as exc:
82
+ # Preserve the unparseable file before replacing it with
83
+ # defaults, so the user's data stays recoverable. Use a
84
+ # timestamped name so repeated corruptions don't clobber
85
+ # an earlier backup (a plain ".json.corrupt" target would
86
+ # be silently overwritten on POSIX / raise on Windows).
87
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
88
+ corrupt = self._file.with_suffix(f".json.corrupt.{ts}")
89
+ try:
90
+ self._file.rename(corrupt)
91
+ logger.warning(
92
+ "techniques.json failed to parse (%s); backed up "
93
+ "corrupt file to %s and reinitialized with defaults",
94
+ exc, corrupt,
95
+ )
96
+ except OSError as rename_exc:
97
+ logger.warning(
98
+ "techniques.json failed to parse (%s) and the "
99
+ "corrupt backup could not be written (%s); "
100
+ "reinitialized with defaults — prior data lost",
101
+ exc, rename_exc,
102
+ )
80
103
  self._data = {"version": 1, "techniques": []}
81
104
  self._loaded_sig = None
82
105
  else: