livepilot 1.9.22 → 1.9.24

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 (118) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +3 -3
  4. package/CHANGELOG.md +84 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +141 -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 -23
  12. package/livepilot/commands/mix.md +34 -19
  13. package/livepilot/commands/perform.md +31 -19
  14. package/livepilot/commands/sounddesign.md +38 -25
  15. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  17. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  18. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  19. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  20. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  21. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  22. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  23. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  24. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  25. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  26. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  27. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  28. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  29. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  30. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  31. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  32. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  33. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  34. package/livepilot/skills/livepilot-release/SKILL.md +29 -15
  35. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  36. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  37. package/livepilot.mcpb +0 -0
  38. package/manifest.json +91 -0
  39. package/mcp_server/__init__.py +1 -1
  40. package/mcp_server/creative_constraints/__init__.py +6 -0
  41. package/mcp_server/creative_constraints/engine.py +277 -0
  42. package/mcp_server/creative_constraints/models.py +75 -0
  43. package/mcp_server/creative_constraints/tools.py +341 -0
  44. package/mcp_server/experiment/__init__.py +6 -0
  45. package/mcp_server/experiment/engine.py +213 -0
  46. package/mcp_server/experiment/models.py +120 -0
  47. package/mcp_server/experiment/tools.py +263 -0
  48. package/mcp_server/hook_hunter/__init__.py +5 -0
  49. package/mcp_server/hook_hunter/analyzer.py +365 -0
  50. package/mcp_server/hook_hunter/models.py +58 -0
  51. package/mcp_server/hook_hunter/tools.py +588 -0
  52. package/mcp_server/memory/taste_graph.py +328 -0
  53. package/mcp_server/memory/tools.py +99 -0
  54. package/mcp_server/mix_engine/critics.py +2 -2
  55. package/mcp_server/mix_engine/models.py +1 -1
  56. package/mcp_server/mix_engine/state_builder.py +2 -2
  57. package/mcp_server/musical_intelligence/__init__.py +8 -0
  58. package/mcp_server/musical_intelligence/detectors.py +434 -0
  59. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  60. package/mcp_server/musical_intelligence/tools.py +224 -0
  61. package/mcp_server/persistence/__init__.py +1 -0
  62. package/mcp_server/persistence/base_store.py +82 -0
  63. package/mcp_server/persistence/project_store.py +106 -0
  64. package/mcp_server/persistence/taste_store.py +122 -0
  65. package/mcp_server/preview_studio/__init__.py +5 -0
  66. package/mcp_server/preview_studio/engine.py +280 -0
  67. package/mcp_server/preview_studio/models.py +74 -0
  68. package/mcp_server/preview_studio/tools.py +466 -0
  69. package/mcp_server/runtime/capability.py +66 -0
  70. package/mcp_server/runtime/capability_probe.py +118 -0
  71. package/mcp_server/runtime/execution_router.py +139 -0
  72. package/mcp_server/runtime/remote_commands.py +82 -0
  73. package/mcp_server/runtime/session_kernel.py +96 -0
  74. package/mcp_server/runtime/tools.py +90 -1
  75. package/mcp_server/semantic_moves/__init__.py +13 -0
  76. package/mcp_server/semantic_moves/compiler.py +116 -0
  77. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  78. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  79. package/mcp_server/semantic_moves/models.py +46 -0
  80. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  81. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  82. package/mcp_server/semantic_moves/registry.py +32 -0
  83. package/mcp_server/semantic_moves/resolvers.py +126 -0
  84. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  85. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  86. package/mcp_server/semantic_moves/tools.py +205 -0
  87. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  88. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  89. package/mcp_server/server.py +10 -0
  90. package/mcp_server/services/__init__.py +1 -0
  91. package/mcp_server/services/motif_service.py +67 -0
  92. package/mcp_server/session_continuity/__init__.py +6 -0
  93. package/mcp_server/session_continuity/models.py +86 -0
  94. package/mcp_server/session_continuity/tools.py +230 -0
  95. package/mcp_server/session_continuity/tracker.py +263 -0
  96. package/mcp_server/song_brain/__init__.py +6 -0
  97. package/mcp_server/song_brain/builder.py +504 -0
  98. package/mcp_server/song_brain/models.py +136 -0
  99. package/mcp_server/song_brain/tools.py +312 -0
  100. package/mcp_server/stuckness_detector/__init__.py +5 -0
  101. package/mcp_server/stuckness_detector/detector.py +400 -0
  102. package/mcp_server/stuckness_detector/models.py +66 -0
  103. package/mcp_server/stuckness_detector/tools.py +195 -0
  104. package/mcp_server/tools/_conductor.py +104 -6
  105. package/mcp_server/tools/analyzer.py +1 -1
  106. package/mcp_server/tools/devices.py +34 -0
  107. package/mcp_server/wonder_mode/__init__.py +6 -0
  108. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  109. package/mcp_server/wonder_mode/engine.py +493 -0
  110. package/mcp_server/wonder_mode/session.py +114 -0
  111. package/mcp_server/wonder_mode/tools.py +290 -0
  112. package/package.json +2 -2
  113. package/remote_script/LivePilot/__init__.py +1 -1
  114. package/remote_script/LivePilot/browser.py +4 -1
  115. package/remote_script/LivePilot/devices.py +29 -0
  116. package/remote_script/LivePilot/tracks.py +11 -4
  117. package/scripts/generate_tool_catalog.py +131 -0
  118. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,290 @@
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
+ from ..persistence.taste_store import PersistentTasteStore
33
+ taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
34
+ anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
35
+ persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
36
+ return build_taste_graph(
37
+ taste_store=taste_store, anti_store=anti_store,
38
+ persistent_store=persistent,
39
+ )
40
+ except Exception:
41
+ pass
42
+ return None
43
+
44
+
45
+ def _get_active_constraints():
46
+ """Read active constraints from creative_constraints module if set."""
47
+ try:
48
+ from ..creative_constraints.tools import _active_constraints
49
+ return _active_constraints
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _get_ledger_entries(ctx: Context) -> list[dict]:
55
+ """Get recent action ledger entries as dicts."""
56
+ try:
57
+ from ..runtime.action_ledger import SessionLedger
58
+ ledger: SessionLedger = ctx.lifespan_context.setdefault(
59
+ "action_ledger", SessionLedger()
60
+ )
61
+ entries = ledger.get_recent_moves(limit=20)
62
+ return [e.to_dict() for e in entries]
63
+ except Exception:
64
+ return []
65
+
66
+
67
+ def _get_stuckness_report(ctx: Context, song_brain: dict) -> dict | None:
68
+ """Run stuckness detection on recent actions if available."""
69
+ try:
70
+ from ..stuckness_detector.detector import detect_stuckness
71
+ action_ledger = _get_ledger_entries(ctx)
72
+ if not action_ledger:
73
+ return None
74
+ # Pass session_info if available for better accuracy
75
+ session_info = {}
76
+ try:
77
+ ableton = ctx.lifespan_context.get("ableton")
78
+ if ableton:
79
+ session_info = ableton.send_command("get_session_info", {})
80
+ except Exception:
81
+ pass
82
+ report = detect_stuckness(
83
+ action_history=action_ledger,
84
+ session_info=session_info,
85
+ song_brain=song_brain,
86
+ )
87
+ return report.to_dict()
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ @mcp.tool()
93
+ def enter_wonder_mode(
94
+ ctx: Context,
95
+ request_text: str,
96
+ kernel_id: str = "",
97
+ ) -> dict:
98
+ """Activate Wonder Mode — stuck-rescue workflow with real diagnosis.
99
+
100
+ Diagnoses why the session needs creative rescue, generates 1-3
101
+ genuinely distinct executable variants (plus honest analytical
102
+ fallbacks), and opens a creative thread for tracking.
103
+
104
+ Returns wonder_session_id for use with create_preview_set,
105
+ commit_preview_variant, and discard_wonder_session.
106
+
107
+ request_text: the creative request or description of being stuck
108
+ kernel_id: optional session kernel reference
109
+ """
110
+ if not request_text.strip():
111
+ return {"error": "request_text cannot be empty"}
112
+
113
+ from .diagnosis import build_diagnosis
114
+ from .session import WonderSession, store_wonder_session
115
+
116
+ song_brain = _get_song_brain_dict()
117
+ taste_graph = _get_taste_graph(ctx)
118
+ active_constraints = _get_active_constraints()
119
+ action_ledger = _get_ledger_entries(ctx)
120
+ stuckness_report = _get_stuckness_report(ctx, song_brain)
121
+
122
+ # 1. Build diagnosis
123
+ diagnosis = build_diagnosis(
124
+ stuckness_report=stuckness_report,
125
+ song_brain=song_brain,
126
+ action_ledger=action_ledger,
127
+ )
128
+
129
+ # 2. Generate variants
130
+ result = engine.generate_wonder_variants(
131
+ request_text=request_text,
132
+ diagnosis=diagnosis.to_dict(),
133
+ kernel_id=kernel_id,
134
+ song_brain=song_brain,
135
+ taste_graph=taste_graph,
136
+ active_constraints=active_constraints,
137
+ )
138
+
139
+ # 3. Create WonderSession (unique per invocation, not deterministic)
140
+ import hashlib, time
141
+ _seed = f"{request_text}:{kernel_id}:{time.time()}"
142
+ session_id = "ws_" + hashlib.sha256(_seed.encode()).hexdigest()[:12]
143
+ ws = WonderSession(
144
+ session_id=session_id,
145
+ request_text=request_text,
146
+ kernel_id=kernel_id,
147
+ diagnosis=diagnosis,
148
+ variants=result["variants"],
149
+ recommended=result.get("recommended", ""),
150
+ variant_count_actual=result.get("variant_count_actual", 0),
151
+ degraded_reason=result.get("degraded_reason", ""),
152
+ status="diagnosing", # will transition below
153
+ )
154
+ ws.transition_to("variants_ready")
155
+
156
+ # 4. Open creative thread (exploration, NOT turn resolution)
157
+ try:
158
+ from ..session_continuity.tracker import open_thread
159
+ thread_domain = diagnosis.candidate_domains[0] if diagnosis.candidate_domains else "exploration"
160
+ thread = open_thread(
161
+ description=f"Wonder: {request_text}",
162
+ domain=thread_domain,
163
+ )
164
+ ws.creative_thread_id = thread.thread_id
165
+ except Exception:
166
+ pass
167
+
168
+ # 5. Store session
169
+ store_wonder_session(ws)
170
+
171
+ # 6. Return full response (NO turn resolution recorded here)
172
+ return {
173
+ "wonder_session_id": ws.session_id,
174
+ "creative_thread_id": ws.creative_thread_id,
175
+ "diagnosis": diagnosis.to_dict(),
176
+ "variants": result["variants"],
177
+ "recommended": result.get("recommended", ""),
178
+ "variant_count_actual": result.get("variant_count_actual", 0),
179
+ "degraded_reason": ws.degraded_reason,
180
+ "mode": "wonder",
181
+ }
182
+
183
+
184
+ @mcp.tool()
185
+ def rank_wonder_variants(
186
+ ctx: Context,
187
+ variants: list[dict] | None = None,
188
+ ) -> dict:
189
+ """Rank wonder-mode variants by taste + identity + novelty + coherence.
190
+
191
+ Standalone re-ranker for any list of variant dicts. Preserves ALL
192
+ input fields (what_changed, compiled_plan, move_id, targets_snapshot).
193
+
194
+ Uses the current SongBrain and session taste graph for scoring.
195
+ When input dicts lack targets_snapshot, sacred element penalty
196
+ is skipped gracefully.
197
+
198
+ variants: list of variant dicts with at least variant_id,
199
+ novelty_level, identity_effect, taste_fit fields
200
+
201
+ Returns ranked list with composite scores, breakdowns, and recommendation.
202
+ """
203
+ if not variants:
204
+ return {"error": "No variants provided", "rankings": []}
205
+
206
+ song_brain = _get_song_brain_dict()
207
+ taste_graph = _get_taste_graph(ctx)
208
+
209
+ novelty_band = 0.5
210
+ taste_evidence = 0
211
+ if taste_graph is not None:
212
+ novelty_band = taste_graph.novelty_band
213
+ taste_evidence = taste_graph.evidence_count
214
+
215
+ ranked = engine.rank_variants(
216
+ variant_dicts=[dict(v) for v in variants], # copy to avoid mutating input
217
+ song_brain=song_brain,
218
+ novelty_band=novelty_band,
219
+ taste_evidence=taste_evidence,
220
+ )
221
+
222
+ return {
223
+ "rankings": ranked,
224
+ "recommended": ranked[0]["variant_id"] if ranked else "",
225
+ }
226
+
227
+
228
+ @mcp.tool()
229
+ def discard_wonder_session(
230
+ ctx: Context,
231
+ wonder_session_id: str,
232
+ ) -> dict:
233
+ """Reject all Wonder variants and close the session.
234
+
235
+ The creative thread stays open — the problem isn't solved.
236
+ Records a rejected turn resolution and updates taste.
237
+
238
+ wonder_session_id: the session to discard
239
+ """
240
+ from .session import get_wonder_session
241
+
242
+ ws = get_wonder_session(wonder_session_id)
243
+ if not ws:
244
+ return {"error": "Wonder session not found", "wonder_session_id": wonder_session_id}
245
+
246
+ if not ws.transition_to("resolved"):
247
+ return {"error": f"Cannot discard session in '{ws.status}' state", "wonder_session_id": wonder_session_id}
248
+
249
+ ws.outcome = "rejected_all"
250
+
251
+ # Record rejected turn
252
+ try:
253
+ from ..session_continuity.tracker import record_turn_resolution
254
+ record_turn_resolution(
255
+ request_text=ws.request_text,
256
+ outcome="rejected",
257
+ move_applied="",
258
+ identity_effect="",
259
+ user_sentiment="disliked",
260
+ )
261
+ except Exception:
262
+ pass
263
+
264
+ # Update taste graph — rejection is a negative signal for all executable variants
265
+ try:
266
+ taste_graph = _get_taste_graph(ctx)
267
+ if taste_graph:
268
+ for v in ws.variants:
269
+ if not v.get("analytical_only") and v.get("move_id") and v.get("family"):
270
+ taste_graph.record_move_outcome(
271
+ move_id=v["move_id"],
272
+ family=v["family"],
273
+ kept=False,
274
+ )
275
+ except Exception:
276
+ pass
277
+
278
+ # Discard linked preview set
279
+ if ws.preview_set_id:
280
+ try:
281
+ from ..preview_studio.engine import discard_set
282
+ discard_set(ws.preview_set_id)
283
+ except Exception:
284
+ pass
285
+
286
+ return {
287
+ "discarded": True,
288
+ "wonder_session_id": wonder_session_id,
289
+ "thread_still_open": bool(ws.creative_thread_id),
290
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.22",
3
+ "version": "1.9.24",
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.22"
8
+ __version__ = "1.9.24"
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()
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """Metadata sync — single source of truth for version and tool count.
3
+
4
+ Reads version from package.json, tool count from test_tools_contract.py,
5
+ and verifies all known locations are in sync.
6
+
7
+ Usage:
8
+ python scripts/sync_metadata.py --check # verify, exit 1 if stale
9
+ python scripts/sync_metadata.py --fix # auto-fix stale references
10
+ """
11
+
12
+ import json
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+
19
+
20
+ def get_version() -> str:
21
+ """Read version from package.json (source of truth)."""
22
+ pkg = json.loads((ROOT / "package.json").read_text())
23
+ return pkg["version"]
24
+
25
+
26
+ def get_tool_count() -> int:
27
+ """Read tool count from test_tools_contract.py assertion."""
28
+ src = (ROOT / "tests" / "test_tools_contract.py").read_text()
29
+ match = re.search(r"assert len\(tools\) == (\d+)", src)
30
+ if match:
31
+ return int(match.group(1))
32
+ raise ValueError("Could not find tool count assertion in test_tools_contract.py")
33
+
34
+
35
+ # Files that must contain the version string
36
+ VERSION_FILES = [
37
+ "package.json",
38
+ "server.json",
39
+ "manifest.json",
40
+ "livepilot/.claude-plugin/plugin.json",
41
+ "livepilot/.Codex-plugin/plugin.json",
42
+ ".claude-plugin/marketplace.json",
43
+ "mcp_server/__init__.py",
44
+ "remote_script/LivePilot/__init__.py",
45
+ "CLAUDE.md",
46
+ "AGENTS.md",
47
+ "livepilot/skills/livepilot-core/references/overview.md",
48
+ "docs/M4L_BRIDGE.md",
49
+ ]
50
+
51
+ # Files that must contain the tool count
52
+ TOOL_COUNT_FILES = [
53
+ "README.md",
54
+ "package.json",
55
+ "server.json",
56
+ "CLAUDE.md",
57
+ "AGENTS.md",
58
+ "CONTRIBUTING.md",
59
+ "livepilot/.claude-plugin/plugin.json",
60
+ "livepilot/.Codex-plugin/plugin.json",
61
+ "livepilot/skills/livepilot-core/SKILL.md",
62
+ "livepilot/skills/livepilot-core/references/overview.md",
63
+ "docs/manual/index.md",
64
+ "docs/manual/tool-reference.md",
65
+ "docs/manual/tool-catalog.md",
66
+ ]
67
+
68
+
69
+ def check_version(version: str) -> list[str]:
70
+ """Check all version files for staleness."""
71
+ issues = []
72
+ for rel_path in VERSION_FILES:
73
+ path = ROOT / rel_path
74
+ if not path.exists():
75
+ continue
76
+ content = path.read_text()
77
+ if version not in content:
78
+ # Find what version IS there
79
+ old = re.search(r"1\.\d+\.\d+", content)
80
+ old_ver = old.group(0) if old else "???"
81
+ if old_ver != version:
82
+ issues.append(f" {rel_path}: has {old_ver}, expected {version}")
83
+ return issues
84
+
85
+
86
+ def check_tool_count(count: int) -> list[str]:
87
+ """Check all tool count files for staleness."""
88
+ issues = []
89
+ count_str = str(count)
90
+ for rel_path in TOOL_COUNT_FILES:
91
+ path = ROOT / rel_path
92
+ if not path.exists():
93
+ continue
94
+ content = path.read_text()
95
+ # Look for "N tools" pattern
96
+ matches = re.findall(r"(\d+)\s+tools", content)
97
+ for m in matches:
98
+ if m != count_str and int(m) > 250: # ignore subset counts like "210 tools"
99
+ issues.append(f" {rel_path}: has '{m} tools', expected '{count_str} tools'")
100
+ break
101
+ return issues
102
+
103
+
104
+ def main():
105
+ mode = sys.argv[1] if len(sys.argv) > 1 else "--check"
106
+
107
+ version = get_version()
108
+ tool_count = get_tool_count()
109
+
110
+ print(f"Source of truth: version={version}, tools={tool_count}")
111
+
112
+ version_issues = check_version(version)
113
+ count_issues = check_tool_count(tool_count)
114
+
115
+ all_issues = version_issues + count_issues
116
+
117
+ if all_issues:
118
+ print(f"\nFound {len(all_issues)} stale reference(s):")
119
+ for issue in all_issues:
120
+ print(issue)
121
+ if mode == "--check":
122
+ sys.exit(1)
123
+ elif mode == "--fix":
124
+ print("\n--fix mode not yet implemented. Fix manually.")
125
+ sys.exit(1)
126
+ else:
127
+ print("All metadata in sync.")
128
+ sys.exit(0)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()