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
@@ -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:
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import json
5
7
  import time
6
8
  from dataclasses import asdict, dataclass, field
7
9
  from typing import Optional
@@ -9,6 +11,38 @@ from typing import Optional
9
11
  from ..runtime.degradation import DegradationInfo
10
12
 
11
13
 
14
+ def compute_session_fingerprint(session_info: Optional[dict]) -> str:
15
+ """Lightweight fingerprint of session topology (track_count + ordered
16
+ track names).
17
+
18
+ PreviewSet and WonderSession cache compiled plans that reference
19
+ positional track/device indices. Those indices are only valid against
20
+ the session topology that existed when the plan was compiled — if the
21
+ user adds/removes/reorders tracks before the plan is committed, replaying
22
+ those indices can silently hit the wrong track.
23
+
24
+ This helper is the single source of truth for that fingerprint, shared
25
+ by preview_studio and wonder_mode so both stamp/verify it the same way.
26
+ Callers stamp it at creation time from session_info they already fetched
27
+ (never triggers its own Ableton round-trip). At commit/replay time they
28
+ fetch a fresh session_info and compare fingerprints.
29
+
30
+ Returns "" when session_info is missing/malformed — an empty fingerprint
31
+ means "no signal available" and callers must skip the staleness check
32
+ (this keeps older cached/persisted objects, built before this field
33
+ existed, committable).
34
+ """
35
+ if not isinstance(session_info, dict) or not session_info:
36
+ return ""
37
+ tracks = session_info.get("tracks")
38
+ if not isinstance(tracks, list):
39
+ tracks = []
40
+ names = [str(t.get("name", "")) for t in tracks if isinstance(t, dict)]
41
+ track_count = session_info.get("track_count", len(names))
42
+ seed = json.dumps({"track_count": track_count, "track_names": names}, sort_keys=True)
43
+ return hashlib.sha256(seed.encode()).hexdigest()[:16]
44
+
45
+
12
46
  @dataclass
13
47
  class PreviewVariant:
14
48
  """One creative option in a preview set."""
@@ -66,6 +100,11 @@ class PreviewSet:
66
100
  # can inspect .degradation.is_degraded to tell synthesized preview
67
101
  # topology apart from a real kernel-backed compile.
68
102
  degradation: DegradationInfo = field(default_factory=DegradationInfo)
103
+ # Session-identity guard — see compute_session_fingerprint(). Stamped at
104
+ # creation time from the session_info the creator already fetched. Empty
105
+ # string means "no signal" (older objects, or created without a live
106
+ # session) and callers must skip the staleness check.
107
+ session_fingerprint: str = ""
69
108
 
70
109
  def to_dict(self) -> dict:
71
110
  return {
@@ -79,4 +118,5 @@ class PreviewSet:
79
118
  "status": self.status,
80
119
  "variant_count": len(self.variants),
81
120
  "degradation": self.degradation.to_dict(),
121
+ "session_fingerprint": self.session_fingerprint,
82
122
  }
@@ -15,6 +15,7 @@ from fastmcp import Context
15
15
 
16
16
  from ..server import mcp
17
17
  from . import engine
18
+ from .models import compute_session_fingerprint
18
19
  import logging
19
20
 
20
21
  logger = logging.getLogger(__name__)
@@ -114,6 +115,11 @@ def create_preview_set(
114
115
  source_kernel_id=kernel_id,
115
116
  variants=preview_variants,
116
117
  created_at_ms=now,
118
+ # Carry forward the fingerprint the WonderSession stamped at
119
+ # enter_wonder_mode time so commit_preview_variant can still
120
+ # detect a topology change even though this PreviewSet was
121
+ # built from ws.variants rather than a fresh kernel fetch.
122
+ session_fingerprint=getattr(ws, "session_fingerprint", ""),
117
123
  )
118
124
  engine.store_preview_set(ps)
119
125
 
@@ -286,6 +292,40 @@ async def commit_preview_variant(
286
292
  "available_variants": available,
287
293
  }
288
294
 
295
+ # ── Session-identity guard (state-layer hardening) ──
296
+ # PreviewSet.session_fingerprint is stamped at creation time from the
297
+ # session_info the creator already had in hand (see engine.py /
298
+ # create_preview_set). The compiled_plan cached on each variant carries
299
+ # positional track/device indices that are only valid against that same
300
+ # topology. If the fingerprint is set, re-check it against a fresh
301
+ # get_session_info before replaying those indices — a track added/
302
+ # removed/reordered since the preview was built means "commit" would
303
+ # silently apply to the wrong track. Absent fingerprint (older/degraded
304
+ # objects predating this field) skips the check entirely — no new
305
+ # round-trip, no behavior change for them.
306
+ if ps.session_fingerprint:
307
+ ableton = _get_ableton(ctx)
308
+ fresh_session_info: dict = {}
309
+ try:
310
+ fresh_session_info = await ableton.send_command_async("get_session_info", {}) or {}
311
+ except Exception as exc:
312
+ logger.debug("commit_preview_variant: session refresh failed: %s", exc)
313
+ fresh_session_info = {}
314
+ if isinstance(fresh_session_info, dict) and "error" not in fresh_session_info:
315
+ current_fingerprint = compute_session_fingerprint(fresh_session_info)
316
+ if current_fingerprint and current_fingerprint != ps.session_fingerprint:
317
+ return {
318
+ "error": (
319
+ "session changed since preview was created — "
320
+ "rebuild the preview set"
321
+ ),
322
+ "code": "STATE_ERROR",
323
+ "set_id": set_id,
324
+ "variant_id": variant_id,
325
+ }
326
+ # else: couldn't refresh (Ableton unreachable) — fail open rather
327
+ # than blocking a legitimate commit on a transient fetch error.
328
+
289
329
  # ── Truth-gap guard: refuse to "commit" a variant that can't execute ──
