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.
Files changed (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  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 +1 -1
  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/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. 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
- _EXPERIMENTS[exp_id] = experiment
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
- return _EXPERIMENTS.get(experiment_id)
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
- return [exp.to_dict() for exp in _EXPERIMENTS.values()]
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 execute_plan_steps_async, filter_apply_steps
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. Undo is itself a
258
- # remote_command, routed through the normal ableton.send_command path.
259
- for _ in range(undo_count):
260
- try:
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
- exp = _EXPERIMENTS.get(experiment_id)
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(ableton)
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
- engine.prepare_for_next_branch(
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.send_command("get_session_info")
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 = lambda: _capture_snapshot_with_render_verify(
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 = lambda: _capture_snapshot(ctx)
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(
@@ -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 = 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
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._last_seen
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._last_seen
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 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
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 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
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 _cmd_lock — send_capture holds it during recording.
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
- 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:
@@ -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(taste_store=taste_store, anti_store=anti_store)
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(taste_store=taste_store, anti_store=anti_store)
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(taste_store=taste_store, anti_store=anti_store)
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
  }