livepilot 1.10.0 → 1.10.2

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +214 -0
  4. package/CONTRIBUTING.md +2 -2
  5. package/LICENSE +62 -21
  6. package/README.md +264 -286
  7. package/livepilot/.Codex-plugin/plugin.json +2 -2
  8. package/livepilot/.claude-plugin/plugin.json +2 -2
  9. package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
  10. package/livepilot/skills/livepilot-core/SKILL.md +5 -5
  11. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  12. package/livepilot/skills/livepilot-devices/SKILL.md +23 -2
  13. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  14. package/livepilot/skills/livepilot-release/SKILL.md +21 -17
  15. package/livepilot/skills/livepilot-sample-engine/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-wonder/SKILL.md +8 -6
  17. package/livepilot.mcpb +0 -0
  18. package/m4l_device/LivePilot_Analyzer.adv +0 -0
  19. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  20. package/m4l_device/livepilot_bridge.js +1 -1
  21. package/manifest.json +4 -4
  22. package/mcp_server/__init__.py +1 -1
  23. package/mcp_server/composer/engine.py +249 -169
  24. package/mcp_server/composer/sample_resolver.py +153 -0
  25. package/mcp_server/composer/tools.py +97 -87
  26. package/mcp_server/memory/taste_accessors.py +47 -0
  27. package/mcp_server/preview_studio/engine.py +9 -2
  28. package/mcp_server/preview_studio/tools.py +78 -35
  29. package/mcp_server/project_brain/tools.py +34 -0
  30. package/mcp_server/runtime/execution_router.py +180 -38
  31. package/mcp_server/runtime/mcp_dispatch.py +46 -0
  32. package/mcp_server/runtime/remote_commands.py +4 -1
  33. package/mcp_server/runtime/tools.py +55 -32
  34. package/mcp_server/sample_engine/moves.py +12 -12
  35. package/mcp_server/sample_engine/slice_workflow.py +190 -0
  36. package/mcp_server/sample_engine/tools.py +104 -1
  37. package/mcp_server/semantic_moves/device_creation_moves.py +7 -7
  38. package/mcp_server/semantic_moves/mix_moves.py +8 -8
  39. package/mcp_server/semantic_moves/models.py +7 -7
  40. package/mcp_server/semantic_moves/performance_moves.py +4 -4
  41. package/mcp_server/semantic_moves/sample_compilers.py +14 -9
  42. package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
  43. package/mcp_server/semantic_moves/tools.py +63 -10
  44. package/mcp_server/semantic_moves/transition_moves.py +4 -4
  45. package/mcp_server/server.py +20 -1
  46. package/mcp_server/session_continuity/tracker.py +4 -1
  47. package/mcp_server/tools/_conductor.py +16 -0
  48. package/mcp_server/tools/_planner_engine.py +24 -0
  49. package/mcp_server/tools/analyzer.py +2 -0
  50. package/mcp_server/tools/planner.py +3 -0
  51. package/mcp_server/wonder_mode/engine.py +59 -13
  52. package/mcp_server/wonder_mode/tools.py +33 -1
  53. package/package.json +8 -8
  54. package/remote_script/LivePilot/__init__.py +1 -1
  55. package/remote_script/LivePilot/devices.py +10 -0
@@ -1,16 +1,34 @@
1
- """ComposerEngine — orchestrate prompt → layers → compiled plan.
1
+ """ComposerEngine — orchestrate prompt → layers → executable plan.
2
2
 
3
3
  Pure computation engine. Does NOT call MCP tools directly.
4
4
  Returns compiled plan dicts that the tool layer (tools.py) executes.
