livepilot 1.23.6 → 1.24.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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/server.json +3 -3
@@ -0,0 +1,144 @@
1
+ """Full-mode brief builder — Phase 1 of the LLM-creative two-phase flow.
2
+
3
+ Takes a prompt (and optional seed_state for "extend an existing project"
4
+ flows) and returns a brief carrying VOCABULARY for the agent to design
5
+ the song's form from.
6
+
7
+ CRITICAL: The brief MUST NOT contain predetermined section sequences, bar
8
+ counts, or fixed variant taxonomies. The agent decides the form per call.
9
+ The framework only provides:
10
+ - Parsed intent (genre/mood/tempo/key from the prompt)
11
+ - Genre/artist character vocabulary (descriptive)
12
+ - The 42-event structural lexicon (named primitives, not a sequence)
13
+ - Atlas instrument candidates per role
14
+ - Live manual snippets for likely devices
15
+ - Optional seed_state for extension flows
16
+ - Research hooks (WebSearch directives for niche styles)
17
+ - An open-ended design_targets text describing the variation surface
18
+
19
+ Phase 1 stubs: genre_context, atlas_candidates_per_role, manual_snippets,
20
+ event_lexicon — empty values now, populated by Phase 4 KnowledgePack.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import asdict, is_dataclass
26
+ from typing import Any, Optional
27
+
28
+ from ..prompt_parser import parse_prompt
29
+ from ..develop.brief_builder import (
30
+ extract_artist_refs,
31
+ detect_research_hooks,
32
+ )
33
+ from ..framework.knowledge_pack import KnowledgePack
34
+
35
+
36
+ # ── design targets ─────────────────────────────────────────────────
37
+
38
+ _DESIGN_TARGETS = (
39
+ "Design a full-track arrangement from the prompt and (when provided) "
40
+ "the seed_state. You decide every aspect of the form: section sequence, "
41
+ "section bar counts, drop placement (or absence), breakdown placement, "
42
+ "outro length, hook reveal/withholding/restatement schedule, element "
43
+ "entry/exit choreography across the timeline. Use the genre_context and "
44
+ "artist_context as flavor (sonic character, gear preferences, harmonic "
45
+ "stance) — they describe what a style sounds like, NOT how to structure "
46
+ "the song. Use the event_lexicon as a vocabulary of named structural "
47
+ "moves to schedule at chosen phrase boundaries. For niche style references "
48
+ "in research_hooks, run WebSearch to ground your form choices in the "
49
+ "actual conventions of that subgenre. Submit your design as a plan to "
50
+ "compose_full_apply with: per-track variant clips at chosen scene slots, "
51
+ "per-section arrangement_clip placements referencing those variants, "
52
+ "and structural events scheduled at phrase boundaries. The form is YOUR "
53
+ "creative product — vocabularies tell you what techno or BoC sound like, "
54
+ "they do not tell you the bar count of an intro."
55
+ )
56
+
57
+
58
+ # ── tempo extraction ───────────────────────────────────────────────
59
+
60
+ def _extract_tempo_from_intent(intent: Any) -> Optional[float]:
61
+ """Pull tempo from CompositionIntent (dataclass or dict)."""
62
+ if is_dataclass(intent):
63
+ intent = asdict(intent)
64
+ if not isinstance(intent, dict):
65
+ return None
66
+ val = intent.get("tempo")
67
+ if val is None:
68
+ return None
69
+ try:
70
+ return float(val)
71
+ except (TypeError, ValueError):
72
+ return None
73
+
74
+
75
+ def _extract_key_from_intent(intent: Any) -> Optional[str]:
76
+ """Pull key from CompositionIntent (dataclass or dict)."""
77
+ if is_dataclass(intent):
78
+ intent = asdict(intent)
79
+ if not isinstance(intent, dict):
80
+ return None
81
+ return intent.get("key")
82
+
83
+
84
+ def _intent_to_dict(intent: Any) -> dict:
85
+ if is_dataclass(intent):
86
+ return asdict(intent)
87
+ if isinstance(intent, dict):
88
+ return intent
89
+ return {"raw_intent": str(intent)}
90
+
91
+
92
+ # ── main entry point ───────────────────────────────────────────────
93
+
94
+ def build_full_brief(
95
+ ctx: Any,
96
+ prompt: str,
97
+ seed_state: Optional[dict] = None,
98
+ ) -> dict:
99
+ """Build a Phase-1 full-mode brief.
100
+
101
+ Args:
102
+ ctx: Lifespan context — Phase 4 KnowledgePack will use this to fetch
103
+ atlas candidates + manual snippets. Phase 1 stub doesn't need it.
104
+ prompt: free-text directive ("dark techno 128bpm in Am",
105
+ "make it sound like Burial", etc.)
106
+ seed_state: optional dict from develop's introspect_seed — when
107
+ present, full mode extends the existing project; when
108
+ None, full mode generates from prompt only.
109
+
110
+ Returns dict with vocabulary fields. NEVER returns form-prescriptive fields.
111
+ """
112
+ parsed_intent = parse_prompt(prompt)
113
+ intent_dict = _intent_to_dict(parsed_intent)
114
+
115
+ # Tempo + key precedence: seed wins when present, else prompt
116
+ seed_tempo = seed_state.get("tempo") if seed_state else None
117
+ seed_key = seed_state.get("key") if seed_state else None
118
+ tempo = seed_tempo if seed_tempo is not None else _extract_tempo_from_intent(parsed_intent)
119
+ key = seed_key if seed_key is not None else _extract_key_from_intent(parsed_intent)
120
+
121
+ # Vocabulary lookups
122
+ artist_refs = extract_artist_refs(prompt or "")
123
+ research_hooks = detect_research_hooks(prompt or "")
124
+
125
+ # Build knowledge pack — populates genre_context, artist_context, event_lexicon
126
+ if artist_refs:
127
+ intent_dict["artists"] = artist_refs
128
+ kp = KnowledgePack()
129
+ knowledge = kp.build(intent_dict, mode="full")
130
+
131
+ return {
132
+ "mode": "full",
133
+ "tempo": tempo,
134
+ "key": key,
135
+ "parsed_intent": intent_dict,
136
+ "genre_context": knowledge["genre_context"],
137
+ "artist_context": knowledge["artist_context"],
138
+ "event_lexicon": knowledge["event_lexicon"],
139
+ "atlas_candidates_per_role": knowledge["atlas_candidates_per_role"],
140
+ "manual_snippets": knowledge["manual_snippets"],
141
+ "seed_state": seed_state,
142
+ "research_hooks": research_hooks,
143
+ "design_targets": _DESIGN_TARGETS,
144
+ }
@@ -0,0 +1,541 @@
1
+ """ComposerEngine — orchestrate prompt → layers → executable plan.
2
+
3
+ Pure computation engine. Does NOT call MCP tools directly.
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.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ from ..prompt_parser import CompositionIntent, parse_prompt
30
+ from .layer_planner import LayerSpec, plan_layers, plan_sections
31
+ from ..sample_resolver import resolve_sample_for_layer
32
+
33
+
34
+ # ── Result Models ──────────────────────────────────────────────────
35
+
36
+ @dataclass
37
+ class CompositionResult:
38
+ """Result of a full composition run."""
39
+
40
+ intent: CompositionIntent = field(default_factory=CompositionIntent)
41
+ layers: list[LayerSpec] = field(default_factory=list)
42
+ sections: list[dict] = field(default_factory=list)
43
+ plan: list[dict] = field(default_factory=list) # executable steps only
44
+ credits_estimated: int = 0
45
+ dry_run: bool = False
46
+ warnings: list[str] = field(default_factory=list)
47
+ resolved_samples: dict = field(default_factory=dict) # role -> local_path
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "intent": self.intent.to_dict(),
52
+ "layer_count": len(self.layers),
53
+ "layers": [l.to_dict() for l in self.layers],
54
+ "sections": self.sections,
55
+ "plan_step_count": len(self.plan),
56
+ "plan": self.plan,
57
+ "credits_estimated": self.credits_estimated,
58
+ "dry_run": self.dry_run,
59
+ "warnings": self.warnings,
60
+ "resolved_samples": self.resolved_samples,
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class AugmentResult:
66
+ """Result of an augmentation run."""
67
+
68
+ request: str = ""
69
+ intent: CompositionIntent = field(default_factory=CompositionIntent)
70
+ new_layers: list[LayerSpec] = field(default_factory=list)
71
+ plan: list[dict] = field(default_factory=list)
72
+ credits_estimated: int = 0
73
+ warnings: list[str] = field(default_factory=list)
74
+ resolved_samples: dict = field(default_factory=dict)
75
+
76
+ def to_dict(self) -> dict:
77
+ return {
78
+ "request": self.request,
79
+ "intent": self.intent.to_dict(),
80
+ "new_layer_count": len(self.new_layers),
81
+ "new_layers": [l.to_dict() for l in self.new_layers],
82
+ "plan_step_count": len(self.plan),
83
+ "plan": self.plan,
84
+ "credits_estimated": self.credits_estimated,
85
+ "warnings": self.warnings,
86
+ "resolved_samples": self.resolved_samples,
87
+ }
88
+
89
+
90
+ # ── Step builders ──────────────────────────────────────────────────
91
+
92
+ def _step_set_tempo(tempo: int) -> dict:
93
+ return {
94
+ "tool": "set_tempo",
95
+ "params": {"tempo": tempo},
96
+ "description": f"Set tempo to {tempo} BPM",
97
+ }
98
+
99
+
100
+ def _step_create_midi_track(track_index: int, role: str, step_id: str) -> dict:
101
+ return {
102
+ "step_id": step_id,
103
+ "tool": "create_midi_track",
104
+ "params": {"index": track_index},
105
+ "description": f"Create MIDI track for {role}",
106
+ "role": role,
107
+ }
108
+
109
+
110
+ def _step_set_track_name(track_index: int, role: str) -> dict:
111
+ return {
112
+ "tool": "set_track_name",
113
+ "params": {"track_index": track_index, "name": role.title()},
114
+ "description": f"Name track: {role.title()}",
115
+ "role": role,
116
+ }
117
+
118
+
119
+ def _step_load_sample_to_simpler(track_index: int, layer: LayerSpec, file_path: str) -> dict:
120
+ return {
121
+ "tool": "load_sample_to_simpler",
122
+ "params": {"track_index": track_index, "file_path": file_path},
123
+ "description": f"Load sample into Simpler on track {track_index}",
124
+ "backend": "mcp_tool",
125
+ "role": layer.role,
126
+ }
127
+
128
+
129
+ # NOTE: there used to be a _step_suggest_technique helper here that emitted a
130
+ # `suggest_sample_technique` step into the executable plan with params
131
+ # {"technique_id": layer.technique_id}. This was broken: the real tool's
132
+ # signature is (file_path, intent, philosophy, max_suggestions) and takes
133
+ # no technique_id param. The step would have failed at runtime with a
134
+ # "required file_path missing" error.
135
+ #
136
+ # Removed in v1.10.3 (Truth Release). Technique suggestions for composer
137
+ # layers are now surfaced in the descriptive result output (result.layers[*].
138
+ # technique_id) — the agent can call suggest_sample_technique separately
139
+ # with the resolved sample path if it wants per-sample recipe advice. The
140
+ # executable plan emits only real, validated tool calls.
141
+
142
+
143
+ def _processing_steps_with_binding(
144
+ track_index: int,
145
+ layer: LayerSpec,
146
+ layer_idx: int,
147
+ ) -> list[dict]:
148
+ """Build insert_device + set_device_parameter pairs using step_id bindings.
149
+
150
+ Each insert_device carries a unique step_id like 'layer_0_dev_1'. The
151
+ following set_device_parameter steps bind their device_index param to
152
+ that id via $from_step — the async router resolves it to the real
153
+ device index returned by insert_device at runtime.
154
+ """
155
+ steps: list[dict] = []
156
+ for dev_idx, device in enumerate(layer.processing):
157
+ device_name = device.get("name", "")
158
+ if not device_name:
159
+ continue
160
+ step_id = f"layer_{layer_idx}_dev_{dev_idx}"
161
+ steps.append({
162
+ "step_id": step_id,
163
+ "tool": "insert_device",
164
+ "params": {
165
+ "track_index": track_index,
166
+ "device_name": device_name,
167
+ },
168
+ "description": f"Insert {device_name} on track {track_index}",
169
+ "role": layer.role,
170
+ })
171
+ for param_name, param_value in device.get("params", {}).items():
172
+ steps.append({
173
+ "tool": "set_device_parameter",
174
+ "params": {
175
+ "track_index": track_index,
176
+ "device_index": {"$from_step": step_id, "path": "device_index"},
177
+ "parameter_name": param_name,
178
+ "value": param_value,
179
+ },
180
+ "description": f"Set {device_name} {param_name} = {param_value}",
181
+ "role": layer.role,
182
+ })
183
+ return steps
184
+
185
+
186
+ def _mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
187
+ steps: list[dict] = []
188
+ # dB to linear with 0dB -> 0.85 convention (Ableton native scale)
189
+ linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
190
+ steps.append({
191
+ "tool": "set_track_volume",
192
+ "params": {"track_index": track_index, "volume": round(linear, 3)},
193
+ "description": f"Set {layer.role} volume to {layer.volume_db}dB ({linear:.3f} linear)",
194
+ "role": layer.role,
195
+ })
196
+ if layer.pan != 0.0:
197
+ steps.append({
198
+ "tool": "set_track_pan",
199
+ "params": {"track_index": track_index, "pan": layer.pan},
200
+ "description": f"Set {layer.role} pan to {layer.pan}",
201
+ "role": layer.role,
202
+ })
203
+ return steps
204
+
205
+
206
+ def _arrangement_steps(
207
+ track_index: int,
208
+ layer: LayerSpec,
209
+ sections: list[dict],
210
+ ) -> list[dict]:
211
+ """Emit the full arrangement sequence for a layer.
212
+
213
+ For each layer that appears in at least one section, we emit:
214
+
215
+ 1. create_clip — a 1-bar MIDI clip in session slot 0 (the source)
216
+ 2. add_notes — a single C3 trigger note so Simpler actually sounds
217
+ 3. create_arrangement_clip (×N) — one per section the layer is in,
218
+ tiling the 1-bar source across each section's bar count
219
+
220
+ The trigger-clip-plus-tile approach is intentionally minimal: Simpler
221
+ in classic mode plays the full sample on every note, so a single C3
222
+ at bar 0 is enough for a playable baseline. The suggest_sample_technique
223
+ step elsewhere in the plan produces a recipe the agent can use later
224
+ to replace the trigger pattern with something more musical.
225
+ """
226
+ active_sections = [s for s in sections if s["name"] in layer.sections]
227
+ if not active_sections:
228
+ return []
229
+
230
+ steps: list[dict] = []
231
+
232
+ # 1. Source session clip — 1 bar = 4 beats at 4/4
233
+ SOURCE_SLOT = 0
234
+ SOURCE_BEATS = 4.0
235
+ steps.append({
236
+ "tool": "create_clip",
237
+ "params": {
238
+ "track_index": track_index,
239
+ "clip_index": SOURCE_SLOT,
240
+ "length": SOURCE_BEATS,
241
+ },
242
+ "description": f"Create 1-bar source clip for {layer.role} (slot {SOURCE_SLOT})",
243
+ "role": layer.role,
244
+ })
245
+
246
+ # 2. Single trigger note at beat 0 — C3 (MIDI 60), spanning the full
247
+ # clip length (BUG-FULL-MODE-6, 2026-05-01).
248
+ #
249
+ # Earlier comment claimed "Simpler doesn't gate on note-off in classic
250
+ # mode", but that's wrong: with Simpler's default Ve Mode=None
251
+ # (standard ADSR), note-off DOES trigger release. A 1-beat note in a
252
+ # 4-beat clip means only the first beat of the loop plays, then 3
253
+ # beats of silence, then retrigger on the next clip iteration —
254
+ # audibly choppy/short. Spanning the full clip length keeps the
255
+ # sample playing continuously through each iteration.
256
+ #
257
+ # NB: do NOT extend by adding 0.001 to overlap the loop boundary —
258
+ # Live's clip looping handles edge-of-clip retriggering cleanly when
259
+ # duration == clip_length.
260
+ steps.append({
261
+ "tool": "add_notes",
262
+ "params": {
263
+ "track_index": track_index,
264
+ "clip_index": SOURCE_SLOT,
265
+ "notes": [{
266
+ "pitch": 60, # C3
267
+ "start_time": 0.0,
268
+ "duration": SOURCE_BEATS, # full clip length (4 beats)
269
+ "velocity": 100,
270
+ }],
271
+ },
272
+ "description": f"Add C3 trigger note to {layer.role} source clip",
273
+ "role": layer.role,
274
+ })
275
+
276
+ # 3. One arrangement clip per section this layer appears in
277
+ for section in active_sections:
278
+ start_bar = section["start_bar"]
279
+ bar_count = section["bars"]
280
+ steps.append({
281
+ "tool": "create_arrangement_clip",
282
+ "params": {
283
+ "track_index": track_index,
284
+ "clip_slot_index": SOURCE_SLOT,
285
+ "start_time": float(start_bar * 4.0), # bars → beats
286
+ "length": float(bar_count * 4.0),
287
+ "loop_length": SOURCE_BEATS, # tile 1-bar source
288
+ },
289
+ "description": (
290
+ f"Arrange {layer.role} into '{section['name']}' "
291
+ f"(bar {start_bar}, {bar_count} bars)"
292
+ ),
293
+ "role": layer.role,
294
+ "section": section["name"],
295
+ })
296
+
297
+ return steps
298
+
299
+
300
+ # ── Engine ─────────────────────────────────────────────────────────
301
+
302
+ class ComposerEngine:
303
+ """Orchestrates the full composition pipeline.
304
+
305
+ Pure computation — returns compiled plan dicts.
306
+ The tool layer (tools.py) handles actual execution.
307
+
308
+ Async because sample resolution may download from Splice over gRPC.
309
+ Filesystem-only callers still get near-instant resolution — the resolver
310
+ only awaits when it actually hits the network.
311
+ """
312
+
313
+ async def compose(
314
+ self,
315
+ intent: CompositionIntent,
316
+ dry_run: bool = False,
317
+ max_credits: int = 10,
318
+ search_roots: Optional[list] = None,
319
+ splice_client: object = None,
320
+ browser_client: object = None,
321
+ ) -> CompositionResult:
322
+ """Plan a full multi-layer composition from a CompositionIntent.
323
+
324
+ Returns a CompositionResult where `plan` contains only executable
325
+ steps. Unresolved layers are kept in `layers` (descriptive) but
326
+ dropped from `plan`, with warnings naming the unresolved roles.
327
+
328
+ splice_client is typically `ctx.lifespan_context["splice_client"]`
329
+ from the tool layer. When connected, its catalog is searched after
330
+ filesystem and remote samples are downloaded one credit at a time
331
+ (subject to the hard floor).
332
+ """
333
+ result = CompositionResult(intent=intent, dry_run=dry_run)
334
+
335
+ # v1.24: SECTION_TEMPLATES removed per vocabulary-not-form principle (Task 12).
336
+ # plan_layers and plan_sections will raise until Task 14 rewires this to
337
+ # the LLM-creative full-mode flow. DEPRECATED in v1.24.
338
+ layers = plan_layers(intent)
339
+ sections = plan_sections(intent)
340
+ result.layers = layers
341
+ result.sections = sections
342
+ result.credits_estimated = len(layers)
343
+
344
+ if result.credits_estimated > max_credits:
345
+ result.warnings.append(
346
+ f"Estimated {result.credits_estimated} credits needed, "
347
+ f"but budget is {max_credits}. Some layers may use "
348
+ f"downloaded samples or browser fallback."
349
+ )
350
+
351
+ plan: list[dict] = []
352
+
353
+ # Step 1: Tempo
354
+ plan.append(_step_set_tempo(intent.tempo))
355
+
356
+ # Step 2: Per-layer build, resolving samples at plan time
357
+ for layer_idx, layer in enumerate(layers):
358
+ track_index = layer_idx
359
+
360
+ file_path, source = await resolve_sample_for_layer(
361
+ layer,
362
+ search_roots=search_roots,
363
+ splice_client=splice_client,
364
+ browser_client=browser_client,
365
+ credit_budget=max_credits,
366
+ )
367
+ if not file_path:
368
+ result.warnings.append(
369
+ f"Unresolved sample for layer '{layer.role}' "
370
+ f"(query: {layer.search_query!r}). Dropped from plan."
371
+ )
372
+ continue
373
+
374
+ result.resolved_samples[layer.role] = {"path": file_path, "source": source}
375
+
376
+ track_step_id = f"layer_{layer_idx}_track"
377
+ plan.append(_step_create_midi_track(track_index, layer.role, track_step_id))
378
+ plan.append(_step_set_track_name(track_index, layer.role))
379
+
380
+ plan.append(_step_load_sample_to_simpler(track_index, layer, file_path))
381
+
382
+ # technique_id intentionally NOT emitted as an executable step —
383
+ # see note above _step_suggest_technique removal. layer.technique_id
384
+ # is still surfaced in result.layers for descriptive output.
385
+
386
+ plan.extend(_processing_steps_with_binding(track_index, layer, layer_idx))
387
+ plan.extend(_mix_steps(track_index, layer))
388
+ plan.extend(_arrangement_steps(track_index, layer, sections))
389
+
390
+ result.plan = plan
391
+ return result
392
+
393
+ async def augment(
394
+ self,
395
+ request: str,
396
+ max_credits: int = 3,
397
+ max_layers: int = 3,
398
+ search_roots: Optional[list] = None,
399
+ splice_client: object = None,
400
+ browser_client: object = None,
401
+ ) -> AugmentResult:
402
+ """Plan augmentation layers to add to an existing session.
403
+
404
+ Like compose(), resolves samples at plan time and drops unresolved
405
+ layers. Since the actual track count isn't known at plan time, this
406
+ uses track_index: -1 only for create_midi_track (where the Remote
407
+ Script interprets -1 as append-at-end) and then binds later steps
408
+ to the actual created track via $from_step — same pattern as the
409
+ device_index binding in compose().
410
+ """
411
+ intent = parse_prompt(request)
412
+ intent.layer_count = min(intent.layer_count or max_layers, max_layers)
413
+
414
+ result = AugmentResult(request=request, intent=intent)
415
+
416
+ layers = plan_layers(intent)[:max_layers]
417
+ result.new_layers = layers
418
+ result.credits_estimated = len(layers)
419
+
420
+ if result.credits_estimated > max_credits:
421
+ result.warnings.append(
422
+ f"Estimated {result.credits_estimated} credits needed, "
423
+ f"but budget is {max_credits}."
424
+ )
425
+
426
+ plan: list[dict] = []
427
+
428
+ for layer_idx, layer in enumerate(layers):
429
+ file_path, source = await resolve_sample_for_layer(
430
+ layer,
431
+ search_roots=search_roots,
432
+ splice_client=splice_client,
433
+ browser_client=browser_client,
434
+ credit_budget=max_credits,
435
+ )
436
+ if not file_path:
437
+ result.warnings.append(
438
+ f"Unresolved sample for layer '{layer.role}' "
439
+ f"(query: {layer.search_query!r}). Dropped from plan."
440
+ )
441
+ continue
442
+
443
+ result.resolved_samples[layer.role] = {"path": file_path, "source": source}
444
+
445
+ # We don't know the absolute track index yet. create_midi_track's
446
+ # result carries "index" (via Remote Script) — later steps bind
447
+ # track_index to that via $from_step. The composer tools layer
448
+ # passes existing_track_count in via a hint when available.
449
+ track_step_id = f"aug_layer_{layer_idx}_track"
450
+ plan.append({
451
+ "step_id": track_step_id,
452
+ "tool": "create_midi_track",
453
+ "params": {"index": -1}, # append at end — Remote Script convention
454
+ "description": f"Create MIDI track for {layer.role}",
455
+ "role": layer.role,
456
+ })
457
+
458
+ track_ref = {"$from_step": track_step_id, "path": "index"}
459
+
460
+ plan.append({
461
+ "tool": "set_track_name",
462
+ "params": {"track_index": track_ref, "name": f"+ {layer.role.title()}"},
463
+ "description": f"Name new track: + {layer.role.title()}",
464
+ "role": layer.role,
465
+ })
466
+
467
+ plan.append({
468
+ "tool": "load_sample_to_simpler",
469
+ "params": {"track_index": track_ref, "file_path": file_path},
470
+ "description": f"Load sample into Simpler",
471
+ "backend": "mcp_tool",
472
+ "role": layer.role,
473
+ })
474
+
475
+ # technique_id intentionally NOT emitted (see compose() above).
476
+ # Surfaced in result.new_layers for descriptive output only.
477
+
478
+ for dev_idx, device in enumerate(layer.processing):
479
+ device_name = device.get("name", "")
480
+ if not device_name:
481
+ continue
482
+ dev_step_id = f"aug_layer_{layer_idx}_dev_{dev_idx}"
483
+ plan.append({
484
+ "step_id": dev_step_id,
485
+ "tool": "insert_device",
486
+ "params": {"track_index": track_ref, "device_name": device_name},
487
+ "description": f"Insert {device_name}",
488
+ "role": layer.role,
489
+ })
490
+ for param_name, param_value in device.get("params", {}).items():
491
+ plan.append({
492
+ "tool": "set_device_parameter",
493
+ "params": {
494
+ "track_index": track_ref,
495
+ "device_index": {"$from_step": dev_step_id, "path": "device_index"},
496
+ "parameter_name": param_name,
497
+ "value": param_value,
498
+ },
499
+ "description": f"Set {device_name} {param_name} = {param_value}",
500
+ "role": layer.role,
501
+ })
502
+
503
+ linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
504
+ plan.append({
505
+ "tool": "set_track_volume",
506
+ "params": {"track_index": track_ref, "volume": round(linear, 3)},
507
+ "description": f"Set {layer.role} volume to {layer.volume_db}dB",
508
+ "role": layer.role,
509
+ })
510
+ if layer.pan != 0.0:
511
+ plan.append({
512
+ "tool": "set_track_pan",
513
+ "params": {"track_index": track_ref, "pan": layer.pan},
514
+ "description": f"Set {layer.role} pan to {layer.pan}",
515
+ "role": layer.role,
516
+ })
517
+
518
+ result.plan = plan
519
+ return result
520
+
521
+ async def get_plan(
522
+ self,
523
+ intent: CompositionIntent,
524
+ search_roots: Optional[list] = None,
525
+ splice_client: object = None,
526
+ browser_client: object = None,
527
+ ) -> dict:
528
+ """Dry run — return the full composition plan without execution.
529
+
530
+ Passes resolution dependencies through to compose() so the dry-run
531
+ accurately reflects which layers would resolve.
532
+ """
533
+ result = await self.compose(
534
+ intent,
535
+ dry_run=True,
536
+ max_credits=0,
537
+ search_roots=search_roots,
538
+ splice_client=splice_client,
539
+ browser_client=browser_client,
540
+ )
541
+ return result.to_dict()