livepilot 1.9.23 → 1.10.0

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 (191) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +119 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +144 -13
  6. package/bin/livepilot.js +87 -0
  7. package/installer/codex.js +147 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +21 -4
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  12. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  19. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  20. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  21. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  22. package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
  23. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  24. package/livepilot/skills/livepilot-release/SKILL.md +19 -5
  25. package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
  26. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  27. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  29. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  30. package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
  31. package/livepilot.mcpb +0 -0
  32. package/m4l_device/livepilot_bridge.js +1 -1
  33. package/manifest.json +2 -2
  34. package/mcp_server/__init__.py +1 -1
  35. package/mcp_server/atlas/__init__.py +357 -0
  36. package/mcp_server/atlas/device_atlas.json +44067 -0
  37. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  38. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  39. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  73. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  74. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  75. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  76. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  77. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  78. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  79. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  80. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  81. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  82. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  83. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  84. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  85. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  86. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  87. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  88. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  89. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  90. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  101. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  102. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  103. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  104. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  105. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  106. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  107. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  108. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  109. package/mcp_server/atlas/scanner.py +236 -0
  110. package/mcp_server/atlas/tools.py +224 -0
  111. package/mcp_server/composer/__init__.py +1 -0
  112. package/mcp_server/composer/engine.py +452 -0
  113. package/mcp_server/composer/layer_planner.py +427 -0
  114. package/mcp_server/composer/prompt_parser.py +329 -0
  115. package/mcp_server/composer/tools.py +201 -0
  116. package/mcp_server/connection.py +53 -8
  117. package/mcp_server/corpus/__init__.py +377 -0
  118. package/mcp_server/device_forge/__init__.py +1 -0
  119. package/mcp_server/device_forge/builder.py +377 -0
  120. package/mcp_server/device_forge/models.py +142 -0
  121. package/mcp_server/device_forge/templates.py +483 -0
  122. package/mcp_server/device_forge/tools.py +162 -0
  123. package/mcp_server/hook_hunter/analyzer.py +23 -0
  124. package/mcp_server/hook_hunter/models.py +1 -0
  125. package/mcp_server/hook_hunter/tools.py +4 -2
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_graph.py +68 -1
  128. package/mcp_server/memory/tools.py +15 -4
  129. package/mcp_server/musical_intelligence/detectors.py +14 -1
  130. package/mcp_server/musical_intelligence/tools.py +11 -8
  131. package/mcp_server/persistence/__init__.py +1 -0
  132. package/mcp_server/persistence/base_store.py +82 -0
  133. package/mcp_server/persistence/project_store.py +106 -0
  134. package/mcp_server/persistence/taste_store.py +122 -0
  135. package/mcp_server/preview_studio/models.py +1 -0
  136. package/mcp_server/preview_studio/tools.py +56 -13
  137. package/mcp_server/runtime/capability.py +66 -0
  138. package/mcp_server/runtime/capability_probe.py +137 -0
  139. package/mcp_server/runtime/execution_router.py +143 -0
  140. package/mcp_server/runtime/live_version.py +102 -0
  141. package/mcp_server/runtime/remote_commands.py +87 -0
  142. package/mcp_server/runtime/tools.py +18 -4
  143. package/mcp_server/sample_engine/__init__.py +1 -0
  144. package/mcp_server/sample_engine/analyzer.py +216 -0
  145. package/mcp_server/sample_engine/critics.py +390 -0
  146. package/mcp_server/sample_engine/models.py +193 -0
  147. package/mcp_server/sample_engine/moves.py +127 -0
  148. package/mcp_server/sample_engine/planner.py +186 -0
  149. package/mcp_server/sample_engine/sources.py +540 -0
  150. package/mcp_server/sample_engine/techniques.py +908 -0
  151. package/mcp_server/sample_engine/tools.py +442 -0
  152. package/mcp_server/semantic_moves/__init__.py +3 -0
  153. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  154. package/mcp_server/semantic_moves/mix_moves.py +41 -41
  155. package/mcp_server/semantic_moves/performance_moves.py +13 -13
  156. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  157. package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
  158. package/mcp_server/semantic_moves/tools.py +18 -17
  159. package/mcp_server/semantic_moves/transition_moves.py +16 -16
  160. package/mcp_server/server.py +51 -0
  161. package/mcp_server/services/__init__.py +1 -0
  162. package/mcp_server/services/motif_service.py +67 -0
  163. package/mcp_server/session_continuity/tracker.py +29 -1
  164. package/mcp_server/song_brain/builder.py +28 -1
  165. package/mcp_server/song_brain/models.py +4 -0
  166. package/mcp_server/song_brain/tools.py +20 -2
  167. package/mcp_server/sound_design/critics.py +89 -1
  168. package/mcp_server/splice_client/__init__.py +1 -0
  169. package/mcp_server/splice_client/client.py +347 -0
  170. package/mcp_server/splice_client/models.py +96 -0
  171. package/mcp_server/splice_client/protos/__init__.py +1 -0
  172. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  173. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  174. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  175. package/mcp_server/tools/arrangement.py +69 -0
  176. package/mcp_server/tools/automation.py +15 -2
  177. package/mcp_server/tools/devices.py +117 -6
  178. package/mcp_server/tools/notes.py +37 -4
  179. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  180. package/mcp_server/wonder_mode/engine.py +85 -1
  181. package/mcp_server/wonder_mode/tools.py +6 -1
  182. package/package.json +12 -2
  183. package/remote_script/LivePilot/__init__.py +8 -1
  184. package/remote_script/LivePilot/arrangement.py +114 -0
  185. package/remote_script/LivePilot/browser.py +56 -1
  186. package/remote_script/LivePilot/devices.py +236 -6
  187. package/remote_script/LivePilot/mixing.py +8 -3
  188. package/remote_script/LivePilot/server.py +5 -1
  189. package/remote_script/LivePilot/transport.py +3 -0
  190. package/remote_script/LivePilot/version_detect.py +78 -0
  191. package/scripts/sync_metadata.py +132 -0