5
+
6
+ Executability contract (Phase 7 rewrite)
7
+ ----------------------------------------
8
+ The returned plan contains only REAL tool calls with concrete params. It
9
+ never emits:
10
+ - pseudo-tools like _agent_pick_best_sample or _apply_technique
11
+ - placeholder strings like "{downloaded_path}"
12
+ - invalid sentinels like device_index: -1 or track_index: -1
13
+ - hardcoded clip_slot_index: 0 for tracks with no source clip
14
+
15
+ Samples are resolved at PLAN time via sample_resolver.resolve_sample_for_layer.
16
+ Layers that don't resolve to a concrete local file are dropped from `plan`
17
+ but kept in `layers` for descriptive output, and the unresolved role is
18
+ named in `warnings`. Processing chains use step_id + $from_step bindings
19
+ to bind set_device_parameter.device_index to the actual inserted device
20
+ position returned by insert_device.
5
21
  """
6
22
 
7
23
  from __future__ import annotations
8
24
 
9
25
  from dataclasses import dataclass, field
26
+ from pathlib import Path
10
27
  from typing import Any, Optional
11
28
 
12
29
  from .prompt_parser import CompositionIntent, parse_prompt
13
30
  from .layer_planner import LayerSpec, plan_layers, plan_sections
31
+ from .sample_resolver import resolve_sample_for_layer
14
32
 
15
33
 
16
34
  # ── Result Models ──────────────────────────────────────────────────
@@ -22,10 +40,11 @@ class CompositionResult:
22
40
  intent: CompositionIntent = field(default_factory=CompositionIntent)
23
41
  layers: list[LayerSpec] = field(default_factory=list)
24
42
  sections: list[dict] = field(default_factory=list)
25
- plan: list[dict] = field(default_factory=list) # compiled execution steps
43
+ plan: list[dict] = field(default_factory=list) # executable steps only
26
44
  credits_estimated: int = 0
27
45
  dry_run: bool = False
28
46
  warnings: list[str] = field(default_factory=list)
47
+ resolved_samples: dict = field(default_factory=dict) # role -> local_path
29
48
 
30
49
  def to_dict(self) -> dict:
31
50
  return {
@@ -38,6 +57,7 @@ class CompositionResult:
38
57
  "credits_estimated": self.credits_estimated,
39
58
  "dry_run": self.dry_run,
40
59
  "warnings": self.warnings,
60
+ "resolved_samples": self.resolved_samples,
41
61
  }
42
62
 
43
63
 
@@ -51,6 +71,7 @@ class AugmentResult:
51
71
  plan: list[dict] = field(default_factory=list)
52
72
  credits_estimated: int = 0
53
73
  warnings: list[str] = field(default_factory=list)
74
+ resolved_samples: dict = field(default_factory=dict)
54
75
 
55
76
  def to_dict(self) -> dict:
56
77
  return {
@@ -62,13 +83,13 @@ class AugmentResult:
62
83
  "plan": self.plan,
63
84
  "credits_estimated": self.credits_estimated,
64
85
  "warnings": self.warnings,
86
+ "resolved_samples": self.resolved_samples,
65
87
  }
66
88
 
67
89
 
68
- # ── Compiled Step Builders ─────────────────────────────────────────
90
+ # ── Step builders ──────────────────────────────────────────────────
69
91
 
70
- def _compile_set_tempo_step(tempo: int) -> dict:
71
- """Compile a set_tempo step."""
92
+ def _step_set_tempo(tempo: int) -> dict:
72
93
  return {
73
94
  "tool": "set_tempo",
74
95
  "params": {"tempo": tempo},
@@ -76,83 +97,70 @@ def _compile_set_tempo_step(tempo: int) -> dict:
76
97
  }
77
98
 
78
99
 
79
- def _compile_create_track_step(track_index: int, role: str) -> dict:
80
- """Compile a create_midi_track step."""
100
+ def _step_create_midi_track(track_index: int, role: str, step_id: str) -> dict:
81
101
  return {
102
+ "step_id": step_id,
82
103
  "tool": "create_midi_track",
83
104
  "params": {"index": track_index},
84
105
  "description": f"Create MIDI track for {role}",
106
+ "role": role,
85
107
  }
86
108
 
87
109
 
88
- def _compile_name_track_step(track_index: int, role: str) -> dict:
89
- """Compile a set_track_name step."""
110
+ def _step_set_track_name(track_index: int, role: str) -> dict:
90
111
  return {
91
112
  "tool": "set_track_name",
92
113
  "params": {"track_index": track_index, "name": role.title()},
93
114
  "description": f"Name track: {role.title()}",
115
+ "role": role,
94
116
  }
95
117
 
96
118
 
97
- def _compile_search_step(layer: LayerSpec) -> dict:
98
- """Compile a Splice search step."""
99
- return {
100
- "tool": "search_samples",
101
- "params": {
102
- "query": layer.search_query,
103
- "source": "splice",
104
- "max_results": 10,
105
- **{k: v for k, v in layer.splice_filters.items()
106
- if k in ("key", "bpm_range", "material_type")},
107
- },
108
- "description": f"Search Splice: {layer.search_query}",
109
- "role": layer.role,
110
- }
111
-
112
-
113
- def _compile_download_step(layer: LayerSpec) -> dict:
114
- """Compile a Splice download step (placeholder — filled at runtime)."""
115
- return {
116
- "tool": "_splice_download",
117
- "params": {"file_hash": "{best_match.file_hash}"},
118
- "description": f"Download best match for {layer.role}",
119
- "role": layer.role,
120
- "conditional": True, # only if not already downloaded
121
- }
122
-
123
-
124
- def _compile_load_sample_step(track_index: int, layer: LayerSpec) -> dict:
125
- """Compile a load_sample_to_simpler step."""
119
+ def _step_load_sample_to_simpler(track_index: int, layer: LayerSpec, file_path: str) -> dict:
126
120
  return {
127
121
  "tool": "load_sample_to_simpler",
128
- "params": {
129
- "track_index": track_index,
130
- "file_path": "{downloaded_path}",
131
- },
122
+ "params": {"track_index": track_index, "file_path": file_path},
132
123
  "description": f"Load sample into Simpler on track {track_index}",
124
+ "backend": "mcp_tool",
133
125
  "role": layer.role,
134
126
  }
135
127
 
136
128
 
137
- def _compile_technique_step(track_index: int, layer: LayerSpec) -> dict:
138
- """Compile a technique application step."""
129
+ def _step_suggest_technique(track_index: int, layer: LayerSpec) -> dict:
130
+ """Real tool — returns technique recipe for the agent to interpret.
131
+
132
+ Not a pseudo-tool: suggest_sample_technique is a registered MCP tool.
133
+ The agent reads the returned recipe and applies the steps manually; we
134
+ don't try to auto-apply here because the recipe is open-ended.
135
+ """
139
136
  return {
140
- "tool": "_apply_technique",
141
- "params": {
142
- "track_index": track_index,
143
- "technique_id": layer.technique_id,
144
- },
145
- "description": f"Apply technique '{layer.technique_id}' on track {track_index}",
137
+ "tool": "suggest_sample_technique",
138
+ "params": {"technique_id": layer.technique_id},
139
+ "description": f"Get technique recipe '{layer.technique_id}' for track {track_index}",
146
140
  "role": layer.role,
147
141
  }
148
142
 
149
143
 
150
- def _compile_processing_steps(track_index: int, layer: LayerSpec) -> list[dict]:
151
- """Compile device insertion and parameter setting steps."""
144
+ def _processing_steps_with_binding(
145
+ track_index: int,
146
+ layer: LayerSpec,
147
+ layer_idx: int,
148
+ ) -> list[dict]:
149
+ """Build insert_device + set_device_parameter pairs using step_id bindings.
150
+
151
+ Each insert_device carries a unique step_id like 'layer_0_dev_1'. The
152
+ following set_device_parameter steps bind their device_index param to
153
+ that id via $from_step — the async router resolves it to the real
154
+ device index returned by insert_device at runtime.
155
+ """
152
156
  steps: list[dict] = []
153
- for i, device in enumerate(layer.processing):
157
+ for dev_idx, device in enumerate(layer.processing):
154
158
  device_name = device.get("name", "")
159
+ if not device_name:
160
+ continue
161
+ step_id = f"layer_{layer_idx}_dev_{dev_idx}"
155
162
  steps.append({
163
+ "step_id": step_id,
156
164
  "tool": "insert_device",
157
165
  "params": {
158
166
  "track_index": track_index,
@@ -161,13 +169,12 @@ def _compile_processing_steps(track_index: int, layer: LayerSpec) -> list[dict]:
161
169
  "description": f"Insert {device_name} on track {track_index}",
162
170
  "role": layer.role,
163
171
  })
164
- # Parameter setting
165
172
  for param_name, param_value in device.get("params", {}).items():
166
173
  steps.append({
167
174
  "tool": "set_device_parameter",
168
175
  "params": {
169
176
  "track_index": track_index,
170
- "device_index": -1, # last inserted device
177
+ "device_index": {"$from_step": step_id, "path": "device_index"},
171
178
  "parameter_name": param_name,
172
179
  "value": param_value,
173
180
  },
@@ -177,13 +184,14 @@ def _compile_processing_steps(track_index: int, layer: LayerSpec) -> list[dict]:
177
184
  return steps
178
185
 
179
186
 
180
- def _compile_mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
181
- """Compile volume and pan steps."""
182
- steps = []
187
+ def _mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
188
+ steps: list[dict] = []
189
+ # dB to linear with 0dB -> 0.85 convention (Ableton native scale)
190
+ linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
183
191
  steps.append({
184
192
  "tool": "set_track_volume",
185
- "params": {"track_index": track_index, "volume_db": layer.volume_db},
186
- "description": f"Set {layer.role} volume to {layer.volume_db}dB",
193
+ "params": {"track_index": track_index, "volume": round(linear, 3)},
194
+ "description": f"Set {layer.role} volume to {layer.volume_db}dB ({linear:.3f} linear)",
187
195
  "role": layer.role,
188
196
  })
189
197
  if layer.pan != 0.0:
@@ -196,60 +204,86 @@ def _compile_mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
196
204
  return steps
197
205
 
198
206
 
199
- def _compile_arrangement_steps(
207
+ def _arrangement_steps(
200
208
  track_index: int,
201
209
  layer: LayerSpec,
202
210
  sections: list[dict],
203
211
  ) -> list[dict]:
204
- """Compile arrangement clip creation steps for the layer's sections."""
212
+ """Emit the full arrangement sequence for a layer.
213
+
214
+ For each layer that appears in at least one section, we emit:
215
+
216
+ 1. create_clip — a 1-bar MIDI clip in session slot 0 (the source)
217
+ 2. add_notes — a single C3 trigger note so Simpler actually sounds
218
+ 3. create_arrangement_clip (×N) — one per section the layer is in,
219
+ tiling the 1-bar source across each section's bar count
220
+
221
+ The trigger-clip-plus-tile approach is intentionally minimal: Simpler
222
+ in classic mode plays the full sample on every note, so a single C3
223
+ at bar 0 is enough for a playable baseline. The suggest_sample_technique
224
+ step elsewhere in the plan produces a recipe the agent can use later
225
+ to replace the trigger pattern with something more musical.
226
+ """
227
+ active_sections = [s for s in sections if s["name"] in layer.sections]
228
+ if not active_sections:
229
+ return []
230
+
205
231
  steps: list[dict] = []
