livepilot 1.9.21 → 1.9.23

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 (110) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +47 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +47 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -14
  12. package/livepilot/commands/beat.md +68 -21
  13. package/livepilot/commands/evaluate.md +23 -13
  14. package/livepilot/commands/mix.md +35 -11
  15. package/livepilot/commands/perform.md +31 -19
  16. package/livepilot/commands/sounddesign.md +38 -17
  17. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  18. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  19. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  20. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  21. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  22. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  23. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  24. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  25. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  26. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  27. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  28. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  29. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  30. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  31. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  32. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  33. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  34. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  35. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  36. package/livepilot/skills/livepilot-release/SKILL.md +15 -15
  37. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  38. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  39. package/livepilot.mcpb +0 -0
  40. package/m4l_device/livepilot_bridge.js +1 -1
  41. package/manifest.json +91 -0
  42. package/mcp_server/__init__.py +1 -1
  43. package/mcp_server/creative_constraints/__init__.py +6 -0
  44. package/mcp_server/creative_constraints/engine.py +277 -0
  45. package/mcp_server/creative_constraints/models.py +75 -0
  46. package/mcp_server/creative_constraints/tools.py +341 -0
  47. package/mcp_server/experiment/__init__.py +6 -0
  48. package/mcp_server/experiment/engine.py +213 -0
  49. package/mcp_server/experiment/models.py +120 -0
  50. package/mcp_server/experiment/tools.py +263 -0
  51. package/mcp_server/hook_hunter/__init__.py +5 -0
  52. package/mcp_server/hook_hunter/analyzer.py +342 -0
  53. package/mcp_server/hook_hunter/models.py +57 -0
  54. package/mcp_server/hook_hunter/tools.py +586 -0
  55. package/mcp_server/memory/taste_graph.py +261 -0
  56. package/mcp_server/memory/tools.py +88 -0
  57. package/mcp_server/mix_engine/critics.py +2 -2
  58. package/mcp_server/mix_engine/models.py +1 -1
  59. package/mcp_server/mix_engine/state_builder.py +2 -2
  60. package/mcp_server/musical_intelligence/__init__.py +8 -0
  61. package/mcp_server/musical_intelligence/detectors.py +421 -0
  62. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  63. package/mcp_server/musical_intelligence/tools.py +221 -0
  64. package/mcp_server/preview_studio/__init__.py +5 -0
  65. package/mcp_server/preview_studio/engine.py +280 -0
  66. package/mcp_server/preview_studio/models.py +73 -0
  67. package/mcp_server/preview_studio/tools.py +423 -0
  68. package/mcp_server/runtime/session_kernel.py +96 -0
  69. package/mcp_server/runtime/tools.py +90 -1
  70. package/mcp_server/semantic_moves/__init__.py +13 -0
  71. package/mcp_server/semantic_moves/compiler.py +116 -0
  72. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  73. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  74. package/mcp_server/semantic_moves/models.py +46 -0
  75. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  76. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  77. package/mcp_server/semantic_moves/registry.py +32 -0
  78. package/mcp_server/semantic_moves/resolvers.py +126 -0
  79. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  80. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  81. package/mcp_server/semantic_moves/tools.py +204 -0
  82. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  83. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  84. package/mcp_server/server.py +10 -0
  85. package/mcp_server/session_continuity/__init__.py +6 -0
  86. package/mcp_server/session_continuity/models.py +86 -0
  87. package/mcp_server/session_continuity/tools.py +230 -0
  88. package/mcp_server/session_continuity/tracker.py +235 -0
  89. package/mcp_server/song_brain/__init__.py +6 -0
  90. package/mcp_server/song_brain/builder.py +477 -0
  91. package/mcp_server/song_brain/models.py +132 -0
  92. package/mcp_server/song_brain/tools.py +294 -0
  93. package/mcp_server/stuckness_detector/__init__.py +5 -0
  94. package/mcp_server/stuckness_detector/detector.py +400 -0
  95. package/mcp_server/stuckness_detector/models.py +66 -0
  96. package/mcp_server/stuckness_detector/tools.py +195 -0
  97. package/mcp_server/tools/_conductor.py +104 -6
  98. package/mcp_server/tools/analyzer.py +1 -1
  99. package/mcp_server/tools/devices.py +34 -0
  100. package/mcp_server/wonder_mode/__init__.py +6 -0
  101. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  102. package/mcp_server/wonder_mode/engine.py +493 -0
  103. package/mcp_server/wonder_mode/session.py +114 -0
  104. package/mcp_server/wonder_mode/tools.py +285 -0
  105. package/package.json +2 -2
  106. package/remote_script/LivePilot/__init__.py +1 -1
  107. package/remote_script/LivePilot/browser.py +4 -1
  108. package/remote_script/LivePilot/devices.py +29 -0
  109. package/remote_script/LivePilot/tracks.py +11 -4
  110. package/scripts/generate_tool_catalog.py +131 -0
