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.
- package/CHANGELOG.md +86 -0
- package/README.md +10 -7
- package/bin/livepilot.js +156 -37
- 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 +48 -11
- 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/fast/brief_builder.py +6 -2
- 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 +16 -9
- package/mcp_server/connection.py +69 -10
- 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 +217 -62
- 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 +57 -1
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- 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 +27 -25
- 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 +21 -8
- 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/tracks.py +11 -5
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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:
|