livepilot 1.9.22 → 1.9.24

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 (118) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +3 -3
  4. package/CHANGELOG.md +84 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +141 -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 -23
  12. package/livepilot/commands/mix.md +34 -19
  13. package/livepilot/commands/perform.md +31 -19
  14. package/livepilot/commands/sounddesign.md +38 -25
  15. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  17. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  18. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  19. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  20. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  21. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  22. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  23. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  24. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  25. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  26. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  27. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  28. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  29. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  30. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  31. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  32. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  33. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  34. package/livepilot/skills/livepilot-release/SKILL.md +29 -15
  35. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  36. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  37. package/livepilot.mcpb +0 -0
  38. package/manifest.json +91 -0
  39. package/mcp_server/__init__.py +1 -1
  40. package/mcp_server/creative_constraints/__init__.py +6 -0
  41. package/mcp_server/creative_constraints/engine.py +277 -0
  42. package/mcp_server/creative_constraints/models.py +75 -0
  43. package/mcp_server/creative_constraints/tools.py +341 -0
  44. package/mcp_server/experiment/__init__.py +6 -0
  45. package/mcp_server/experiment/engine.py +213 -0
  46. package/mcp_server/experiment/models.py +120 -0
  47. package/mcp_server/experiment/tools.py +263 -0
  48. package/mcp_server/hook_hunter/__init__.py +5 -0
  49. package/mcp_server/hook_hunter/analyzer.py +365 -0
  50. package/mcp_server/hook_hunter/models.py +58 -0
  51. package/mcp_server/hook_hunter/tools.py +588 -0
  52. package/mcp_server/memory/taste_graph.py +328 -0
  53. package/mcp_server/memory/tools.py +99 -0
  54. package/mcp_server/mix_engine/critics.py +2 -2
  55. package/mcp_server/mix_engine/models.py +1 -1
  56. package/mcp_server/mix_engine/state_builder.py +2 -2
  57. package/mcp_server/musical_intelligence/__init__.py +8 -0
  58. package/mcp_server/musical_intelligence/detectors.py +434 -0
  59. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  60. package/mcp_server/musical_intelligence/tools.py +224 -0
  61. package/mcp_server/persistence/__init__.py +1 -0
  62. package/mcp_server/persistence/base_store.py +82 -0
  63. package/mcp_server/persistence/project_store.py +106 -0
  64. package/mcp_server/persistence/taste_store.py +122 -0
  65. package/mcp_server/preview_studio/__init__.py +5 -0
  66. package/mcp_server/preview_studio/engine.py +280 -0
  67. package/mcp_server/preview_studio/models.py +74 -0
  68. package/mcp_server/preview_studio/tools.py +466 -0
  69. package/mcp_server/runtime/capability.py +66 -0
  70. package/mcp_server/runtime/capability_probe.py +118 -0
  71. package/mcp_server/runtime/execution_router.py +139 -0
  72. package/mcp_server/runtime/remote_commands.py +82 -0
  73. package/mcp_server/runtime/session_kernel.py +96 -0
  74. package/mcp_server/runtime/tools.py +90 -1
  75. package/mcp_server/semantic_moves/__init__.py +13 -0
  76. package/mcp_server/semantic_moves/compiler.py +116 -0
  77. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  78. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  79. package/mcp_server/semantic_moves/models.py +46 -0
  80. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  81. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  82. package/mcp_server/semantic_moves/registry.py +32 -0
  83. package/mcp_server/semantic_moves/resolvers.py +126 -0
  84. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  85. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  86. package/mcp_server/semantic_moves/tools.py +205 -0
  87. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  88. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  89. package/mcp_server/server.py +10 -0
  90. package/mcp_server/services/__init__.py +1 -0
  91. package/mcp_server/services/motif_service.py +67 -0
  92. package/mcp_server/session_continuity/__init__.py +6 -0
  93. package/mcp_server/session_continuity/models.py +86 -0
  94. package/mcp_server/session_continuity/tools.py +230 -0
  95. package/mcp_server/session_continuity/tracker.py +263 -0
  96. package/mcp_server/song_brain/__init__.py +6 -0
  97. package/mcp_server/song_brain/builder.py +504 -0
  98. package/mcp_server/song_brain/models.py +136 -0
  99. package/mcp_server/song_brain/tools.py +312 -0
  100. package/mcp_server/stuckness_detector/__init__.py +5 -0
  101. package/mcp_server/stuckness_detector/detector.py +400 -0
  102. package/mcp_server/stuckness_detector/models.py +66 -0
  103. package/mcp_server/stuckness_detector/tools.py +195 -0
  104. package/mcp_server/tools/_conductor.py +104 -6
  105. package/mcp_server/tools/analyzer.py +1 -1
  106. package/mcp_server/tools/devices.py +34 -0
  107. package/mcp_server/wonder_mode/__init__.py +6 -0
  108. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  109. package/mcp_server/wonder_mode/engine.py +493 -0
  110. package/mcp_server/wonder_mode/session.py +114 -0
  111. package/mcp_server/wonder_mode/tools.py +290 -0
  112. package/package.json +2 -2
  113. package/remote_script/LivePilot/__init__.py +1 -1
  114. package/remote_script/LivePilot/browser.py +4 -1
  115. package/remote_script/LivePilot/devices.py +29 -0
  116. package/remote_script/LivePilot/tracks.py +11 -4
  117. package/scripts/generate_tool_catalog.py +131 -0
  118. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,224 @@
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
+ # Motif data — via shared motif service
49
+ motif_graph = None
50
+ try:
51
+ from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
52
+ session_info = ableton.send_command("get_session_info", {})
53
+ track_list = session_info.get("tracks", [])
54
+ notes_by_track = fetch_notes_from_ableton(ableton, track_list)
55
+ motif_graph = get_motif_data(notes_by_track)
56
+ except Exception:
57
+ pass
58
+
59
+ report = detectors.detect_repetition_fatigue(scenes, motif_graph)
60
+ return report.to_dict()
61
+
62
+
63
+ @mcp.tool()
64
+ def detect_role_conflicts(ctx: Context) -> dict:
65
+ """Detect role conflicts — are tracks fighting for the same musical space?
66
+
67
+ Checks for: multiple bass tracks, competing leads, overlapping drum layers.
68
+ Also flags missing essential roles (no bass, no drums).
69
+
70
+ Returns conflict list with severity and recommendations.
71
+ """
72
+ ableton = _get_ableton(ctx)
73
+ session = ableton.send_command("get_session_info")
74
+ tracks = session.get("tracks", [])
75
+
76
+ conflicts = detectors.detect_role_conflicts(tracks)
77
+ return {
78
+ "conflicts": [c.to_dict() for c in conflicts],
79
+ "conflict_count": len(conflicts),
80
+ "track_count": len(tracks),
81
+ }
82
+
83
+
84
+ @mcp.tool()
85
+ def infer_section_purposes(ctx: Context) -> dict:
86
+ """Infer what each section/scene is trying to do musically.
87
+
88
+ Labels each scene as: setup, tension, payoff, contrast, release,
89
+ development, or outro — based on density, position, and energy changes.
90
+
91
+ Use this to understand the song's structure before making arrangement decisions.
92
+ """
93
+ ableton = _get_ableton(ctx)
94
+ session = ableton.send_command("get_session_info")
95
+ total_tracks = session.get("track_count", 6)
96
+
97
+ # Get scene matrix for density analysis
98
+ try:
99
+ matrix = ableton.send_command("get_scene_matrix")
100
+ except Exception:
101
+ matrix = {}
102
+
103
+ scenes = []
104
+ for i, scene_data in enumerate(matrix.get("scenes", [])):
105
+ row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
106
+ scenes.append({
107
+ "name": scene_data.get("name", f"Scene {i}"),
108
+ "clips": row,
109
+ })
110
+
111
+ purposes = detectors.infer_section_purposes(scenes, total_tracks)
112
+ return {
113
+ "sections": [p.to_dict() for p in purposes],
114
+ "section_count": len(purposes),
115
+ "purpose_summary": {p.purpose: sum(1 for s in purposes if s.purpose == p.purpose)
116
+ for p in purposes},
117
+ }
118
+
119
+
120
+ @mcp.tool()
121
+ def score_emotional_arc(ctx: Context) -> dict:
122
+ """Score the emotional arc of the arrangement.
123
+
124
+ Measures: arc clarity (build→climax→resolve), contrast between sections,
125
+ payoff strength (does the climax feel earned?), and resolution (does it end well?).
126
+
127
+ Returns an overall score (0-1) and specific issues with recommendations.
128
+ """
129
+ ableton = _get_ableton(ctx)
130
+ session = ableton.send_command("get_session_info")
131
+ total_tracks = session.get("track_count", 6)
132
+
133
+ try:
134
+ matrix = ableton.send_command("get_scene_matrix")
135
+ except Exception:
136
+ matrix = {}
137
+
138
+ scenes = []
139
+ for i, scene_data in enumerate(matrix.get("scenes", [])):
140
+ row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
141
+ scenes.append({
142
+ "name": scene_data.get("name", f"Scene {i}"),
143
+ "clips": row,
144
+ })
145
+
146
+ purposes = detectors.infer_section_purposes(scenes, total_tracks)
147
+ arc = detectors.score_emotional_arc(purposes)
148
+ return arc.to_dict()
149
+
150
+
151
+ # ── Phrase Evaluation ────────────────────────────────────────────────
152
+
153
+
154
+ @mcp.tool()
155
+ def analyze_phrase_arc(
156
+ ctx: Context,
157
+ file_path: str,
158
+ target: str = "loop",
159
+ ) -> dict:
160
+ """Analyze a captured audio phrase for musical quality.
161
+
162
+ Evaluates: arc clarity, contrast, fatigue risk, payoff strength,
163
+ identity strength, and translation risk.
164
+
165
+ file_path: path to a captured audio file (from capture_audio)
166
+ target: what the phrase is ("loop", "drop", "chorus", "transition", "intro", "outro")
167
+
168
+ Requires capture_audio + analyze_loudness + analyze_spectrum_offline first.
169
+ """
170
+ from . import phrase_critic
171
+
172
+ ableton = _get_ableton(ctx)
173
+
174
+ # Run offline analysis on the file
175
+ loudness_data = None
176
+ spectrum_data = None
177
+
178
+ # Direct Python calls to perception engine — not TCP
179
+ try:
180
+ from ..tools._perception_engine import compute_loudness
181
+ loudness_data = compute_loudness(file_path, detail="full")
182
+ except Exception:
183
+ pass
184
+
185
+ try:
186
+ from ..tools._perception_engine import compute_spectral
187
+ spectrum_data = compute_spectral(file_path)
188
+ except Exception:
189
+ pass
190
+
191
+ critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
192
+ critique.render_id = file_path.split("/")[-1] if "/" in file_path else file_path
193
+ return critique.to_dict()
194
+
195
+
196
+ @mcp.tool()
197
+ def compare_phrase_renders(
198
+ ctx: Context,
199
+ file_paths: list,
200
+ target: str = "loop",
201
+ ) -> dict:
202
+ """Compare multiple audio captures and rank by musical quality.
203
+
204
+ file_paths: list of paths to captured audio files
205
+ target: what the phrases are ("loop", "drop", "chorus", etc.)
206
+
207
+ Returns ranked list with scores and notes for each.
208
+ """
209
+ from . import phrase_critic
210
+
211
+ critiques = []
212
+ for path in file_paths:
213
+ # Try to get cached analysis or run fresh
214
+ critique = phrase_critic.analyze_phrase(target=target)
215
+ critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
216
+ critiques.append(critique)
217
+
218
+ ranking = phrase_critic.compare_phrases(critiques)
219
+ return {
220
+ "ranking": ranking,
221
+ "count": len(ranking),
222
+ "target": target,
223
+ "best": ranking[0] if ranking else None,
224
+ }
@@ -0,0 +1 @@
1
+ """Persistent storage for LivePilot state that survives server restart."""
@@ -0,0 +1,82 @@
1
+ """Persistent JSON store with atomic writes and corruption recovery.
2
+
3
+ Follows the TechniqueStore pattern: lazy init, atomic tmp+rename,
4
+ fsync to disk, corruption recovery via .corrupt rename.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import threading
12
+ from pathlib import Path
13
+
14
+
15
+ class PersistentJsonStore:
16
+ """Thread-safe, crash-safe JSON file store."""
17
+
18
+ def __init__(self, path: Path):
19
+ self._path = Path(path)
20
+ self._lock = threading.RLock()
21
+
22
+ @property
23
+ def path(self) -> Path:
24
+ return self._path
25
+
26
+ def read(self) -> dict:
27
+ """Read the store. Returns {} if missing or corrupt."""
28
+ with self._lock:
29
+ if not self._path.exists():
30
+ return {}
31
+ try:
32
+ return json.loads(self._path.read_text(encoding="utf-8"))
33
+ except (json.JSONDecodeError, OSError):
34
+ corrupt = self._path.with_suffix(self._path.suffix + ".corrupt")
35
+ try:
36
+ self._path.rename(corrupt)
37
+ except OSError:
38
+ pass
39
+ return {}
40
+
41
+ def write(self, data: dict) -> None:
42
+ """Atomically write data to disk."""
43
+ with self._lock:
44
+ self._path.parent.mkdir(parents=True, exist_ok=True)
45
+ tmp = self._path.with_suffix(".tmp")
46
+ try:
47
+ with open(tmp, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2, default=str)
49
+ f.flush()
50
+ os.fsync(f.fileno())
51
+ os.replace(str(tmp), str(self._path))
52
+ except OSError:
53
+ try:
54
+ tmp.unlink(missing_ok=True)
55
+ except OSError:
56
+ pass
57
+ raise
58
+
59
+ def update(self, updater) -> dict:
60
+ """Read-modify-write atomically. updater(data) -> modified data."""
61
+ with self._lock:
62
+ data = self._read_unlocked()
63
+ data = updater(data)
64
+ self._write_unlocked(data)
65
+ return data
66
+
67
+ def _read_unlocked(self) -> dict:
68
+ if not self._path.exists():
69
+ return {}
70
+ try:
71
+ return json.loads(self._path.read_text(encoding="utf-8"))
72
+ except (json.JSONDecodeError, OSError):
73
+ return {}
74
+
75
+ def _write_unlocked(self, data: dict) -> None:
76
+ self._path.parent.mkdir(parents=True, exist_ok=True)
77
+ tmp = self._path.with_suffix(".tmp")
78
+ with open(tmp, "w", encoding="utf-8") as f:
79
+ json.dump(data, f, indent=2, default=str)
80
+ f.flush()
81
+ os.fsync(f.fileno())
82
+ os.replace(str(tmp), str(self._path))
@@ -0,0 +1,106 @@
1
+ """Per-project persistent state — threads, turns, Wonder outcomes.
2
+
3
+ Stores session continuity data scoped to a project identity.
4
+ Located at ~/.livepilot/projects/<hash>/state.json.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .base_store import PersistentJsonStore
15
+
16
+
17
+ _PROJECTS_DIR = Path.home() / ".livepilot" / "projects"
18
+ _MAX_TURNS = 50
19
+ _MAX_WONDER_OUTCOMES = 10
20
+
21
+
22
+ def project_hash(session_info: dict) -> str:
23
+ """Compute a stable project fingerprint from session info.
24
+
25
+ Uses tempo + track count + sorted track names. This is imperfect
26
+ but stable enough for per-song state within a production session.
27
+ """
28
+ tempo = session_info.get("tempo", 120.0)
29
+ tracks = session_info.get("tracks", [])
30
+ track_names = sorted(t.get("name", "") for t in tracks if isinstance(t, dict))
31
+ seed = f"{tempo:.1f}|{len(tracks)}|{'|'.join(track_names)}"
32
+ return hashlib.sha256(seed.encode()).hexdigest()[:12]
33
+
34
+
35
+ class ProjectStore:
36
+ """Persistent per-project state."""
37
+
38
+ def __init__(self, project_id: str, base_dir: Optional[Path] = None):
39
+ base = base_dir or _PROJECTS_DIR
40
+ self._store = PersistentJsonStore(base / project_id / "state.json")
41
+ self._project_id = project_id
42
+
43
+ @property
44
+ def project_id(self) -> str:
45
+ return self._project_id
46
+
47
+ def get_all(self) -> dict:
48
+ data = self._store.read()
49
+ return data if data.get("version") == 1 else self._default()
50
+
51
+ def save_thread(self, thread: dict) -> None:
52
+ """Save or update a creative thread."""
53
+ def _update(data: dict) -> dict:
54
+ data = data if data.get("version") == 1 else self._default()
55
+ threads = data.setdefault("threads", [])
56
+ # Update existing or append
57
+ for i, t in enumerate(threads):
58
+ if t.get("thread_id") == thread.get("thread_id"):
59
+ threads[i] = thread
60
+ return data
61
+ threads.append(thread)
62
+ return data
63
+ self._store.update(_update)
64
+
65
+ def save_turn(self, turn: dict) -> None:
66
+ """Save a turn resolution (capped at MAX_TURNS)."""
67
+ def _update(data: dict) -> dict:
68
+ data = data if data.get("version") == 1 else self._default()
69
+ turns = data.setdefault("turns", [])
70
+ turns.append(turn)
71
+ # Cap at max
72
+ if len(turns) > _MAX_TURNS:
73
+ data["turns"] = turns[-_MAX_TURNS:]
74
+ data["last_updated_ms"] = int(time.time() * 1000)
75
+ return data
76
+ self._store.update(_update)
77
+
78
+ def save_wonder_outcome(self, outcome: dict) -> None:
79
+ """Save a Wonder session outcome (capped at MAX_WONDER_OUTCOMES)."""
80
+ def _update(data: dict) -> dict:
81
+ data = data if data.get("version") == 1 else self._default()
82
+ outcomes = data.setdefault("wonder_outcomes", [])
83
+ outcomes.append(outcome)
84
+ if len(outcomes) > _MAX_WONDER_OUTCOMES:
85
+ data["wonder_outcomes"] = outcomes[-_MAX_WONDER_OUTCOMES:]
86
+ return data
87
+ self._store.update(_update)
88
+
89
+ def get_threads(self) -> list[dict]:
90
+ return self.get_all().get("threads", [])
91
+
92
+ def get_turns(self) -> list[dict]:
93
+ return self.get_all().get("turns", [])
94
+
95
+ def get_wonder_outcomes(self) -> list[dict]:
96
+ return self.get_all().get("wonder_outcomes", [])
97
+
98
+ @staticmethod
99
+ def _default() -> dict:
100
+ return {
101
+ "version": 1,
102
+ "threads": [],
103
+ "turns": [],
104
+ "wonder_outcomes": [],
105
+ "last_updated_ms": 0,
106
+ }
@@ -0,0 +1,122 @@
1
+ """Persistent taste state — survives server restart.
2
+
3
+ Stores move outcomes, novelty preference, device affinity,
4
+ anti-preferences, and dimension weights. Located at
5
+ ~/.livepilot/taste.json.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .base_store import PersistentJsonStore
15
+
16
+
17
+ _DEFAULT_PATH = Path.home() / ".livepilot" / "taste.json"
18
+
19
+
20
+ class PersistentTasteStore:
21
+ """Persistent backing for TasteGraph data."""
22
+
23
+ def __init__(self, path: Optional[Path] = None):
24
+ self._store = PersistentJsonStore(path or _DEFAULT_PATH)
25
+
26
+ def get_all(self) -> dict:
27
+ """Get all persisted taste data."""
28
+ data = self._store.read()
29
+ return data if data.get("version") == 1 else self._default()
30
+
31
+ def record_move_outcome(
32
+ self, move_id: str, family: str, kept: bool, score: float = 0.0,
33
+ ) -> None:
34
+ """Persist a move outcome."""
35
+ def _update(data: dict) -> dict:
36
+ data = data if data.get("version") == 1 else self._default()
37
+ outcomes = data.setdefault("move_outcomes", {})
38
+ entry = outcomes.setdefault(move_id, {
39
+ "family": family, "kept_count": 0, "undone_count": 0,
40
+ })
41
+ entry["family"] = family
42
+ if kept:
43
+ entry["kept_count"] = entry.get("kept_count", 0) + 1
44
+ else:
45
+ entry["undone_count"] = entry.get("undone_count", 0) + 1
46
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
47
+ data["last_updated_ms"] = int(time.time() * 1000)
48
+ return data
49
+ self._store.update(_update)
50
+
51
+ def update_novelty(self, chose_bold: bool) -> None:
52
+ """Update novelty band from experiment choice."""
53
+ def _update(data: dict) -> dict:
54
+ data = data if data.get("version") == 1 else self._default()
55
+ band = data.get("novelty_band", 0.5)
56
+ if chose_bold:
57
+ data["novelty_band"] = min(1.0, band + 0.05)
58
+ else:
59
+ data["novelty_band"] = max(0.0, band - 0.05)
60
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
61
+ return data
62
+ self._store.update(_update)
63
+
64
+ def record_device_use(self, device_name: str, positive: bool = True) -> None:
65
+ """Persist device affinity."""
66
+ def _update(data: dict) -> dict:
67
+ data = data if data.get("version") == 1 else self._default()
68
+ affinities = data.setdefault("device_affinities", {})
69
+ entry = affinities.setdefault(device_name, {
70
+ "affinity": 0.0, "use_count": 0,
71
+ })
72
+ entry["use_count"] = entry.get("use_count", 0) + 1
73
+ aff = entry.get("affinity", 0.0)
74
+ if positive:
75
+ entry["affinity"] = min(1.0, aff + 0.05)
76
+ else:
77
+ entry["affinity"] = max(-1.0, aff - 0.08)
78
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
79
+ return data
80
+ self._store.update(_update)
81
+
82
+ def record_anti_preference(self, dimension: str, direction: str) -> None:
83
+ """Persist an anti-preference."""
84
+ def _update(data: dict) -> dict:
85
+ data = data if data.get("version") == 1 else self._default()
86
+ antis = data.setdefault("anti_preferences", [])
87
+ existing = next(
88
+ (a for a in antis if a["dimension"] == dimension and a["direction"] == direction),
89
+ None,
90
+ )
91
+ if existing:
92
+ existing["count"] = existing.get("count", 0) + 1
93
+ existing["strength"] = min(1.0, existing["count"] * 0.2)
94
+ else:
95
+ antis.append({
96
+ "dimension": dimension, "direction": direction,
97
+ "count": 1, "strength": 0.2,
98
+ })
99
+ data["evidence_count"] = data.get("evidence_count", 0) + 1
100
+ return data
101
+ self._store.update(_update)
102
+
103
+ def record_dimension_weight(self, dimension: str, value: float) -> None:
104
+ """Persist a dimension weight update."""
105
+ def _update(data: dict) -> dict:
106
+ data = data if data.get("version") == 1 else self._default()
107
+ data.setdefault("dimension_weights", {})[dimension] = round(value, 3)
108
+ return data
109
+ self._store.update(_update)
110
+
111
+ @staticmethod
112
+ def _default() -> dict:
113
+ return {
114
+ "version": 1,
115
+ "move_outcomes": {},
116
+ "novelty_band": 0.5,
117
+ "device_affinities": {},
118
+ "anti_preferences": [],
119
+ "dimension_weights": {},
120
+ "evidence_count": 0,
121
+ "last_updated_ms": 0,
122
+ }
@@ -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
+ """