206
232
 
207
- for section in sections:
208
- if section["name"] not in layer.sections:
209
- continue
233
+ # 1. Source session clip — 1 bar = 4 beats at 4/4
234
+ SOURCE_SLOT = 0
235
+ SOURCE_BEATS = 4.0
236
+ steps.append({
237
+ "tool": "create_clip",
238
+ "params": {
239
+ "track_index": track_index,
240
+ "clip_index": SOURCE_SLOT,
241
+ "length": SOURCE_BEATS,
242
+ },
243
+ "description": f"Create 1-bar source clip for {layer.role} (slot {SOURCE_SLOT})",
244
+ "role": layer.role,
245
+ })
210
246
 
211
- # Check for volume offset in section layer refs (e.g. "drums:-6dB")
212
- volume_offset_db = 0.0
213
- for layer_ref in section.get("layers", []):
214
- parts = layer_ref.split(":")
215
- if parts[0] == layer.role and len(parts) > 1:
216
- try:
217
- volume_offset_db = float(parts[1].replace("dB", ""))
218
- except ValueError:
219
- pass
247
+ # 2. Single trigger note at beat 0 C3 (MIDI 60), 1 beat duration.
248
+ # Simpler plays the full sample from this note; shorter durations
249
+ # are fine because Simpler doesn't gate on note-off in classic mode.
250
+ steps.append({
251
+ "tool": "add_notes",
252
+ "params": {
253
+ "track_index": track_index,
254
+ "clip_index": SOURCE_SLOT,
255
+ "notes": [{
256
+ "pitch": 60, # C3
257
+ "start_time": 0.0,
258
+ "duration": 1.0, # 1 beat
259
+ "velocity": 100,
260
+ }],
261
+ },
262
+ "description": f"Add C3 trigger note to {layer.role} source clip",
263
+ "role": layer.role,
264
+ })
220
265
 
