livepilot 1.23.3 → 1.23.5

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 (45) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +106 -8
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/server.py +45 -24
  18. package/mcp_server/tools/agent_os.py +33 -9
  19. package/mcp_server/tools/analyzer.py +38 -7
  20. package/mcp_server/tools/browser.py +20 -1
  21. package/mcp_server/tools/devices.py +78 -11
  22. package/mcp_server/tools/perception.py +5 -1
  23. package/mcp_server/tools/tracks.py +39 -2
  24. package/mcp_server/user_corpus/__init__.py +48 -0
  25. package/mcp_server/user_corpus/manifest.py +142 -0
  26. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  27. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  28. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  29. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  30. package/mcp_server/user_corpus/runner.py +261 -0
  31. package/mcp_server/user_corpus/scanner.py +115 -0
  32. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  33. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  34. package/mcp_server/user_corpus/scanners/als.py +144 -0
  35. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  36. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  37. package/mcp_server/user_corpus/tools.py +904 -0
  38. package/mcp_server/user_corpus/wizard.py +224 -0
  39. package/package.json +2 -2
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/browser.py +7 -2
  42. package/remote_script/LivePilot/server.py +38 -22
  43. package/remote_script/LivePilot/transport.py +15 -5
  44. package/requirements.txt +3 -3
  45. package/server.json +2 -2
