livepilot 1.9.21 → 1.9.23
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 +2 -2
- package/CHANGELOG.md +47 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +47 -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 -14
- package/livepilot/commands/beat.md +68 -21
- package/livepilot/commands/evaluate.md +23 -13
- package/livepilot/commands/mix.md +35 -11
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -17
- 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 +15 -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/m4l_device/livepilot_bridge.js +1 -1
- 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 +342 -0
- package/mcp_server/hook_hunter/models.py +57 -0
- package/mcp_server/hook_hunter/tools.py +586 -0
- package/mcp_server/memory/taste_graph.py +261 -0
- package/mcp_server/memory/tools.py +88 -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 +421 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +221 -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 +73 -0
- package/mcp_server/preview_studio/tools.py +423 -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 +204 -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/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 +235 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +477 -0
- package/mcp_server/song_brain/models.py +132 -0
- package/mcp_server/song_brain/tools.py +294 -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 +285 -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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Wonder Mode MCP tools — 3 tools for stuck-rescue workflow.
|
|
2
|
+
|
|
3
|
+
enter_wonder_mode — diagnose + generate distinct variants + open thread
|
|
4
|
+
rank_wonder_variants — standalone re-ranker for any variant list
|
|
5
|
+
discard_wonder_session — reject all variants, keep thread open
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastmcp import Context
|
|
11
|
+
|
|
12
|
+
from ..server import mcp
|
|
13
|
+
from . import engine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_song_brain_dict() -> dict:
|
|
17
|
+
try:
|
|
18
|
+
from ..song_brain.tools import _current_brain
|
|
19
|
+
if _current_brain is not None:
|
|
20
|
+
return _current_brain.to_dict()
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_taste_graph(ctx: Context):
|
|
27
|
+
"""Return the TasteGraph object (not dict) for engine use."""
|
|
28
|
+
try:
|
|
29
|
+
from ..memory.taste_graph import build_taste_graph
|
|
30
|
+
from ..memory.taste_memory import TasteMemoryStore
|
|
31
|
+
from ..memory.anti_memory import AntiMemoryStore
|
|
32
|
+
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
33
|
+
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
34
|
+
return build_taste_graph(taste_store=taste_store, anti_store=anti_store)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_active_constraints():
|
|
41
|
+
"""Read active constraints from creative_constraints module if set."""
|
|
42
|
+
try:
|
|
43
|
+
from ..creative_constraints.tools import _active_constraints
|
|
44
|
+
return _active_constraints
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_ledger_entries(ctx: Context) -> list[dict]:
|
|
50
|
+
"""Get recent action ledger entries as dicts."""
|
|
51
|
+
try:
|
|
52
|
+
from ..runtime.action_ledger import SessionLedger
|
|
53
|
+
ledger: SessionLedger = ctx.lifespan_context.setdefault(
|
|
54
|
+
"action_ledger", SessionLedger()
|
|
55
|
+
)
|
|
56
|
+
entries = ledger.get_recent_moves(limit=20)
|
|
57
|
+
return [e.to_dict() for e in entries]
|
|
58
|
+
except Exception:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_stuckness_report(ctx: Context, song_brain: dict) -> dict | None:
|
|
63
|
+
"""Run stuckness detection on recent actions if available."""
|
|
64
|
+
try:
|
|
65
|
+
from ..stuckness_detector.detector import detect_stuckness
|
|
66
|
+
action_ledger = _get_ledger_entries(ctx)
|
|
67
|
+
if not action_ledger:
|
|
68
|
+
return None
|
|
69
|
+
# Pass session_info if available for better accuracy
|
|
70
|
+
session_info = {}
|
|
71
|
+
try:
|
|
72
|
+
ableton = ctx.lifespan_context.get("ableton")
|
|
73
|
+
if ableton:
|
|
74
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
report = detect_stuckness(
|
|
78
|
+
action_history=action_ledger,
|
|
79
|
+
session_info=session_info,
|
|
80
|
+
song_brain=song_brain,
|
|
81
|
+
)
|
|
82
|
+
return report.to_dict()
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
def enter_wonder_mode(
|
|
89
|
+
ctx: Context,
|
|
90
|
+
request_text: str,
|
|
91
|
+
kernel_id: str = "",
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Activate Wonder Mode — stuck-rescue workflow with real diagnosis.
|
|
94
|
+
|
|
95
|
+
Diagnoses why the session needs creative rescue, generates 1-3
|
|
96
|
+
genuinely distinct executable variants (plus honest analytical
|
|
97
|
+
fallbacks), and opens a creative thread for tracking.
|
|
98
|
+
|
|
99
|
+
Returns wonder_session_id for use with create_preview_set,
|
|
100
|
+
commit_preview_variant, and discard_wonder_session.
|
|
101
|
+
|
|
102
|
+
request_text: the creative request or description of being stuck
|
|
103
|
+
kernel_id: optional session kernel reference
|
|
104
|
+
"""
|
|
105
|
+
if not request_text.strip():
|
|
106
|
+
return {"error": "request_text cannot be empty"}
|
|
107
|
+
|
|
108
|
+
from .diagnosis import build_diagnosis
|
|
109
|
+
from .session import WonderSession, store_wonder_session
|
|
110
|
+
|
|
111
|
+
song_brain = _get_song_brain_dict()
|
|
112
|
+
taste_graph = _get_taste_graph(ctx)
|
|
113
|
+
active_constraints = _get_active_constraints()
|
|
114
|
+
action_ledger = _get_ledger_entries(ctx)
|
|
115
|
+
stuckness_report = _get_stuckness_report(ctx, song_brain)
|
|
116
|
+
|
|
117
|
+
# 1. Build diagnosis
|
|
118
|
+
diagnosis = build_diagnosis(
|
|
119
|
+
stuckness_report=stuckness_report,
|
|
120
|
+
song_brain=song_brain,
|
|
121
|
+
action_ledger=action_ledger,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 2. Generate variants
|
|
125
|
+
result = engine.generate_wonder_variants(
|
|
126
|
+
request_text=request_text,
|
|
127
|
+
diagnosis=diagnosis.to_dict(),
|
|
128
|
+
kernel_id=kernel_id,
|
|
129
|
+
song_brain=song_brain,
|
|
130
|
+
taste_graph=taste_graph,
|
|
131
|
+
active_constraints=active_constraints,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# 3. Create WonderSession (unique per invocation, not deterministic)
|
|
135
|
+
import hashlib, time
|
|
136
|
+
_seed = f"{request_text}:{kernel_id}:{time.time()}"
|
|
137
|
+
session_id = "ws_" + hashlib.sha256(_seed.encode()).hexdigest()[:12]
|
|
138
|
+
ws = WonderSession(
|
|
139
|
+
session_id=session_id,
|
|
140
|
+
request_text=request_text,
|
|
141
|
+
kernel_id=kernel_id,
|
|
142
|
+
diagnosis=diagnosis,
|
|
143
|
+
variants=result["variants"],
|
|
144
|
+
recommended=result.get("recommended", ""),
|
|
145
|
+
variant_count_actual=result.get("variant_count_actual", 0),
|
|
146
|
+
degraded_reason=result.get("degraded_reason", ""),
|
|
147
|
+
status="diagnosing", # will transition below
|
|
148
|
+
)
|
|
149
|
+
ws.transition_to("variants_ready")
|
|
150
|
+
|
|
151
|
+
# 4. Open creative thread (exploration, NOT turn resolution)
|
|
152
|
+
try:
|
|
153
|
+
from ..session_continuity.tracker import open_thread
|
|
154
|
+
thread_domain = diagnosis.candidate_domains[0] if diagnosis.candidate_domains else "exploration"
|
|
155
|
+
thread = open_thread(
|
|
156
|
+
description=f"Wonder: {request_text}",
|
|
157
|
+
domain=thread_domain,
|
|
158
|
+
)
|
|
159
|
+
ws.creative_thread_id = thread.thread_id
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# 5. Store session
|
|
164
|
+
store_wonder_session(ws)
|
|
165
|
+
|
|
166
|
+
# 6. Return full response (NO turn resolution recorded here)
|
|
167
|
+
return {
|
|
168
|
+
"wonder_session_id": ws.session_id,
|
|
169
|
+
"creative_thread_id": ws.creative_thread_id,
|
|
170
|
+
"diagnosis": diagnosis.to_dict(),
|
|
171
|
+
"variants": result["variants"],
|
|
172
|
+
"recommended": result.get("recommended", ""),
|
|
173
|
+
"variant_count_actual": result.get("variant_count_actual", 0),
|
|
174
|
+
"degraded_reason": ws.degraded_reason,
|
|
175
|
+
"mode": "wonder",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def rank_wonder_variants(
|
|
181
|
+
ctx: Context,
|
|
182
|
+
variants: list[dict] | None = None,
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""Rank wonder-mode variants by taste + identity + novelty + coherence.
|
|
185
|
+
|
|
186
|
+
Standalone re-ranker for any list of variant dicts. Preserves ALL
|
|
187
|
+
input fields (what_changed, compiled_plan, move_id, targets_snapshot).
|
|
188
|
+
|
|
189
|
+
Uses the current SongBrain and session taste graph for scoring.
|
|
190
|
+
When input dicts lack targets_snapshot, sacred element penalty
|
|
191
|
+
is skipped gracefully.
|
|
192
|
+
|
|
193
|
+
variants: list of variant dicts with at least variant_id,
|
|
194
|
+
novelty_level, identity_effect, taste_fit fields
|
|
195
|
+
|
|
196
|
+
Returns ranked list with composite scores, breakdowns, and recommendation.
|
|
197
|
+
"""
|
|
198
|
+
if not variants:
|
|
199
|
+
return {"error": "No variants provided", "rankings": []}
|
|
200
|
+
|
|
201
|
+
song_brain = _get_song_brain_dict()
|
|
202
|
+
taste_graph = _get_taste_graph(ctx)
|
|
203
|
+
|
|
204
|
+
novelty_band = 0.5
|
|
205
|
+
taste_evidence = 0
|
|
206
|
+
if taste_graph is not None:
|
|
207
|
+
novelty_band = taste_graph.novelty_band
|
|
208
|
+
taste_evidence = taste_graph.evidence_count
|
|
209
|
+
|
|
210
|
+
ranked = engine.rank_variants(
|
|
211
|
+
variant_dicts=[dict(v) for v in variants], # copy to avoid mutating input
|
|
212
|
+
song_brain=song_brain,
|
|
213
|
+
novelty_band=novelty_band,
|
|
214
|
+
taste_evidence=taste_evidence,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"rankings": ranked,
|
|
219
|
+
"recommended": ranked[0]["variant_id"] if ranked else "",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@mcp.tool()
|
|
224
|
+
def discard_wonder_session(
|
|
225
|
+
ctx: Context,
|
|
226
|
+
wonder_session_id: str,
|
|
227
|
+
) -> dict:
|
|
228
|
+
"""Reject all Wonder variants and close the session.
|
|
229
|
+
|
|
230
|
+
The creative thread stays open — the problem isn't solved.
|
|
231
|
+
Records a rejected turn resolution and updates taste.
|
|
232
|
+
|
|
233
|
+
wonder_session_id: the session to discard
|
|
234
|
+
"""
|
|
235
|
+
from .session import get_wonder_session
|
|
236
|
+
|
|
237
|
+
ws = get_wonder_session(wonder_session_id)
|
|
238
|
+
if not ws:
|
|
239
|
+
return {"error": "Wonder session not found", "wonder_session_id": wonder_session_id}
|
|
240
|
+
|
|
241
|
+
if not ws.transition_to("resolved"):
|
|
242
|
+
return {"error": f"Cannot discard session in '{ws.status}' state", "wonder_session_id": wonder_session_id}
|
|
243
|
+
|
|
244
|
+
ws.outcome = "rejected_all"
|
|
245
|
+
|
|
246
|
+
# Record rejected turn
|
|
247
|
+
try:
|
|
248
|
+
from ..session_continuity.tracker import record_turn_resolution
|
|
249
|
+
record_turn_resolution(
|
|
250
|
+
request_text=ws.request_text,
|
|
251
|
+
outcome="rejected",
|
|
252
|
+
move_applied="",
|
|
253
|
+
identity_effect="",
|
|
254
|
+
user_sentiment="disliked",
|
|
255
|
+
)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
# Update taste graph — rejection is a negative signal for all executable variants
|
|
260
|
+
try:
|
|
261
|
+
taste_graph = _get_taste_graph(ctx)
|
|
262
|
+
if taste_graph:
|
|
263
|
+
for v in ws.variants:
|
|
264
|
+
if not v.get("analytical_only") and v.get("move_id") and v.get("family"):
|
|
265
|
+
taste_graph.record_move_outcome(
|
|
266
|
+
move_id=v["move_id"],
|
|
267
|
+
family=v["family"],
|
|
268
|
+
kept=False,
|
|
269
|
+
)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Discard linked preview set
|
|
274
|
+
if ws.preview_set_id:
|
|
275
|
+
try:
|
|
276
|
+
from ..preview_studio.engine import discard_set
|
|
277
|
+
discard_set(ws.preview_set_id)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"discarded": True,
|
|
283
|
+
"wonder_session_id": wonder_session_id,
|
|
284
|
+
"thread_still_open": bool(ws.creative_thread_id),
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.23",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 293 tools, 39 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "MIT",
|
|
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.9.
|
|
8
|
+
__version__ = "1.9.23"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -184,10 +184,13 @@ def search_browser(song, params):
|
|
|
184
184
|
_search_recursive(item, name_filter, loadable_only, results, 0, max_depth,
|
|
185
185
|
max_results)
|
|
186
186
|
truncated = len(results) >= max_results
|
|
187
|
-
result = {"path": path, "
|
|
187
|
+
result = {"path": path, "items": results, "total_results": len(results)}
|
|
188
188
|
if truncated:
|
|
189
189
|
result["truncated"] = True
|
|
190
190
|
result["max_results"] = max_results
|
|
191
|
+
# Legacy alias for backward compatibility
|
|
192
|
+
result["results"] = results
|
|
193
|
+
result["count"] = len(results)
|
|
191
194
|
return result
|
|
192
195
|
|
|
193
196
|
|
|
@@ -209,6 +209,35 @@ def delete_device(song, params):
|
|
|
209
209
|
return {"deleted": device_index}
|
|
210
210
|
|
|
211
211
|
|
|
212
|
+
@register("move_device")
|
|
213
|
+
def move_device(song, params):
|
|
214
|
+
"""Move a device to a new position on the same or different track.
|
|
215
|
+
|
|
216
|
+
Uses Song.move_device(device, target_track, target_index).
|
|
217
|
+
"""
|
|
218
|
+
track_index = int(params["track_index"])
|
|
219
|
+
device_index = int(params["device_index"])
|
|
220
|
+
target_index = int(params.get("target_index", device_index))
|
|
221
|
+
target_track_index = params.get("target_track_index", None)
|
|
222
|
+
|
|
223
|
+
track = get_track(song, track_index)
|
|
224
|
+
device = get_device(track, device_index)
|
|
225
|
+
|
|
226
|
+
if target_track_index is not None:
|
|
227
|
+
target_track = get_track(song, int(target_track_index))
|
|
228
|
+
else:
|
|
229
|
+
target_track = track
|
|
230
|
+
|
|
231
|
+
song.move_device(device, target_track, target_index)
|
|
232
|
+
return {
|
|
233
|
+
"moved": device.name,
|
|
234
|
+
"from_track": track_index,
|
|
235
|
+
"from_index": device_index,
|
|
236
|
+
"to_track": int(target_track_index) if target_track_index is not None else track_index,
|
|
237
|
+
"to_index": target_index,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
212
241
|
@register("load_device_by_uri")
|
|
213
242
|
def load_device_by_uri(song, params):
|
|
214
243
|
"""Load a device onto a track using a browser URI.
|
|
@@ -120,8 +120,9 @@ def create_midi_track(song, params):
|
|
|
120
120
|
track = list(song.tracks)[new_index]
|
|
121
121
|
if "name" in params:
|
|
122
122
|
track.name = str(params["name"])
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
color = params.get("color_index", params.get("color", None))
|
|
124
|
+
if color is not None:
|
|
125
|
+
track.color_index = int(color)
|
|
125
126
|
# Ableton auto-arms newly created tracks — disarm to avoid surprises
|
|
126
127
|
if track.arm and not params.get("arm", False):
|
|
127
128
|
track.arm = False
|
|
@@ -140,8 +141,9 @@ def create_audio_track(song, params):
|
|
|
140
141
|
track = list(song.tracks)[new_index]
|
|
141
142
|
if "name" in params:
|
|
142
143
|
track.name = str(params["name"])
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
color = params.get("color_index", params.get("color", None))
|
|
145
|
+
if color is not None:
|
|
146
|
+
track.color_index = int(color)
|
|
145
147
|
# Ableton auto-arms newly created tracks — disarm to avoid surprises
|
|
146
148
|
if track.arm and not params.get("arm", False):
|
|
147
149
|
track.arm = False
|
|
@@ -157,6 +159,11 @@ def create_return_track(song, params):
|
|
|
157
159
|
return {"index": new_index, "name": return_tracks[new_index].name}
|
|
158
160
|
|
|
159
161
|
|
|
162
|
+
# NOTE: move_track is not supported by the Live Object Model.
|
|
163
|
+
# Tracks can only be created, deleted, and duplicated — not reordered.
|
|
164
|
+
# Users must reorder tracks manually in Ableton's GUI.
|
|
165
|
+
|
|
166
|
+
|
|
160
167
|
@register("delete_track")
|
|
161
168
|
def delete_track(song, params):
|
|
162
169
|
"""Delete a track by index."""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate tool catalog from live runtime metadata.
|
|
3
|
+
|
|
4
|
+
Produces a markdown tool catalog validated against mcp.list_tools().
|
|
5
|
+
This is the single source of truth — hand-edited catalogs are replaced.
|
|
6
|
+
|
|
7
|
+
Usage: python3 scripts/generate_tool_catalog.py > docs/manual/tool-catalog-generated.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import sys
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
sys.path.insert(0, str(ROOT))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_tools() -> list[dict]:
|
|
21
|
+
"""Get all registered tools with metadata."""
|
|
22
|
+
from mcp_server.server import mcp
|
|
23
|
+
|
|
24
|
+
tools_raw = asyncio.run(mcp.list_tools())
|
|
25
|
+
tools = []
|
|
26
|
+
for t in tools_raw:
|
|
27
|
+
# Get the module path to determine domain
|
|
28
|
+
func = t.fn if hasattr(t, "fn") else None
|
|
29
|
+
module = ""
|
|
30
|
+
if func:
|
|
31
|
+
module = func.__module__ if hasattr(func, "__module__") else ""
|
|
32
|
+
|
|
33
|
+
# Get parameter names
|
|
34
|
+
params = []
|
|
35
|
+
if func:
|
|
36
|
+
sig = inspect.signature(func)
|
|
37
|
+
for name, param in sig.parameters.items():
|
|
38
|
+
if name == "ctx":
|
|
39
|
+
continue
|
|
40
|
+
required = param.default is inspect.Parameter.empty
|
|
41
|
+
params.append({"name": name, "required": required})
|
|
42
|
+
|
|
43
|
+
tools.append({
|
|
44
|
+
"name": t.name,
|
|
45
|
+
"description": t.description[:120] if hasattr(t, "description") and t.description else "",
|
|
46
|
+
"module": module,
|
|
47
|
+
"params": params,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return tools
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def infer_domain(module: str) -> str:
|
|
54
|
+
"""Infer domain from module path."""
|
|
55
|
+
if "semantic_moves" in module:
|
|
56
|
+
return "Semantic Moves"
|
|
57
|
+
if "experiment" in module:
|
|
58
|
+
return "Experiments"
|
|
59
|
+
if "musical_intelligence" in module:
|
|
60
|
+
return "Musical Intelligence"
|
|
61
|
+
if "memory.tools" in module:
|
|
62
|
+
return "Memory Fabric"
|
|
63
|
+
if "mix_engine" in module:
|
|
64
|
+
return "Mix Engine"
|
|
65
|
+
if "sound_design" in module:
|
|
66
|
+
return "Sound Design"
|
|
67
|
+
if "transition_engine" in module:
|
|
68
|
+
return "Transition Engine"
|
|
69
|
+
if "reference_engine" in module:
|
|
70
|
+
return "Reference Engine"
|
|
71
|
+
if "translation_engine" in module:
|
|
72
|
+
return "Translation Engine"
|
|
73
|
+
if "performance_engine" in module:
|
|
74
|
+
return "Performance Engine"
|
|
75
|
+
if "project_brain" in module:
|
|
76
|
+
return "Project Brain"
|
|
77
|
+
if "evaluation" in module:
|
|
78
|
+
return "Evaluation"
|
|
79
|
+
if "runtime" in module:
|
|
80
|
+
return "Runtime"
|
|
81
|
+
|
|
82
|
+
# Core tools — extract from module name
|
|
83
|
+
parts = module.split(".")
|
|
84
|
+
for p in reversed(parts):
|
|
85
|
+
if p in ("transport", "tracks", "clips", "notes", "devices", "scenes",
|
|
86
|
+
"mixing", "browser", "arrangement", "memory", "analyzer",
|
|
87
|
+
"automation", "theory", "generative", "harmony", "midi_io",
|
|
88
|
+
"perception", "agent_os", "composition", "motif", "research",
|
|
89
|
+
"planner"):
|
|
90
|
+
return p.replace("_", " ").title()
|
|
91
|
+
|
|
92
|
+
return "Other"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main():
|
|
96
|
+
tools = get_tools()
|
|
97
|
+
total = len(tools)
|
|
98
|
+
|
|
99
|
+
# Group by domain
|
|
100
|
+
domains = defaultdict(list)
|
|
101
|
+
for t in tools:
|
|
102
|
+
domain = infer_domain(t["module"])
|
|
103
|
+
domains[domain].append(t)
|
|
104
|
+
|
|
105
|
+
print(f"# LivePilot — Full Tool Catalog (Generated)")
|
|
106
|
+
print()
|
|
107
|
+
print(f"{total} tools across {len(domains)} domains.")
|
|
108
|
+
print()
|
|
109
|
+
print("> Auto-generated from `mcp.list_tools()`. Do not hand-edit.")
|
|
110
|
+
print("> Regenerate: `python3 scripts/generate_tool_catalog.py`")
|
|
111
|
+
print()
|
|
112
|
+
print("---")
|
|
113
|
+
print()
|
|
114
|
+
|
|
115
|
+
for domain in sorted(domains.keys()):
|
|
116
|
+
tool_list = sorted(domains[domain], key=lambda t: t["name"])
|
|
117
|
+
print(f"## {domain} ({len(tool_list)})")
|
|
118
|
+
print()
|
|
119
|
+
print("| Tool | Description |")
|
|
120
|
+
print("|------|-------------|")
|
|
121
|
+
for t in tool_list:
|
|
122
|
+
desc = t["description"].split("\n")[0].strip()
|
|
123
|
+
print(f"| `{t['name']}` | {desc} |")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
print(f"---")
|
|
127
|
+
print(f"*Generated from {total} registered tools.*")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|