266
+ # 3. One arrangement clip per section this layer appears in
267
+ for section in active_sections:
221
268
  start_bar = section["start_bar"]
222
269
  bar_count = section["bars"]
223
-
224
270
  steps.append({
225
271
  "tool": "create_arrangement_clip",
226
272
  "params": {
227
273
  "track_index": track_index,
228
- "start_time": start_bar * 4.0, # bars → beats (4/4)
229
- "length": bar_count * 4.0,
274
+ "clip_slot_index": SOURCE_SLOT,
275
+ "start_time": float(start_bar * 4.0), # bars → beats
276
+ "length": float(bar_count * 4.0),
277
+ "loop_length": SOURCE_BEATS, # tile 1-bar source
230
278
  },
231
- "description": f"Create arrangement clip: {layer.role} in {section['name']} "
232
- f"(bar {start_bar}, {bar_count} bars)",
279
+ "description": (
280
+ f"Arrange {layer.role} into '{section['name']}' "
281
+ f"(bar {start_bar}, {bar_count} bars)"
282
+ ),
233
283
  "role": layer.role,
234
284
  "section": section["name"],
235
285
  })
236
286
 
237
- # Add volume automation at section boundaries if there's an offset
238
- if volume_offset_db != 0.0:
239
- steps.append({
240
- "tool": "set_arrangement_automation",
241
- "params": {
242
- "track_index": track_index,
243
- "parameter_name": "Volume",
244
- "time": start_bar * 4.0,
245
- "value": layer.volume_db + volume_offset_db,
246
- },
247
- "description": f"Automate volume fade: {layer.role} at {volume_offset_db}dB "
248
- f"in {section['name']}",
249
- "role": layer.role,
250
- "section": section["name"],
251
- })
252
-
253
287
  return steps