@@ -0,0 +1,221 @@
1
+ """Musical intelligence MCP tools — song-level analysis and critique.
2
+
3
+ 4 tools that look beyond parameters into musical meaning:
4
+ detect_repetition_fatigue — is the arrangement getting stale?
5
+ detect_role_conflicts — are tracks fighting for the same space?
6
+ infer_section_purposes — what is each section trying to do?
7
+ score_emotional_arc — does the song have a satisfying arc?
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastmcp import Context
13
+
14
+ from ..server import mcp
15
+ from . import detectors
16
+
17
+
18
+ def _get_ableton(ctx: Context):
19
+ return ctx.lifespan_context["ableton"]
20
+
21
+
22
+ @mcp.tool()
23
+ def detect_repetition_fatigue(ctx: Context) -> dict:
24
+ """Detect repetition fatigue — are patterns overused?
25
+
26
+ Analyzes clip reuse across scenes, motif overuse, and section staleness.
27
+ Returns fatigue level (0=fresh, 1=stale), specific issues, and recommendations.
28
+
29
+ Use this when the track "feels repetitive" or when arrangement
30
+ has been looping without variation.
31
+ """
32
+ ableton = _get_ableton(ctx)
33
+
34
+ # Get scene matrix for clip reuse analysis
35
+ try:
36
+ matrix = ableton.send_command("get_scene_matrix")
37
+ except Exception:
38
+ matrix = {}
39
+
40
+ scenes = []
41
+ for i, scene_data in enumerate(matrix.get("scenes", [])):
42
+ row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
43
+ scenes.append({
44
+ "name": scene_data.get("name", f"Scene {i}"),
45
+ "clips": row,
46
+ })
47
+
48
+ # Try to get motif graph for deeper analysis
49
+ motif_graph = None
50
+ try:
51
+ motif_graph = ableton.send_command("get_motif_graph")
52
+ except Exception:
53
+ pass
54
+
55
+ report = detectors.detect_repetition_fatigue(scenes, motif_graph)
56
+ return report.to_dict()
57
+
58
+
59
+ @mcp.tool()
60
+ def detect_role_conflicts(ctx: Context) -> dict:
61
+ """Detect role conflicts — are tracks fighting for the same musical space?
62
+
63
+ Checks for: multiple bass tracks, competing leads, overlapping drum layers.
64
+ Also flags missing essential roles (no bass, no drums).
65
+
66
+ Returns conflict list with severity and recommendations.
67
+ """
68
+ ableton = _get_ableton(ctx)
69
+ session = ableton.send_command("get_session_info")
70
+ tracks = session.get("tracks", [])
71
+
72
+ conflicts = detectors.detect_role_conflicts(tracks)
73
+ return {
74
+ "conflicts": [c.to_dict() for c in conflicts],
75
+ "conflict_count": len(conflicts),
76
+ "track_count": len(tracks),
77
+ }
78
+
79
+
80
+ @mcp.tool()
81
+ def infer_section_purposes(ctx: Context) -> dict:
82
+ """Infer what each section/scene is trying to do musically.
83
+
84
+ Labels each scene as: setup, tension, payoff, contrast, release,
85
+ development, or outro — based on density, position, and energy changes.
86
+
87
+ Use this to understand the song's structure before making arrangement decisions.
88
+ """
89
+ ableton = _get_ableton(ctx)
90
+ session = ableton.send_command("get_session_info")
91
+ total_tracks = session.get("track_count", 6)
92
+
93
+ # Get scene matrix for density analysis
94
+ try:
95
+ matrix = ableton.send_command("get_scene_matrix")
96
+ except Exception:
97
+ matrix = {}
98
+
99
+ scenes = []
100
+ for i, scene_data in enumerate(matrix.get("scenes", [])):
101
+ row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
102
+ scenes.append({
103
+ "name": scene_data.get("name", f"Scene {i}"),
104
+ "clips": row,
105
+ })
106
+
107
+ purposes = detectors.infer_section_purposes(scenes, total_tracks)
108
+ return {
109
+ "sections": [p.to_dict() for p in purposes],
110
+ "section_count": len(purposes),
111
+ "purpose_summary": {p.purpose: sum(1 for s in purposes if s.purpose == p.purpose)
112
+ for p in purposes},
113
+ }
114
+
115
+
116
+ @mcp.tool()
117
+ def score_emotional_arc(ctx: Context) -> dict:
118
+ """Score the emotional arc of the arrangement.
119
+
120
+ Measures: arc clarity (build→climax→resolve), contrast between sections,
121
+ payoff strength (does the climax feel earned?), and resolution (does it end well?).
122
+
123
+ Returns an overall score (0-1) and specific issues with recommendations.
124
+ """
125
+ ableton = _get_ableton(ctx)
126
+ session = ableton.send_command("get_session_info")
127
+ total_tracks = session.get("track_count", 6)
128
+
129
+ try:
130
+ matrix = ableton.send_command("get_scene_matrix")
131
+ except Exception:
132
+ matrix = {}
133
+
134
+ scenes = []
135
+ for i, scene_data in enumerate(matrix.get("scenes", [])):
136
+ row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
137
+ scenes.append({
138
+ "name": scene_data.get("name", f"Scene {i}"),
139
+ "clips": row,
140
+ })
141
+
142
+ purposes = detectors.infer_section_purposes(scenes, total_tracks)
143
+ arc = detectors.score_emotional_arc(purposes)
144
+ return arc.to_dict()
145
+
146
+
147
+ # ── Phrase Evaluation ────────────────────────────────────────────────
148
+
149
+
150
+ @mcp.tool()
151
+ def analyze_phrase_arc(
152
+ ctx: Context,
153
+ file_path: str,
154
+ target: str = "loop",
155
+ ) -> dict:
156
+ """Analyze a captured audio phrase for musical quality.
157
+
158
+ Evaluates: arc clarity, contrast, fatigue risk, payoff strength,
159
+ identity strength, and translation risk.
160
+
161
+ file_path: path to a captured audio file (from capture_audio)
162
+ target: what the phrase is ("loop", "drop", "chorus", "transition", "intro", "outro")
163
+
164
+ Requires capture_audio + analyze_loudness + analyze_spectrum_offline first.
165
+ """
166
+ from . import phrase_critic
167
+
168
+ ableton = _get_ableton(ctx)
169
+
170
+ # Run offline analysis on the file
171
+ loudness_data = None
172
+ spectrum_data = None
173
+
174
+ try:
175
+ loudness_data = ableton.send_command("analyze_loudness_offline", {
176
+ "file_path": file_path, "detail": "full",
177
+ })
178
+ except Exception:
179
+ pass
180
+
181
+ try:
182
+ spectrum_data = ableton.send_command("analyze_spectrum_offline_internal", {
183
+ "file_path": file_path,
184
+ })
185
+ except Exception:
186
+ pass
187
+
188
+ critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
189
+ critique.render_id = file_path.split("/")[-1] if "/" in file_path else file_path
190
+ return critique.to_dict()
191
+
192
+
193
+ @mcp.tool()
194
+ def compare_phrase_renders(
195
+ ctx: Context,
196
+ file_paths: list,
197
+ target: str = "loop",
198
+ ) -> dict:
199
+ """Compare multiple audio captures and rank by musical quality.
200
+
201
+ file_paths: list of paths to captured audio files
202
+ target: what the phrases are ("loop", "drop", "chorus", etc.)
203
+
204
+ Returns ranked list with scores and notes for each.
205
+ """
206
+ from . import phrase_critic
207
+
208
+ critiques = []
209
+ for path in file_paths:
210
+ # Try to get cached analysis or run fresh
211
+ critique = phrase_critic.analyze_phrase(target=target)
212
+ critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
213
+ critiques.append(critique)
214
+
215
+ ranking = phrase_critic.compare_phrases(critiques)
216
+ return {
217
+ "ranking": ranking,
218
+ "count": len(ranking),
219
+ "target": target,
220
+ "best": ranking[0] if ranking else None,
221
+ }
@@ -0,0 +1,5 @@
1
+ """Preview Studio — fast A/B/C creative comparison for Stage 2.
2
+
3
+ Generates multiple creative options (safe/strong/unexpected) and lets
4
+ the agent compare them before committing.
5
+ """
@@ -0,0 +1,280 @@
1
+ """Preview Studio engine — pure computation, zero I/O.
2
+
3
+ Creates, compares, and ranks preview variants using the creative triptych
4
+ pattern (safe / strong / unexpected).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import time
12
+ from typing import Optional
13
+
14
+ from .models import PreviewSet, PreviewVariant
15
+
16
+
17
+ # ── In-memory store ───────────────────────────────────────────────
18
+
19
+ _preview_sets: dict[str, PreviewSet] = {}
20
+ _MAX_PREVIEW_SETS = 20
21
+
22
+
23
+ def get_preview_set(set_id: str) -> Optional[PreviewSet]:
24
+ return _preview_sets.get(set_id)
25
+
26
+
27
+ def store_preview_set(ps: PreviewSet) -> None:
28
+ _preview_sets[ps.set_id] = ps
29
+ # Evict oldest sets if over limit
30
+ while len(_preview_sets) > _MAX_PREVIEW_SETS:
31
+ oldest_key = next(iter(_preview_sets))
32
+ del _preview_sets[oldest_key]
33
+
34
+
35
+ # ── Creation ──────────────────────────────────────────────────────
36
+
37
+
38
+ def create_preview_set(
39
+ request_text: str,
40
+ kernel_id: str,
41
+ strategy: str = "creative_triptych",
42
+ available_moves: Optional[list[dict]] = None,
43
+ song_brain: Optional[dict] = None,
44
+ taste_graph: Optional[dict] = None,
45
+ ) -> PreviewSet:
46
+ """Create a preview set with variant slots.
47
+
48
+ For creative_triptych, generates 3 variants: safe, strong, unexpected.
49
+ Each variant gets a move_id from available_moves ranked by novelty.
50
+ """
51
+ set_id = _compute_set_id(request_text, kernel_id)
52
+ now = int(time.time() * 1000)
53
+
54
+ moves = available_moves or []
55
+ song_brain = song_brain or {}
56
+ taste_graph = taste_graph or {}
57
+
58
+ if strategy == "creative_triptych":
59
+ variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
60
+ elif strategy == "binary":
61
+ variants = _build_binary(request_text, moves, song_brain, set_id, now)
62
+ else:
63
+ variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
64
+
65
+ ps = PreviewSet(
66
+ set_id=set_id,
67
+ request_text=request_text,
68
+ strategy=strategy,
69
+ source_kernel_id=kernel_id,
70
+ variants=variants,
71
+ created_at_ms=now,
72
+ )
73
+ store_preview_set(ps)
74
+ return ps
75
+
76
+
77
+ def _build_triptych(
78
+ request_text: str,
79
+ moves: list[dict],
80
+ song_brain: dict,
81
+ taste_graph: dict,
82
+ set_id: str,
83
+ now: int,
84
+ ) -> list[PreviewVariant]:
85
+ """Build safe / strong / unexpected variants."""
86
+ identity = song_brain.get("identity_core", "")
87
+ sacred = [e.get("description", "") for e in song_brain.get("sacred_elements", [])]
88
+ sacred_text = ", ".join(sacred[:3]) if sacred else "core elements"
89
+
90
+ profiles = [
91
+ {
92
+ "label": "safe",
93
+ "novelty": 0.2,
94
+ "intent": f"Close to current identity, minimal risk. {request_text}",
95
+ "identity_effect": "preserves",
96
+ "what_preserved": f"Preserves {sacred_text}",
97
+ "why_it_matters": "Low risk — good when identity is fragile",
98
+ },
99
+ {
100
+ "label": "strong",
101
+ "novelty": 0.5,
102
+ "intent": f"Musically assertive approach. {request_text}",
103
+ "identity_effect": "evolves",
104
+ "what_preserved": f"Maintains {sacred_text} while pushing forward",
105
+ "why_it_matters": "Best balance of impact and safety",
106
+ },
107
+ {
108
+ "label": "unexpected",
109
+ "novelty": 0.8,
110
+ "intent": f"Surprising but taste-filtered. {request_text}",
111
+ "identity_effect": "contrasts",
112
+ "what_preserved": f"Respects {sacred_text} but reframes context",
113
+ "why_it_matters": "High novelty — may unlock a new direction",
114
+ },
115
+ ]
116
+
117
+ variants = []
118
+ for i, profile in enumerate(profiles):
119
+ # Pick a move if available
120
+ move_id = ""
121
+ compiled_plan = None
122
+ if moves and i < len(moves):
123
+ move_id = moves[i].get("move_id", "")
124
+ compiled_plan = moves[i].get("compile_plan")
125
+
126
+ variants.append(PreviewVariant(
127
+ variant_id=f"{set_id}_{profile['label']}",
128
+ label=profile["label"],
129
+ intent=profile["intent"],
130
+ novelty_level=profile["novelty"],
131
+ identity_effect=profile["identity_effect"],
132
+ what_preserved=profile["what_preserved"],
133
+ why_it_matters=profile["why_it_matters"],
134
+ move_id=move_id,
135
+ compiled_plan=compiled_plan,
136
+ taste_fit=_estimate_taste_fit(profile["novelty"], taste_graph),
137
+ created_at_ms=now,
138
+ ))
139
+
140
+ return variants
141
+
142
+
143
+ def _build_binary(
144
+ request_text: str,
145
+ moves: list[dict],
146
+ song_brain: dict,
147
+ set_id: str,
148
+ now: int,
149
+ ) -> list[PreviewVariant]:
150
+ """Build simple A/B comparison."""
151
+ return [
152
+ PreviewVariant(
153
+ variant_id=f"{set_id}_a",
154
+ label="option_a",
155
+ intent=f"Primary approach: {request_text}",
156
+ novelty_level=0.3,
157
+ identity_effect="preserves",
158
+ move_id=moves[0].get("move_id", "") if moves else "",
159
+ created_at_ms=now,
160
+ ),
161
+ PreviewVariant(
162
+ variant_id=f"{set_id}_b",
163
+ label="option_b",
164
+ intent=f"Alternative approach: {request_text}",
165
+ novelty_level=0.6,
166
+ identity_effect="evolves",
167
+ move_id=moves[1].get("move_id", "") if len(moves) > 1 else "",
168
+ created_at_ms=now,
169
+ ),
170
+ ]
171
+
172
+
173
+ # ── Comparison ────────────────────────────────────────────────────
174
+
175
+
176
+ def compare_variants(
177
+ preview_set: PreviewSet,
178
+ criteria: Optional[dict] = None,
179
+ ) -> dict:
180
+ """Compare variants within a preview set and rank them."""
181
+ criteria = criteria or {}
182
+ weight_taste = criteria.get("taste_weight", 0.3)
183
+ weight_novelty = criteria.get("novelty_weight", 0.2)
184
+ weight_identity = criteria.get("identity_weight", 0.5)
185
+
186
+ rankings = []
187
+ for v in preview_set.variants:
188
+ # Score components
189
+ taste_score = v.taste_fit
190
+ novelty_score = 1.0 - abs(v.novelty_level - 0.5) * 2 # bell curve around 0.5
191
+ identity_score = _identity_effect_score(v.identity_effect)
192
+
193
+ composite = (
194
+ taste_score * weight_taste
195
+ + novelty_score * weight_novelty
196
+ + identity_score * weight_identity
197
+ )
198
+ v.score = round(composite, 3)
199
+
200
+ rankings.append({
201
+ "variant_id": v.variant_id,
202
+ "label": v.label,
203
+ "score": v.score,
204
+ "taste_fit": v.taste_fit,
205
+ "novelty_level": v.novelty_level,
206
+ "identity_effect": v.identity_effect,
207
+ "summary": v.intent,
208
+ "what_preserved": v.what_preserved,
209
+ "why_it_matters": v.why_it_matters,
210
+ })
211
+
212
+ rankings.sort(key=lambda r: r["score"], reverse=True)
213
+
214
+ comparison = {
215
+ "rankings": rankings,
216
+ "recommended": rankings[0]["variant_id"] if rankings else "",
217
+ "criteria_used": {
218
+ "taste_weight": weight_taste,
219
+ "novelty_weight": weight_novelty,
220
+ "identity_weight": weight_identity,
221
+ },
222
+ }
223
+
224
+ preview_set.comparison = comparison
225
+ preview_set.status = "compared"
226
+ return comparison
227
+
228
+
229
+ def commit_variant(preview_set: PreviewSet, variant_id: str) -> Optional[PreviewVariant]:
230
+ """Mark a variant as committed and discard others."""
231
+ chosen = None
232
+ for v in preview_set.variants:
233
+ if v.variant_id == variant_id:
234
+ v.status = "committed"
235
+ chosen = v
236
+ else:
237
+ v.status = "discarded"
238
+
239
+ if chosen:
240
+ preview_set.committed_variant_id = variant_id
241
+ preview_set.status = "committed"
242
+
243
+ return chosen
244
+
245
+
246
+ def discard_set(set_id: str) -> bool:
247
+ """Discard an entire preview set."""
248
+ ps = _preview_sets.pop(set_id, None)
249
+ if ps:
250
+ ps.status = "discarded"
251
+ for v in ps.variants:
252
+ v.status = "discarded"
253
+ return True
254
+ return False
255
+
256
+
257
+ # ── Helpers ───────────────────────────────────────────────────────
258
+
259
+
260
+ def _compute_set_id(request_text: str, kernel_id: str) -> str:
261
+ seed = json.dumps({"request": request_text, "kernel": kernel_id}, sort_keys=True)
262
+ return "ps_" + hashlib.sha256(seed.encode()).hexdigest()[:10]
263
+
264
+
265
+ def _estimate_taste_fit(novelty: float, taste_graph: dict) -> float:
266
+ """Estimate how well a novelty level fits user taste."""
267
+ boldness = taste_graph.get("transition_boldness", 0.5)
268
+ # Users who like boldness prefer higher novelty
269
+ fit = 1.0 - abs(novelty - boldness) * 0.5
270
+ return round(max(0.0, min(1.0, fit)), 3)
271
+
272
+
273
+ def _identity_effect_score(effect: str) -> float:
274
+ """Score identity effects — preserves is safest."""
275
+ return {
276
+ "preserves": 0.9,
277
+ "evolves": 0.7,
278
+ "contrasts": 0.4,
279
+ "resets": 0.2,
280
+ }.get(effect, 0.5)
@@ -0,0 +1,73 @@
1
+ """Preview Studio data models — pure dataclasses, zero I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class PreviewVariant:
12
+ """One creative option in a preview set."""
13
+
14
+ variant_id: str = ""
15
+ label: str = "" # "safe", "strong", "unexpected"
16
+ intent: str = "" # what this variant is trying to achieve
17
+ novelty_level: float = 0.0 # 0=conservative, 1=radical
18
+ songbrain_delta: str = "" # what changed vs identity
19
+ taste_fit: float = 0.5 # 0-1 how well it matches user taste
20
+ render_ref: str = "" # reference to cached render
21
+ summary: str = "" # one-line musical explanation
22
+
23
+ # What changed, why it matters, what it preserves
24
+ what_changed: str = ""
25
+ why_it_matters: str = ""
26
+ what_preserved: str = ""
27
+
28
+ # Move / plan data
29
+ move_id: str = ""
30
+ compiled_plan: Optional[dict] = None
31
+
32
+ # Scoring
33
+ score: float = 0.0
34
+ identity_effect: str = "preserves" # preserves, evolves, contrasts, resets
35
+
36
+ # State
37
+ status: str = "pending" # pending, rendered, committed, discarded
38
+ created_at_ms: int = 0
39
+
40
+ def to_dict(self) -> dict:
41
+ d = asdict(self)
42
+ # Remove None compiled_plan for cleaner output
43
+ if d.get("compiled_plan") is None:
44
+ d.pop("compiled_plan", None)
45
+ return d
46
+
47
+
48
+ @dataclass
49
+ class PreviewSet:
50
+ """A set of variants tied to one user request."""
51
+
52
+ set_id: str = ""
53
+ request_text: str = ""
54
+ strategy: str = "creative_triptych" # creative_triptych, binary, custom
55
+ source_kernel_id: str = ""
56
+ variants: list[PreviewVariant] = field(default_factory=list)
57
+ comparison: Optional[dict] = None
58
+ committed_variant_id: str = ""
59
+ status: str = "pending" # pending, compared, committed, discarded
60
+ created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "set_id": self.set_id,
65
+ "request_text": self.request_text,
66
+ "strategy": self.strategy,
67
+ "source_kernel_id": self.source_kernel_id,
68
+ "variants": [v.to_dict() for v in self.variants],
69
+ "comparison": self.comparison,
70
+ "committed_variant_id": self.committed_variant_id,
71
+ "status": self.status,
72
+ "variant_count": len(self.variants),
73
+ }