@@ -0,0 +1,224 @@
1
+ """First-run setup wizard — detects sensible scan candidates on the user's
2
+ filesystem and returns approval prompts the agent (in Claude Code) drives
3
+ through conversation.
4
+
5
+ The wizard does NOT scan anything. It surveys, returns candidates with
6
+ file counts, and lets the calling agent confirm each with the user before
7
+ adding to the manifest.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import platform
13
+ from dataclasses import dataclass, asdict
14
+ from pathlib import Path
15
+ from typing import Iterable
16
+
17
+
18
+ # ─── Candidate categories the wizard offers ─────────────────────────────────
19
+
20
+
21
+ @dataclass
22
+ class WizardCandidate:
23
+ """One scannable folder the wizard surfaces for approval."""
24
+ category: str # "user_library_racks", "max_devices", "plugins", ...
25
+ suggested_id: str # e.g. "user-library-racks"
26
+ type: str # scanner type_id
27
+ path: str
28
+ file_count: int # estimated files (capped during enumeration)
29
+ sample_filenames: list[str]
30
+ description: str # 1-2 sentence prompt the agent reads to the user
31
+ recommended_default: bool # whether to auto-confirm (false = always ask)
32
+
33
+
34
+ def survey_filesystem() -> list[WizardCandidate]:
35
+ """Inspect the OS-standard locations + return candidates for approval.
36
+
37
+ Does not write anything. Caller (skill/agent) walks each candidate and asks
38
+ the user "scan this? y/n", then calls corpus_add_source for each yes.
39
+ """
40
+ candidates: list[WizardCandidate] = []
41
+ if platform.system() == "Darwin":
42
+ candidates.extend(_macos_candidates())
43
+ elif platform.system() == "Windows":
44
+ candidates.extend(_windows_candidates())
45
+ else:
46
+ candidates.extend(_linux_candidates())
47
+ return [c for c in candidates if c.file_count > 0]
48
+
49
+
50
+ def _count_files(root: Path, extensions: Iterable[str], cap: int = 5000) -> tuple[int, list[str]]:
51
+ """Count files matching any of the extensions under root. Return (count, samples).
52
+
53
+ Cap prevents pathological scans during the survey. Caller should still rescan
54
+ for real (the wizard is just for sizing + showing the user what's there).
55
+ """
56
+ if not root.exists():
57
+ return 0, []
58
+ exts = tuple(e.lower() for e in extensions)
59
+ count = 0
60
+ samples: list[str] = []
61
+ try:
62
+ for p in root.rglob("*"):
63
+ try:
64
+ if not p.is_file() and not p.is_dir():
65
+ continue
66
+ # Bundle-style "files" (.amxd, .vst3) appear as dirs OR files
67
+ # depending on OS — count both
68
+ if p.suffix.lower() in exts:
69
+ count += 1
70
+ if len(samples) < 5:
71
+ samples.append(p.name)
72
+ if count >= cap:
73
+ break
74
+ except (PermissionError, OSError):
75
+ continue
76
+ except (PermissionError, OSError):
77
+ pass
78
+ return count, samples
79
+
80
+
81
+ def _macos_candidates() -> list[WizardCandidate]:
82
+ home = Path.home()
83
+ out: list[WizardCandidate] = []
84
+
85
+ # 1. User Library racks (.adg / .adv)
86
+ user_lib = home / "Music/Ableton/User Library/Presets"
87
+ if user_lib.exists():
88
+ n, samples = _count_files(user_lib, [".adg", ".adv"])
89
+ if n > 0:
90
+ out.append(WizardCandidate(
91
+ category="user_library_racks", suggested_id="user-library-racks",
92
+ type="adg", path=str(user_lib), file_count=n, sample_filenames=samples,
93
+ description=(
94
+ f"Ableton User Library — {n} rack/effect presets (.adg/.adv). "
95
+ "Indexes every saved chain you've made + third-party racks under "
96
+ "your User Library. Good first scan."
97
+ ),
98
+ recommended_default=True,
99
+ ))
100
+
101
+ # 2. Max for Live devices (.amxd) — multiple plausible locations
102
+ for label, p in (
103
+ ("max_for_live_devices", home / "Documents/Max 9/Max for Live Devices"),
104
+ ("max_for_live_devices_v8", home / "Documents/Max 8/Max for Live Devices"),
105
+ ("user_library_m4l", home / "Music/Ableton/User Library/MAX MONTY/m4l_2024"),
106
+ ("user_library_m4l_alt", home / "Music/Ableton/User Library/Presets/Audio Effects/Max Audio Effect"),
107
+ ):
108
+ if p.exists():
109
+ n, samples = _count_files(p, [".amxd"])
110
+ if n > 0:
111
+ out.append(WizardCandidate(
112
+ category="max_devices", suggested_id=label.replace("_", "-"),
113
+ type="amxd", path=str(p), file_count=n, sample_filenames=samples,
114
+ description=(
115
+ f"Max for Live devices at {p.name} — {n} .amxd files. "
116
+ "Captures device type (audio/instrument/midi), Max version, "
117
+ "and any Live-exposed parameters."
118
+ ),
119
+ recommended_default=True,
120
+ ))
121
+
122
+ # 3. Plugin presets (.aupreset / .vstpreset / .fxp / .nksf)
123
+ for label, p in (
124
+ ("au_presets", home / "Library/Audio/Presets"),
125
+ ("vst3_presets", home / "Library/Audio/VST3 Presets"),
126
+ ):
127
+ if p.exists():
128
+ n, samples = _count_files(p, [".aupreset", ".vstpreset", ".fxp", ".fxb", ".nksf"])
129
+ if n > 0:
130
+ out.append(WizardCandidate(
131
+ category="plugin_presets", suggested_id=label.replace("_", "-"),
132
+ type="plugin-preset", path=str(p), file_count=n, sample_filenames=samples,
133
+ description=(
134
+ f"Plugin presets at {p.name} — {n} preset files. Captures "
135
+ "plugin name + vendor + format. Param values are opaque per-plugin "
136
+ "binary (same as PluginDevice in .als)."
137
+ ),
138
+ recommended_default=False, # often noisy — opt-in
139
+ ))
140
+
141
+ # 4. Sample library (audio files) — .wav/.aif/.flac
142
+ # Note: corpus has no built-in sample scanner yet; these are advisory.
143
+ for label, p in (
144
+ ("apple_loops", Path("/Library/Audio/Apple Loops")),
145
+ ("user_apple_loops", home / "Library/Audio/Apple Loops"),
146
+ ("user_samples", home / "Music/Samples"),
147
+ ):
148
+ if p.exists():
149
+ n, _ = _count_files(p, [".wav", ".aif", ".aiff", ".flac"])
150
+ if n > 0:
151
+ out.append(WizardCandidate(
152
+ category="samples_advisory", suggested_id=label.replace("_", "-"),
153
+ type="sample", # NOTE: scanner not yet implemented
154
+ path=str(p), file_count=n, sample_filenames=[],
155
+ description=(
156
+ f"Sample library at {p.name} — {n} audio files. "
157
+ "(Sample scanner is not yet implemented — this is a survey "
158
+ "preview only. Skip for now or wait for the next build.)"
159
+ ),
160
+ recommended_default=False,
161
+ ))
162
+
163
+ # 5. Plugins are NOT a corpus_add_source — they're handled by
164
+ # corpus_detect_plugins. Surface as a separate "want to detect plugins?" step.
165
+ return out
166
+
167
+
168
+ def _windows_candidates() -> list[WizardCandidate]:
169
+ home = Path.home()
170
+ out: list[WizardCandidate] = []
171
+ user_lib = home / "Documents" / "Ableton" / "User Library" / "Presets"
172
+ if user_lib.exists():
173
+ n, samples = _count_files(user_lib, [".adg", ".adv"])
174
+ if n > 0:
175
+ out.append(WizardCandidate(
176
+ category="user_library_racks", suggested_id="user-library-racks",
177
+ type="adg", path=str(user_lib), file_count=n, sample_filenames=samples,
178
+ description=f"Ableton User Library racks — {n} .adg/.adv presets.",
179
+ recommended_default=True,
180
+ ))
181
+ return out
182
+
183
+
184
+ def _linux_candidates() -> list[WizardCandidate]:
185
+ return [] # Ableton Live isn't supported on Linux; corpus is mostly empty there
186
+
187
+
188
+ # ─── Aggregate decision packet ──────────────────────────────────────────────
189
+
190
+
191
+ def build_setup_proposal() -> dict:
192
+ """Return the full first-run setup proposal: candidates + plugin-detection prompt.
193
+
194
+ Caller (skill/agent) walks each item, confirms with user, then dispatches
195
+ corpus_add_source / corpus_detect_plugins as approved.
196
+ """
197
+ candidates = survey_filesystem()
198
+ return {
199
+ "candidates": [asdict(c) for c in candidates],
200
+ "candidate_count": len(candidates),
201
+ "categories": sorted({c.category for c in candidates}),
202
+ "plugin_detection_offer": {
203
+ "prompt": (
204
+ "Also detect installed VST3/AU/VST2/AAX plugins via "
205
+ "corpus_detect_plugins? This walks the OS-standard plugin "
206
+ "folders, parses each bundle's identity metadata, and writes "
207
+ "_inventory.json. Independent of the file scans above."
208
+ ),
209
+ "tool": "corpus_detect_plugins",
210
+ "recommended_default": True,
211
+ },
212
+ "instructions": (
213
+ "Walk the user through each candidate one at a time. For each, "
214
+ "summarize file_count + path + description, then ASK 'add this?' "
215
+ "and only call corpus_add_source on yes. After all candidates are "
216
+ "decided, ask about plugin_detection_offer separately. Finally "
217
+ "call corpus_scan() to index everything they approved."
218
+ ),
219
+ "do_not_scan": [
220
+ "Personal .als project folders unless the user explicitly points at one — "
221
+ "they're sensitive content that the user should opt into per-folder."
222
+ ],
223
+ "schema_version": 1,
224
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.23.3",
3
+ "version": "1.23.5",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 \u2014 433 tools, 53 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
5
+ "description": "Agentic production system for Ableton Live 12 \u2014 453 tools, 54 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.23.3"
8
+ __version__ = "1.23.5"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -48,8 +48,13 @@ def _navigate_path(browser, path):
48
48
  if not parts:
49
49
  raise ValueError("Path cannot be empty")
50
50
 
51
- # First part must be a category name
52
- first = parts[0].lower()
51
+ # First part must be a category name (normalise common aliases first)
52
+ _path_aliases = {
53
+ "effects": "audio_effects", "fx": "audio_effects",
54
+ "audio_fx": "audio_effects", "audiofx": "audio_effects",
55
+ "midi_fx": "midi_effects", "midifx": "midi_effects",
56
+ }
57
+ first = _path_aliases.get(parts[0].lower(), parts[0].lower())
53
58
  if first not in categories:
54
59
  raise ValueError(
55
60
  "Unknown category '%s'. Available: %s"
@@ -86,6 +86,12 @@ class LivePilotServer(object):
86
86
  self._command_queue = queue.Queue()
87
87
  self._client_lock = threading.Lock()
88
88
  self._client_connected = False
89
+ # Track the active client socket so we can close it from the accept
90
+ # loop when a new connection arrives. See _server_loop's kick-stale
91
+ # flow — without this, an unclean MCP-server restart leaves the
92
+ # Remote Script in a state where new connections get rejected until
93
+ # the old socket times out (often requiring an Ableton restart).
94
+ self._current_client = None
89
95
 
90
96
  # ── Public API ───────────────────────────────────────────────────────
91
97
 
@@ -138,30 +144,35 @@ class LivePilotServer(object):
138
144
  while self._running:
139
145
  try:
140
146
  client, addr = self._server_socket.accept()
147
+ # Single-client design: a new connection means the previous one
148
+ # is dead. Close the stale socket and join its thread (outside
149
+ # the lock so the thread's finally block can acquire it), then
150
+ # accept the new connection. Without this, the server could
151
+ # reject reconnections for up to 1s after an unclean MCP-server
152
+ # restart — the old recv() loop hadn't yet observed EOF.
153
+ stale_thread = None
154
+ stale_client = None
155
+ with self._client_lock:
156
+ if self._client_connected and self._current_client is not None:
157
+ stale_client = self._current_client
158
+ stale_thread = self._client_thread
159
+ self._log(
160
+ "Replacing stale client with new connection from %s:%d" % addr
161
+ )
162
+ if stale_client is not None:
163
+ try:
164
+ stale_client.close()
165
+ except OSError:
166
+ pass
167
+ if stale_thread is not None and stale_thread.is_alive():
168
+ # 2s is generous — the old recv() unblocks the moment we
169
+ # close the socket above, then the thread's finally block
170
+ # acquires the lock, resets _client_connected, and exits.
171
+ stale_thread.join(timeout=2)
172
+
141
173
  with self._client_lock:
142
- if self._client_connected:
143
- # Reject concurrent clients with an explicit message
144
- self._log("Rejected client from %s:%d (another client is connected)" % addr)
145
- try:
146
- reject = json.dumps({
147
- "id": "system",
148
- "ok": False,
149
- "error": {
150
- "code": "STATE_ERROR",
151
- "message": "Another client is already connected. "
152
- "LivePilot accepts one client at a time. "
153
- "Disconnect the current client first."
154
- }
155
- }) + "\n"
156
- client.sendall(reject.encode("utf-8"))
157
- except OSError:
158
- pass
159
- try:
160
- client.close()
161
- except OSError:
162
- pass
163
- continue
164
174
  self._client_connected = True
175
+ self._current_client = client
165
176
  self._client_thread = threading.Thread(
166
177
  target=self._run_client_session,
167
178
  args=(client, addr),
@@ -194,6 +205,11 @@ class LivePilotServer(object):
194
205
  pass
195
206
  with self._client_lock:
196
207
  self._client_connected = False
208
+ # Only clear _current_client if it still points at us — the
209
+ # accept loop may have already replaced us with a new client
210
+ # (in which case _current_client is the new socket).
211
+ if self._current_client is client:
212
+ self._current_client = None
197
213
  self._log("Client disconnected")
198
214
 
199
215
  def _handle_client(self, client):
@@ -11,16 +11,26 @@ def get_session_info(song, params):
11
11
  """Return comprehensive session state."""
12
12
  tracks_info = []
13
13
  for i, track in enumerate(song.tracks):
14
- tracks_info.append({
14
+ track_data = {
15
15
  "index": i,
16
16
  "name": track.name,
17
17
  "color_index": track.color_index,
18
- "has_midi_input": track.has_midi_input,
19
- "has_audio_input": track.has_audio_input,
20
18
  "mute": track.mute,
21
19
  "solo": track.solo,
22
- "arm": track.arm,
23
- })
20
+ }
21
+ # Group tracks (and any Return tracks that leak into song.tracks)
22
+ # don't expose `arm` / `has_midi_input` / `has_audio_input`. The
23
+ # Live Object Model raises a RuntimeError on access — and crucially
24
+ # `hasattr()` returns True regardless, so we must use try/except.
25
+ try:
26
+ track_data["arm"] = track.arm
27
+ track_data["has_midi_input"] = track.has_midi_input
28
+ track_data["has_audio_input"] = track.has_audio_input
29
+ except Exception:
30
+ track_data["arm"] = None
31
+ track_data["has_midi_input"] = None
32
+ track_data["has_audio_input"] = None
33
+ tracks_info.append(track_data)
24
34
 
25
35
  return_tracks_info = []
26
36
  for i, track in enumerate(song.return_tracks):
package/requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # LivePilot MCP Server dependencies
2
- numpy>=1.24.0
3
- fastmcp>=3.0.0,<3.3.0 # pinned upper bound — _get_all_tools() accesses private internals
2
+ numpy>=2.4.4
3
+ fastmcp>=3.2.4,<3.3.0 # pinned upper bound — _get_all_tools() accesses private internals
4
4
  midiutil>=1.2.1
5
5
  pretty_midi>=0.2.11
6
6
  # v1.8 Perception Layer (offline analysis)
@@ -12,7 +12,7 @@ mutagen>=1.47.0
12
12
  # Without these, SpliceGRPCClient silently disables itself and search_samples
13
13
  # falls back to the SQLite sounds.db which only returns locally downloaded
14
14
  # samples (see docs/2026-04-14-bugs-discovered.md — P0-2).
15
- grpcio>=1.60.0
15
+ grpcio>=1.80.0
16
16
  protobuf>=7.34.1
17
17
 
18
18
  # Development / testing (not required for runtime)
package/server.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.dreamrec/livepilot",
4
- "description": "433-tool agentic MCP production system for Ableton Live 12 \u2014 53 domains, 44 semantic moves, device atlas (5264 devices), Splice intelligence (gRPC + GraphQL), 9-band spectral perception auto-loaded, Creative Director skill, technique memory, 12 creative engines",
4
+ "description": "453-tool agentic MCP production system for Ableton Live 12 \u2014 53 domains, 44 semantic moves, device atlas (5264 devices), Splice intelligence (gRPC + GraphQL), 9-band spectral perception auto-loaded, Creative Director skill, technique memory, 12 creative engines",
5
5
  "repository": {
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.23.3",
9
+ "version": "1.23.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",