livepilot 1.27.2 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -17,7 +17,9 @@ The engine itself is pure orchestration logic.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import asyncio
|
|
20
21
|
import hashlib
|
|
22
|
+
import threading
|
|
21
23
|
import time
|
|
22
24
|
from typing import Optional
|
|
23
25
|
|
|
@@ -28,8 +30,15 @@ import logging
|
|
|
28
30
|
logger = logging.getLogger(__name__)
|
|
29
31
|
|
|
30
32
|
# ── In-memory experiment store ───────────────────────────────────────────────
|
|
33
|
+
#
|
|
34
|
+
# _EXPERIMENTS is mutated from both threadpooled sync tools (create_experiment)
|
|
35
|
+
# and event-loop async tools (run_experiment / commit_branch_async) — a
|
|
36
|
+
# module-level lock around the dict ops keeps inserts/reads race-free under
|
|
37
|
+
# mixed sync/async concurrent callers, mirroring preview_studio.engine and
|
|
38
|
+
# wonder_mode.session.
|
|
31
39
|
|
|
32
40
|
_EXPERIMENTS: dict[str, ExperimentSet] = {}
|
|
41
|
+
_EXPERIMENTS_LOCK = threading.Lock()
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
def _gen_id(prefix: str, seed: str) -> str:
|
|
@@ -89,7 +98,8 @@ def create_experiment_from_seeds(
|
|
|
89
98
|
created_at_ms=now,
|
|
90
99
|
)
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
with _EXPERIMENTS_LOCK:
|
|
102
|
+
_EXPERIMENTS[exp_id] = experiment
|
|
93
103
|
return experiment
|
|
94
104
|
|
|
95
105
|
|
|
@@ -114,12 +124,15 @@ def create_experiment(
|
|
|
114
124
|
|
|
115
125
|
def get_experiment(experiment_id: str) -> Optional[ExperimentSet]:
|
|
116
126
|
"""Get an experiment by ID."""
|
|
117
|
-
|
|
127
|
+
with _EXPERIMENTS_LOCK:
|
|
128
|
+
return _EXPERIMENTS.get(experiment_id)
|
|
118
129
|
|
|
119
130
|
|
|
120
131
|
def list_experiments() -> list[dict]:
|
|
121
132
|
"""List all experiment sets."""
|
|
122
|
-
|
|
133
|
+
with _EXPERIMENTS_LOCK:
|
|
134
|
+
exps = list(_EXPERIMENTS.values())
|
|
135
|
+
return [exp.to_dict() for exp in exps]
|
|
123
136
|
|
|
124
137
|
# ── Run experiments (requires Ableton connection) ────────────────────────────
|
|
125
138
|
|
|
@@ -211,12 +224,16 @@ async def run_branch_async(
|
|
|
211
224
|
analyze_mix) are skipped in the apply pass — they're used for snapshot
|
|
212
225
|
capture separately.
|
|
213
226
|
"""
|
|
214
|
-
from ..runtime.execution_router import
|
|
227
|
+
from ..runtime.execution_router import (
|
|
228
|
+
execute_plan_steps_async,
|
|
229
|
+
filter_apply_steps,
|
|
230
|
+
undo_remote_steps,
|
|
231
|
+
)
|
|
215
232
|
|
|
216
233
|
branch.status = "running"
|
|
217
234
|
branch.compiled_plan = compiled_plan
|
|
218
235
|
|
|
219
|
-
branch.before_snapshot = capture_fn
|
|
236
|
+
branch.before_snapshot = await asyncio.to_thread(capture_fn)
|
|
220
237
|
|
|
221
238
|
# Filter out read-only verification steps from the apply pass (canonical
|
|
222
239
|
# list lives in execution_router.READ_ONLY_TOOLS).
|
|
@@ -244,24 +261,13 @@ async def run_branch_async(
|
|
|
244
261
|
]
|
|
245
262
|
|
|
246
263
|
steps_executed = sum(1 for r in exec_results if r.ok)
|
|
247
|
-
# Only remote_command steps land on Ableton's linear undo stack. Bridge
|
|
248
|
-
# (M4L/OSC) and mcp_tool mutations do NOT, so issuing one `undo` per
|
|
249
|
-
# successful step over-undoes and walks back unrelated prior user edits.
|
|
250
|
-
# Count undos from remote_command successes alone.
|
|
251
|
-
undo_count = sum(
|
|
252
|
-
1 for r in exec_results if r.ok and r.backend == "remote_command"
|
|
253
|
-
)
|
|
254
264
|
branch.executed_at_ms = int(time.time() * 1000)
|
|
255
|
-
branch.after_snapshot = capture_fn
|
|
265
|
+
branch.after_snapshot = await asyncio.to_thread(capture_fn)
|
|
256
266
|
|
|
257
|
-
# Undo only the remote_command steps back to checkpoint
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
ableton.send_command("undo", {})
|
|
262
|
-
except Exception as exc:
|
|
263
|
-
logger.debug("run_branch_async failed: %s", exc)
|
|
264
|
-
break
|
|
267
|
+
# Undo only the remote_command steps back to checkpoint (bridge/mcp_tool
|
|
268
|
+
# mutations don't land on Ableton's linear undo stack — see
|
|
269
|
+
# undo_remote_steps' docstring).
|
|
270
|
+
await undo_remote_steps(ableton, exec_results)
|
|
265
271
|
|
|
266
272
|
# A branch is "evaluated" only if it actually applied at least one step.
|
|
267
273
|
# If every step failed, mark it "failed" — this is the truth-release
|
|
@@ -442,7 +448,8 @@ def commit_branch(
|
|
|
442
448
|
|
|
443
449
|
def discard_experiment(experiment_id: str) -> dict:
|
|
444
450
|
"""Discard an entire experiment set."""
|
|
445
|
-
|
|
451
|
+
with _EXPERIMENTS_LOCK:
|
|
452
|
+
exp = _EXPERIMENTS.get(experiment_id)
|
|
446
453
|
if not exp:
|
|
447
454
|
return {"error": f"Experiment {experiment_id} not found"}
|
|
448
455
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import asyncio
|
|
14
|
+
import functools
|
|
13
15
|
import time
|
|
14
16
|
from typing import Optional
|
|
15
17
|
|
|
@@ -351,7 +353,7 @@ async def run_experiment(
|
|
|
351
353
|
if experiment.baseline_transport is None:
|
|
352
354
|
from .baseline import capture_baseline
|
|
353
355
|
try:
|
|
354
|
-
experiment.baseline_transport = capture_baseline
|
|
356
|
+
experiment.baseline_transport = await asyncio.to_thread(capture_baseline, ableton)
|
|
355
357
|
except Exception as exc:
|
|
356
358
|
logger.debug("baseline capture failed: %s", exc)
|
|
357
359
|
experiment.baseline_transport = None
|
|
@@ -365,7 +367,8 @@ async def run_experiment(
|
|
|
365
367
|
# Between branches (not before the first), restore the baseline so
|
|
366
368
|
# the next before_snapshot reads from the same reference state.
|
|
367
369
|
if pending_seen > 0:
|
|
368
|
-
|
|
370
|
+
await asyncio.to_thread(
|
|
371
|
+
engine.prepare_for_next_branch,
|
|
369
372
|
ableton, experiment.baseline_transport, stabilize_ms=300,
|
|
370
373
|
)
|
|
371
374
|
pending_seen += 1
|
|
@@ -414,7 +417,7 @@ async def run_experiment(
|
|
|
414
417
|
results.append(branch.to_dict())
|
|
415
418
|
continue
|
|
416
419
|
|
|
417
|
-
session_info = ableton.
|
|
420
|
+
session_info = await ableton.send_command_async("get_session_info")
|
|
418
421
|
kernel = {"session_info": session_info, "mode": "explore"}
|
|
419
422
|
plan = compiler.compile(move, kernel)
|
|
420
423
|
compiled_dict = plan.to_dict()
|
|
@@ -422,12 +425,19 @@ async def run_experiment(
|
|
|
422
425
|
# Pick the capture function — render-verify mode captures audio
|
|
423
426
|
# and extracts a TimbralFingerprint, adding latency but giving
|
|
424
427
|
# classify_branch_outcome real measurable evidence.
|
|
428
|
+
#
|
|
429
|
+
# NOTE: bound via functools.partial (not a lambda) so the blocking
|
|
430
|
+
# `_capture_snapshot*` call is never a "bare" call site in this
|
|
431
|
+
# function's body — it is only ever invoked downstream inside
|
|
432
|
+
# `engine.run_branch_async` via `await asyncio.to_thread(capture_fn)`,
|
|
433
|
+
# which is where the actual off-loop offload happens.
|
|
425
434
|
if render_verify:
|
|
426
|
-
capture_fn =
|
|
435
|
+
capture_fn = functools.partial(
|
|
436
|
+
_capture_snapshot_with_render_verify,
|
|
427
437
|
ctx, duration_seconds=render_duration_seconds,
|
|
428
438
|
)
|
|
429
439
|
else:
|
|
430
|
-
capture_fn =
|
|
440
|
+
capture_fn = functools.partial(_capture_snapshot, ctx)
|
|
431
441
|
|
|
432
442
|
# Run the branch through the async router
|
|
433
443
|
await engine.run_branch_async(
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -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
|
|
@@ -159,15 +160,21 @@ class MidiToolCache:
|
|
|
159
160
|
self._context: Optional[dict] = None
|
|
160
161
|
self._notes: Optional[list] = None
|
|
161
162
|
self._last_seen = 0.0
|
|
163
|
+
self._request_time = 0.0
|
|
162
164
|
self._connected = False
|
|
163
165
|
self._target_tool: Optional[str] = None
|
|
164
166
|
self._target_params: dict = {}
|
|
165
167
|
|
|
166
168
|
def set_request(self, context: dict, notes: list) -> None:
|
|
167
169
|
with self._lock:
|
|
170
|
+
now = time.monotonic()
|
|
168
171
|
self._context = context
|
|
169
172
|
self._notes = notes
|
|
170
|
-
self._last_seen =
|
|
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
|
|
171
178
|
self._connected = True
|
|
172
179
|
|
|
173
180
|
def mark_ready(self) -> None:
|
|
@@ -180,7 +187,7 @@ class MidiToolCache:
|
|
|
180
187
|
with self._lock:
|
|
181
188
|
if self._context is None:
|
|
182
189
|
return None
|
|
183
|
-
age = time.monotonic() - self.
|
|
190
|
+
age = time.monotonic() - self._request_time
|
|
184
191
|
if age > self._max_age:
|
|
185
192
|
return None
|
|
186
193
|
return dict(self._context)
|
|
@@ -189,7 +196,7 @@ class MidiToolCache:
|
|
|
189
196
|
with self._lock:
|
|
190
197
|
if self._notes is None:
|
|
191
198
|
return None
|
|
192
|
-
age = time.monotonic() - self.
|
|
199
|
+
age = time.monotonic() - self._request_time
|
|
193
200
|
if age > self._max_age:
|
|
194
201
|
return None
|
|
195
202
|
return list(self._notes)
|
|
@@ -511,6 +518,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
511
518
|
self._seen_request_id = False
|
|
512
519
|
self._capture_future: Optional[asyncio.Future] = None
|
|
513
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()
|
|
514
525
|
|
|
515
526
|
def set_miditool_handler(self, handler: Optional[Callable[[str, dict, list], None]]) -> None:
|
|
516
527
|
"""Register a callback invoked on each /miditool/request packet.
|
|
@@ -524,7 +535,27 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
524
535
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
525
536
|
self.transport = transport
|
|
526
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
|
+
|
|
527
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
|
|
528
559
|
try:
|
|
529
560
|
self._parse_osc(data)
|
|
530
561
|
except Exception as exc:
|
|
@@ -738,7 +769,8 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
738
769
|
"""Decode a /miditool/request packet and dispatch to the configured handler.
|
|
739
770
|
|
|
740
771
|
Payload: base64(JSON({request_id, context, notes})).
|
|
741
|
-
Packet arrives on the
|
|
772
|
+
Packet arrives on the asyncio event-loop thread (SpectralReceiver is a
|
|
773
|
+
DatagramProtocol, not a separate thread); the registered handler is
|
|
742
774
|
expected to schedule work on an event loop rather than block here.
|
|
743
775
|
"""
|
|
744
776
|
try:
|
|
@@ -886,6 +918,7 @@ class M4LBridge:
|
|
|
886
918
|
# essentially never block; _safe_sendto guards the rare case anyway.
|
|
887
919
|
self._sock.setblocking(False)
|
|
888
920
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
921
|
+
self._closed = False
|
|
889
922
|
self._cmd_lock: Optional[asyncio.Lock] = None
|
|
890
923
|
# BUG-audit-C1: send_capture uses _capture_future, which is
|
|
891
924
|
# independent of _response_callback used by send_command.
|
|
@@ -900,6 +933,16 @@ class M4LBridge:
|
|
|
900
933
|
if self.receiver.miditool_cache is None and miditool_cache is not None:
|
|
901
934
|
self.receiver.miditool_cache = miditool_cache
|
|
902
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
|
+
|
|
903
946
|
def _safe_sendto(self, data: bytes) -> None:
|
|
904
947
|
"""Send a UDP packet without ever blocking the event loop.
|
|
905
948
|
|
|
@@ -922,7 +965,8 @@ class M4LBridge:
|
|
|
922
965
|
"""Handle a decoded /miditool/request: run the configured generator,
|
|
923
966
|
send /miditool/response back with {request_id, notes}.
|
|
924
967
|
|
|
925
|
-
Invoked from SpectralReceiver on the
|
|
968
|
+
Invoked from SpectralReceiver on the asyncio event-loop thread
|
|
969
|
+
(it is a DatagramProtocol, not a separate UDP thread). We do not
|
|
926
970
|
await anything here — generators are synchronous, pure Python.
|
|
927
971
|
If no target is configured, pass notes through unchanged (identity).
|
|
928
972
|
"""
|
|
@@ -1064,7 +1108,7 @@ class M4LBridge:
|
|
|
1064
1108
|
async def cancel_capture_future(self) -> None:
|
|
1065
1109
|
"""Resolve any in-progress capture future with a stopped result.
|
|
1066
1110
|
|
|
1067
|
-
Does NOT acquire
|
|
1111
|
+
Does NOT acquire _capture_lock — send_capture holds it during recording.
|
|
1068
1112
|
Resolving (not cancelling) the future lets send_capture return a
|
|
1069
1113
|
clean partial-result dict instead of raising CancelledError.
|
|
1070
1114
|
"""
|
|
@@ -1118,4 +1162,22 @@ class M4LBridge:
|
|
|
1118
1162
|
return addr_bytes + tag_bytes + arg_data
|
|
1119
1163
|
|
|
1120
1164
|
def close(self) -> None:
|
|
1121
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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:
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
10
|
+
|
|
9
11
|
from fastmcp import Context
|
|
10
12
|
|
|
11
13
|
from ..server import mcp
|
|
@@ -14,12 +16,29 @@ from .promotion import batch_evaluate_promotions
|
|
|
14
16
|
from .session_memory import SessionMemoryStore
|
|
15
17
|
from .taste_memory import TasteMemoryStore
|
|
16
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
def _get_anti_memory(ctx: Context) -> AntiMemoryStore:
|
|
19
23
|
"""Get or create the session-scoped AntiMemoryStore."""
|
|
20
24
|
return ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
21
25
|
|
|
22
26
|
|
|
27
|
+
def _get_persistent_taste_store(ctx: Context):
|
|
28
|
+
"""Return the persistent taste store from the live server context, or None.
|
|
29
|
+
|
|
30
|
+
Only the live server lifespan injects "persistent_taste" (the same key the
|
|
31
|
+
wonder/runtime/preview_studio tools use, so all share ONE instance); tests
|
|
32
|
+
build their own context without it, so taste tools stay session-only
|
|
33
|
+
(hermetic) there. When present, taste write-backs and reads persist across
|
|
34
|
+
restarts (P2-29).
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
return ctx.lifespan_context.get("persistent_taste")
|
|
38
|
+
except AttributeError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
23
42
|
def _get_session_memory(ctx: Context) -> SessionMemoryStore:
|
|
24
43
|
"""Get or create the session-scoped SessionMemoryStore."""
|
|
25
44
|
return ctx.lifespan_context.setdefault("session_memory", SessionMemoryStore())
|
|
@@ -43,12 +62,25 @@ def record_anti_preference(
|
|
|
43
62
|
) -> dict:
|
|
44
63
|
"""Record a user dislike for a dimension+direction. direction must be 'increase' or 'decrease'."""
|
|
45
64
|
if direction not in ("increase", "decrease"):
|
|
46
|
-
return {"error": "direction must be 'increase' or 'decrease'"
|
|
65
|
+
return {"error": "direction must be 'increase' or 'decrease'",
|
|
66
|
+
"code": "INVALID_PARAM"}
|
|
47
67
|
store = _get_anti_memory(ctx)
|
|
48
68
|
pref = store.record_dislike(dimension, direction)
|
|
69
|
+
# P2-29: persist to the taste store so the anti-preference survives a
|
|
70
|
+
# server restart (best-effort — the session store above is authoritative
|
|
71
|
+
# for the in-session response).
|
|
72
|
+
persistent = _get_persistent_taste_store(ctx)
|
|
73
|
+
persisted = False
|
|
74
|
+
if persistent is not None:
|
|
75
|
+
try:
|
|
76
|
+
persistent.record_anti_preference(dimension, direction)
|
|
77
|
+
persisted = True
|
|
78
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
79
|
+
logger.debug("persist anti_preference failed: %s", exc)
|
|
49
80
|
return {
|
|
50
81
|
"recorded": pref.to_dict(),
|
|
51
82
|
"should_caution": store.should_caution(dimension, direction),
|
|
83
|
+
"persisted": persisted,
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
|
|
@@ -143,7 +175,10 @@ def get_taste_graph(ctx: Context) -> dict:
|
|
|
143
175
|
|
|
144
176
|
taste_store = _get_taste_memory(ctx)
|
|
145
177
|
anti_store = _get_anti_memory(ctx)
|
|
146
|
-
graph = build_taste_graph(
|
|
178
|
+
graph = build_taste_graph(
|
|
179
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
180
|
+
persistent_store=_get_persistent_taste_store(ctx),
|
|
181
|
+
)
|
|
147
182
|
return graph.to_dict()
|
|
148
183
|
|
|
149
184
|
|
|
@@ -158,7 +193,10 @@ def explain_taste_inference(ctx: Context) -> dict:
|
|
|
158
193
|
|
|
159
194
|
taste_store = _get_taste_memory(ctx)
|
|
160
195
|
anti_store = _get_anti_memory(ctx)
|
|
161
|
-
graph = build_taste_graph(
|
|
196
|
+
graph = build_taste_graph(
|
|
197
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
198
|
+
persistent_store=_get_persistent_taste_store(ctx),
|
|
199
|
+
)
|
|
162
200
|
return graph.explain()
|
|
163
201
|
|
|
164
202
|
|
|
@@ -178,7 +216,10 @@ def rank_moves_by_taste(
|
|
|
178
216
|
|
|
179
217
|
taste_store = _get_taste_memory(ctx)
|
|
180
218
|
anti_store = _get_anti_memory(ctx)
|
|
181
|
-
graph = build_taste_graph(
|
|
219
|
+
graph = build_taste_graph(
|
|
220
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
221
|
+
persistent_store=_get_persistent_taste_store(ctx),
|
|
222
|
+
)
|
|
182
223
|
|
|
183
224
|
if isinstance(move_specs, str):
|
|
184
225
|
import json
|
|
@@ -218,10 +259,27 @@ def record_positive_preference(
|
|
|
218
259
|
matching_signals.append(sig_name)
|
|
219
260
|
if matching_signals:
|
|
220
261
|
taste_store.update_from_outcome({"signals": matching_signals})
|
|
262
|
+
# P2-29 (dimension-weight half): persist the updated dimension weight so it
|
|
263
|
+
# survives a server restart — symmetric with record_anti_preference. Without
|
|
264
|
+
# this the persisted dimension_weights hydration branch in build_taste_graph
|
|
265
|
+
# stayed dead (no production writer). Best-effort; session store is
|
|
266
|
+
# authoritative for the in-session response.
|
|
267
|
+
persisted = False
|
|
268
|
+
persistent = _get_persistent_taste_store(ctx)
|
|
269
|
+
if persistent is not None and matching_signals:
|
|
270
|
+
try:
|
|
271
|
+
for dim in taste_store.get_taste_dimensions():
|
|
272
|
+
if dim.name == dimension and dim.evidence_count > 0:
|
|
273
|
+
persistent.record_dimension_weight(dimension, dim.value)
|
|
274
|
+
persisted = True
|
|
275
|
+
break
|
|
276
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
277
|
+
logger.debug("persist dimension_weight failed: %s", exc)
|
|
221
278
|
return {
|
|
222
279
|
"recorded": bool(matching_signals),
|
|
223
280
|
"dimension": dimension,
|
|
224
281
|
"direction": direction,
|
|
225
282
|
"signals_matched": matching_signals,
|
|
226
283
|
"evidence": evidence,
|
|
284
|
+
"persisted": persisted,
|
|
227
285
|
}
|