290
330
  # If the variant was flagged blocked/failed upstream or lacks a
291
331
  # compiled plan, the old code still marked preview_set.status='committed'
@@ -503,7 +543,11 @@ async def render_preview_variant(
503
543
  plan = variant.compiled_plan
504
544
  steps = plan if isinstance(plan, list) else plan.get("steps", [])
505
545
 
506
- from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
546
+ from ..runtime.execution_router import (
547
+ execute_plan_steps_async,
548
+ filter_apply_steps,
549
+ undo_remote_steps,
550
+ )
507
551
 
508
552
  # Read-only verification steps (meters/spectrum/info) don't create undo
509
553
  # points in Ableton — counting them and then undoing walks back earlier
@@ -511,6 +555,7 @@ async def render_preview_variant(
511
555
  apply_steps = filter_apply_steps(steps)
512
556
 
513
557
  applied_count = 0
558
+ exec_results: list = []
514
559
  playback_started = False
515
560
  preview_mode = "metadata_only_preview"
516
561
  spectral_before: Optional[dict] = None
@@ -523,7 +568,7 @@ async def render_preview_variant(
523
568
 
524
569
  try:
525
570
  # ── 1. Capture BEFORE metadata ──
526
- before_info = ableton.send_command("get_session_info", {}) or {}
571
+ before_info = await ableton.send_command_async("get_session_info", {}) or {}
527
572
 
528
573
  # ── 2. Apply the variant (write steps only) ──
529
574
  exec_results = await execute_plan_steps_async(
@@ -542,7 +587,7 @@ async def render_preview_variant(
542
587
  }
543
588
 
544
589
  # ── 3. Capture AFTER metadata (variant is live) ──
545
- after_info = ableton.send_command("get_session_info", {}) or {}
590
+ after_info = await ableton.send_command_async("get_session_info", {}) or {}
546
591
 
547
592
  # ── 4. Audible capture WHILE variant is still applied ──
548
593
  # This is the critical ordering fix: previously this block ran AFTER
@@ -558,7 +603,7 @@ async def render_preview_variant(
558
603
  tempo = before_info.get("tempo", 120) or 120
559
604
  play_seconds = min(bars * (60.0 / tempo) * 4, 8.0)
560
605
 
561
- ableton.send_command("start_playback", {})
606
+ await ableton.send_command_async("start_playback", {})
562
607
  playback_started = True
563
608
 
564
609
  import asyncio as _asyncio
@@ -567,7 +612,7 @@ async def render_preview_variant(
567
612
 
568
613
  spectral_after = cache.get_all()
569
614
 
570
- ableton.send_command("stop_playback", {})
615
+ await ableton.send_command_async("stop_playback", {})
571
616
  playback_started = False
572
617
 
573
618
  preview_mode = "audible_preview"
@@ -582,16 +627,15 @@ async def render_preview_variant(
582
627
  # ── 5. Cleanup: stop playback if still running, then undo everything ──
583
628
  if playback_started:
584
629
  try:
585
- ableton.send_command("stop_playback", {})
630
+ await ableton.send_command_async("stop_playback", {})
586
631
  except Exception as exc:
587
632
  logger.debug("render_preview_variant failed: %s", exc)
588
633
 
589
- for _ in range(applied_count):
590
- try:
591
- ableton.send_command("undo")
592
- except Exception as exc:
593
- logger.debug("render_preview_variant failed: %s", exc)
594
- break
634
+ # Only remote_command steps land on Ableton's linear undo stack.
635
+ # Bridge (M4L/OSC) and mcp_tool steps do NOT, so counting them and
636
+ # then issuing that many `undo`s walks back unrelated prior user
637
+ # edits undo_remote_steps filters to remote_command successes.
638
+ await undo_remote_steps(ableton, exec_results)
595
639
 
596
640
  variant.status = "rendered"
597
641
  variant.preview_mode = preview_mode
@@ -48,6 +48,10 @@ def build_arrangement_graph(
48
48
  section_type=ce_sec.section_type.value,
49
49
  energy=ce_sec.energy,
50
50
  density=ce_sec.density,
51
+ # Clip-presence active tracks — includes audio tracks that carry
52
+ # no MIDI notes. Without this, role inference would only see
53
+ # tracks that appear in notes_map and drop every audio track.
54
+ tracks_active=list(getattr(ce_sec, "tracks_active", []) or []),
51
55
  ))
52
56
 
53
57
  # Build boundary list (transitions between adjacent sections)