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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcpbignore +40 -0
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +84 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +141 -72
- package/bin/livepilot.js +135 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
- package/livepilot/commands/arrange.md +42 -23
- package/livepilot/commands/mix.md +34 -19
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -25
- package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +60 -4
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
- package/livepilot/skills/livepilot-core/references/overview.md +4 -4
- package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
- package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
- package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
- package/livepilot/skills/livepilot-release/SKILL.md +29 -15
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
- package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
- package/livepilot.mcpb +0 -0
- package/manifest.json +91 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_constraints/__init__.py +6 -0
- package/mcp_server/creative_constraints/engine.py +277 -0
- package/mcp_server/creative_constraints/models.py +75 -0
- package/mcp_server/creative_constraints/tools.py +341 -0
- package/mcp_server/experiment/__init__.py +6 -0
- package/mcp_server/experiment/engine.py +213 -0
- package/mcp_server/experiment/models.py +120 -0
- package/mcp_server/experiment/tools.py +263 -0
- package/mcp_server/hook_hunter/__init__.py +5 -0
- package/mcp_server/hook_hunter/analyzer.py +365 -0
- package/mcp_server/hook_hunter/models.py +58 -0
- package/mcp_server/hook_hunter/tools.py +588 -0
- package/mcp_server/memory/taste_graph.py +328 -0
- package/mcp_server/memory/tools.py +99 -0
- package/mcp_server/mix_engine/critics.py +2 -2
- package/mcp_server/mix_engine/models.py +1 -1
- package/mcp_server/mix_engine/state_builder.py +2 -2
- package/mcp_server/musical_intelligence/__init__.py +8 -0
- package/mcp_server/musical_intelligence/detectors.py +434 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +224 -0
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/__init__.py +5 -0
- package/mcp_server/preview_studio/engine.py +280 -0
- package/mcp_server/preview_studio/models.py +74 -0
- package/mcp_server/preview_studio/tools.py +466 -0
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +118 -0
- package/mcp_server/runtime/execution_router.py +139 -0
- package/mcp_server/runtime/remote_commands.py +82 -0
- package/mcp_server/runtime/session_kernel.py +96 -0
- package/mcp_server/runtime/tools.py +90 -1
- package/mcp_server/semantic_moves/__init__.py +13 -0
- package/mcp_server/semantic_moves/compiler.py +116 -0
- package/mcp_server/semantic_moves/mix_compilers.py +291 -0
- package/mcp_server/semantic_moves/mix_moves.py +157 -0
- package/mcp_server/semantic_moves/models.py +46 -0
- package/mcp_server/semantic_moves/performance_compilers.py +208 -0
- package/mcp_server/semantic_moves/performance_moves.py +81 -0
- package/mcp_server/semantic_moves/registry.py +32 -0
- package/mcp_server/semantic_moves/resolvers.py +126 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
- package/mcp_server/semantic_moves/tools.py +205 -0
- package/mcp_server/semantic_moves/transition_compilers.py +222 -0
- package/mcp_server/semantic_moves/transition_moves.py +76 -0
- package/mcp_server/server.py +10 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/__init__.py +6 -0
- package/mcp_server/session_continuity/models.py +86 -0
- package/mcp_server/session_continuity/tools.py +230 -0
- package/mcp_server/session_continuity/tracker.py +263 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +504 -0
- package/mcp_server/song_brain/models.py +136 -0
- package/mcp_server/song_brain/tools.py +312 -0
- package/mcp_server/stuckness_detector/__init__.py +5 -0
- package/mcp_server/stuckness_detector/detector.py +400 -0
- package/mcp_server/stuckness_detector/models.py +66 -0
- package/mcp_server/stuckness_detector/tools.py +195 -0
- package/mcp_server/tools/_conductor.py +104 -6
- package/mcp_server/tools/analyzer.py +1 -1
- package/mcp_server/tools/devices.py +34 -0
- package/mcp_server/wonder_mode/__init__.py +6 -0
- package/mcp_server/wonder_mode/diagnosis.py +84 -0
- package/mcp_server/wonder_mode/engine.py +493 -0
- package/mcp_server/wonder_mode/session.py +114 -0
- package/mcp_server/wonder_mode/tools.py +290 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +4 -1
- package/remote_script/LivePilot/devices.py +29 -0
- package/remote_script/LivePilot/tracks.py +11 -4
- package/scripts/generate_tool_catalog.py +131 -0
- 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
|
+
}
|