livepilot 1.1.0

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 (63) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.mcpregistry_github_token +1 -0
  3. package/.mcpregistry_registry_token +1 -0
  4. package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
  5. package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
  6. package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
  7. package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
  8. package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
  9. package/.playwright-mcp/glama-snapshot.md +2140 -0
  10. package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
  11. package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
  12. package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
  13. package/CHANGELOG.md +33 -0
  14. package/LICENSE +21 -0
  15. package/README.md +296 -0
  16. package/bin/livepilot.js +376 -0
  17. package/installer/install.js +95 -0
  18. package/installer/paths.js +79 -0
  19. package/mcp_server/__init__.py +2 -0
  20. package/mcp_server/__main__.py +5 -0
  21. package/mcp_server/connection.py +207 -0
  22. package/mcp_server/server.py +40 -0
  23. package/mcp_server/tools/__init__.py +1 -0
  24. package/mcp_server/tools/arrangement.py +399 -0
  25. package/mcp_server/tools/browser.py +78 -0
  26. package/mcp_server/tools/clips.py +187 -0
  27. package/mcp_server/tools/devices.py +238 -0
  28. package/mcp_server/tools/mixing.py +113 -0
  29. package/mcp_server/tools/notes.py +266 -0
  30. package/mcp_server/tools/scenes.py +63 -0
  31. package/mcp_server/tools/tracks.py +148 -0
  32. package/mcp_server/tools/transport.py +113 -0
  33. package/package.json +38 -0
  34. package/plugin/.mcp.json +8 -0
  35. package/plugin/agents/livepilot-producer/AGENT.md +61 -0
  36. package/plugin/commands/beat.md +18 -0
  37. package/plugin/commands/mix.md +15 -0
  38. package/plugin/commands/session.md +13 -0
  39. package/plugin/commands/sounddesign.md +16 -0
  40. package/plugin/plugin.json +18 -0
  41. package/plugin/skills/livepilot-core/SKILL.md +160 -0
  42. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  43. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  44. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  45. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  46. package/plugin/skills/livepilot-core/references/overview.md +191 -0
  47. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  48. package/remote_script/LivePilot/__init__.py +42 -0
  49. package/remote_script/LivePilot/arrangement.py +678 -0
  50. package/remote_script/LivePilot/browser.py +325 -0
  51. package/remote_script/LivePilot/clips.py +172 -0
  52. package/remote_script/LivePilot/devices.py +466 -0
  53. package/remote_script/LivePilot/diagnostics.py +198 -0
  54. package/remote_script/LivePilot/mixing.py +194 -0
  55. package/remote_script/LivePilot/notes.py +339 -0
  56. package/remote_script/LivePilot/router.py +74 -0
  57. package/remote_script/LivePilot/scenes.py +75 -0
  58. package/remote_script/LivePilot/server.py +286 -0
  59. package/remote_script/LivePilot/tracks.py +229 -0
  60. package/remote_script/LivePilot/transport.py +147 -0
  61. package/remote_script/LivePilot/utils.py +112 -0
  62. package/requirements.txt +2 -0
  63. package/server.json +20 -0
