livepilot 1.27.2 → 1.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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)
|