livepilot 1.27.1 → 1.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -84,11 +84,17 @@ def _fetch_mix_data(ctx: Context) -> dict:
84
84
 
85
85
 
86
86
  @mcp.tool()
87
- def analyze_mix(ctx: Context) -> dict:
87
+ def analyze_mix(ctx: Context, target_style: str = "dynamic") -> dict:
88
88
  """Build full mix state and run all critics.
89
89
 
90
90
  Returns the complete mix analysis including all sub-states
91
91
  (balance, masking, dynamics, stereo, depth) and all detected issues.
92
+
93
+ target_style: intended dynamics target for the dynamics critic.
94
+ "dynamic" (default) — standard mix expectations.
95
+ "loud_master" — deliberately loud, heavily-limited master: the
96
+ over_compressed check is suppressed (3-6dB crest is the intended
97
+ sound there, not a defect).
92
98
  """
93
99
  data = _fetch_mix_data(ctx)
94
100
  mix_state = build_mix_state(
@@ -97,7 +103,9 @@ def analyze_mix(ctx: Context) -> dict:
97
103
  spectrum=data["spectrum"],
98
104
  rms_data=data["rms_data"],
99
105
  )
100
- issues = run_all_mix_critics(mix_state)
106
+ issues = run_all_mix_critics(
107
+ mix_state, dynamics_context={"target_style": target_style}
108
+ )
101
109
  moves = plan_mix_moves(issues, mix_state)
102
110
  sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
103
111
  sonic_character = extract_character_profile(sonic_snapshot or {})
@@ -113,10 +121,12 @@ def analyze_mix(ctx: Context) -> dict:
113
121
 
114
122
 
115
123
  @mcp.tool()
116
- def get_mix_issues(ctx: Context) -> dict:
124
+ def get_mix_issues(ctx: Context, target_style: str = "dynamic") -> dict:
117
125
  """Run all mix critics and return detected issues only.
118
126
 
119
127
  Lighter than analyze_mix — skips move planning.
128
+
129
+ target_style: "dynamic" (default) or "loud_master" — see analyze_mix.
120
130
  """
121
131
  data = _fetch_mix_data(ctx)
122
132
  mix_state = build_mix_state(
@@ -125,7 +135,9 @@ def get_mix_issues(ctx: Context) -> dict:
125
135
  spectrum=data["spectrum"],
126
136
  rms_data=data["rms_data"],
127
137
  )
128
- issues = run_all_mix_critics(mix_state)
138
+ issues = run_all_mix_critics(
139
+ mix_state, dynamics_context={"target_style": target_style}
140
+ )
129
141
  sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
130
142
 
