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.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +47 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +47 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -14
  12. package/livepilot/commands/beat.md +68 -21
  13. package/livepilot/commands/evaluate.md +23 -13
  14. package/livepilot/commands/mix.md +35 -11
  15. package/livepilot/commands/perform.md +31 -19
  16. package/livepilot/commands/sounddesign.md +38 -17
  17. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  18. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  19. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  20. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  21. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  22. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  23. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  24. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  25. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  26. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  27. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  28. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  29. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  30. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  31. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  32. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  33. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  34. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  35. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  36. package/livepilot/skills/livepilot-release/SKILL.md +15 -15
  37. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  38. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  39. package/livepilot.mcpb +0 -0
  40. package/m4l_device/livepilot_bridge.js +1 -1
  41. package/manifest.json +91 -0
  42. package/mcp_server/__init__.py +1 -1
  43. package/mcp_server/creative_constraints/__init__.py +6 -0
  44. package/mcp_server/creative_constraints/engine.py +277 -0
  45. package/mcp_server/creative_constraints/models.py +75 -0
  46. package/mcp_server/creative_constraints/tools.py +341 -0
  47. package/mcp_server/experiment/__init__.py +6 -0
  48. package/mcp_server/experiment/engine.py +213 -0
  49. package/mcp_server/experiment/models.py +120 -0
  50. package/mcp_server/experiment/tools.py +263 -0
  51. package/mcp_server/hook_hunter/__init__.py +5 -0
  52. package/mcp_server/hook_hunter/analyzer.py +342 -0
  53. package/mcp_server/hook_hunter/models.py +57 -0
  54. package/mcp_server/hook_hunter/tools.py +586 -0
  55. package/mcp_server/memory/taste_graph.py +261 -0
  56. package/mcp_server/memory/tools.py +88 -0
  57. package/mcp_server/mix_engine/critics.py +2 -2
  58. package/mcp_server/mix_engine/models.py +1 -1
  59. package/mcp_server/mix_engine/state_builder.py +2 -2
  60. package/mcp_server/musical_intelligence/__init__.py +8 -0
  61. package/mcp_server/musical_intelligence/detectors.py +421 -0
  62. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  63. package/mcp_server/musical_intelligence/tools.py +221 -0
  64. package/mcp_server/preview_studio/__init__.py +5 -0
  65. package/mcp_server/preview_studio/engine.py +280 -0
  66. package/mcp_server/preview_studio/models.py +73 -0
  67. package/mcp_server/preview_studio/tools.py +423 -0
  68. package/mcp_server/runtime/session_kernel.py +96 -0
  69. package/mcp_server/runtime/tools.py +90 -1
  70. package/mcp_server/semantic_moves/__init__.py +13 -0
  71. package/mcp_server/semantic_moves/compiler.py +116 -0
  72. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  73. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  74. package/mcp_server/semantic_moves/models.py +46 -0
  75. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  76. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  77. package/mcp_server/semantic_moves/registry.py +32 -0
  78. package/mcp_server/semantic_moves/resolvers.py +126 -0
  79. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  80. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  81. package/mcp_server/semantic_moves/tools.py +204 -0
  82. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  83. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  84. package/mcp_server/server.py +10 -0
  85. package/mcp_server/session_continuity/__init__.py +6 -0
  86. package/mcp_server/session_continuity/models.py +86 -0
  87. package/mcp_server/session_continuity/tools.py +230 -0
  88. package/mcp_server/session_continuity/tracker.py +235 -0
  89. package/mcp_server/song_brain/__init__.py +6 -0
  90. package/mcp_server/song_brain/builder.py +477 -0
  91. package/mcp_server/song_brain/models.py +132 -0
  92. package/mcp_server/song_brain/tools.py +294 -0
  93. package/mcp_server/stuckness_detector/__init__.py +5 -0
  94. package/mcp_server/stuckness_detector/detector.py +400 -0
  95. package/mcp_server/stuckness_detector/models.py +66 -0
  96. package/mcp_server/stuckness_detector/tools.py +195 -0
  97. package/mcp_server/tools/_conductor.py +104 -6
  98. package/mcp_server/tools/analyzer.py +1 -1
  99. package/mcp_server/tools/devices.py +34 -0
  100. package/mcp_server/wonder_mode/__init__.py +6 -0
  101. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  102. package/mcp_server/wonder_mode/engine.py +493 -0
  103. package/mcp_server/wonder_mode/session.py +114 -0
  104. package/mcp_server/wonder_mode/tools.py +285 -0
  105. package/package.json +2 -2
  106. package/remote_script/LivePilot/__init__.py +1 -1
  107. package/remote_script/LivePilot/browser.py +4 -1
  108. package/remote_script/LivePilot/devices.py +29 -0
  109. package/remote_script/LivePilot/tracks.py +11 -4
  110. 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.21",
3
+ "version": "1.9.23",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 237 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
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.21"
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, "results": results, "count": len(results)}
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
- if "color_index" in params:
124
- track.color_index = int(params["color_index"])
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
- if "color_index" in params:
144
- track.color_index = int(params["color_index"])
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()