@@ -124,6 +124,7 @@ class SpectralCache:
124
124
  return result
125
125
 
126
126
 
127
+
127
128
  class SpectralReceiver(asyncio.DatagramProtocol):
128
129
  """Receives OSC-formatted UDP packets from the M4L device.
129
130
 
@@ -99,6 +99,9 @@ class TasteGraph:
99
99
 
100
100
  # ── Update methods ───────────────────────────────────────────────
101
101
 
102
+ # Persistent store reference (set by build_taste_graph when available)
103
+ _persistent_store: object = None
104
+
102
105
  def record_move_outcome(
103
106
  self, move_id: str, family: str, kept: bool, score: float = 0.0
104
107
  ) -> None:
@@ -120,6 +123,13 @@ class TasteGraph:
120
123
  self.evidence_count += 1
121
124
  self.last_updated_ms = now
122
125
 
126
+ # Write-back to persistent store
127
+ if self._persistent_store is not None:
128
+ try:
129
+ self._persistent_store.record_move_outcome(move_id, family, kept, score)
130
+ except Exception:
131
+ pass # persistence is best-effort
132
+
123
133
  def record_device_use(self, device_name: str, positive: bool = True) -> None:
124
134
  """Update device affinity from usage."""
125
135
  now = int(time.time() * 1000)
@@ -245,10 +255,17 @@ class TasteGraph:
245
255
  def build_taste_graph(
246
256
  taste_store=None, # TasteMemoryStore
247
257
  anti_store=None, # AntiMemoryStore
258
+ persistent_store=None, # PersistentTasteStore (optional)
248
259
  ) -> TasteGraph:
249
- """Build a TasteGraph from existing memory stores."""
260
+ """Build a TasteGraph from existing memory stores.
261
+
262
+ When persistent_store is provided, hydrates move_family_scores,
263
+ device_affinities, and novelty_band from disk — these survive
264
+ server restart.
265
+ """
250
266
  graph = TasteGraph()
251
267
 
268
+ # Session-scoped dimensions (in-memory)
252
269
  if taste_store:
253
270
  for dim in taste_store.get_taste_dimensions():
254
271
  if dim.evidence_count > 0:
@@ -258,4 +275,54 @@ def build_taste_graph(
258
275
  for pref in anti_store.get_anti_preferences():
259
276
  graph.dimension_avoidances[pref.dimension] = pref.direction
260
277
 
278
+ # Persistent state (from disk)
279
+ if persistent_store is not None:
280
+ persisted = persistent_store.get_all()
281
+
282
+ # Move family scores
283
+ for move_id, outcome in persisted.get("move_outcomes", {}).items():
284
+ family = outcome.get("family", "")
285
+ if family and family not in graph.move_family_scores:
286
+ from .taste_graph import MoveFamilyScore
287
+ graph.move_family_scores[family] = MoveFamilyScore(family=family)
288
+ if family:
289
+ fam = graph.move_family_scores[family]
290
+ fam.kept_count += outcome.get("kept_count", 0)
291
+ fam.undone_count += outcome.get("undone_count", 0)
292
+ total = fam.kept_count + fam.undone_count
293
+ if total > 0:
294
+ fam.score = round((fam.kept_count - fam.undone_count) / total, 3)
295
+
296
+ # Novelty band
297
+ graph.novelty_band = persisted.get("novelty_band", 0.5)
298
+
299
+ # Device affinities
300
+ for dev_name, dev_data in persisted.get("device_affinities", {}).items():
301
+ from .taste_graph import DeviceAffinity
302
+ graph.device_affinities[dev_name] = DeviceAffinity(
303
+ device_name=dev_name,
304
+ affinity=dev_data.get("affinity", 0.0),
305
+ use_count=dev_data.get("use_count", 0),
306
+ )
307
+
308
+ # Evidence count
309
+ graph.evidence_count = max(
310
+ graph.evidence_count, persisted.get("evidence_count", 0)
311
+ )
312
+
313
+ # Dimension weights from persistent store (merged, session takes precedence)
314
+ for dim, val in persisted.get("dimension_weights", {}).items():
315
+ if dim not in graph.dimension_weights:
316
+ graph.dimension_weights[dim] = val
317
+
318
+ # Anti-preferences from persistent store
319
+ for anti in persisted.get("anti_preferences", []):
320
+ dim = anti.get("dimension", "")
321
+ direction = anti.get("direction", "")
322
+ if dim and dim not in graph.dimension_avoidances:
323
+ graph.dimension_avoidances[dim] = direction
324
+
325
+ # Attach persistent store for write-back
326
+ graph._persistent_store = persistent_store
327
+
261
328
  return graph
@@ -189,12 +189,23 @@ def record_positive_preference(
189
189
  not just what they dislike.
190
190
  """