254
288
 
255
289
 
@@ -260,104 +294,113 @@ class ComposerEngine:
260
294
 
261
295
  Pure computation — returns compiled plan dicts.
262
296
  The tool layer (tools.py) handles actual execution.
297
+
298
+ Async because sample resolution may download from Splice over gRPC.
299
+ Filesystem-only callers still get near-instant resolution — the resolver
300
+ only awaits when it actually hits the network.
263
301
  """
264
302
 
265
- def compose(
303
+ async def compose(
266
304
  self,
267
305
  intent: CompositionIntent,
268
306
  dry_run: bool = False,
269
307
  max_credits: int = 10,
308
+ search_roots: Optional[list] = None,
309
+ splice_client: object = None,
310
+ browser_client: object = None,
270
311
  ) -> CompositionResult:
271
312
  """Plan a full multi-layer composition from a CompositionIntent.
272
313
 
273
- Returns a CompositionResult with compiled execution steps.
314
+ Returns a CompositionResult where `plan` contains only executable
315
+ steps. Unresolved layers are kept in `layers` (descriptive) but
316
+ dropped from `plan`, with warnings naming the unresolved roles.
317
+
318
+ splice_client is typically `ctx.lifespan_context["splice_client"]`
319
+ from the tool layer. When connected, its catalog is searched after
320
+ filesystem and remote samples are downloaded one credit at a time
321
+ (subject to the hard floor).
274
322
  """
275
- result = CompositionResult(
276
- intent=intent,
277
- dry_run=dry_run,
278
- )
323
+ result = CompositionResult(intent=intent, dry_run=dry_run)
279
324
 
280
- # Plan layers and sections
281
325
  layers = plan_layers(intent)
282
326
  sections = plan_sections(intent)
283
327
  result.layers = layers
284
328
  result.sections = sections
329
+ result.credits_estimated = len(layers)
285
330
 
286
- # Estimate credits needed (1 per non-downloaded layer)
287
- credits_needed = len(layers)
288
- result.credits_estimated = credits_needed
289
-
290
- if credits_needed > max_credits:
331
+ if result.credits_estimated > max_credits:
291
332
  result.warnings.append(
292
- f"Estimated {credits_needed} credits needed, "
333
+ f"Estimated {result.credits_estimated} credits needed, "
293
334
  f"but budget is {max_credits}. Some layers may use "
294
335
  f"downloaded samples or browser fallback."
295
336
  )
296
337
 
297
- # Compile the execution plan
298
338
  plan: list[dict] = []
299
339
 
300
- # Step 1: Set tempo
301
- plan.append(_compile_set_tempo_step(intent.tempo))
302
-
303
- # Step 2: Create tracks and layers
304
- for track_idx, layer in enumerate(layers):
305
- # Create track
306
- plan.append(_compile_create_track_step(track_idx, layer.role))
307
- plan.append(_compile_name_track_step(track_idx, layer.role))
340
+ # Step 1: Tempo
341
+ plan.append(_step_set_tempo(intent.tempo))
308
342
 
309
- # Search for sample
310
- plan.append(_compile_search_step(layer))
343
+ # Step 2: Per-layer build, resolving samples at plan time
344
+ for layer_idx, layer in enumerate(layers):
345
+ track_index = layer_idx
311
346
 
312
- # Download if needed
313
- plan.append(_compile_download_step(layer))
347
+ file_path, source = await resolve_sample_for_layer(
348
+ layer,
349
+ search_roots=search_roots,
350
+ splice_client=splice_client,
351
+ browser_client=browser_client,
352
+ credit_budget=max_credits,
353
+ )
354
+ if not file_path:
355
+ result.warnings.append(
356
+ f"Unresolved sample for layer '{layer.role}' "
357
+ f"(query: {layer.search_query!r}). Dropped from plan."
358
+ )
359
+ continue
314
360
 
315
- # Load into Simpler
316
- plan.append(_compile_load_sample_step(track_idx, layer))
361
+ result.resolved_samples[layer.role] = {"path": file_path, "source": source}
317
362
 
318
- # Apply technique
319
- if layer.technique_id:
320
- plan.append(_compile_technique_step(track_idx, layer))
363
+ track_step_id = f"layer_{layer_idx}_track"
364
+ plan.append(_step_create_midi_track(track_index, layer.role, track_step_id))
365
+ plan.append(_step_set_track_name(track_index, layer.role))
321
366
 
322
- # Insert processing devices
323
- plan.extend(_compile_processing_steps(track_idx, layer))
367
+ plan.append(_step_load_sample_to_simpler(track_index, layer, file_path))
324
368
 
325
- # Set mix levels
326
- plan.extend(_compile_mix_steps(track_idx, layer))
369
+ if layer.technique_id:
370
+ plan.append(_step_suggest_technique(track_index, layer))
327
371
 
328
- # Arrange into sections
329
- plan.extend(_compile_arrangement_steps(track_idx, layer, sections))
372
+ plan.extend(_processing_steps_with_binding(track_index, layer, layer_idx))
373
+ plan.extend(_mix_steps(track_index, layer))
374
+ plan.extend(_arrangement_steps(track_index, layer, sections))
330
375
 
331
376
  result.plan = plan
332
377
  return result
333
378
 
334
- def augment(
379
+ async def augment(
335
380
  self,
336
381
  request: str,
337
382
  max_credits: int = 3,
338
383
  max_layers: int = 3,
384
+ search_roots: Optional[list] = None,
385
+ splice_client: object = None,
386
+ browser_client: object = None,
339
387
  ) -> AugmentResult:
340
388
  """Plan augmentation layers to add to an existing session.