@@ -0,0 +1,325 @@
1
+ """
2
+ LivePilot - Browser domain handlers (5 commands).
3
+ """
4
+
5
+ import Live
6
+
7
+ from .router import register
8
+ from .utils import get_track
9
+
10
+
11
+ def _get_browser():
12
+ """Get the browser from the Application object (not Song)."""
13
+ return Live.Application.get_application().browser
14
+
15
+
16
+ def _get_categories(browser):
17
+ """Return a dict of browser category name -> browser item."""
18
+ return {
19
+ "instruments": browser.instruments,
20
+ "audio_effects": browser.audio_effects,
21
+ "midi_effects": browser.midi_effects,
22
+ "sounds": browser.sounds,
23
+ "drums": browser.drums,
24
+ "samples": browser.samples,
25
+ "packs": browser.packs,
26
+ "user_library": browser.user_library,
27
+ }
28
+
29
+
30
+ def _navigate_path(browser, path):
31
+ """Walk the browser tree by slash-separated path, return the item."""
32
+ categories = _get_categories(browser)
33
+ parts = [p.strip() for p in path.strip("/").split("/") if p.strip()]
34
+ if not parts:
35
+ raise ValueError("Path cannot be empty")
36
+
37
+ # First part must be a category name
38
+ first = parts[0].lower()
39
+ if first not in categories:
40
+ raise ValueError(
41
+ "Unknown category '%s'. Available: %s"
42
+ % (first, ", ".join(sorted(categories.keys())))
43
+ )
44
+ current = categories[first]
45
+
46
+ # Walk remaining parts by child name
47
+ for part in parts[1:]:
48
+ children = list(current.children)
49
+ matched = None
50
+ for child in children:
51
+ if child.name == part:
52
+ matched = child
53
+ break
54
+ if matched is None:
55
+ child_names = [c.name for c in children[:20]]
56
+ raise ValueError(
57
+ "Item '%s' not found in '%s'. Available: %s"
58
+ % (part, current.name, ", ".join(child_names))
59
+ )
60
+ current = matched
61
+
62
+ return current
63
+
64
+
65
+ def _search_recursive(item, name_filter, loadable_only, results, depth, max_depth,
66
+ max_results=100):
67
+ """Recursively search browser children."""
68
+ if depth > max_depth or len(results) >= max_results:
69
+ return
70
+ for child in item.children:
71
+ if len(results) >= max_results:
72
+ return
73
+ match = True
74
+ if name_filter and name_filter.lower() not in child.name.lower():
75
+ match = False
76
+ if loadable_only and not child.is_loadable:
77
+ match = False
78
+ if match:
79
+ entry = {
80
+ "name": child.name,
81
+ "is_loadable": child.is_loadable,
82
+ }
83
+ try:
84
+ entry["uri"] = child.uri
85
+ except Exception:
86
+ entry["uri"] = None
87
+ results.append(entry)
88
+ if child.is_folder:
89
+ _search_recursive(
90
+ child, name_filter, loadable_only, results, depth + 1, max_depth,
91
+ max_results
92
+ )
93
+
94
+
95
+ @register("get_browser_tree")
96
+ def get_browser_tree(song, params):
97
+ """Return an overview of the browser categories."""
98
+ category_type = str(params.get("category_type", "all")).lower()
99
+ browser = _get_browser()
100
+ categories = _get_categories(browser)
101
+
102
+ if category_type != "all":
103
+ if category_type not in categories:
104
+ raise ValueError(
105
+ "Unknown category '%s'. Available: %s"
106
+ % (category_type, ", ".join(sorted(categories.keys())))
107
+ )
108
+ categories = {category_type: categories[category_type]}
109
+
110
+ result = []
111
+ for name, item in categories.items():
112
+ children = list(item.children)
113
+ child_names = [c.name for c in children[:20]]
114
+ result.append({
115
+ "name": name,
116
+ "children_count": len(children),
117
+ "children_preview": child_names,
118
+ })
119
+ return {"categories": result}
120
+
121
+
122
+ @register("get_browser_items")
123
+ def get_browser_items(song, params):
124
+ """List items at a browser path."""
125
+ path = str(params["path"])
126
+ browser = _get_browser()
127
+ item = _navigate_path(browser, path)
128
+
129
+ result = []
130
+ for child in item.children:
131
+ entry = {
132
+ "name": child.name,
133
+ "is_loadable": child.is_loadable,
134
+ "is_folder": child.is_folder,
135
+ }
136
+ if child.is_loadable:
137
+ try:
138
+ entry["uri"] = child.uri
139
+ except Exception:
140
+ entry["uri"] = None
141
+ result.append(entry)
142
+ return {"path": path, "items": result}
143
+
144
+
145
+ @register("search_browser")
146
+ def search_browser(song, params):
147
+ """Search the browser tree by name filter."""
148
+ path = str(params["path"])
149
+ name_filter = params.get("name_filter", None)
150
+ loadable_only = bool(params.get("loadable_only", False))
151
+ max_depth = int(params.get("max_depth", 8))
152
+ max_results = int(params.get("max_results", 100))
153
+ browser = _get_browser()
154
+ item = _navigate_path(browser, path)
155
+
156
+ results = []
157
+ _search_recursive(item, name_filter, loadable_only, results, 0, max_depth,
158
+ max_results)
159
+ truncated = len(results) >= max_results
160
+ result = {"path": path, "results": results, "count": len(results)}
161
+ if truncated:
162
+ result["truncated"] = True
163
+ result["max_results"] = max_results
164
+ return result
165
+
166
+
167
+ @register("load_browser_item")
168
+ def load_browser_item(song, params):
169
+ """Load a browser item onto a track by URI.
170
+
171
+ First tries URI-based matching (exact child.uri comparison).
172
+ Falls back to name extraction from the URI's last path segment.
173
+ Searches all browser categories including user_library and samples.
174
+ """
175
+ track_index = int(params["track_index"])
176
+ uri = str(params["uri"])
177
+ track = get_track(song, track_index)
178
+ browser = _get_browser()
179
+
180
+ # All categories to search
181
+ category_attrs = (
182
+ "user_library", "samples", "instruments", "audio_effects",
183
+ "midi_effects", "packs", "sounds", "drums",
184
+ )
185
+ categories = []
186
+ for attr in category_attrs:
187
+ try:
188
+ categories.append(getattr(browser, attr))
189
+ except Exception:
190
+ pass
191
+
192
+ _iterations = [0]
193
+ MAX_ITERATIONS = 10000
194
+
195
+ # ── Strategy 1: match by URI directly ────────────────────────────
196
+ def find_by_uri(parent, target_uri, depth=0):
197
+ if depth > 8 or _iterations[0] > MAX_ITERATIONS:
198
+ return None
199
+ try:
200
+ children = list(parent.children)
201
+ except Exception:
202
+ return None
203
+ for child in children:
204
+ _iterations[0] += 1
205
+ if _iterations[0] > MAX_ITERATIONS:
206
+ return None
207
+ try:
208
+ if child.uri == target_uri and child.is_loadable:
209
+ return child
210
+ except Exception:
211
+ pass
212
+ result = find_by_uri(child, target_uri, depth + 1)
213
+ if result is not None:
214
+ return result
215
+ return None
216
+
217
+ for category in categories:
218
+ _iterations[0] = 0
219
+ found = find_by_uri(category, uri)
220
+ if found is not None:
221
+ song.view.selected_track = track
222
+ browser.load_item(found)
223
+ device_count = len(list(track.devices))
224
+ return {
225
+ "track_index": track_index,
226
+ "loaded": True,
227
+ "name": found.name,
228
+ "device_count": device_count,
229
+ }
230
+
231
+ # ── Strategy 2: extract name from URI, search by name ────────────
232
+ device_name = uri
233
+ if "#" in uri:
234
+ device_name = uri.split("#", 1)[1]
235
+ for sep in (":", "/"):
236
+ if sep in device_name:
237
+ device_name = device_name.rsplit(sep, 1)[1]
238
+ # URL-decode
239
+ try:
240
+ from urllib.parse import unquote
241
+ device_name = unquote(device_name)
242
+ except ImportError:
243
+ device_name = device_name.replace("%20", " ")
244
+ # Strip file extensions
245
+ for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als", ".wav", ".aif", ".aiff", ".mp3"):
246
+ if device_name.lower().endswith(ext):
247
+ device_name = device_name[:-len(ext)]
248
+ break
249
+
250
+ target = device_name.lower()
251
+
252
+ def find_by_name(parent, depth=0):
253
+ if depth > 8 or _iterations[0] > MAX_ITERATIONS:
254
+ return None
255
+ try:
256
+ children = list(parent.children)
257
+ except Exception:
258
+ return None
259
+ for child in children:
260
+ _iterations[0] += 1
261
+ if _iterations[0] > MAX_ITERATIONS:
262
+ return None
263
+ child_lower = child.name.lower()
264
+ if (child_lower == target or target in child_lower) and child.is_loadable:
265
+ return child
266
+ result = find_by_name(child, depth + 1)
267
+ if result is not None:
268
+ return result
269
+ return None
270
+
271
+ for category in categories:
272
+ _iterations[0] = 0
273
+ found = find_by_name(category)
274
+ if found is not None:
275
+ song.view.selected_track = track
276
+ browser.load_item(found)
277
+ device_count = len(list(track.devices))
278
+ return {
279
+ "track_index": track_index,
280
+ "loaded": True,
281
+ "name": found.name,
282
+ "device_count": device_count,
283
+ }
284
+
285
+ raise ValueError(
286
+ "Item '%s' not found in browser" % device_name
287
+ )
288
+
289
+
290
+ @register("get_device_presets")
291
+ def get_device_presets(song, params):
292
+ """List available presets for a device type by searching the browser."""
293
+ device_name = str(params["device_name"])
294
+ browser = _get_browser()
295
+
296
+ categories = {
297
+ "audio_effects": browser.audio_effects,
298
+ "instruments": browser.instruments,
299
+ "midi_effects": browser.midi_effects,
300
+ }
301
+ results = []
302
+ found_category = None
303
+ for cat_name, cat_item in categories.items():
304
+ for item in cat_item.children:
305
+ if item.name.lower() == device_name.lower():
306
+ found_category = cat_name
307
+ for preset in item.children:
308
+ if preset.is_loadable:
309
+ entry = {
310
+ "name": preset.name,
311
+ "is_folder": preset.is_folder,
312
+ }
313
+ try:
314
+ entry["uri"] = preset.uri
315
+ except Exception:
316
+ entry["uri"] = None
317
+ results.append(entry)
318
+ break
319
+ if found_category:
320
+ break
321
+ return {
322
+ "device_name": device_name,
323
+ "category": found_category,
324
+ "presets": results,
325
+ }
@@ -0,0 +1,172 @@
1
+ """
2
+ LivePilot - Clip domain handlers (10 commands).
3
+ """
4
+
5
+ from .router import register
6
+ from .utils import get_clip, get_clip_slot
7
+
8
+
9
+ @register("get_clip_info")
10
+ def get_clip_info(song, params):
11
+ """Return detailed info for a single clip."""
12
+ track_index = int(params["track_index"])
13
+ clip_index = int(params["clip_index"])
14
+ clip = get_clip(song, track_index, clip_index)
15
+
16
+ return {
17
+ "track_index": track_index,
18
+ "clip_index": clip_index,
19
+ "name": clip.name,
20
+ "color_index": clip.color_index,
21
+ "length": clip.length,
22
+ "is_playing": clip.is_playing,
23
+ "is_recording": clip.is_recording,
24
+ "looping": clip.looping,
25
+ "loop_start": clip.loop_start,
26
+ "loop_end": clip.loop_end,
27
+ "start_marker": clip.start_marker,
28
+ "end_marker": clip.end_marker,
29
+ "launch_mode": clip.launch_mode,
30
+ "launch_quantization": clip.launch_quantization,
31
+ }
32
+
33
+
34
+ @register("create_clip")
35
+ def create_clip(song, params):
36
+ """Create an empty MIDI clip in the given clip slot."""
37
+ track_index = int(params["track_index"])
38
+ clip_index = int(params["clip_index"])
39
+ length = float(params["length"])
40
+ if length <= 0:
41
+ raise ValueError("Clip length must be > 0")
42
+
43
+ clip_slot = get_clip_slot(song, track_index, clip_index)
44
+ clip_slot.create_clip(length)
45
+ clip = clip_slot.clip
46
+
47
+ return {
48
+ "track_index": track_index,
49
+ "clip_index": clip_index,
50
+ "name": clip.name,
51
+ "length": clip.length,
52
+ }
53
+
54
+
55
+ @register("delete_clip")
56
+ def delete_clip(song, params):
57
+ """Delete the clip in the given clip slot."""
58
+ track_index = int(params["track_index"])
59
+ clip_index = int(params["clip_index"])
60
+ clip_slot = get_clip_slot(song, track_index, clip_index)
61
+ clip_slot.delete_clip()
62
+ return {"track_index": track_index, "clip_index": clip_index, "deleted": True}
63
+
64
+
65
+ @register("duplicate_clip")
66
+ def duplicate_clip(song, params):
67
+ """Duplicate a clip from one slot to another."""
68
+ track_index = int(params["track_index"])
69
+ clip_index = int(params["clip_index"])
70
+ target_track = int(params["target_track"])
71
+ target_clip = int(params["target_clip"])
72
+
73
+ source_slot = get_clip_slot(song, track_index, clip_index)
74
+ target_slot = get_clip_slot(song, target_track, target_clip)
75
+ source_slot.duplicate_clip_to(target_slot)
76
+
77
+ return {
78
+ "source_track": track_index,
79
+ "source_clip": clip_index,
80
+ "target_track": target_track,
81
+ "target_clip": target_clip,
82
+ "duplicated": True,
83
+ }
84
+
85
+
86
+ @register("fire_clip")
87
+ def fire_clip(song, params):
88
+ """Launch/fire a clip slot."""
89
+ track_index = int(params["track_index"])
90
+ clip_index = int(params["clip_index"])
91
+ clip_slot = get_clip_slot(song, track_index, clip_index)
92
+ clip_slot.fire()
93
+ return {"track_index": track_index, "clip_index": clip_index, "fired": True}
94
+
95
+
96
+ @register("stop_clip")
97
+ def stop_clip(song, params):
98
+ """Stop a clip slot."""
99
+ track_index = int(params["track_index"])
100
+ clip_index = int(params["clip_index"])
101
+ clip_slot = get_clip_slot(song, track_index, clip_index)
102
+ clip_slot.stop()
103
+ return {"track_index": track_index, "clip_index": clip_index, "stopped": True}
104
+
105
+
106
+ @register("set_clip_name")
107
+ def set_clip_name(song, params):
108
+ """Rename a clip."""
109
+ track_index = int(params["track_index"])
110
+ clip_index = int(params["clip_index"])
111
+ clip = get_clip(song, track_index, clip_index)
112
+ clip.name = str(params["name"])
113
+ return {
114
+ "track_index": track_index,
115
+ "clip_index": clip_index,
116
+ "name": clip.name,
117
+ }
118
+
119
+
120
+ @register("set_clip_color")
121
+ def set_clip_color(song, params):
122
+ """Set a clip's color."""
123
+ track_index = int(params["track_index"])
124
+ clip_index = int(params["clip_index"])
125
+ clip = get_clip(song, track_index, clip_index)
126
+ clip.color_index = int(params["color_index"])
127
+ return {
128
+ "track_index": track_index,
129
+ "clip_index": clip_index,
130
+ "color_index": clip.color_index,
131
+ }
132
+
133
+
134
+ @register("set_clip_loop")
135
+ def set_clip_loop(song, params):
136
+ """Enable/disable clip looping and optionally set loop start/end."""
137
+ track_index = int(params["track_index"])
138
+ clip_index = int(params["clip_index"])
139
+ clip = get_clip(song, track_index, clip_index)
140
+
141
+ clip.looping = bool(params["enabled"])
142
+ if "start" in params:
143
+ clip.loop_start = float(params["start"])
144
+ if "end" in params:
145
+ clip.loop_end = float(params["end"])
146
+
147
+ return {
148
+ "track_index": track_index,
149
+ "clip_index": clip_index,
150
+ "looping": clip.looping,
151
+ "loop_start": clip.loop_start,
152
+ "loop_end": clip.loop_end,
153
+ }
154
+
155
+
156
+ @register("set_clip_launch")
157
+ def set_clip_launch(song, params):
158
+ """Set clip launch mode and optional quantization."""
159
+ track_index = int(params["track_index"])
160
+ clip_index = int(params["clip_index"])
161
+ clip = get_clip(song, track_index, clip_index)
162
+
163
+ clip.launch_mode = int(params["mode"])
164
+ if "quantization" in params:
165
+ clip.launch_quantization = int(params["quantization"])
166
+
167
+ return {
168
+ "track_index": track_index,
169
+ "clip_index": clip_index,
170
+ "launch_mode": clip.launch_mode,
171
+ "launch_quantization": clip.launch_quantization,
172
+ }