191
191
  taste_store = _get_taste_memory(ctx)
192
- # Map to outcome signal
193
- signal = f"{dimension}_{direction}_kept"
194
- taste_store.update_from_outcome({"signal": signal})
192
+ # Find matching outcome signals for this dimension+direction
193
+ from ..memory.taste_memory import _OUTCOME_SIGNALS
194
+ matching_signals = []
195
+ dim_signals = _OUTCOME_SIGNALS.get(dimension, {})
196
+ for sig_name, adjustment in dim_signals.items():
197
+ # "increase" preference → match positive-adjustment signals (kept)
198
+ # "decrease" preference → match negative-adjustment signals (undone/less)
199
+ if direction == "increase" and adjustment > 0:
200
+ matching_signals.append(sig_name)
201
+ elif direction == "decrease" and adjustment < 0:
202
+ matching_signals.append(sig_name)
203
+ if matching_signals:
204
+ taste_store.update_from_outcome({"signals": matching_signals})
195
205
  return {
196
- "recorded": True,
206
+ "recorded": bool(matching_signals),
197
207
  "dimension": dimension,
198
208
  "direction": direction,
209
+ "signals_matched": matching_signals,
199
210
  "evidence": evidence,
200
211
  }
@@ -96,9 +96,22 @@ def detect_repetition_fatigue(
96
96
  # 3. Motif fatigue from motif_graph
97
97
  if motif_graph:
98
98
  motifs = motif_graph.get("motifs", [])
99
+ num_sections = max(1, len(scenes))
99
100
  for motif in motifs:
100
101
  fatigue_risk = motif.get("fatigue_risk", 0)
101
- if fatigue_risk > 0.6:
102
+ recurrence = motif.get("recurrence", 0)
103
+
104
+ # Motif appearing in >60% of sections = fatigue signal
105
+ if recurrence > 0.6 and num_sections >= 3:
106
+ adjusted_fatigue = max(fatigue_risk, recurrence * 0.8)
107
+ report.issues.append({
108
+ "type": "motif_overuse",
109
+ "severity": round(adjusted_fatigue, 3),
110
+ "detail": f"Motif {motif.get('name', motif.get('motif_id', '?'))} appears in {recurrence:.0%} of sections",
111
+ "motif_id": motif.get("motif_id", motif.get("name", "")),
112
+ "evidence": "motif_recurrence",
113
+ })
114
+ elif fatigue_risk > 0.6:
102
115
  report.issues.append({
103
116
  "type": "motif_overuse",
104
117
  "severity": fatigue_risk,
@@ -45,10 +45,14 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
45
45
  "clips": row,
46
46
  })
47
47
 
48
- # Try to get motif graph for deeper analysis
48
+ # Motif data via shared motif service
49
49
  motif_graph = None
50
50
  try:
51
- motif_graph = ableton.send_command("get_motif_graph")
51
+ from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
52
+ session_info = ableton.send_command("get_session_info", {})
53
+ track_list = session_info.get("tracks", [])
54
+ notes_by_track = fetch_notes_from_ableton(ableton, track_list)
55
+ motif_graph = get_motif_data(notes_by_track)
52
56
  except Exception:
53
57
  pass
54
58
 
@@ -171,17 +175,16 @@ def analyze_phrase_arc(
171
175
  loudness_data = None
172
176
  spectrum_data = None
173
177
 
178
+ # Direct Python calls to perception engine — not TCP
174
179
  try:
175
- loudness_data = ableton.send_command("analyze_loudness_offline", {
176
- "file_path": file_path, "detail": "full",
177
- })
180
+ from ..tools._perception_engine import compute_loudness
181
+ loudness_data = compute_loudness(file_path, detail="full")
178
182
  except Exception:
179
183
  pass
180
184
 
181
185
  try:
182
- spectrum_data = ableton.send_command("analyze_spectrum_offline_internal", {
183
- "file_path": file_path,
184
- })
186
+ from ..tools._perception_engine import compute_spectral
187
+ spectrum_data = compute_spectral(file_path)
185
188
  except Exception:
186
189
  pass
187
190
 
@@ -0,0 +1 @@
1
+ """Persistent storage for LivePilot state that survives server restart."""
@@ -0,0 +1,82 @@
1
+ """Persistent JSON store with atomic writes and corruption recovery.
2
+
3
+ Follows the TechniqueStore pattern: lazy init, atomic tmp+rename,
4
+ fsync to disk, corruption recovery via .corrupt rename.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import threading
12
+ from pathlib import Path
13
+
14
+
15
+ class PersistentJsonStore:
16
+ """Thread-safe, crash-safe JSON file store."""
17
+
18
+ def __init__(self, path: Path):
19
+ self._path = Path(path)
20
+ self._lock = threading.RLock()
21
+
22
+ @property
23
+ def path(self) -> Path:
24
+ return self._path
25
+
26
+ def read(self) -> dict:
27
+ """Read the store. Returns {} if missing or corrupt."""
28
+ with self._lock:
29
+ if not self._path.exists():
30
+ return {}
31
+ try:
32
+ return json.loads(self._path.read_text(encoding="utf-8"))
33
+ except (json.JSONDecodeError, OSError):
34
+ corrupt = self._path.with_suffix(self._path.suffix + ".corrupt")
35
+ try:
36
+ self._path.rename(corrupt)
37
+ except OSError:
38
+ pass
39
+ return {}
40
+
41
+ def write(self, data: dict) -> None:
42
+ """Atomically write data to disk."""
43
+ with self._lock:
44
+ self._path.parent.mkdir(parents=True, exist_ok=True)
45
+ tmp = self._path.with_suffix(".tmp")
46
+ try:
47
+ with open(tmp, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2, default=str)
49
+ f.flush()
50
+ os.fsync(f.fileno())
51
+ os.replace(str(tmp), str(self._path))
52
+ except OSError:
53
+ try:
54
+ tmp.unlink(missing_ok=True)
55
+ except OSError:
56
+ pass
57
+ raise
58
+
59
+ def update(self, updater) -> dict:
60
+ """Read-modify-write atomically. updater(data) -> modified data."""
61
+ with self._lock:
62
+ data = self._read_unlocked()
63
+ data = updater(data)
64
+ self._write_unlocked(data)
65
+ return data
66
+
67
+ def _read_unlocked(self) -> dict:
68
+ if not self._path.exists():
69
+ return {}
70
+ try:
71
+ return json.loads(self._path.read_text(encoding="utf-8"))
72
+ except (json.JSONDecodeError, OSError):
73
+ return {}
74
+
75
+ def _write_unlocked(self, data: dict) -> None:
76
+ self._path.parent.mkdir(parents=True, exist_ok=True)
77
+ tmp = self._path.with_suffix(".tmp")
78
+ with open(tmp, "w", encoding="utf-8") as f:
79
+ json.dump(data, f, indent=2, default=str)
80
+ f.flush()
81
+ os.fsync(f.fileno())
82
+ os.replace(str(tmp), str(self._path))
@@ -0,0 +1,106 @@
1
+ """Per-project persistent state — threads, turns, Wonder outcomes.
2
+
3
+ Stores session continuity data scoped to a project identity.
4
+ Located at ~/.livepilot/projects/<hash>/state.json.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .base_store import PersistentJsonStore
15
+
16
+
17
+ _PROJECTS_DIR = Path.home() / ".livepilot" / "projects"
18
+ _MAX_TURNS = 50
19
+ _MAX_WONDER_OUTCOMES = 10
20
+
21
+
22
+ def project_hash(session_info: dict) -> str:
23
+ """Compute a stable project fingerprint from session info.
24
+
25
+ Uses tempo + track count + sorted track names. This is imperfect
26
+ but stable enough for per-song state within a production session.
27
+ """
28
+ tempo = session_info.get("tempo", 120.0)
29
+ tracks = session_info.get("tracks", [])
30
+ track_names = sorted(t.get("name", "") for t in tracks if isinstance(t, dict))
31
+ seed = f"{tempo:.1f}|{len(tracks)}|{'|'.join(track_names)}"
32
+ return hashlib.sha256(seed.encode()).hexdigest()[:12]
33
+
34
+
35
+ class ProjectStore:
36
+ """Persistent per-project state."""
37
+
38
+ def __init__(self, project_id: str, base_dir: Optional[Path] = None):
39
+ base = base_dir or _PROJECTS_DIR
40
+ self._store = PersistentJsonStore(base / project_id / "state.json")
41
+ self._project_id = project_id
42
+
43
+ @property
44
+ def project_id(self) -> str:
45
+ return self._project_id
46
+
47
+ def get_all(self) -> dict:
48
+ data = self._store.read()
49
+ return data if data.get("version") == 1 else self._default()
50
+
51
+ def save_thread(self, thread: dict) -> None:
52
+ """Save or update a creative thread."""
53
+ def _update(data: dict) -> dict:
54
+ data = data if data.get("version") == 1 else self._default()
55
+ threads = data.setdefault("threads", [])
56
+ # Update existing or append
57
+ for i, t in enumerate(threads):
58
+ if t.get("thread_id") == thread.get("thread_id"):
59
+ threads[i] = thread
60
+ return data
61
+ threads.append(thread)
62
+ return data
63
+ self._store.update(_update)
64
+
65
+ def save_turn(self, turn: dict) -> None:
66
+ """Save a turn resolution (capped at MAX_TURNS)."""
67
+ def _update(data: dict) -> dict:
68
+ data = data if data.get("version") == 1 else self._default()
69
+ turns = data.setdefault("turns", [])
70
+ turns.append(turn)
71
+ # Cap at max
72
+ if len(turns) > _MAX_TURNS:
73
+ data["turns"] = turns[-_MAX_TURNS:]
74
+ data["last_updated_ms"] = int(time.time() * 1000)
75
+ return data
76
+ self._store.update(_update)
77
+
78
+ def save_wonder_outcome(self, outcome: dict) -> None:
79
+ """Save a Wonder session outcome (capped at MAX_WONDER_OUTCOMES)."""
80
+ def _update(data: dict) -> dict:
81
+ data = data if data.get("version") == 1 else self._default()
82
+ outcomes = data.setdefault("wonder_outcomes", [])
83
+ outcomes.append(outcome)
84
+ if len(outcomes) > _MAX_WONDER_OUTCOMES:
85
+ data["wonder_outcomes"] = outcomes[-_MAX_WONDER_OUTCOMES:]
86
+ return data
87
+ self._store.update(_update)
88
+
89
+ def get_threads(self) -> list[dict]:
90
+ return self.get_all().get("threads", [])
91
+
92
+ def get_turns(self) -> list[dict]:
93
+ return self.get_all().get("turns", [])
94
+
95
+ def get_wonder_outcomes(self) -> list[dict]:
96
+ return self.get_all().get("wonder_outcomes", [])
97
+
98
+ @staticmethod
99
+ def _default() -> dict:
100
+ return {
101
+ "version": 1,
102
+ "threads": [],
103
+ "turns": [],
104
+ "wonder_outcomes": [],
105
+ "last_updated_ms": 0,
106
+ }
@@ -0,0 +1,122 @@
1
+ """Persistent taste state — survives server restart.
2
+
3
+ Stores move outcomes, novelty preference, device affinity,
4
+ anti-preferences, and dimension weights. Located at
5
+ ~/.livepilot/taste.json.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .base_store import PersistentJsonStore
15
+
16
+
17
+ _DEFAULT_PATH = Path.home() / ".livepilot" / "taste.json"
18
+
19
+
20
+ class PersistentTasteStore:
21
+ """Persistent backing for TasteGraph data."""
22
+
23
+ def __init__(self, path: Optional[Path] = None):
24
+ self._store = PersistentJsonStore(path or _DEFAULT_PATH)
25
+
26
+ def get_all(self) -> dict:
27
+ """Get all persisted taste data."""
28
+ data = self._store.read()
29
+ return data if data.get("version") == 1 else self._default()
30
+
31
+ def record_move_outcome(
32
+ self, move_id: str, family: str, kept: bool, score: float = 0.0,
33
+ ) -> None:
34
+ """Persist a move outcome."""
35
+ def _update(data: dict) -> dict:
36
+ data = data if data.get("version") == 1 else self._default()
37
+ outcomes = data.setdefault("move_outcomes", {})
38
+ entry = outcomes.setdefault(move_id, {
39
+ "family": family, "kept_count": 0, "undone_count": 0,
40
+ })
41
+ entry["family"] = family
42
+ if kept:
43
+ entry["kept_count"] = entry.get("kept_count", 0) + 1
44
+ else:
45
+ entry["undone_count"] = entry.get("undone_count", 0) + 1
46
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
47
+ data["last_updated_ms"] = int(time.time() * 1000)
48
+ return data
49
+ self._store.update(_update)
50
+
51
+ def update_novelty(self, chose_bold: bool) -> None:
52
+ """Update novelty band from experiment choice."""
53
+ def _update(data: dict) -> dict:
54
+ data = data if data.get("version") == 1 else self._default()
55
+ band = data.get("novelty_band", 0.5)
56
+ if chose_bold:
57
+ data["novelty_band"] = min(1.0, band + 0.05)
58
+ else:
59
+ data["novelty_band"] = max(0.0, band - 0.05)
60
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
61
+ return data
62
+ self._store.update(_update)
63
+
64
+ def record_device_use(self, device_name: str, positive: bool = True) -> None:
65
+ """Persist device affinity."""
66
+ def _update(data: dict) -> dict:
67
+ data = data if data.get("version") == 1 else self._default()
68
+ affinities = data.setdefault("device_affinities", {})
69
+ entry = affinities.setdefault(device_name, {
70
+ "affinity": 0.0, "use_count": 0,
71
+ })
72
+ entry["use_count"] = entry.get("use_count", 0) + 1
73
+ aff = entry.get("affinity", 0.0)
74
+ if positive:
75
+ entry["affinity"] = min(1.0, aff + 0.05)
76
+ else:
77
+ entry["affinity"] = max(-1.0, aff - 0.08)
78
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
79
+ return data
80
+ self._store.update(_update)
81
+
82
+ def record_anti_preference(self, dimension: str, direction: str) -> None:
83
+ """Persist an anti-preference."""
84
+ def _update(data: dict) -> dict:
85
+ data = data if data.get("version") == 1 else self._default()
86
+ antis = data.setdefault("anti_preferences", [])
87
+ existing = next(
88
+ (a for a in antis if a["dimension"] == dimension and a["direction"] == direction),
89
+ None,
90
+ )
91
+ if existing:
92
+ existing["count"] = existing.get("count", 0) + 1
93
+ existing["strength"] = min(1.0, existing["count"] * 0.2)
94
+ else:
95
+ antis.append({
96
+ "dimension": dimension, "direction": direction,
97
+ "count": 1, "strength": 0.2,
98
+ })
99
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
100
+ return data
101
+ self._store.update(_update)
102
+
103
+ def record_dimension_weight(self, dimension: str, value: float) -> None:
104
+ """Persist a dimension weight update."""
105
+ def _update(data: dict) -> dict:
106
+ data = data if data.get("version") == 1 else self._default()
107
+ data.setdefault("dimension_weights", {})[dimension] = round(value, 3)
108
+ return data
109
+ self._store.update(_update)
110
+
111
+ @staticmethod
112
+ def _default() -> dict:
113
+ return {
114
+ "version": 1,
115
+ "move_outcomes": {},
116
+ "novelty_band": 0.5,
117
+ "device_affinities": {},
118
+ "anti_preferences": [],
119
+ "dimension_weights": {},
120
+ "evidence_count": 0,
121
+ "last_updated_ms": 0,
122
+ }
@@ -35,6 +35,7 @@ class PreviewVariant:
35
35
 
36
36
  # State
37
37
  status: str = "pending" # pending, rendered, committed, discarded
38
+ preview_mode: str = "" # audible_preview, metadata_only_preview, analytical_preview
38
39
  created_at_ms: int = 0
39
40
 
40
41
  def to_dict(self) -> dict:
@@ -154,15 +154,20 @@ def create_preview_set(
154
154
  import sys
155
155
  print(f"LivePilot: SongBrain unavailable in preview_studio: {_e}", file=sys.stderr)
156
156
 
157
- # Get taste graph — use session-scoped stores, extract numeric weights
157
+ # Get taste graph — session + persistent stores
158
158
  taste_graph: dict = {}
159
159
  try:
160
160
  from ..memory.taste_graph import build_taste_graph
161
161
  from ..memory.taste_memory import TasteMemoryStore
162
162
  from ..memory.anti_memory import AntiMemoryStore
163
+ from ..persistence.taste_store import PersistentTasteStore
163
164
  taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
164
165
  anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
165
- graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
166
+ persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
167
+ graph = build_taste_graph(
168
+ taste_store=taste_store, anti_store=anti_store,
169
+ persistent_store=persistent,
170
+ )
166
171
  taste_graph = graph.to_dict()
167
172
  except Exception:
168
173
  pass
@@ -269,14 +274,19 @@ def commit_preview_variant(
269
274
  except Exception:
270
275
  pass
271
276
 
272
- # Update taste graph
277
+ # Update taste graph (with persistent backing)
273
278
  try:
274
279
  from ..memory.taste_graph import build_taste_graph
275
280
  from ..memory.taste_memory import TasteMemoryStore
276
281
  from ..memory.anti_memory import AntiMemoryStore
282
+ from ..persistence.taste_store import PersistentTasteStore
277
283
  taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
278
284
  anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
279
- graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
285
+ persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
286
+ graph = build_taste_graph(
287
+ taste_store=taste_store, anti_store=anti_store,
288
+ persistent_store=persistent,
289
+ )
280
290
  # Look up family from WonderSession's variant list
281
291
  family = ""
282
292
  for v in ws.variants:
@@ -349,18 +359,17 @@ def render_preview_variant(
349
359
  # compiled_plan may be a list (from semantic moves) or a dict with "steps" key
350
360
  plan = variant.compiled_plan
351
361
  steps = plan if isinstance(plan, list) else plan.get("steps", [])
362
+
363
+ from ..runtime.execution_router import execute_plan_steps
364
+
352
365
  applied_count = 0
353
366
  try:
354
367
  # Capture before state
355
368
  before_info = ableton.send_command("get_session_info", {})
356
369
 
357
- # Apply the plan steps, tracking how many succeed
358
- for step in steps:
359
- cmd = step.get("tool") or step.get("command")
360
- args = step.get("params") or step.get("args", {})
361
- if cmd:
362
- ableton.send_command(cmd, args)
363
- applied_count += 1
370
+ # Execute through unified router
371
+ exec_results = execute_plan_steps(steps, ableton=ableton, ctx=ctx)
372
+ applied_count = sum(1 for r in exec_results if r.ok)
364
373
 
365
374
  # Capture after state
366
375
  after_info = ableton.send_command("get_session_info", {})
@@ -374,29 +383,63 @@ def render_preview_variant(
374
383
  except Exception:
375
384
  break
376
385
 
386
+ # Determine preview mode: audible (M4L available) or metadata-only
387
+ preview_mode = "metadata_only_preview"
388
+ spectral_before = None
389
+ spectral_after = None
390
+
391
+ # Try audible preview — capture spectrum via M4L spectral cache
392
+ try:
393
+ from ..m4l_bridge import SpectralCache
394
+ cache = ctx.lifespan_context.get("spectral")
395
+ if cache and isinstance(cache, SpectralCache) and cache.is_connected:
396
+ spectral_before = cache.get_all()
397
+ # Play for the requested bar count
398
+ tempo = before_info.get("tempo", 120)
399
+ play_seconds = bars * (60.0 / tempo) * 4 # bars * beat_duration * 4 beats
400
+ ableton.send_command("start_playback", {})
401
+ import time as _time
402
+ _time.sleep(min(play_seconds, 8.0)) # cap at 8 seconds
403
+ spectral_after = cache.get_all()
404
+ ableton.send_command("stop_playback", {})
405
+ preview_mode = "audible_preview"
406
+ except Exception:
407
+ pass # fall back to metadata_only
408
+
377
409
  variant.status = "rendered"
410
+ variant.preview_mode = preview_mode
378
411
  variant.render_ref = f"render_{variant_id}_{bars}bars"
379
412
 
380
- return {
413
+ result = {
381
414
  "rendered": True,
382
415
  "variant_id": variant_id,
383
416
  "label": variant.label,
384
417
  "bars": bars,
418
+ "preview_mode": preview_mode,
385
419
  "before_summary": {"tempo": before_info.get("tempo"), "tracks": before_info.get("track_count")},
386
420
  "after_summary": {"tempo": after_info.get("tempo"), "tracks": after_info.get("track_count")},
387
421
  "identity_effect": variant.identity_effect,
388
422
  "what_changed": variant.what_changed,
389
423
  "what_preserved": variant.what_preserved,
390
424
  }
425
+
426
+ if spectral_before and spectral_after:
427
+ result["spectral_comparison"] = {
428
+ "before": spectral_before,
429
+ "after": spectral_after,
430
+ }
431
+
432
+ return result
391
433
  else:
392
434
  # Analytical preview — no live render
393
435
  variant.status = "rendered"
436
+ variant.preview_mode = "analytical_preview"
394
437
  return {
395
438
  "rendered": True,
396
439
  "variant_id": variant_id,
397
440
  "label": variant.label,
398
441
  "bars": bars,
399
- "mode": "analytical",
442
+ "preview_mode": "analytical_preview",
400
443
  "intent": variant.intent,
401
444
  "novelty_level": variant.novelty_level,
402
445
  "identity_effect": variant.identity_effect,