341
389
 
342
- Parses the request as a composition prompt but limits to max_layers.
390
+ Like compose(), resolves samples at plan time and drops unresolved
391
+ layers. Since the actual track count isn't known at plan time, this
392
+ uses track_index: -1 only for create_midi_track (where the Remote
393
+ Script interprets -1 as append-at-end) and then binds later steps
394
+ to the actual created track via $from_step — same pattern as the
395
+ device_index binding in compose().
343
396
  """
344
397
  intent = parse_prompt(request)
345
-
346
- # Override layer count to respect max_layers
347
398
  intent.layer_count = min(intent.layer_count or max_layers, max_layers)
348
399
 
349
- result = AugmentResult(
350
- request=request,
351
- intent=intent,
352
- )
400
+ result = AugmentResult(request=request, intent=intent)
353
401
 
354
- # Plan layers
355
- layers = plan_layers(intent)
356
- # Limit to max_layers
357
- layers = layers[:max_layers]
402
+ layers = plan_layers(intent)[:max_layers]
358
403
  result.new_layers = layers
359
-
360
- # Estimate credits
361
404
  result.credits_estimated = len(layers)
362
405
 
363
406
  if result.credits_estimated > max_credits:
@@ -366,50 +409,72 @@ class ComposerEngine:
366
409
  f"but budget is {max_credits}."
367
410
  )
368
411
 
369
- # Compile augmentation plan
370
- # Track indices start from a placeholder — the tool layer will
371
- # determine the actual track offset at runtime
372
412
  plan: list[dict] = []
373
- for offset, layer in enumerate(layers):
374
- track_placeholder = f"{{existing_track_count}} + {offset}"
375
413
 
414
+ for layer_idx, layer in enumerate(layers):
415
+ file_path, source = await resolve_sample_for_layer(
416
+ layer,
417
+ search_roots=search_roots,
418
+ splice_client=splice_client,
419
+ browser_client=browser_client,
420
+ credit_budget=max_credits,
421
+ )
422
+ if not file_path:
423
+ result.warnings.append(
424
+ f"Unresolved sample for layer '{layer.role}' "
425
+ f"(query: {layer.search_query!r}). Dropped from plan."
426
+ )
427
+ continue
428
+
429
+ result.resolved_samples[layer.role] = {"path": file_path, "source": source}
430
+
431
+ # We don't know the absolute track index yet. create_midi_track's
432
+ # result carries "index" (via Remote Script) — later steps bind
433
+ # track_index to that via $from_step. The composer tools layer
434
+ # passes existing_track_count in via a hint when available.
435
+ track_step_id = f"aug_layer_{layer_idx}_track"
376
436
  plan.append({
437
+ "step_id": track_step_id,
377
438
  "tool": "create_midi_track",
378
- "params": {"index": -1}, # append at end
439
+ "params": {"index": -1}, # append at end — Remote Script convention
379
440
  "description": f"Create MIDI track for {layer.role}",
380
441
  "role": layer.role,
381
442
  })
382
443
 
444
+ track_ref = {"$from_step": track_step_id, "path": "index"}
445
+
383
446
  plan.append({
384
447
  "tool": "set_track_name",
385
- "params": {"track_index": -1, "name": f"+ {layer.role.title()}"},
448
+ "params": {"track_index": track_ref, "name": f"+ {layer.role.title()}"},
386
449
  "description": f"Name new track: + {layer.role.title()}",
387
450
  "role": layer.role,
388
451
  })
389
452
 
390
- plan.append(_compile_search_step(layer))
391
- plan.append(_compile_download_step(layer))
392
-
393
453
  plan.append({
394
454
  "tool": "load_sample_to_simpler",
395
- "params": {"track_index": -1, "file_path": "{downloaded_path}"},
455
+ "params": {"track_index": track_ref, "file_path": file_path},
396
456
  "description": f"Load sample into Simpler",
457
+ "backend": "mcp_tool",
397
458
  "role": layer.role,
398
459
  })
399
460
 
400
461
  if layer.technique_id:
401
462
  plan.append({
402
- "tool": "_apply_technique",
403
- "params": {"track_index": -1, "technique_id": layer.technique_id},
404
- "description": f"Apply technique '{layer.technique_id}'",
463
+ "tool": "suggest_sample_technique",
464
+ "params": {"technique_id": layer.technique_id},
465
+ "description": f"Get technique recipe '{layer.technique_id}'",
405
466
  "role": layer.role,
406
467
  })
407
468
 
408
- for device in layer.processing:
469
+ for dev_idx, device in enumerate(layer.processing):
409
470
  device_name = device.get("name", "")
471
+ if not device_name:
472
+ continue
473
+ dev_step_id = f"aug_layer_{layer_idx}_dev_{dev_idx}"
410
474
  plan.append({
475
+ "step_id": dev_step_id,
411
476
  "tool": "insert_device",
412
- "params": {"track_index": -1, "device_name": device_name},
477
+ "params": {"track_index": track_ref, "device_name": device_name},
413
478
  "description": f"Insert {device_name}",
414
479
  "role": layer.role,
415
480
  })
@@ -417,8 +482,8 @@ class ComposerEngine:
417
482
  plan.append({
418
483
  "tool": "set_device_parameter",
419
484
  "params": {
420
- "track_index": -1,
421
- "device_index": -1,
485
+ "track_index": track_ref,
486
+ "device_index": {"$from_step": dev_step_id, "path": "device_index"},
422
487
  "parameter_name": param_name,
423
488
  "value": param_value,
424
489
  },
@@ -426,16 +491,17 @@ class ComposerEngine:
426
491
  "role": layer.role,
427
492
  })
428
493
 
494
+ linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
429
495
  plan.append({
430
496
  "tool": "set_track_volume",
431
- "params": {"track_index": -1, "volume_db": layer.volume_db},
497
+ "params": {"track_index": track_ref, "volume": round(linear, 3)},
432
498
  "description": f"Set {layer.role} volume to {layer.volume_db}dB",
433
499
  "role": layer.role,
434
500
  })
435
501
  if layer.pan != 0.0:
436
502
  plan.append({
437
503
  "tool": "set_track_pan",
438
- "params": {"track_index": -1, "pan": layer.pan},
504
+ "params": {"track_index": track_ref, "pan": layer.pan},
439
505
  "description": f"Set {layer.role} pan to {layer.pan}",
440
506
  "role": layer.role,
441
507
  })
@@ -443,10 +509,24 @@ class ComposerEngine:
443
509
  result.plan = plan
444
510
  return result
445
511
 
446
- def get_plan(
512
+ async def get_plan(
447
513
  self,
448
514
  intent: CompositionIntent,
515
+ search_roots: Optional[list] = None,
516
+ splice_client: object = None,
517
+ browser_client: object = None,
449
518
  ) -> dict:
450
- """Dry run — return the full composition plan without execution."""
451
- result = self.compose(intent, dry_run=True, max_credits=0)
519
+ """Dry run — return the full composition plan without execution.
520
+
521
+ Passes resolution dependencies through to compose() so the dry-run
522
+ accurately reflects which layers would resolve.
523
+ """
524
+ result = await self.compose(
525
+ intent,
526
+ dry_run=True,
527
+ max_credits=0,
528
+ search_roots=search_roots,
529
+ splice_client=splice_client,
530
+ browser_client=browser_client,
531
+ )
452
532
  return result.to_dict()