131
143
  return {
@@ -40,6 +40,39 @@ _POSITIONAL_FALLBACK_ROLES = {
40
40
  "default": "verse",
41
41
  }
42
42
 
43
+ # _composition_engine's SectionType vocabulary is RICHER than the performance
44
+ # VALID_ROLES set (it adds loop / pre_chorus / bridge / unknown / transition_fx).
45
+ # Feeding one of those straight into SceneRole(role=...) trips its __post_init__
46
+ # guard and raises ValueError, which crashes get_performance_state /
47
+ # get_performance_safe_moves / plan_scene_handoff the moment ANY scene resolves
48
+ # to an out-of-vocabulary type (e.g. a scene named "Drums"/"My Idea" → unknown).
49
+ # Map every composition type into the performance vocabulary by musical role.
50
+ _SECTION_TYPE_TO_PERF_ROLE = {
51
+ "intro": "intro",
52
+ "verse": "verse",
53
+ "chorus": "chorus",
54
+ "build": "build",
55
+ "drop": "drop",
56
+ "breakdown": "breakdown",
57
+ "outro": "outro",
58
+ "transition": "transition",
59
+ "transition_fx": "transition",
60
+ "pre_chorus": "build", # a pre-chorus builds toward the chorus
61
+ "bridge": "breakdown", # a bridge is a contrasting, lower-energy section
62
+ "loop": "verse", # a bare loop / idea reads as a steady verse
63
+ "unknown": "verse", # neutral default
64
+ }
65
+
66
+
67
+ def _to_performance_role(value: str) -> str:
68
+ """Coerce any composition SectionType value into the performance vocabulary.
69
+
70
+ Falls back to 'verse' for any value not in the map so a future SectionType
71
+ can never crash the performance tools.
72
+ """
73
+ role = _SECTION_TYPE_TO_PERF_ROLE.get(value)
74
+ return role if role is not None else "verse"
75
+
43
76
 
44
77
  def _positional_fallback_role(index: int, scene_count: int) -> str:
45
78
  """Map a scene index to a role when no composition data is available.
@@ -148,21 +181,36 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
148
181
  name = scene_data.get("name", f"Scene {i}")
149
182
  ce_sec = ce_by_scene_idx.get(i)
150
183
  if ce_sec is not None:
151
- # SectionType is an enum; .value gives the string vocabulary
184
+ # SectionType is an enum; .value gives the string vocabulary, which
185
+ # we then coerce into the (narrower) performance VALID_ROLES set.
152
186
  stype = ce_sec.section_type
153
- role = stype.value if hasattr(stype, "value") else str(stype)
187
+ raw = stype.value if hasattr(stype, "value") else str(stype)
188
+ role = _to_performance_role(raw)
154
189
  energy = float(ce_sec.energy)
155
190
  else:
156
191
  # Unnamed scene or build failed — positional fallback
157
192
  role = _positional_fallback_role(i, scene_count)
158
193
  energy = _positional_fallback_energy(role)
159
194
 
160
- scene_roles.append(SceneRole(
161
- scene_index=i,
162
- name=name,
163
- energy_level=energy,
164
- role=role,
165
- ))
195
+ # Clamp energy and defensively construct: SceneRole.__post_init__ raises
196
+ # on an out-of-range energy or an unmapped role, and a single raise here
197
+ # would take down the whole tool. A bad value degrades to a safe scene.
198
+ energy = max(0.0, min(1.0, energy))
199
+ try:
200
+ scene_roles.append(SceneRole(
201
+ scene_index=i,
202
+ name=name,
203
+ energy_level=energy,
204
+ role=role,
205
+ ))
206
+ except ValueError as exc:
207
+ logger.debug("_fetch_scene_data SceneRole(%r) invalid: %s", role, exc)
208
+ scene_roles.append(SceneRole(
209
+ scene_index=i,
210
+ name=name,
211
+ energy_level=energy,
212
+ role="verse",
213
+ ))
166
214
 
167
215
  # Determine current scene — default to 0 since session_info
168
216
  # doesn't expose a selected_scene field
@@ -70,6 +70,17 @@ class PersistentJsonStore:
70
70
  try:
71
71
  return json.loads(self._path.read_text(encoding="utf-8"))
72
72
  except (json.JSONDecodeError, OSError):
73
+ # Mirror the read() corruption-recovery path: preserve the
74
+ # corrupt file as <name>.corrupt so it can be inspected/recovered
75
+ # rather than being silently overwritten by the next _write_unlocked
76
+ # call (which update() always performs after _read_unlocked).
77
+ # Best-effort — a backup failure must not prevent the store from
78
+ # recovering with defaults.
79
+ corrupt = self._path.with_suffix(self._path.suffix + ".corrupt")
80
+ try:
81
+ self._path.rename(corrupt)
82
+ except OSError:
83
+ pass
73
84
  return {}
74
85
 
75
86
  def _write_unlocked(self, data: dict) -> None:
@@ -7,16 +7,35 @@ Located at ~/.livepilot/projects/<hash>/state.json.
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import logging
10
11
  import time
11
12
  from pathlib import Path
12
- from typing import Optional
13
+ from typing import Callable, Optional
13
14
 
14
15
  from .base_store import PersistentJsonStore
15
16
 
17
+ logger = logging.getLogger(__name__)
18
+
16
19
 
17
20
  _PROJECTS_DIR = Path.home() / ".livepilot" / "projects"
18
21
  _MAX_TURNS = 50
19
22
  _MAX_WONDER_OUTCOMES = 10
23
+ # Creative threads accumulate one entry per open_thread() call, and
24
+ # session_continuity mints a fresh thread_id (ms-timestamp-seeded hash) every
25
+ # time — unlike turns/wonder_outcomes there was previously NO cap here, so
26
+ # the list grew monotonically forever and every save_thread() became a
27
+ # full read-modify-write over an ever-larger threads array.
28
+ _MAX_THREADS = 100
29
+
30
+ # Schema version of the on-disk shape this build writes/expects.
31
+ CURRENT_VERSION = 1
32
+
33
+ # Migration table: source version -> callable that upgrades data from that
34
+ # version to the next. Empty today — CURRENT_VERSION has been 1 since this
35
+ # store existed. Scaffolding for the day a schema bump actually needs a real
36
+ # migration; until then any version mismatch backs up the raw file and warns
37
+ # instead of silently discarding the project's threads/turns/wonder history.
38
+ _MIGRATIONS: dict[int, Callable[[dict], dict]] = {}
20
39
 
21
40
 
22
41
  def project_hash(session_info: dict) -> str:
@@ -86,6 +105,41 @@ def project_hash(session_info: dict) -> str:
86
105
  return hashlib.sha256(seed.encode()).hexdigest()[:12]
87
106
 
88
107
 
108
+ def _prune_threads(threads: list[dict], max_threads: int) -> list[dict]:
109
+ """Cap the thread list, preferring to drop resolved threads first.
110
+
111
+ Mirrors the capping pattern already used by save_turn (_MAX_TURNS) and
112
+ save_wonder_outcome (_MAX_WONDER_OUTCOMES) — but threads never had one,
113
+ and open_thread() mints a fresh thread_id every call, so the list grew
114
+ monotonically forever.
115
+
116
+ Eviction order:
117
+ 1. Resolved threads, oldest-touched first — a long resolved history
118
+ shouldn't crowd out genuinely open creative threads.
119
+ 2. If resolved threads alone aren't enough to reach the cap, fall back
120
+ to dropping the oldest-touched threads overall (any status), so the
121
+ most-recently-touched threads always survive.
122
+ """
123
+ if len(threads) <= max_threads:
124
+ return threads
125
+
126
+ overflow = len(threads) - max_threads
127
+
128
+ resolved_idx = sorted(
129
+ (i for i, t in enumerate(threads) if t.get("status") == "resolved"),
130
+ key=lambda i: threads[i].get("last_touched_ms", 0),
131
+ )
132
+ drop: set[int] = set(resolved_idx[:overflow])
133
+
134
+ if len(drop) < overflow:
135
+ remaining_idx = [i for i in range(len(threads)) if i not in drop]
136
+ remaining_idx.sort(key=lambda i: threads[i].get("last_touched_ms", 0))
137
+ need_more = overflow - len(drop)
138
+ drop.update(remaining_idx[:need_more])
139
+
140
+ return [t for i, t in enumerate(threads) if i not in drop]
141
+
142
+
89
143
  class ProjectStore:
90
144
  """Persistent per-project state."""
91
145
 
@@ -98,28 +152,98 @@ class ProjectStore:
98
152
  def project_id(self) -> str:
99
153
  return self._project_id
100
154
 
155
+ def _coerce(self, data: dict) -> dict:
156
+ """Return schema-current data — migrating forward or resetting.
157
+
158
+ - version == CURRENT_VERSION: pass through unchanged (hot path).
159
+ - version has a registered migration chain to CURRENT_VERSION: run it.
160
+ - anything else (missing/non-int version, or a version this build
161
+ doesn't know how to migrate — e.g. it's newer than this build):
162
+ back up the raw file to ``<path>.pre-migration``, log a WARNING,
163
+ and fall back to defaults. Never silently discard.
164
+ """
165
+ version = data.get("version")
166
+ if version == CURRENT_VERSION:
167
+ return data
168
+ migrated = self._migrate(data, version)
169
+ if migrated is not None:
170
+ return migrated
171
+ if data:
172
+ self._backup_unmigrated(version)
173
+ return self._default()
174
+
175
+ @staticmethod
176
+ def _migrate(data: dict, version) -> Optional[dict]:
177
+ """Run the migration chain from ``version`` to CURRENT_VERSION.
178
+
179
+ Returns None if there's no registered path (unknown/future version),
180
+ signalling the caller to fall back to defaults.
181
+ """
182
+ if not isinstance(version, int):
183
+ return None
184
+ migrated = dict(data)
185
+ seen: set[int] = set()
186
+ while migrated.get("version") != CURRENT_VERSION:
187
+ v = migrated.get("version")
188
+ if not isinstance(v, int) or v in seen or v not in _MIGRATIONS:
189
+ return None
190
+ seen.add(v)
191
+ migrated = _MIGRATIONS[v](migrated)
192
+ return migrated
193
+
194
+ def _backup_unmigrated(self, version) -> None:
195
+ """Preserve the raw on-disk file before falling back to defaults."""
196
+ path = self._store.path
197
+ backup = path.with_suffix(path.suffix + ".pre-migration")
198
+ try:
199
+ if path.exists():
200
+ path.replace(backup)
201
+ logger.warning(
202
+ "ProjectStore(%s): unrecognized schema version %r in %s "
203
+ "(expected %r) — backed up to %s and falling back to "
204
+ "defaults; the original data was NOT discarded",
205
+ self._project_id, version, path, CURRENT_VERSION, backup,
206
+ )
207
+ else:
208
+ logger.warning(
209
+ "ProjectStore(%s): unrecognized schema version %r "
210
+ "(expected %r, no on-disk file to back up) — falling "
211
+ "back to defaults",
212
+ self._project_id, version, CURRENT_VERSION,
213
+ )
214
+ except OSError as exc:
215
+ logger.warning(
216
+ "ProjectStore(%s): failed to back up unrecognized-version "
217
+ "file %s (%s) — falling back to defaults without a backup",
218
+ self._project_id, path, exc,
219
+ )
220
+
101
221
  def get_all(self) -> dict:
102
222
  data = self._store.read()
103
- return data if data.get("version") == 1 else self._default()
223
+ return self._coerce(data)
104
224
 
105
225
  def save_thread(self, thread: dict) -> None:
106
- """Save or update a creative thread."""
226
+ """Save or update a creative thread (capped at _MAX_THREADS)."""
107
227
  def _update(data: dict) -> dict:
108
- data = data if data.get("version") == 1 else self._default()
228
+ data = self._coerce(data)
109
229
  threads = data.setdefault("threads", [])
110
230
  # Update existing or append
111
231
  for i, t in enumerate(threads):
112
232
  if t.get("thread_id") == thread.get("thread_id"):
113
233
  threads[i] = thread
114
- return data
115
- threads.append(thread)
234
+ break
235
+ else:
236
+ threads.append(thread)
237
+ if len(threads) > _MAX_THREADS:
238
+ threads = _prune_threads(threads, _MAX_THREADS)
239
+ data["threads"] = threads
116
240
  return data
117
241
  self._store.update(_update)
118
242
 
119
243
  def save_turn(self, turn: dict) -> None:
120
244
  """Save a turn resolution (capped at MAX_TURNS)."""
121
245
  def _update(data: dict) -> dict:
122
- data = data if data.get("version") == 1 else self._default()
246
+ data = self._coerce(data)
123
247
  turns = data.setdefault("turns", [])
124
248
  turns.append(turn)
125
249
  # Cap at max
@@ -132,7 +256,7 @@ class ProjectStore:
132
256
  def save_wonder_outcome(self, outcome: dict) -> None:
133
257
  """Save a Wonder session outcome (capped at MAX_WONDER_OUTCOMES)."""
134
258
  def _update(data: dict) -> dict:
135
- data = data if data.get("version") == 1 else self._default()
259
+ data = self._coerce(data)
136
260
  outcomes = data.setdefault("wonder_outcomes", [])
137
261
  outcomes.append(outcome)
138
262
  if len(outcomes) > _MAX_WONDER_OUTCOMES:
@@ -7,15 +7,31 @@ anti-preferences, and dimension weights. Located at
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
10
11
  import time
11
12
  from pathlib import Path
12
- from typing import Optional
13
+ from typing import Callable, Optional
13
14
 
14
15
  from .base_store import PersistentJsonStore
15
16
 
17
+ logger = logging.getLogger(__name__)
18
+
16
19
 
17
20
  _DEFAULT_PATH = Path.home() / ".livepilot" / "taste.json"
18
21
 
22
+ # Schema version of the on-disk shape this build writes/expects.
23
+ CURRENT_VERSION = 1
24
+
25
+ # Migration table: source version -> callable that upgrades data from that
26
+ # version to the next. Empty today — CURRENT_VERSION has been 1 since this
27
+ # store existed, so there has never been a real migration to run. This is
28
+ # scaffolding: when a future schema bump lands, add `{OLD: _migrate_fn}` here
29
+ # and bump CURRENT_VERSION. Without this table, ANY version mismatch (a
30
+ # future bump, a downgrade, a hand-edited file) silently discarded the
31
+ # user's entire taste/anti-preference history back to defaults with no
32
+ # warning and no backup — that's the bug this scaffolding closes.
33
+ _MIGRATIONS: dict[int, Callable[[dict], dict]] = {}
34
+
19
35
 
20
36
  class PersistentTasteStore:
21
37
  """Persistent backing for TasteGraph data."""
@@ -23,17 +39,84 @@ class PersistentTasteStore:
23
39
  def __init__(self, path: Optional[Path] = None):
24
40
  self._store = PersistentJsonStore(path or _DEFAULT_PATH)
25
41
 
42
+ def _coerce(self, data: dict) -> dict:
43
+ """Return schema-current data — migrating forward or resetting.
44
+
45
+ - version == CURRENT_VERSION: pass through unchanged (hot path).
46
+ - version has a registered migration chain to CURRENT_VERSION: run it.
47
+ - anything else (missing/non-int version, or a version this build
48
+ doesn't know how to migrate — e.g. it's newer than this build):
49
+ back up the raw file to ``<path>.pre-migration``, log a WARNING,
50
+ and fall back to defaults. Never silently discard.
51
+ """
52
+ version = data.get("version")
53
+ if version == CURRENT_VERSION:
54
+ return data
55
+ migrated = self._migrate(data, version)
56
+ if migrated is not None:
57
+ return migrated
58
+ if data:
59
+ self._backup_unmigrated(version)
60
+ return self._default()
61
+
62
+ @staticmethod
63
+ def _migrate(data: dict, version) -> Optional[dict]:
64
+ """Run the migration chain from ``version`` to CURRENT_VERSION.
65
+
66
+ Returns None if there's no registered path (unknown/future version),
67
+ signalling the caller to fall back to defaults.
68
+ """
69
+ if not isinstance(version, int):
70
+ return None
71
+ migrated = dict(data)
72
+ seen: set[int] = set()
73
+ while migrated.get("version") != CURRENT_VERSION:
74
+ v = migrated.get("version")
75
+ if not isinstance(v, int) or v in seen or v not in _MIGRATIONS:
76
+ return None
77
+ seen.add(v)
78
+ migrated = _MIGRATIONS[v](migrated)
79
+ return migrated
80
+
81
+ def _backup_unmigrated(self, version) -> None:
82
+ """Preserve the raw on-disk file before falling back to defaults."""
83
+ path = self._store.path
84
+ backup = path.with_suffix(path.suffix + ".pre-migration")
85
+ try:
86
+ if path.exists():
87
+ path.replace(backup)
88
+ logger.warning(
89
+ "PersistentTasteStore: unrecognized schema version %r in "
90
+ "%s (expected %r) — backed up to %s and falling back to "
91
+ "defaults; the original data was NOT discarded",
92
+ version, path, CURRENT_VERSION, backup,
93
+ )
94
+ else:
95
+ logger.warning(
96
+ "PersistentTasteStore: unrecognized schema version %r "
97
+ "(expected %r, no on-disk file to back up) — falling "
98
+ "back to defaults",
99
+ version, CURRENT_VERSION,
100
+ )
101
+ except OSError as exc:
102
+ logger.warning(
103
+ "PersistentTasteStore: failed to back up unrecognized-"
104
+ "version file %s (%s) — falling back to defaults without "
105
+ "a backup",
106
+ path, exc,
107
+ )
108
+
26
109
  def get_all(self) -> dict:
27
110
  """Get all persisted taste data."""
28
111
  data = self._store.read()
29
- return data if data.get("version") == 1 else self._default()
112
+ return self._coerce(data)
30
113
 
31
114
  def record_move_outcome(
32
115
  self, move_id: str, family: str, kept: bool, score: float = 0.0,
33
116
  ) -> None:
34
117
  """Persist a move outcome."""
35
118
  def _update(data: dict) -> dict:
36
- data = data if data.get("version") == 1 else self._default()
119
+ data = self._coerce(data)
37
120
  outcomes = data.setdefault("move_outcomes", {})
38
121
  entry = outcomes.setdefault(move_id, {
39
122
  "family": family, "kept_count": 0, "undone_count": 0,
@@ -57,7 +140,7 @@ class PersistentTasteStore:
57
140
  field mirrors the "improve" band.
58
141
  """
59
142
  def _update(data: dict) -> dict:
60
- data = data if data.get("version") == 1 else self._default()
143
+ data = self._coerce(data)
61
144
  # Ensure the per-mode dict exists (migrating from legacy shape).
62
145
  bands = data.get("novelty_bands")
63
146
  if not isinstance(bands, dict) or not bands:
@@ -78,7 +161,7 @@ class PersistentTasteStore:
78
161
  def record_device_use(self, device_name: str, positive: bool = True) -> None:
79
162
  """Persist device affinity."""
80
163
  def _update(data: dict) -> dict:
81
- data = data if data.get("version") == 1 else self._default()
164
+ data = self._coerce(data)
82
165
  affinities = data.setdefault("device_affinities", {})
83
166
  entry = affinities.setdefault(device_name, {
84
167
  "affinity": 0.0, "use_count": 0,
@@ -96,7 +179,7 @@ class PersistentTasteStore:
96
179
  def record_anti_preference(self, dimension: str, direction: str) -> None:
97
180
  """Persist an anti-preference."""
98
181
  def _update(data: dict) -> dict:
99
- data = data if data.get("version") == 1 else self._default()
182
+ data = self._coerce(data)
100
183
  antis = data.setdefault("anti_preferences", [])
101
184
  existing = next(
102
185
  (a for a in antis if a["dimension"] == dimension and a["direction"] == direction),
@@ -117,7 +200,7 @@ class PersistentTasteStore:
117
200
  def record_dimension_weight(self, dimension: str, value: float) -> None:
118
201
  """Persist a dimension weight update."""
119
202
  def _update(data: dict) -> dict:
120
- data = data if data.get("version") == 1 else self._default()
203
+ data = self._coerce(data)
121
204
  data.setdefault("dimension_weights", {})[dimension] = round(value, 3)
122
205
  return data
123
206
  self._store.update(_update)
@@ -8,29 +8,43 @@ from __future__ import annotations
8
8
 
9
9
  import hashlib
10
10
  import json
11
+ import threading
11
12
  import time
12
13
  from typing import Optional
13
14
 
14
15
  from ..runtime.degradation import DegradationInfo
15
- from .models import PreviewSet, PreviewVariant
16
+ from .models import PreviewSet, PreviewVariant, compute_session_fingerprint
16
17
 
17
18
 
18
19
  # ── In-memory store ───────────────────────────────────────────────
20
+ #
21
+ # _preview_sets is mutated from both threadpooled sync tools (e.g.
22
+ # compare_preview_variants) and event-loop async tools (e.g.
23
+ # commit_preview_variant) — two concurrent create_preview_set calls used to
24
+ # be able to race in store_preview_set's check-then-evict loop: both read
25
+ # the same `oldest_key`, both `del` it, and the second raised KeyError. One
26
+ # module-level lock around the get/store/evict critical sections closes
27
+ # that race.
19
28
 
20
29
  _preview_sets: dict[str, PreviewSet] = {}
30
+ _preview_sets_lock = threading.Lock()
21
31
  _MAX_PREVIEW_SETS = 20
22
32
 
23
33
 
24
34
  def get_preview_set(set_id: str) -> Optional[PreviewSet]:
25
- return _preview_sets.get(set_id)
35
+ with _preview_sets_lock:
36
+ return _preview_sets.get(set_id)
26
37
 
27
38
 
28
39
  def store_preview_set(ps: PreviewSet) -> None:
29
- _preview_sets[ps.set_id] = ps
30
- # Evict oldest sets if over limit
31
- while len(_preview_sets) > _MAX_PREVIEW_SETS:
32
- oldest_key = next(iter(_preview_sets))
33
- del _preview_sets[oldest_key]
40
+ with _preview_sets_lock:
41
+ _preview_sets[ps.set_id] = ps
42
+ # Evict oldest sets if over limit
43
+ while len(_preview_sets) > _MAX_PREVIEW_SETS:
44
+ oldest_key = next(iter(_preview_sets), None)
45
+ if oldest_key is None:
46
+ break
47
+ _preview_sets.pop(oldest_key, None)
34
48
 
35
49
 
36
50
  # Statuses that represent work a user (or caller) has already invested in:
@@ -50,13 +64,13 @@ def _resolve_set_id(base_id: str) -> str:
50
64
  to a distinct, still-deterministic id (base_id + a monotonic suffix) so the
51
65
  protected set survives and the new set gets its own slot.
52
66
  """
53
- existing = _preview_sets.get(base_id)
67
+ existing = get_preview_set(base_id)
54
68
  if existing is None or existing.status not in _PROTECTED_STATUSES:
55
69
  return base_id
56
70
  suffix = 2
57
71
  while True:
58
72
  candidate = f"{base_id}_b{suffix}"
59
- occupant = _preview_sets.get(candidate)
73
+ occupant = get_preview_set(candidate)
60
74
  if occupant is None or occupant.status not in _PROTECTED_STATUSES:
61
75
  return candidate
62
76
  suffix += 1
@@ -86,6 +100,13 @@ def create_preview_set(
86
100
  synthesizes an empty-but-valid kernel (see ``_build_triptych``) and
87
101
  flags the resulting PreviewSet with ``degradation.is_degraded=True``
88
102
  so callers can tell a synthesized compile from a real one.
103
+
104
+ When kernel carries a real ``session_info`` (track topology), the
105
+ resulting PreviewSet is stamped with a session_fingerprint so a
106
+ later commit can detect that the session's track/device layout
107
+ changed since this plan's indices were compiled — no extra Ableton
108
+ round-trip is made here, we just hash data the caller already
109
+ fetched.
89
110
  """
90
111
  set_id = _resolve_set_id(_compute_set_id(request_text, kernel_id))
91
112
  now = int(time.time() * 1000)
@@ -117,6 +138,13 @@ def create_preview_set(
117
138
  request_text, moves, song_brain, taste_graph, set_id, now, kernel,
118
139
  )
119
140
 
141
+ # Stamp session identity from data the caller already fetched (kernel's
142
+ # session_info) — never a new round-trip. Absent/synthesized kernels
143
+ # yield "", which downstream commit checks treat as "no signal".
144
+ session_fingerprint = compute_session_fingerprint(
145
+ (kernel or {}).get("session_info")
146
+ )
147
+
120
148
  ps = PreviewSet(
121
149
  set_id=set_id,
122
150
  request_text=request_text,
@@ -125,6 +153,7 @@ def create_preview_set(
125
153
  variants=variants,
126
154
  created_at_ms=now,
127
155
  degradation=degradation,
156
+ session_fingerprint=session_fingerprint,
128
157
  )
129
158
  store_preview_set(ps)
130
159
  return ps
@@ -433,7 +462,8 @@ def commit_variant(preview_set: PreviewSet, variant_id: str) -> Optional[Preview
433
462
 
434
463
  def discard_set(set_id: str) -> bool:
435
464
  """Discard an entire preview set."""
436
- ps = _preview_sets.pop(set_id, None)
465
+ with _preview_sets_lock:
466
+ ps = _preview_sets.pop(set_id, None)
437
467
  if ps:
438
468
  ps.status = "discarded"
439
469
  for v in ps.variants: