livepilot 1.0.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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +409 -0
- package/bin/livepilot.js +390 -0
- package/installer/install.js +95 -0
- package/installer/paths.js +79 -0
- package/mcp_server/__init__.py +2 -0
- package/mcp_server/__main__.py +5 -0
- package/mcp_server/connection.py +210 -0
- package/mcp_server/memory/__init__.py +5 -0
- package/mcp_server/memory/technique_store.py +296 -0
- package/mcp_server/server.py +87 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +407 -0
- package/mcp_server/tools/browser.py +86 -0
- package/mcp_server/tools/clips.py +218 -0
- package/mcp_server/tools/devices.py +256 -0
- package/mcp_server/tools/memory.py +198 -0
- package/mcp_server/tools/mixing.py +121 -0
- package/mcp_server/tools/notes.py +269 -0
- package/mcp_server/tools/scenes.py +89 -0
- package/mcp_server/tools/tracks.py +175 -0
- package/mcp_server/tools/transport.py +117 -0
- package/package.json +37 -0
- package/plugin/agents/livepilot-producer/AGENT.md +62 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/memory.md +22 -0
- package/plugin/commands/mix.md +15 -0
- package/plugin/commands/session.md +13 -0
- package/plugin/commands/sounddesign.md +16 -0
- package/plugin/plugin.json +19 -0
- package/plugin/skills/livepilot-core/SKILL.md +208 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
- package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
- package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
- package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
- package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
- package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
- package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
- package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
- package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
- package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/memory-guide.md +107 -0
- package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
- package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
- package/plugin/skills/livepilot-core/references/overview.md +209 -0
- package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
- package/remote_script/LivePilot/__init__.py +42 -0
- package/remote_script/LivePilot/arrangement.py +693 -0
- package/remote_script/LivePilot/browser.py +424 -0
- package/remote_script/LivePilot/clips.py +211 -0
- package/remote_script/LivePilot/devices.py +596 -0
- package/remote_script/LivePilot/diagnostics.py +198 -0
- package/remote_script/LivePilot/mixing.py +194 -0
- package/remote_script/LivePilot/notes.py +339 -0
- package/remote_script/LivePilot/router.py +74 -0
- package/remote_script/LivePilot/scenes.py +99 -0
- package/remote_script/LivePilot/server.py +293 -0
- package/remote_script/LivePilot/tracks.py +268 -0
- package/remote_script/LivePilot/transport.py +151 -0
- package/remote_script/LivePilot/utils.py +123 -0
- package/requirements.txt +2 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""TCP client for communicating with Ableton Live's Remote Script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
CONNECT_TIMEOUT = 5
|
|
14
|
+
RECV_TIMEOUT = 20
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AbletonConnectionError(Exception):
|
|
18
|
+
"""Raised when communication with Ableton Live fails."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Error messages with user-friendly context
|
|
23
|
+
_ERROR_HINTS = {
|
|
24
|
+
"INDEX_ERROR": "Check that the track, clip, device, or scene index exists. "
|
|
25
|
+
"Use get_session_info to see current indices.",
|
|
26
|
+
"NOT_FOUND": "The requested item could not be found in the Live session. "
|
|
27
|
+
"Verify names and indices with get_session_info or get_track_info.",
|
|
28
|
+
"INVALID_PARAM": "A parameter value was out of range or the wrong type. "
|
|
29
|
+
"Use get_device_parameters to check valid ranges.",
|
|
30
|
+
"STATE_ERROR": "The operation isn't valid in the current state. "
|
|
31
|
+
"For example, you can't add notes to a clip that doesn't exist yet.",
|
|
32
|
+
"TIMEOUT": "Ableton took too long to respond. This can happen with heavy sessions. "
|
|
33
|
+
"Try again, or check if Ableton is unresponsive.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _friendly_error(code: str, message: str, command_type: str) -> str:
|
|
38
|
+
"""Format an error from the Remote Script into a user-friendly message."""
|
|
39
|
+
hint = _ERROR_HINTS.get(code, "")
|
|
40
|
+
parts = [f"[{code}] {message}"]
|
|
41
|
+
if hint:
|
|
42
|
+
parts.append(hint)
|
|
43
|
+
return " ".join(parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AbletonConnection:
|
|
47
|
+
"""TCP client that sends JSON commands to the LivePilot Remote Script."""
|
|
48
|
+
|
|
49
|
+
MAX_LOG_ENTRIES = 50
|
|
50
|
+
|
|
51
|
+
def __init__(self, host: Optional[str] = None, port: Optional[int] = None):
|
|
52
|
+
self.host = host or os.environ.get("LIVE_MCP_HOST", "127.0.0.1")
|
|
53
|
+
self.port = port or int(os.environ.get("LIVE_MCP_PORT", "9878"))
|
|
54
|
+
self._socket: Optional[socket.socket] = None
|
|
55
|
+
self._recv_buf: bytes = b""
|
|
56
|
+
self._command_log: deque[dict] = deque(maxlen=self.MAX_LOG_ENTRIES)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Connection lifecycle
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def connect(self) -> None:
|
|
63
|
+
"""Open a TCP connection to the Remote Script."""
|
|
64
|
+
try:
|
|
65
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
66
|
+
sock.settimeout(CONNECT_TIMEOUT)
|
|
67
|
+
sock.connect((self.host, self.port))
|
|
68
|
+
sock.settimeout(RECV_TIMEOUT)
|
|
69
|
+
self._socket = sock
|
|
70
|
+
except ConnectionRefusedError:
|
|
71
|
+
self._socket = None
|
|
72
|
+
raise AbletonConnectionError(
|
|
73
|
+
f"Cannot reach Ableton Live on {self.host}:{self.port}. "
|
|
74
|
+
"Make sure Ableton Live is running and the LivePilot Remote Script "
|
|
75
|
+
"is enabled in Preferences > Link, Tempo & MIDI > Control Surface. "
|
|
76
|
+
"Run 'npx livepilot --doctor' for a full diagnostic."
|
|
77
|
+
)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
self._socket = None
|
|
80
|
+
raise AbletonConnectionError(
|
|
81
|
+
f"Could not connect to Ableton Live at {self.host}:{self.port} — {exc}"
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
def disconnect(self) -> None:
|
|
85
|
+
"""Close the TCP connection."""
|
|
86
|
+
if self._socket is not None:
|
|
87
|
+
try:
|
|
88
|
+
self._socket.close()
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
self._socket = None
|
|
92
|
+
|
|
93
|
+
def is_connected(self) -> bool:
|
|
94
|
+
"""Return True if a socket is currently held."""
|
|
95
|
+
return self._socket is not None
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# High-level API
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def ping(self) -> bool:
|
|
102
|
+
"""Send a ping and return True if a pong is received."""
|
|
103
|
+
try:
|
|
104
|
+
resp = self._send_raw({"type": "ping"})
|
|
105
|
+
return resp.get("result", {}).get("pong") is True
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def send_command(self, command_type: str, params: Optional[dict] = None) -> dict:
|
|
110
|
+
"""Send a command to Ableton and return the result dict.
|
|
111
|
+
|
|
112
|
+
Retries once on socket errors with a fresh connection.
|
|
113
|
+
"""
|
|
114
|
+
# Ensure we have a connection
|
|
115
|
+
if not self.is_connected():
|
|
116
|
+
self.connect()
|
|
117
|
+
|
|
118
|
+
command: dict = {"type": command_type}
|
|
119
|
+
if params:
|
|
120
|
+
command["params"] = params
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response = self._send_raw(command)
|
|
124
|
+
except (OSError, AbletonConnectionError):
|
|
125
|
+
# Retry once with a fresh connection
|
|
126
|
+
self.disconnect()
|
|
127
|
+
self.connect()
|
|
128
|
+
response = self._send_raw(command)
|
|
129
|
+
|
|
130
|
+
# Log the command and response
|
|
131
|
+
log_entry = {
|
|
132
|
+
"command": command_type,
|
|
133
|
+
"params": params,
|
|
134
|
+
"timestamp": time.time(),
|
|
135
|
+
"ok": response.get("ok", True),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Handle error responses — Remote Script uses {"ok": false, "error": {"code": ..., "message": ...}}
|
|
139
|
+
if response.get("ok") is False:
|
|
140
|
+
err = response.get("error", {})
|
|
141
|
+
code = err.get("code", "INTERNAL") if isinstance(err, dict) else "INTERNAL"
|
|
142
|
+
message = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
|
143
|
+
log_entry["error"] = code
|
|
144
|
+
self._command_log.append(log_entry)
|
|
145
|
+
friendly = _friendly_error(code, message, command_type)
|
|
146
|
+
raise AbletonConnectionError(friendly)
|
|
147
|
+
|
|
148
|
+
self._command_log.append(log_entry)
|
|
149
|
+
return response.get("result", {})
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Command log
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def get_recent_commands(self, limit: int = 20) -> list[dict]:
|
|
156
|
+
"""Return the most recent commands sent to Ableton (newest first)."""
|
|
157
|
+
entries = list(self._command_log)
|
|
158
|
+
entries.reverse()
|
|
159
|
+
return entries[:limit]
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Low-level transport
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _send_raw(self, command: dict) -> dict:
|
|
166
|
+
"""Send a JSON command (with request_id) and read the response."""
|
|
167
|
+
if self._socket is None:
|
|
168
|
+
raise AbletonConnectionError("Not connected to Ableton Live")
|
|
169
|
+
|
|
170
|
+
# Don't mutate the caller's dict
|
|
171
|
+
envelope = {**command, "id": str(uuid.uuid4())[:8]}
|
|
172
|
+
payload = json.dumps(envelope) + "\n"
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
self._socket.sendall(payload.encode("utf-8"))
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
self.disconnect()
|
|
178
|
+
raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
|
|
179
|
+
|
|
180
|
+
# Read until newline, preserving any trailing bytes in _recv_buf
|
|
181
|
+
buf = getattr(self, "_recv_buf", b"")
|
|
182
|
+
try:
|
|
183
|
+
while b"\n" not in buf:
|
|
184
|
+
chunk = self._socket.recv(4096)
|
|
185
|
+
if not chunk:
|
|
186
|
+
self._recv_buf = b""
|
|
187
|
+
self.disconnect()
|
|
188
|
+
raise AbletonConnectionError("Connection closed by Ableton")
|
|
189
|
+
buf += chunk
|
|
190
|
+
except socket.timeout as exc:
|
|
191
|
+
self._recv_buf = buf
|
|
192
|
+
self.disconnect()
|
|
193
|
+
raise AbletonConnectionError(
|
|
194
|
+
f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
|
|
195
|
+
) from exc
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
self._recv_buf = b""
|
|
198
|
+
self.disconnect()
|
|
199
|
+
raise AbletonConnectionError(
|
|
200
|
+
f"Socket error reading response: {exc}"
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
line, remainder = buf.split(b"\n", 1)
|
|
204
|
+
self._recv_buf = remainder
|
|
205
|
+
try:
|
|
206
|
+
return json.loads(line)
|
|
207
|
+
except json.JSONDecodeError as exc:
|
|
208
|
+
raise AbletonConnectionError(
|
|
209
|
+
f"Invalid JSON from Ableton: {line[:200]}"
|
|
210
|
+
) from exc
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Persistent JSON store for LivePilot techniques (beat patterns, device chains, etc.)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
VALID_TYPES = frozenset(
|
|
13
|
+
["beat_pattern", "device_chain", "mix_template", "browser_pin", "preference"]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
VALID_SORT_FIELDS = frozenset(
|
|
17
|
+
["updated_at", "created_at", "rating", "replay_count", "name"]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TechniqueStore:
|
|
22
|
+
"""Thread-safe JSON-backed store for techniques."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_dir: Optional[str] = None):
|
|
25
|
+
if base_dir is None:
|
|
26
|
+
base_dir = os.path.join(os.path.expanduser("~"), ".livepilot", "memory")
|
|
27
|
+
self._base_dir = Path(base_dir)
|
|
28
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self._file = self._base_dir / "techniques.json"
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
if self._file.exists():
|
|
33
|
+
try:
|
|
34
|
+
with open(self._file, "r") as f:
|
|
35
|
+
self._data = json.load(f)
|
|
36
|
+
except (json.JSONDecodeError, ValueError):
|
|
37
|
+
corrupt = self._file.with_suffix(".json.corrupt")
|
|
38
|
+
self._file.rename(corrupt)
|
|
39
|
+
self._data = {"version": 1, "techniques": []}
|
|
40
|
+
else:
|
|
41
|
+
self._data = {"version": 1, "techniques": []}
|
|
42
|
+
self._flush()
|
|
43
|
+
|
|
44
|
+
# ── persistence ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def _flush(self) -> None:
|
|
47
|
+
"""Atomic write: tmp file then rename."""
|
|
48
|
+
tmp = self._file.with_suffix(".tmp")
|
|
49
|
+
with open(tmp, "w") as f:
|
|
50
|
+
json.dump(self._data, f, indent=2)
|
|
51
|
+
f.flush()
|
|
52
|
+
os.fsync(f.fileno())
|
|
53
|
+
os.replace(str(tmp), str(self._file))
|
|
54
|
+
|
|
55
|
+
# ── public API ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def save(
|
|
58
|
+
self,
|
|
59
|
+
name: str,
|
|
60
|
+
type: str,
|
|
61
|
+
qualities: dict,
|
|
62
|
+
payload: dict,
|
|
63
|
+
tags: Optional[list[str]] = None,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Create a new technique. Returns {id, name, type, summary}."""
|
|
66
|
+
if type not in VALID_TYPES:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"INVALID_PARAM: type must be one of {sorted(VALID_TYPES)}, got '{type}'"
|
|
69
|
+
)
|
|
70
|
+
if not qualities.get("summary"):
|
|
71
|
+
raise ValueError("INVALID_PARAM: qualities must contain non-empty 'summary'")
|
|
72
|
+
|
|
73
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
74
|
+
technique = {
|
|
75
|
+
"id": str(uuid.uuid4()),
|
|
76
|
+
"name": name,
|
|
77
|
+
"type": type,
|
|
78
|
+
"qualities": qualities,
|
|
79
|
+
"payload": payload,
|
|
80
|
+
"tags": tags or [],
|
|
81
|
+
"created_at": now,
|
|
82
|
+
"updated_at": now,
|
|
83
|
+
"favorite": False,
|
|
84
|
+
"rating": 0,
|
|
85
|
+
"replay_count": 0,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._data["techniques"].append(technique)
|
|
90
|
+
self._flush()
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"id": technique["id"],
|
|
94
|
+
"name": technique["name"],
|
|
95
|
+
"type": technique["type"],
|
|
96
|
+
"summary": qualities["summary"],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def get(self, technique_id: str) -> dict:
|
|
100
|
+
"""Return full technique by id."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
for t in self._data["techniques"]:
|
|
103
|
+
if t["id"] == technique_id:
|
|
104
|
+
return dict(t)
|
|
105
|
+
raise ValueError(f"NOT_FOUND: technique '{technique_id}' does not exist")
|
|
106
|
+
|
|
107
|
+
def search(
|
|
108
|
+
self,
|
|
109
|
+
query: Optional[str] = None,
|
|
110
|
+
type_filter: Optional[str] = None,
|
|
111
|
+
tags: Optional[list[str]] = None,
|
|
112
|
+
limit: int = 10,
|
|
113
|
+
) -> list[dict]:
|
|
114
|
+
"""Search techniques. Returns summaries (no payload)."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
results = list(self._data["techniques"])
|
|
117
|
+
|
|
118
|
+
# filter by type
|
|
119
|
+
if type_filter:
|
|
120
|
+
results = [t for t in results if t["type"] == type_filter]
|
|
121
|
+
|
|
122
|
+
# filter by tags (match any)
|
|
123
|
+
if tags:
|
|
124
|
+
tag_set = set(tags)
|
|
125
|
+
results = [t for t in results if tag_set & set(t.get("tags", []))]
|
|
126
|
+
|
|
127
|
+
# text search — all query words must appear somewhere in the technique
|
|
128
|
+
if query:
|
|
129
|
+
words = query.lower().split()
|
|
130
|
+
filtered = []
|
|
131
|
+
for t in results:
|
|
132
|
+
searchable = self._searchable_text(t)
|
|
133
|
+
if all(w in searchable for w in words):
|
|
134
|
+
filtered.append(t)
|
|
135
|
+
results = filtered
|
|
136
|
+
|
|
137
|
+
results = self._multi_sort(results)
|
|
138
|
+
|
|
139
|
+
results = results[:limit]
|
|
140
|
+
|
|
141
|
+
# strip payload
|
|
142
|
+
return [self._summary(t) for t in results]
|
|
143
|
+
|
|
144
|
+
def list_techniques(
|
|
145
|
+
self,
|
|
146
|
+
type_filter: Optional[str] = None,
|
|
147
|
+
tags: Optional[list[str]] = None,
|
|
148
|
+
sort_by: str = "updated_at",
|
|
149
|
+
limit: int = 20,
|
|
150
|
+
) -> list[dict]:
|
|
151
|
+
"""List techniques as compact summaries."""
|
|
152
|
+
if sort_by not in VALID_SORT_FIELDS:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"INVALID_PARAM: sort_by must be one of {sorted(VALID_SORT_FIELDS)}, got '{sort_by}'"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
with self._lock:
|
|
158
|
+
results = list(self._data["techniques"])
|
|
159
|
+
|
|
160
|
+
if type_filter:
|
|
161
|
+
results = [t for t in results if t["type"] == type_filter]
|
|
162
|
+
|
|
163
|
+
if tags:
|
|
164
|
+
tag_set = set(tags)
|
|
165
|
+
results = [t for t in results if tag_set & set(t.get("tags", []))]
|
|
166
|
+
|
|
167
|
+
reverse = sort_by != "name"
|
|
168
|
+
results.sort(key=lambda t: t.get(sort_by, ""), reverse=reverse)
|
|
169
|
+
|
|
170
|
+
results = results[:limit]
|
|
171
|
+
|
|
172
|
+
return [self._compact_summary(t) for t in results]
|
|
173
|
+
|
|
174
|
+
def favorite(
|
|
175
|
+
self,
|
|
176
|
+
technique_id: str,
|
|
177
|
+
favorite: Optional[bool] = None,
|
|
178
|
+
rating: Optional[int] = None,
|
|
179
|
+
) -> dict:
|
|
180
|
+
"""Set favorite flag and/or rating."""
|
|
181
|
+
if rating is not None and (rating < 0 or rating > 5):
|
|
182
|
+
raise ValueError("INVALID_PARAM: rating must be between 0 and 5")
|
|
183
|
+
|
|
184
|
+
with self._lock:
|
|
185
|
+
t = self._find(technique_id)
|
|
186
|
+
if favorite is not None:
|
|
187
|
+
t["favorite"] = favorite
|
|
188
|
+
if rating is not None:
|
|
189
|
+
t["rating"] = rating
|
|
190
|
+
t["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
191
|
+
self._flush()
|
|
192
|
+
return self._compact_summary(t)
|
|
193
|
+
|
|
194
|
+
def update(
|
|
195
|
+
self,
|
|
196
|
+
technique_id: str,
|
|
197
|
+
name: Optional[str] = None,
|
|
198
|
+
tags: Optional[list[str]] = None,
|
|
199
|
+
qualities: Optional[dict] = None,
|
|
200
|
+
) -> dict:
|
|
201
|
+
"""Update technique fields. Qualities are merged (lists replaced)."""
|
|
202
|
+
with self._lock:
|
|
203
|
+
t = self._find(technique_id)
|
|
204
|
+
if name is not None:
|
|
205
|
+
t["name"] = name
|
|
206
|
+
if tags is not None:
|
|
207
|
+
t["tags"] = tags
|
|
208
|
+
if qualities is not None:
|
|
209
|
+
existing = t.get("qualities", {})
|
|
210
|
+
existing.update(qualities)
|
|
211
|
+
t["qualities"] = existing
|
|
212
|
+
t["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
213
|
+
self._flush()
|
|
214
|
+
return self._compact_summary(t)
|
|
215
|
+
|
|
216
|
+
def delete(self, technique_id: str) -> dict:
|
|
217
|
+
"""Delete technique after creating a timestamped backup."""
|
|
218
|
+
with self._lock:
|
|
219
|
+
t = self._find(technique_id)
|
|
220
|
+
# backup
|
|
221
|
+
backup_dir = self._base_dir / "backups"
|
|
222
|
+
backup_dir.mkdir(exist_ok=True)
|
|
223
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
224
|
+
backup_file = backup_dir / f"{technique_id}_{ts}.json"
|
|
225
|
+
with open(backup_file, "w") as f:
|
|
226
|
+
json.dump(t, f, indent=2)
|
|
227
|
+
# remove
|
|
228
|
+
self._data["techniques"] = [
|
|
229
|
+
x for x in self._data["techniques"] if x["id"] != technique_id
|
|
230
|
+
]
|
|
231
|
+
self._flush()
|
|
232
|
+
return {"id": technique_id, "deleted": True}
|
|
233
|
+
|
|
234
|
+
def increment_replay(self, technique_id: str) -> None:
|
|
235
|
+
"""Increment replay_count and set last_replayed_at."""
|
|
236
|
+
with self._lock:
|
|
237
|
+
t = self._find(technique_id)
|
|
238
|
+
t["replay_count"] = t.get("replay_count", 0) + 1
|
|
239
|
+
t["last_replayed_at"] = datetime.now(timezone.utc).isoformat()
|
|
240
|
+
self._flush()
|
|
241
|
+
|
|
242
|
+
# ── private helpers ──────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def _find(self, technique_id: str) -> dict:
|
|
245
|
+
"""Find technique by id (must hold lock). Returns mutable ref."""
|
|
246
|
+
for t in self._data["techniques"]:
|
|
247
|
+
if t["id"] == technique_id:
|
|
248
|
+
return t
|
|
249
|
+
raise ValueError(f"NOT_FOUND: technique '{technique_id}' does not exist")
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _searchable_text(t: dict) -> str:
|
|
253
|
+
"""Build a single lowercase string from all searchable fields."""
|
|
254
|
+
parts = [t.get("name", "")]
|
|
255
|
+
parts.extend(t.get("tags", []))
|
|
256
|
+
for v in t.get("qualities", {}).values():
|
|
257
|
+
if isinstance(v, str):
|
|
258
|
+
parts.append(v)
|
|
259
|
+
elif isinstance(v, list):
|
|
260
|
+
parts.extend(str(item) for item in v)
|
|
261
|
+
return " ".join(parts).lower()
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _multi_sort(results: list[dict]) -> list[dict]:
|
|
265
|
+
"""Sort: favorites first, rating desc, replay_count desc, updated_at desc."""
|
|
266
|
+
return sorted(
|
|
267
|
+
results,
|
|
268
|
+
key=lambda t: (
|
|
269
|
+
t.get("favorite", False),
|
|
270
|
+
t.get("rating", 0),
|
|
271
|
+
t.get("replay_count", 0),
|
|
272
|
+
t.get("updated_at", ""),
|
|
273
|
+
),
|
|
274
|
+
reverse=True,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _summary(t: dict) -> dict:
|
|
279
|
+
"""Everything except payload."""
|
|
280
|
+
return {k: v for k, v in t.items() if k != "payload"}
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _compact_summary(t: dict) -> dict:
|
|
284
|
+
"""Compact: id, name, type, tags, summary, favorite, rating, replay_count, timestamps."""
|
|
285
|
+
return {
|
|
286
|
+
"id": t["id"],
|
|
287
|
+
"name": t["name"],
|
|
288
|
+
"type": t["type"],
|
|
289
|
+
"tags": t.get("tags", []),
|
|
290
|
+
"summary": t.get("qualities", {}).get("summary", ""),
|
|
291
|
+
"favorite": t.get("favorite", False),
|
|
292
|
+
"rating": t.get("rating", 0),
|
|
293
|
+
"replay_count": t.get("replay_count", 0),
|
|
294
|
+
"created_at": t.get("created_at", ""),
|
|
295
|
+
"updated_at": t.get("updated_at", ""),
|
|
296
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""FastMCP entry point for LivePilot."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP, Context # noqa: F401
|
|
6
|
+
|
|
7
|
+
from .connection import AbletonConnection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def lifespan(server):
|
|
12
|
+
"""Create and yield the shared AbletonConnection for all tools."""
|
|
13
|
+
ableton = AbletonConnection()
|
|
14
|
+
try:
|
|
15
|
+
yield {"ableton": ableton}
|
|
16
|
+
finally:
|
|
17
|
+
ableton.disconnect()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("LivePilot", lifespan=lifespan)
|
|
21
|
+
|
|
22
|
+
# Import tool modules so they register with `mcp`
|
|
23
|
+
from .tools import transport # noqa: F401, E402
|
|
24
|
+
from .tools import tracks # noqa: F401, E402
|
|
25
|
+
from .tools import clips # noqa: F401, E402
|
|
26
|
+
from .tools import notes # noqa: F401, E402
|
|
27
|
+
from .tools import devices # noqa: F401, E402
|
|
28
|
+
from .tools import scenes # noqa: F401, E402
|
|
29
|
+
from .tools import mixing # noqa: F401, E402
|
|
30
|
+
from .tools import browser # noqa: F401, E402
|
|
31
|
+
from .tools import arrangement # noqa: F401, E402
|
|
32
|
+
from .tools import memory # noqa: F401, E402
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Schema coercion patch — accept strings for numeric parameters
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Some MCP clients (with deferred tools) send all parameter
|
|
39
|
+
# values as strings. Their client-side Zod validators reject "0" against
|
|
40
|
+
# {"type": "integer"} before the request even reaches our server.
|
|
41
|
+
#
|
|
42
|
+
# Fix: widen every integer/number property in the advertised JSON Schema to
|
|
43
|
+
# also accept strings. Server-side Pydantic validation (lax mode) coerces
|
|
44
|
+
# "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _coerce_schema_property(prop: dict) -> None:
|
|
48
|
+
"""Widen a single JSON Schema property to also accept strings."""
|
|
49
|
+
if prop.get("type") in ("integer", "number"):
|
|
50
|
+
original_type = prop.pop("type")
|
|
51
|
+
prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
|
|
52
|
+
elif "anyOf" in prop:
|
|
53
|
+
for variant in prop["anyOf"]:
|
|
54
|
+
_coerce_schema_property(variant)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_all_tools():
|
|
58
|
+
"""Get all registered tools, compatible with FastMCP 0.x and 3.x."""
|
|
59
|
+
# FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
|
|
60
|
+
if hasattr(mcp, "_tool_manager"):
|
|
61
|
+
return list(mcp._tool_manager._tools.values())
|
|
62
|
+
# FastMCP 3.x: mcp._local_provider._components (dict of key -> Tool)
|
|
63
|
+
if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
|
|
64
|
+
return list(mcp._local_provider._components.values())
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _patch_tool_schemas() -> None:
|
|
69
|
+
"""Post-process all registered tool schemas for string coercion."""
|
|
70
|
+
for tool in _get_all_tools():
|
|
71
|
+
props = tool.parameters.get("properties", {})
|
|
72
|
+
for name, prop in props.items():
|
|
73
|
+
if name == "ctx":
|
|
74
|
+
continue # skip the Context parameter
|
|
75
|
+
_coerce_schema_property(prop)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_patch_tool_schemas()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
"""Run the MCP server over stdio."""
|
|
83
|
+
mcp.run(transport="stdio")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool modules for LivePilot."""
|