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.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +409 -0
  4. package/bin/livepilot.js +390 -0
  5. package/installer/install.js +95 -0
  6. package/installer/paths.js +79 -0
  7. package/mcp_server/__init__.py +2 -0
  8. package/mcp_server/__main__.py +5 -0
  9. package/mcp_server/connection.py +210 -0
  10. package/mcp_server/memory/__init__.py +5 -0
  11. package/mcp_server/memory/technique_store.py +296 -0
  12. package/mcp_server/server.py +87 -0
  13. package/mcp_server/tools/__init__.py +1 -0
  14. package/mcp_server/tools/arrangement.py +407 -0
  15. package/mcp_server/tools/browser.py +86 -0
  16. package/mcp_server/tools/clips.py +218 -0
  17. package/mcp_server/tools/devices.py +256 -0
  18. package/mcp_server/tools/memory.py +198 -0
  19. package/mcp_server/tools/mixing.py +121 -0
  20. package/mcp_server/tools/notes.py +269 -0
  21. package/mcp_server/tools/scenes.py +89 -0
  22. package/mcp_server/tools/tracks.py +175 -0
  23. package/mcp_server/tools/transport.py +117 -0
  24. package/package.json +37 -0
  25. package/plugin/agents/livepilot-producer/AGENT.md +62 -0
  26. package/plugin/commands/beat.md +18 -0
  27. package/plugin/commands/memory.md +22 -0
  28. package/plugin/commands/mix.md +15 -0
  29. package/plugin/commands/session.md +13 -0
  30. package/plugin/commands/sounddesign.md +16 -0
  31. package/plugin/plugin.json +19 -0
  32. package/plugin/skills/livepilot-core/SKILL.md +208 -0
  33. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  34. package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  35. package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  36. package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  37. package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  38. package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  39. package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  40. package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  41. package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  42. package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  43. package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  44. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  45. package/plugin/skills/livepilot-core/references/memory-guide.md +107 -0
  46. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  47. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  48. package/plugin/skills/livepilot-core/references/overview.md +209 -0
  49. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  50. package/remote_script/LivePilot/__init__.py +42 -0
  51. package/remote_script/LivePilot/arrangement.py +693 -0
  52. package/remote_script/LivePilot/browser.py +424 -0
  53. package/remote_script/LivePilot/clips.py +211 -0
  54. package/remote_script/LivePilot/devices.py +596 -0
  55. package/remote_script/LivePilot/diagnostics.py +198 -0
  56. package/remote_script/LivePilot/mixing.py +194 -0
  57. package/remote_script/LivePilot/notes.py +339 -0
  58. package/remote_script/LivePilot/router.py +74 -0
  59. package/remote_script/LivePilot/scenes.py +99 -0
  60. package/remote_script/LivePilot/server.py +293 -0
  61. package/remote_script/LivePilot/tracks.py +268 -0
  62. package/remote_script/LivePilot/transport.py +151 -0
  63. package/remote_script/LivePilot/utils.py +123 -0
  64. package/requirements.txt +2 -0
@@ -0,0 +1,218 @@
1
+ """Clip MCP tools — info, create, delete, duplicate, fire, stop, properties, warp.
2
+
3
+ 11 tools matching the Remote Script clips domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from fastmcp import Context
11
+
12
+ from ..server import mcp
13
+
14
+
15
+ def _get_ableton(ctx: Context):
16
+ """Extract AbletonConnection from lifespan context."""
17
+ return ctx.lifespan_context["ableton"]
18
+
19
+
20
+ def _validate_track_index(track_index: int):
21
+ """Validate track index. Must be >= 0 for regular tracks."""
22
+ if track_index < 0:
23
+ raise ValueError("track_index must be >= 0")
24
+
25
+
26
+ def _validate_clip_index(clip_index: int):
27
+ if clip_index < 0:
28
+ raise ValueError("clip_index must be >= 0")
29
+
30
+
31
+ def _validate_color_index(color_index: int):
32
+ if not 0 <= color_index <= 69:
33
+ raise ValueError("color_index must be between 0 and 69")
34
+
35
+
36
+ @mcp.tool()
37
+ def get_clip_info(ctx: Context, track_index: int, clip_index: int) -> dict:
38
+ """Get detailed info about a clip: name, length, loop, launch settings."""
39
+ _validate_track_index(track_index)
40
+ _validate_clip_index(clip_index)
41
+ return _get_ableton(ctx).send_command("get_clip_info", {
42
+ "track_index": track_index,
43
+ "clip_index": clip_index,
44
+ })
45
+
46
+
47
+ @mcp.tool()
48
+ def create_clip(ctx: Context, track_index: int, clip_index: int, length: float) -> dict:
49
+ """Create an empty MIDI clip in a clip slot (length in beats)."""
50
+ _validate_track_index(track_index)
51
+ _validate_clip_index(clip_index)
52
+ if length <= 0:
53
+ raise ValueError("length must be > 0")
54
+ return _get_ableton(ctx).send_command("create_clip", {
55
+ "track_index": track_index,
56
+ "clip_index": clip_index,
57
+ "length": length,
58
+ })
59
+
60
+
61
+ @mcp.tool()
62
+ def delete_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
63
+ """Delete a clip from a clip slot. This removes all notes and automation. Use undo to revert."""
64
+ _validate_track_index(track_index)
65
+ _validate_clip_index(clip_index)
66
+ return _get_ableton(ctx).send_command("delete_clip", {
67
+ "track_index": track_index,
68
+ "clip_index": clip_index,
69
+ })
70
+
71
+
72
+ @mcp.tool()
73
+ def duplicate_clip(
74
+ ctx: Context,
75
+ track_index: int,
76
+ clip_index: int,
77
+ target_track: int,
78
+ target_clip: int,
79
+ ) -> dict:
80
+ """Duplicate a clip from one slot to another."""
81
+ _validate_track_index(track_index)
82
+ _validate_clip_index(clip_index)
83
+ _validate_track_index(target_track)
84
+ _validate_clip_index(target_clip)
85
+ return _get_ableton(ctx).send_command("duplicate_clip", {
86
+ "track_index": track_index,
87
+ "clip_index": clip_index,
88
+ "target_track": target_track,
89
+ "target_clip": target_clip,
90
+ })
91
+
92
+
93
+ @mcp.tool()
94
+ def fire_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
95
+ """Launch/fire a clip slot."""
96
+ _validate_track_index(track_index)
97
+ _validate_clip_index(clip_index)
98
+ return _get_ableton(ctx).send_command("fire_clip", {
99
+ "track_index": track_index,
100
+ "clip_index": clip_index,
101
+ })
102
+
103
+
104
+ @mcp.tool()
105
+ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
106
+ """Stop a playing clip."""
107
+ _validate_track_index(track_index)
108
+ _validate_clip_index(clip_index)
109
+ return _get_ableton(ctx).send_command("stop_clip", {
110
+ "track_index": track_index,
111
+ "clip_index": clip_index,
112
+ })
113
+
114
+
115
+ @mcp.tool()
116
+ def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
117
+ """Rename a clip."""
118
+ _validate_track_index(track_index)
119
+ _validate_clip_index(clip_index)
120
+ if not name.strip():
121
+ raise ValueError("Clip name cannot be empty")
122
+ return _get_ableton(ctx).send_command("set_clip_name", {
123
+ "track_index": track_index,
124
+ "clip_index": clip_index,
125
+ "name": name,
126
+ })
127
+
128
+
129
+ @mcp.tool()
130
+ def set_clip_color(ctx: Context, track_index: int, clip_index: int, color_index: int) -> dict:
131
+ """Set clip color (0-69, Ableton's color palette)."""
132
+ _validate_track_index(track_index)
133
+ _validate_clip_index(clip_index)
134
+ _validate_color_index(color_index)
135
+ return _get_ableton(ctx).send_command("set_clip_color", {
136
+ "track_index": track_index,
137
+ "clip_index": clip_index,
138
+ "color_index": color_index,
139
+ })
140
+
141
+
142
+ @mcp.tool()
143
+ def set_clip_loop(
144
+ ctx: Context,
145
+ track_index: int,
146
+ clip_index: int,
147
+ enabled: bool,
148
+ start: Optional[float] = None,
149
+ end: Optional[float] = None,
150
+ ) -> dict:
151
+ """Enable/disable clip looping and optionally set loop start/end (in beats)."""
152
+ _validate_track_index(track_index)
153
+ _validate_clip_index(clip_index)
154
+ params = {
155
+ "track_index": track_index,
156
+ "clip_index": clip_index,
157
+ "enabled": enabled,
158
+ }
159
+ if start is not None:
160
+ if start < 0:
161
+ raise ValueError("Loop start must be >= 0")
162
+ params["start"] = start
163
+ if end is not None:
164
+ if end <= 0:
165
+ raise ValueError("Loop end must be > 0")
166
+ params["end"] = end
167
+ if start is not None and end is not None and start >= end:
168
+ raise ValueError("Loop start must be less than loop end")
169
+ return _get_ableton(ctx).send_command("set_clip_loop", params)
170
+
171
+
172
+ @mcp.tool()
173
+ def set_clip_launch(
174
+ ctx: Context,
175
+ track_index: int,
176
+ clip_index: int,
177
+ mode: int,
178
+ quantization: Optional[int] = None,
179
+ ) -> dict:
180
+ """Set clip launch mode (0=Trigger, 1=Gate, 2=Toggle, 3=Repeat) and optional quantization."""
181
+ _validate_track_index(track_index)
182
+ _validate_clip_index(clip_index)
183
+ if not 0 <= mode <= 3:
184
+ raise ValueError("Launch mode must be 0-3 (Trigger, Gate, Toggle, Repeat)")
185
+ params = {
186
+ "track_index": track_index,
187
+ "clip_index": clip_index,
188
+ "mode": mode,
189
+ }
190
+ if quantization is not None:
191
+ params["quantization"] = quantization
192
+ return _get_ableton(ctx).send_command("set_clip_launch", params)
193
+
194
+
195
+ _VALID_WARP_MODES = {0, 1, 2, 3, 4, 6}
196
+
197
+
198
+ @mcp.tool()
199
+ def set_clip_warp_mode(
200
+ ctx: Context,
201
+ track_index: int,
202
+ clip_index: int,
203
+ mode: int,
204
+ warping: Optional[bool] = None,
205
+ ) -> dict:
206
+ """Set warp mode for an audio clip (0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 6=Complex Pro)."""
207
+ _validate_track_index(track_index)
208
+ _validate_clip_index(clip_index)
209
+ if mode not in _VALID_WARP_MODES:
210
+ raise ValueError("Warp mode must be one of: 0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 6=Complex Pro")
211
+ params = {
212
+ "track_index": track_index,
213
+ "clip_index": clip_index,
214
+ "mode": mode,
215
+ }
216
+ if warping is not None:
217
+ params["warping"] = warping
218
+ return _get_ableton(ctx).send_command("set_clip_warp_mode", params)
@@ -0,0 +1,256 @@
1
+ """Device MCP tools — parameters, racks, browser loading.
2
+
3
+ 12 tools matching the Remote Script devices domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional
10
+
11
+ from fastmcp import Context
12
+
13
+ from ..server import mcp
14
+
15
+
16
+ def _ensure_list(value: Any) -> list:
17
+ """Parse JSON strings into lists. MCP clients may serialize list params as strings."""
18
+ if isinstance(value, str):
19
+ return json.loads(value)
20
+ return value
21
+
22
+
23
+ def _get_ableton(ctx: Context):
24
+ """Extract AbletonConnection from lifespan context."""
25
+ return ctx.lifespan_context["ableton"]
26
+
27
+
28
+ MASTER_TRACK_INDEX = -1000
29
+
30
+
31
+ def _validate_track_index(track_index: int):
32
+ if track_index < 0 and track_index != MASTER_TRACK_INDEX:
33
+ if track_index < -100:
34
+ raise ValueError(
35
+ "track_index must be >= 0 for regular tracks, "
36
+ "negative for return tracks (-1=A, -2=B), or -1000 for master"
37
+ )
38
+ # Negative values -1..-99 are valid return track indices
39
+
40
+
41
+ def _validate_device_index(device_index: int):
42
+ if device_index < 0:
43
+ raise ValueError("device_index must be >= 0")
44
+
45
+
46
+ def _validate_chain_index(chain_index: int):
47
+ if chain_index < 0:
48
+ raise ValueError("chain_index must be >= 0")
49
+
50
+
51
+ @mcp.tool()
52
+ def get_device_info(ctx: Context, track_index: int, device_index: int) -> dict:
53
+ """Get info about a device: name, class, type, active state, parameter count.
54
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
55
+ _validate_track_index(track_index)
56
+ _validate_device_index(device_index)
57
+ return _get_ableton(ctx).send_command("get_device_info", {
58
+ "track_index": track_index,
59
+ "device_index": device_index,
60
+ })
61
+
62
+
63
+ @mcp.tool()
64
+ def get_device_parameters(ctx: Context, track_index: int, device_index: int) -> dict:
65
+ """Get all parameters for a device with names, values, and ranges.
66
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
67
+ _validate_track_index(track_index)
68
+ _validate_device_index(device_index)
69
+ return _get_ableton(ctx).send_command("get_device_parameters", {
70
+ "track_index": track_index,
71
+ "device_index": device_index,
72
+ })
73
+
74
+
75
+ @mcp.tool()
76
+ def set_device_parameter(
77
+ ctx: Context,
78
+ track_index: int,
79
+ device_index: int,
80
+ value: float,
81
+ parameter_name: Optional[str] = None,
82
+ parameter_index: Optional[int] = None,
83
+ ) -> dict:
84
+ """Set a device parameter by name or index.
85
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
86
+ _validate_track_index(track_index)
87
+ _validate_device_index(device_index)
88
+ if parameter_name is None and parameter_index is None:
89
+ raise ValueError("Must provide parameter_name or parameter_index")
90
+ if parameter_index is not None and parameter_index < 0:
91
+ raise ValueError("parameter_index must be >= 0")
92
+ params = {
93
+ "track_index": track_index,
94
+ "device_index": device_index,
95
+ "value": value,
96
+ }
97
+ if parameter_name is not None:
98
+ params["parameter_name"] = parameter_name
99
+ if parameter_index is not None:
100
+ params["parameter_index"] = parameter_index
101
+ return _get_ableton(ctx).send_command("set_device_parameter", params)
102
+
103
+
104
+ @mcp.tool()
105
+ def batch_set_parameters(
106
+ ctx: Context,
107
+ track_index: int,
108
+ device_index: int,
109
+ parameters: list,
110
+ ) -> dict:
111
+ """Set multiple device parameters in one call. parameters is a list of objects: [{"name_or_index": "Dry/Wet", "value": 0.5}, ...].
112
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
113
+ _validate_track_index(track_index)
114
+ _validate_device_index(device_index)
115
+ parameters = _ensure_list(parameters)
116
+ if not parameters:
117
+ raise ValueError("parameters list cannot be empty")
118
+ for entry in parameters:
119
+ if "name_or_index" not in entry or "value" not in entry:
120
+ raise ValueError("Each parameter must have 'name_or_index' and 'value'")
121
+ return _get_ableton(ctx).send_command("batch_set_parameters", {
122
+ "track_index": track_index,
123
+ "device_index": device_index,
124
+ "parameters": parameters,
125
+ })
126
+
127
+
128
+ @mcp.tool()
129
+ def toggle_device(ctx: Context, track_index: int, device_index: int, active: bool) -> dict:
130
+ """Enable or disable a device.
131
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
132
+ _validate_track_index(track_index)
133
+ _validate_device_index(device_index)
134
+ return _get_ableton(ctx).send_command("toggle_device", {
135
+ "track_index": track_index,
136
+ "device_index": device_index,
137
+ "active": active,
138
+ })
139
+
140
+
141
+ @mcp.tool()
142
+ def delete_device(ctx: Context, track_index: int, device_index: int) -> dict:
143
+ """Delete a device from a track. Use undo to revert if needed.
144
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
145
+ _validate_track_index(track_index)
146
+ _validate_device_index(device_index)
147
+ return _get_ableton(ctx).send_command("delete_device", {
148
+ "track_index": track_index,
149
+ "device_index": device_index,
150
+ })
151
+
152
+
153
+ @mcp.tool()
154
+ def load_device_by_uri(ctx: Context, track_index: int, uri: str) -> dict:
155
+ """Load a device onto a track using a browser URI string.
156
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
157
+ _validate_track_index(track_index)
158
+ if not uri.strip():
159
+ raise ValueError("URI cannot be empty")
160
+ return _get_ableton(ctx).send_command("load_device_by_uri", {
161
+ "track_index": track_index,
162
+ "uri": uri,
163
+ })
164
+
165
+
166
+ @mcp.tool()
167
+ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> dict:
168
+ """Search the browser for a device by name and load it onto a track.
169
+ track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
170
+ _validate_track_index(track_index)
171
+ if not device_name.strip():
172
+ raise ValueError("device_name cannot be empty")
173
+ return _get_ableton(ctx).send_command("find_and_load_device", {
174
+ "track_index": track_index,
175
+ "device_name": device_name,
176
+ })
177
+
178
+
179
+ @mcp.tool()
180
+ def set_simpler_playback_mode(
181
+ ctx: Context,
182
+ track_index: int,
183
+ device_index: int,
184
+ playback_mode: int,
185
+ slice_by: Optional[int] = None,
186
+ sensitivity: Optional[float] = None,
187
+ ) -> dict:
188
+ """Set Simpler's playback mode. playback_mode: 0=Classic, 1=One-Shot, 2=Slice. slice_by (Slice only): 0=Transient, 1=Beat, 2=Region, 3=Manual. sensitivity (0.0-1.0, Transient only)."""
189
+ _validate_track_index(track_index)
190
+ _validate_device_index(device_index)
191
+ if playback_mode not in (0, 1, 2):
192
+ raise ValueError("playback_mode must be 0 (Classic), 1 (One-Shot), or 2 (Slice)")
193
+ params = {
194
+ "track_index": track_index,
195
+ "device_index": device_index,
196
+ "playback_mode": playback_mode,
197
+ }
198
+ if slice_by is not None:
199
+ params["slice_by"] = slice_by
200
+ if sensitivity is not None:
201
+ params["sensitivity"] = sensitivity
202
+ return _get_ableton(ctx).send_command("set_simpler_playback_mode", params)
203
+
204
+
205
+ @mcp.tool()
206
+ def get_rack_chains(ctx: Context, track_index: int, device_index: int) -> dict:
207
+ """Get all chains in a rack device with volume, pan, mute, solo."""
208
+ _validate_track_index(track_index)
209
+ _validate_device_index(device_index)
210
+ return _get_ableton(ctx).send_command("get_rack_chains", {
211
+ "track_index": track_index,
212
+ "device_index": device_index,
213
+ })
214
+
215
+
216
+ @mcp.tool()
217
+ def set_chain_volume(
218
+ ctx: Context,
219
+ track_index: int,
220
+ device_index: int,
221
+ chain_index: int,
222
+ volume: Optional[float] = None,
223
+ pan: Optional[float] = None,
224
+ ) -> dict:
225
+ """Set volume and/or pan for a chain in a rack device."""
226
+ _validate_track_index(track_index)
227
+ _validate_device_index(device_index)
228
+ _validate_chain_index(chain_index)
229
+ if volume is not None and not 0.0 <= volume <= 1.0:
230
+ raise ValueError("volume must be between 0.0 and 1.0")
231
+ if pan is not None and not -1.0 <= pan <= 1.0:
232
+ raise ValueError("pan must be between -1.0 and 1.0")
233
+ if volume is None and pan is None:
234
+ raise ValueError("Must provide volume and/or pan")
235
+ params = {
236
+ "track_index": track_index,
237
+ "device_index": device_index,
238
+ "chain_index": chain_index,
239
+ }
240
+ if volume is not None:
241
+ params["volume"] = volume
242
+ if pan is not None:
243
+ params["pan"] = pan
244
+ return _get_ableton(ctx).send_command("set_chain_volume", params)
245
+
246
+
247
+ @mcp.tool()
248
+ def get_device_presets(ctx: Context, device_name: str) -> dict:
249
+ """List available presets for an Ableton device (e.g. 'Corpus', 'Drum Buss', 'Wavetable').
250
+ Searches audio_effects, instruments, and midi_effects categories.
251
+ Returns preset names and URIs that can be loaded with load_device_by_uri."""
252
+ if not device_name.strip():
253
+ raise ValueError("device_name cannot be empty")
254
+ return _get_ableton(ctx).send_command("get_device_presets", {
255
+ "device_name": device_name,
256
+ })
@@ -0,0 +1,198 @@
1
+ """Memory MCP tools — technique library for saving, recalling, and replaying patterns.
2
+
3
+ 8 tools for the agent's long-term memory system.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from fastmcp import Context
11
+
12
+ from ..server import mcp
13
+ from ..memory.technique_store import TechniqueStore
14
+
15
+ _store = TechniqueStore()
16
+
17
+
18
+ def _get_store() -> TechniqueStore:
19
+ return _store
20
+
21
+
22
+ @mcp.tool()
23
+ def memory_learn(
24
+ ctx: Context,
25
+ name: str,
26
+ type: str,
27
+ qualities: dict,
28
+ payload: dict,
29
+ tags: Optional[list] = None,
30
+ ) -> dict:
31
+ """Save a new technique to the memory library with stylistic qualities. type must be one of: beat_pattern, device_chain, mix_template, browser_pin, preference. qualities must include at minimum a 'summary' field."""
32
+ return _get_store().save(name=name, type=type, qualities=qualities, payload=payload, tags=tags)
33
+
34
+
35
+ @mcp.tool()
36
+ def memory_recall(
37
+ ctx: Context,
38
+ query: Optional[str] = None,
39
+ type: Optional[str] = None,
40
+ tags: Optional[list] = None,
41
+ limit: int = 10,
42
+ ) -> dict:
43
+ """Search the technique library by text query and/or filters. Returns summaries (no payload)."""
44
+ results = _get_store().search(query=query, type_filter=type, tags=tags, limit=limit)
45
+ return {"count": len(results), "techniques": results}
46
+
47
+
48
+ @mcp.tool()
49
+ def memory_get(ctx: Context, technique_id: str) -> dict:
50
+ """Fetch a full technique by ID, including payload for replay."""
51
+ return _get_store().get(technique_id)
52
+
53
+
54
+ @mcp.tool()
55
+ def memory_replay(ctx: Context, technique_id: str, adapt: bool = False) -> dict:
56
+ """Retrieve a technique with a replay plan for the agent to execute. adapt=false: returns step-by-step replay plan for exact reconstruction. adapt=true: returns technique for creative adaptation."""
57
+ store = _get_store()
58
+ technique = store.get(technique_id)
59
+ store.increment_replay(technique_id)
60
+
61
+ mode = "adapt" if adapt else "exact"
62
+
63
+ if adapt:
64
+ steps = [
65
+ "Read qualities and payload as inspiration",
66
+ "Create something new that matches the stylistic palette",
67
+ ]
68
+ else:
69
+ steps = _generate_replay_steps(technique)
70
+
71
+ return {
72
+ "technique": technique,
73
+ "replay_plan": {
74
+ "mode": mode,
75
+ "type": technique["type"],
76
+ "steps": steps,
77
+ },
78
+ }
79
+
80
+
81
+ def _generate_replay_steps(technique: dict) -> list[str]:
82
+ """Generate human-readable replay steps based on technique type."""
83
+ t_type = technique["type"]
84
+ payload = technique.get("payload", {})
85
+
86
+ if t_type == "beat_pattern":
87
+ steps = []
88
+ kit = payload.get("kit_name")
89
+ if kit:
90
+ steps.append(f"Load kit '{kit}' using search_browser + load_browser_item")
91
+ steps.append(
92
+ f"Create a MIDI clip using create_clip "
93
+ f"(length={payload.get('clip_length', 4)} beats)"
94
+ )
95
+ notes = payload.get("notes", [])
96
+ if notes:
97
+ steps.append(f"Add {len(notes)} notes using add_notes with the stored note data")
98
+ tempo = payload.get("tempo")
99
+ if tempo:
100
+ steps.append(f"Set tempo to {tempo} BPM using set_tempo")
101
+ return steps or ["Replay the beat pattern from the stored payload"]
102
+
103
+ elif t_type == "device_chain":
104
+ steps = []
105
+ devices = payload.get("devices", [])
106
+ for i, dev in enumerate(devices):
107
+ dev_name = dev.get("name", f"device {i + 1}")
108
+ steps.append(f"Load '{dev_name}' using find_and_load_device")
109
+ params = dev.get("params", {})
110
+ if params:
111
+ steps.append(
112
+ f"Set {len(params)} parameters on '{dev_name}' using batch_set_parameters"
113
+ )
114
+ return steps or ["Load the device chain from the stored payload"]
115
+
116
+ elif t_type == "mix_template":
117
+ steps = []
118
+ returns = payload.get("returns", [])
119
+ if returns:
120
+ steps.append(
121
+ f"Create {len(returns)} return tracks using create_return_track"
122
+ )
123
+ for ret in returns:
124
+ devices = ret.get("devices", [])
125
+ for dev in devices:
126
+ dev_name = dev if isinstance(dev, str) else dev.get("name", "device")
127
+ steps.append(
128
+ f"Load '{dev_name}' on return track using find_and_load_device"
129
+ )
130
+ sends = payload.get("sends_pattern", {})
131
+ if sends:
132
+ steps.append(
133
+ "Apply send levels from sends_pattern "
134
+ "(note: role names must be resolved to track indices by the agent)"
135
+ )
136
+ return steps or ["Apply the mix template from the stored payload"]
137
+
138
+ elif t_type == "browser_pin":
139
+ uri = payload.get("uri", "")
140
+ name = payload.get("name", "item")
141
+ steps = [f"Load browser item '{name}' by URI '{uri}' using load_browser_item"]
142
+ return steps
143
+
144
+ elif t_type == "preference":
145
+ key = payload.get("key", "preference")
146
+ value = payload.get("value", "")
147
+ steps = [f"Apply preference '{key}' = '{value}'"]
148
+ return steps
149
+
150
+ return ["Replay the technique from the stored payload"]
151
+
152
+
153
+ @mcp.tool()
154
+ def memory_list(
155
+ ctx: Context,
156
+ type: Optional[str] = None,
157
+ tags: Optional[list] = None,
158
+ sort_by: str = "updated_at",
159
+ limit: int = 20,
160
+ ) -> dict:
161
+ """Browse the technique library with optional filtering."""
162
+ results = _get_store().list_techniques(
163
+ type_filter=type, tags=tags, sort_by=sort_by, limit=limit
164
+ )
165
+ return {"count": len(results), "techniques": results}
166
+
167
+
168
+ @mcp.tool()
169
+ def memory_favorite(
170
+ ctx: Context,
171
+ technique_id: str,
172
+ favorite: Optional[bool] = None,
173
+ rating: Optional[int] = None,
174
+ ) -> dict:
175
+ """Star and/or rate a technique (rating 0-5)."""
176
+ return _get_store().favorite(
177
+ technique_id=technique_id, favorite=favorite, rating=rating
178
+ )
179
+
180
+
181
+ @mcp.tool()
182
+ def memory_update(
183
+ ctx: Context,
184
+ technique_id: str,
185
+ name: Optional[str] = None,
186
+ tags: Optional[list] = None,
187
+ qualities: Optional[dict] = None,
188
+ ) -> dict:
189
+ """Update name, tags, or qualities on an existing technique. Qualities are merged (lists replace)."""
190
+ return _get_store().update(
191
+ technique_id=technique_id, name=name, tags=tags, qualities=qualities
192
+ )
193
+
194
+
195
+ @mcp.tool()
196
+ def memory_delete(ctx: Context, technique_id: str) -> dict:
197
+ """Delete a technique from the library (creates backup first)."""
198
+ return _get_store().delete(technique_id)