livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -11,8 +11,10 @@ from .models import AutomationGraph
11
11
  def build_automation_graph(
12
12
  track_infos: list[dict],
13
13
  sections: list[dict] | None = None,
14
+ clip_automation: list[dict] | None = None,
14
15
  ) -> AutomationGraph:
15
- """Build an AutomationGraph by scanning track device info for automation.
16
+ """Build an AutomationGraph covering both device-parameter automation
17
+ hints and real clip envelopes (BUG-E2).
16
18
 
17
19
  Args:
18
20
  track_infos: list of per-track info dicts. Each may contain:
@@ -20,18 +22,52 @@ def build_automation_graph(
20
22
  - name: track name
21
23
  - devices: [{name, class_name, parameters: [{name, value, is_automated, ...}]}]
22
24
  sections: optional list of section dicts (for density_by_section).
25
+ clip_automation: optional list of per-clip envelope descriptors:
26
+ [{section_id, track_index, track_name, clip_index,
27
+ parameter_name, parameter_type, device_name}].
28
+ This is the ground truth — `device.parameters[i].is_automated`
29
+ only reflects mapping state, not the presence of an envelope.
23
30
 
24
31
  Returns:
25
32
  AutomationGraph with automated_params and density_by_section.
26
33
  """
27
34
  graph = AutomationGraph()
28
35
 
29
- if not track_infos:
36
+ if not track_infos and not clip_automation:
30
37
  return graph
31
38
 
32
- automated_params = []
39
+ automated_params: list[dict] = []
40
+ # Track which (track_index, device_name, param_name) we've already seen
41
+ # so device-hint entries don't duplicate clip-envelope entries.
42
+ seen: set[tuple[int, str, str]] = set()
33
43
 
34
- for track in track_infos:
44
+ # 1) Seed with real clip envelopes. These are the source of truth.
45
+ per_section_counts: dict[str, int] = {}
46
+ for env in clip_automation or []:
47
+ t_idx = int(env.get("track_index", 0))
48
+ dev = str(env.get("device_name") or env.get("parameter_type") or "")
49
+ name = str(env.get("parameter_name") or "")
50
+ key = (t_idx, dev, name)
51
+ if key in seen:
52
+ continue
53
+ seen.add(key)
54
+ automated_params.append({
55
+ "track_index": t_idx,
56
+ "track_name": env.get("track_name", ""),
57
+ "device_name": dev or None,
58
+ "param_name": name,
59
+ "parameter_type": env.get("parameter_type", ""),
60
+ "clip_index": env.get("clip_index"),
61
+ "section_id": env.get("section_id"),
62
+ "source": "clip_envelope",
63
+ })
64
+ sec = env.get("section_id")
65
+ if sec:
66
+ per_section_counts[sec] = per_section_counts.get(sec, 0) + 1
67
+
68
+ # 2) Add device-level hints (track-wide is_automated flags) that
69
+ # aren't already covered by an envelope entry.
70
+ for track in track_infos or []:
35
71
  t_idx = track.get("index", 0)
36
72
  t_name = track.get("name", "")
37
73
  devices = track.get("devices", [])
@@ -41,29 +77,45 @@ def build_automation_graph(
41
77
  parameters = device.get("parameters", [])
42
78
 
43
79
  for param in parameters:
44
- if param.get("is_automated", False) or param.get("automation_state", 0) > 0:
45
- automated_params.append({
46
- "track_index": t_idx,
47
- "track_name": t_name,
48
- "device_name": device_name,
49
- "param_name": param.get("name", ""),
50
- "param_value": param.get("value"),
51
- })
80
+ is_flagged = (
81
+ param.get("is_automated", False)
82
+ or param.get("automation_state", 0) > 0
83
+ )
84
+ if not is_flagged:
85
+ continue
86
+ p_name = param.get("name", "")
87
+ key = (t_idx, str(device_name), str(p_name))
88
+ if key in seen:
89
+ continue
90
+ seen.add(key)
91
+ automated_params.append({
92
+ "track_index": t_idx,
93
+ "track_name": t_name,
94
+ "device_name": device_name,
95
+ "param_name": p_name,
96
+ "param_value": param.get("value"),
97
+ "source": "device_hint",
98
+ })
52
99
 
53
100
  graph.automated_params = automated_params
54
101
 
55
- # Compute density_by_section if sections are provided
102
+ # Compute density_by_section.
56
103
  if sections:
57
104
  total_automated = len(automated_params)
58
105
  for sec in sections:
59
106
  section_id = sec.get("section_id", "")
60
- # Without per-section automation data, distribute evenly
61
- # and weight by section density (more active tracks = more automation)
62
- sec_density = sec.get("density", 0.0)
63
- # Automation density approximation: section density * param count ratio
64
- if total_automated > 0:
107
+ if per_section_counts:
108
+ # Use real per-section counts when we have them.
109
+ count = per_section_counts.get(section_id, 0)
110
+ # Normalize by max(1, largest section count) so the
111
+ # densest section is 1.0 and others fall below.
112
+ max_ct = max(per_section_counts.values()) or 1
113
+ graph.density_by_section[section_id] = round(count / max_ct, 3)
114
+ elif total_automated > 0:
115
+ # Fallback: approximate from section density (old behavior)
116
+ sec_density = sec.get("density", 0.0)
65
117
  graph.density_by_section[section_id] = round(
66
- sec_density * min(total_automated / max(len(track_infos), 1), 1.0),
118
+ sec_density * min(total_automated / max(len(track_infos or []), 1), 1.0),
67
119
  3,
68
120
  )
69
121
  else:
@@ -23,6 +23,7 @@ def build_project_state_from_data(
23
23
  track_infos: Optional[list[dict]] = None,
24
24
  notes_map: Optional[dict[str, dict[int, list[dict]]]] = None,
25
25
  arrangement_clips: Optional[dict] = None,
26
+ clip_automation: Optional[list[dict]] = None,
26
27
  analyzer_ok: bool = False,
27
28
  flucoma_ok: bool = False,
28
29
  plugin_health: Optional[dict[str, Any]] = None,
@@ -105,6 +106,7 @@ def build_project_state_from_data(
105
106
  state.automation_graph = build_automation_graph(
106
107
  track_infos=track_infos or [],
107
108
  sections=section_dicts_for_auto,
109
+ clip_automation=clip_automation or [],
108
110
  )
109
111
  state.automation_graph.freshness.mark_fresh(state.revision)
110
112
 
@@ -88,14 +88,20 @@ def build_project_brain(ctx: Context) -> dict:
88
88
  # Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
89
89
  # falls back to "assume all tracks active in every section" which destroys
90
90
  # section-scoped role confidence.
91
+ #
92
+ # BUG-E1: section_id must match what build_section_graph_from_scenes emits.
93
+ # The composition engine emits `sec_{i:02d}` using the RAW enumerate index
94
+ # of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
95
+ # ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
96
+ # Our notes_map must mirror that or keys won't align.
91
97
  notes_map: dict[str, dict[int, list[dict]]] = {}
92
98
  try:
93
99
  for scene_idx, scene in enumerate(scenes or []):
94
- section_id = str(
95
- scene.get("section_id")
96
- or scene.get("name")
97
- or f"scene_{scene_idx}"
98
- )
100
+ scene_name = str(scene.get("name", "")).strip()
101
+ if not scene_name:
102
+ continue # mirror _ce_build_sections: unnamed scenes skipped
103
+ section_id = f"sec_{scene_idx:02d}"
104
+
99
105
  per_track: dict[int, list[dict]] = {}
100
106
  for track in tracks:
101
107
  t_idx = track.get("index", 0)
@@ -119,6 +125,49 @@ def build_project_brain(ctx: Context) -> dict:
119
125
  # Overall failure: empty map, degrade to "all tracks active" fallback
120
126
  notes_map = {}
121
127
 
128
+ # 5c. Scan clip automation across the session (BUG-E2).
129
+ # Device-parameter is_automated flags only reflect whether a parameter
130
+ # is mapped somewhere — they don't reveal clip envelopes. Ableton's
131
+ # automation actually lives on each clip (session + arrangement). We
132
+ # walk every clip slot that has a clip and ask get_clip_automation, then
133
+ # aggregate into a flat list keyed by section.
134
+ clip_automation: list[dict] = []
135
+ try:
136
+ # Iterate session scenes x tracks, plus arrangement clips we already have.
137
+ # Use the raw enumerate index for section_id so it stays aligned with
138
+ # arrangement_graph sections (which use the same scheme — see E1 fix).
139
+ for scene_idx, scene in enumerate(scenes or []):
140
+ scene_name = str(scene.get("name", "")).strip()
141
+ if not scene_name:
142
+ continue
143
+ section_id = f"sec_{scene_idx:02d}"
144
+ for track in tracks:
145
+ t_idx = track.get("index", 0)
146
+ try:
147
+ auto_resp = ableton.send_command("get_clip_automation", {
148
+ "track_index": t_idx,
149
+ "clip_index": scene_idx,
150
+ })
151
+ except Exception as exc:
152
+ # No clip in slot, or remote script rejected — skip
153
+ logger.debug("build_project_brain automation skip: %s", exc)
154
+ continue
155
+ if not isinstance(auto_resp, dict):
156
+ continue
157
+ envs = auto_resp.get("envelopes") or []
158
+ for env in envs:
159
+ clip_automation.append({
160
+ "section_id": section_id,
161
+ "track_index": t_idx,
162
+ "track_name": track.get("name", ""),
163
+ "clip_index": scene_idx,
164
+ "parameter_name": env.get("parameter_name", ""),
165
+ "parameter_type": env.get("parameter_type", ""),
166
+ "device_name": env.get("device_name"),
167
+ })
168
+ except Exception as exc:
169
+ logger.debug("build_project_brain automation scan failed: %s", exc)
170
+
122
171
  # 6. Probe capabilities (direct SpectralCache access, not TCP)
123
172
  analyzer_ok = False
124
173
  analyzer_fresh = False
@@ -146,6 +195,7 @@ def build_project_brain(ctx: Context) -> dict:
146
195
  track_infos=track_infos if track_infos else None,
147
196
  notes_map=notes_map if notes_map else None,
148
197
  arrangement_clips=arrangement_clips if arrangement_clips else None,
198
+ clip_automation=clip_automation if clip_automation else None,
149
199
  analyzer_ok=analyzer_ok,
150
200
  flucoma_ok=flucoma_ok,
151
201
  session_ok=True,
@@ -96,11 +96,22 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
96
96
  # Estimate density from arrangement pattern count
97
97
  density_arc = _estimate_density_from_patterns(style_tactics)
98
98
 
99
+ # BUG-B50 fix: the old code hardcoded loudness_posture=0.0 and
100
+ # empty spectral_contour / width_depth because StyleTactic entries
101
+ # don't carry those fields. We now derive them heuristically from
102
+ # the device_chain (each device's params leak its intended sonic
103
+ # shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
104
+ # Utility Width > 0.6 = wider stereo). Rough but non-empty values
105
+ # are better than zeros for downstream reference-gap analysis.
106
+ loudness_posture = _derive_loudness_posture(style_tactics)
107
+ spectral_contour = _derive_spectral_contour(style_tactics)
108
+ width_depth = _derive_width_depth(style_tactics)
109
+
99
110
  return ReferenceProfile(
100
111
  source_type="style",
101
- loudness_posture=0.0, # style doesn't specify loudness
102
- spectral_contour={}, # style doesn't specify spectrum
103
- width_depth={},
112
+ loudness_posture=loudness_posture,
113
+ spectral_contour=spectral_contour,
114
+ width_depth=width_depth,
104
115
  density_arc=density_arc,
105
116
  section_pacing=section_pacing,
106
117
  harmonic_character=harmonic_character,
@@ -147,3 +158,118 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
147
158
  densities.append(min(0.9, 0.3 + n * 0.15))
148
159
 
149
160
  return densities
161
+
162
+
163
+ # ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
164
+
165
+
166
+ def _derive_loudness_posture(style_tactics: list[dict]) -> float:
167
+ """Heuristic: compression / saturation / limiter devices imply a
168
+ specific loudness posture. Glue / Ratio>3 → loud, saturator drive
169
+ high → cranked/loud, delay/reverb-heavy → quieter mix."""
170
+ loud_signals = 0
171
+ quiet_signals = 0
172
+ for tactic in style_tactics:
173
+ for dev in tactic.get("device_chain", []):
174
+ name = str(dev.get("name", "")).lower()
175
+ params = dev.get("params", {}) or {}
176
+ if "glue" in name or "limiter" in name:
177
+ loud_signals += 2
178
+ if name == "saturator" or "overdrive" in name:
179
+ drive = float(params.get("Drive", 0) or 0)
180
+ if drive >= 6:
181
+ loud_signals += 1
182
+ if name == "compressor":
183
+ ratio = float(params.get("Ratio", 0) or 0)
184
+ if ratio >= 4:
185
+ loud_signals += 1
186
+ if name == "reverb":
187
+ # Heavy reverb tails push the mix quieter
188
+ wet = float(params.get("Dry/Wet", 0) or 0)
189
+ if wet >= 0.6:
190
+ quiet_signals += 1
191
+ # Normalize to -1..+1 range (loud=+1, quiet=-1, neutral=0)
192
+ if loud_signals == 0 and quiet_signals == 0:
193
+ return 0.0
194
+ net = (loud_signals - quiet_signals) / max(
195
+ loud_signals + quiet_signals, 1
196
+ )
197
+ return round(net, 2)
198
+
199
+
200
+ def _derive_spectral_contour(style_tactics: list[dict]) -> dict:
201
+ """Heuristic: Auto Filter frequencies + device names leak the
202
+ target spectrum. Low-pass filters → dark, EQ Eight with no params
203
+ → neutral, saturators → mid-forward."""
204
+ brightness = 0.5 # neutral starting point
205
+ mid_emphasis = 0.5
206
+ dark_hits = 0
207
+ bright_hits = 0
208
+ for tactic in style_tactics:
209
+ for dev in tactic.get("device_chain", []):
210
+ name = str(dev.get("name", "")).lower()
211
+ params = dev.get("params", {}) or {}
212
+ if "auto filter" in name or name == "autofilter":
213
+ freq = float(params.get("Frequency", 1500) or 1500)
214
+ # Ableton Auto Filter Frequency is 20-22000; below 2k hints dark
215
+ if freq < 2000:
216
+ dark_hits += 1
217
+ elif freq > 5000:
218
+ bright_hits += 1
219
+ if "saturator" in name or "overdrive" in name:
220
+ mid_emphasis += 0.1
221
+ if dark_hits > bright_hits:
222
+ brightness = 0.3
223
+ elif bright_hits > dark_hits:
224
+ brightness = 0.75
225
+ return {
226
+ "band_balance": {
227
+ "sub": 0.45,
228
+ "low": 0.5,
229
+ "mid": min(1.0, mid_emphasis),
230
+ "high_mid": round(0.3 + brightness * 0.4, 2),
231
+ "high": round(0.2 + brightness * 0.5, 2),
232
+ },
233
+ "brightness": round(brightness, 2),
234
+ "centroid_hint": "dark" if brightness < 0.4
235
+ else "bright" if brightness > 0.65
236
+ else "neutral",
237
+ }
238
+
239
+
240
+ def _derive_width_depth(style_tactics: list[dict]) -> dict:
241
+ """Heuristic: Utility Width, Chorus-Ensemble, or heavy reverb
242
+ implies a wider stereo field; dry chains imply narrow."""
243
+ width_value = 0.5
244
+ depth_value = 0.5
245
+ for tactic in style_tactics:
246
+ for dev in tactic.get("device_chain", []):
247
+ name = str(dev.get("name", "")).lower()
248
+ params = dev.get("params", {}) or {}
249
+ if name == "utility":
250
+ w = params.get("Width")
251
+ if w is not None:
252
+ try:
253
+ width_value = max(width_value, float(w))
254
+ except (TypeError, ValueError):
255
+ pass
256
+ if "reverb" in name:
257
+ wet = float(params.get("Dry/Wet", 0) or 0)
258
+ if wet > 0.4:
259
+ depth_value = max(depth_value, 0.7)
260
+ width_value = max(width_value, 0.65)
261
+ if "chorus" in name or "ensemble" in name:
262
+ width_value = max(width_value, 0.75)
263
+ if "delay" in name:
264
+ wet = float(params.get("Dry/Wet", 0) or 0)
265
+ if wet > 0.2:
266
+ depth_value = max(depth_value, 0.6)
267
+ return {
268
+ "stereo_width": round(width_value, 2),
269
+ "depth": round(depth_value, 2),
270
+ "depth_hint": (
271
+ "deep" if depth_value > 0.6 else
272
+ "intimate" if depth_value < 0.4 else
273
+ "neutral"
274
+ ),
275
+ }
@@ -30,6 +30,20 @@ def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) ->
30
30
  return compare_to_reference(mix_path, reference_path, normalize=True)
31
31
 
32
32
 
33
+ def _fetch_memory_tactics(style: str) -> list[dict]:
34
+ """BUG-B19: pull user-saved techniques tagged with *style* so they
35
+ feed into the reference-profile lookup path. Best-effort — returns
36
+ empty list when the memory store isn't available."""
37
+ if not style:
38
+ return []
39
+ try:
40
+ from ..tools.research import _memory_store
41
+ return _memory_store.search(query=style, limit=10)
42
+ except Exception as exc:
43
+ logger.debug("_fetch_memory_tactics failed for %r: %s", style, exc)
44
+ return []
45
+
46
+
33
47
  def _fetch_project_snapshot(ctx: Context) -> dict:
34
48
  """Build a lightweight project snapshot for gap analysis."""
35
49
  ableton = ctx.lifespan_context["ableton"]
@@ -110,10 +124,21 @@ def build_reference_profile(
110
124
  return profile.to_dict()
111
125
 
112
126
  if style:
113
- tactics = get_style_tactics(style)
127
+ # BUG-B19 fix: also pass user-memory tactics so custom styles
128
+ # (e.g. "prefuse73" saved via memory_learn) resolve to real
129
+ # profiles instead of NOT_FOUND. Silently ignore if memory
130
+ # isn't available (keeps the built-in path working).
131
+ memory_tactics = _fetch_memory_tactics(style)
132
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
114
133
  if not tactics:
115
134
  return {
116
- "error": f"No style tactics found for '{style}'",
135
+ "error": (
136
+ f"No style tactics found for '{style}' — neither in the "
137
+ f"built-in library (burial / daft punk / techno / ambient / "
138
+ f"trap / lo-fi) nor in your saved memories. Use "
139
+ f"memory_learn to save a '{style}' technique or try "
140
+ f"reference_path=<audio-file> for an audio-based profile."
141
+ ),
117
142
  "code": "NOT_FOUND",
118
143
  }
119
144
  tactic_dicts = [t.to_dict() for t in tactics]
@@ -158,9 +183,17 @@ def analyze_reference_gaps(
158
183
  return comparison
159
184
  profile = build_audio_reference_profile(comparison)
160
185
  elif style:
161
- tactics = get_style_tactics(style)
186
+ # BUG-B19: hydrate from saved memories too
187
+ memory_tactics = _fetch_memory_tactics(style)
188
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
162
189
  if not tactics:
163
- return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
190
+ return {
191
+ "error": (
192
+ f"No style tactics found for '{style}' — neither in the "
193
+ f"built-in library nor in saved memories."
194
+ ),
195
+ "code": "NOT_FOUND",
196
+ }
164
197
  tactic_dicts = [t.to_dict() for t in tactics]
165
198
  profile = build_style_reference_profile(tactic_dicts)
166
199
  else:
@@ -212,9 +245,17 @@ def plan_reference_moves(
212
245
  return comparison
213
246
  profile = build_audio_reference_profile(comparison)
214
247
  elif style:
215
- tactics = get_style_tactics(style)
248
+ # BUG-B19: hydrate from saved memories too
249
+ memory_tactics = _fetch_memory_tactics(style)
250
+ tactics = get_style_tactics(style, memory_tactics=memory_tactics)
216
251
  if not tactics:
217
- return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
252
+ return {
253
+ "error": (
254
+ f"No style tactics found for '{style}' — neither in the "
255
+ f"built-in library nor in saved memories."
256
+ ),
257
+ "code": "NOT_FOUND",
258
+ }
218
259
  tactic_dicts = [t.to_dict() for t in tactics]
219
260
  profile = build_style_reference_profile(tactic_dicts)
220
261
  else:
@@ -50,6 +50,56 @@ MCP_TOOLS: frozenset[str] = frozenset({
50
50
  })
51
51
 
52
52
 
53
+ # Tools that observe session state without mutating it. Executors use this set
54
+ # to separate "apply pass" steps (writes) from "verification reads". Counting
55
+ # reads toward applied_count and then calling undo that many times walks back
56
+ # earlier user edits — see preview_studio/tools.py and experiment/engine.py.
57
+ READ_ONLY_TOOLS: frozenset[str] = frozenset({
58
+ "get_track_meters",
59
+ "get_master_spectrum",
60
+ "get_master_meters",
61
+ "get_master_rms",
62
+ "get_mix_snapshot",
63
+ "get_session_info",
64
+ "get_track_info",
65
+ "get_track_routing",
66
+ "get_return_tracks",
67
+ "get_device_info",
68
+ "get_device_parameters",
69
+ "get_clip_info",
70
+ "get_notes",
71
+ "get_arrangement_notes",
72
+ "get_arrangement_clips",
73
+ "get_scenes_info",
74
+ "get_scene_matrix",
75
+ "get_playing_clips",
76
+ "get_cue_points",
77
+ "get_rack_chains",
78
+ "get_clip_automation",
79
+ "analyze_mix",
80
+ "get_emotional_arc",
81
+ "get_motif_graph",
82
+ "get_session_diagnostics",
83
+ "ping",
84
+ })
85
+
86
+
87
+ def filter_apply_steps(steps: list) -> list:
88
+ """Return only the steps that mutate session state.
89
+
90
+ Read-only steps (meters, spectrum, info) do not create undo points in
91
+ Ableton. Including them in an applied_count and then undoing that many
92
+ times walks back earlier user edits. Always filter writes from reads
93
+ before the apply pass and before the undo loop.
94
+ """
95
+ out = []
96
+ for s in steps:
97
+ tool = (s.get("tool") if isinstance(s, dict) else getattr(s, "tool", "")) or ""
98
+ if tool and tool not in READ_ONLY_TOOLS:
99
+ out.append(s)
100
+ return out
101
+
102
+
53
103
  @dataclass
54
104
  class ExecutionResult:
55
105
  """Result of executing a single plan step."""
@@ -276,8 +326,22 @@ async def execute_plan_steps_async(
276
326
  results.append(result)
277
327
 
278
328
  # Record successful step result for future bindings
279
- if result.ok and step_id and isinstance(result.result, dict):
280
- step_results[step_id] = result.result
329
+ if result.ok and step_id:
330
+ if isinstance(result.result, dict):
331
+ step_results[step_id] = result.result
332
+ else:
333
+ # Log but DO NOT silently drop the binding without telling
334
+ # anyone — the previous version let non-dict results slip
335
+ # past, which meant any downstream {"$from_step": step_id}
336
+ # reference blew up with a confusing "step_id not found"
337
+ # instead of the real "result wasn't a dict" cause.
338
+ import logging as _logging
339
+ _logging.getLogger(__name__).warning(
340
+ "step_results: dropping non-dict result for "
341
+ "step_id=%s tool=%s type=%s. Any $from_step refs to "
342
+ "this step_id will fail with 'step_id not found'.",
343
+ step_id, tool, type(result.result).__name__,
344
+ )
281
345
 
282
346
  if not result.ok and stop_on_failure:
283
347
  break
@@ -14,18 +14,33 @@ To add a new in-process tool to plans:
14
14
  2. Add an _adapter function here that imports the real implementation and
15
15
  adapts its kwargs from a plan-style params dict.
16
16
  3. Register the adapter in build_mcp_dispatch_registry.
17
+
18
+ Every entry in MCP_TOOLS must have a matching adapter here — the contract
19
+ test tests/test_mcp_dispatch_contract.py enforces this.
17
20
  """
18
21
 
19
22
  from __future__ import annotations
20
23
 
24
+ import inspect
21
25
  from typing import Any, Callable
22
26
 
23
27
 
24
- async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
25
- """Adapter for mcp_server.tools.analyzer.load_sample_to_simpler.
28
+ async def _call(fn, ctx, params: dict) -> Any:
29
+ """Call an MCP tool with ctx + kwargs from a plan params dict.
26
30
 
27
- Accepts the plan-step params dict and unpacks into the real tool's kwargs.
31
+ Filters params to the tool's declared parameters, awaits if coroutine.
32
+ Unknown params are dropped silently — plans may carry extra metadata.
28
33
  """
34
+ sig = inspect.signature(fn)
35
+ accepted = set(sig.parameters.keys())
36
+ kwargs = {k: v for k, v in params.items() if k in accepted}
37
+ result = fn(ctx, **kwargs)
38
+ if inspect.isawaitable(result):
39
+ result = await result
40
+ return result
41
+
42
+
43
+ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
29
44
  from ..tools.analyzer import load_sample_to_simpler
30
45
  return await load_sample_to_simpler(
31
46
  ctx,
@@ -35,12 +50,69 @@ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
35
50
  )
36
51
 
37
52
 
53
+ async def _apply_automation_shape(params: dict, ctx: Any = None) -> dict:
54
+ from ..tools.automation import apply_automation_shape
55
+ return await _call(apply_automation_shape, ctx, params)
56
+
57
+
58
+ async def _apply_gesture_template(params: dict, ctx: Any = None) -> dict:
59
+ from ..tools.composition import apply_gesture_template
60
+ return await _call(apply_gesture_template, ctx, params)
61
+
62
+
63
+ async def _analyze_mix(params: dict, ctx: Any = None) -> dict:
64
+ from ..mix_engine.tools import analyze_mix
65
+ return await _call(analyze_mix, ctx, params)
66
+
67
+
68
+ async def _get_master_spectrum(params: dict, ctx: Any = None) -> dict:
69
+ from ..tools.analyzer import get_master_spectrum
70
+ return await _call(get_master_spectrum, ctx, params)
71
+
72
+
73
+ async def _get_emotional_arc(params: dict, ctx: Any = None) -> dict:
74
+ from ..tools.research import get_emotional_arc
75
+ return await _call(get_emotional_arc, ctx, params)
76
+
77
+
78
+ async def _get_motif_graph(params: dict, ctx: Any = None) -> dict:
79
+ from ..tools.motif import get_motif_graph
80
+ return await _call(get_motif_graph, ctx, params)
81
+
82
+
83
+ async def _generate_m4l_effect(params: dict, ctx: Any = None) -> dict:
84
+ from ..device_forge.tools import generate_m4l_effect
85
+ return await _call(generate_m4l_effect, ctx, params)
86
+
87
+
88
+ async def _install_m4l_device(params: dict, ctx: Any = None) -> dict:
89
+ from ..device_forge.tools import install_m4l_device
90
+ return await _call(install_m4l_device, ctx, params)
91
+
92
+
93
+ async def _list_genexpr_templates(params: dict, ctx: Any = None) -> dict:
94
+ from ..device_forge.tools import list_genexpr_templates
95
+ return await _call(list_genexpr_templates, ctx, params)
96
+
97
+
38
98
  def build_mcp_dispatch_registry() -> dict[str, Callable]:
39
99
  """Return the canonical registry of MCP-only tools for plan execution.
40
100
 
41
101
  Callers (typically the server lifespan init) should call this once and
42
102
  pass the registry to execute_plan_steps_async via the mcp_registry kwarg.
103
+
104
+ INVARIANT: the set of keys here must equal MCP_TOOLS in execution_router.
105
+ Enforced by tests/test_mcp_dispatch_contract.py.
43
106
  """
44
107
  return {
45
108
  "load_sample_to_simpler": _load_sample_to_simpler,
109
+ "apply_automation_shape": _apply_automation_shape,
110
+ "apply_gesture_template": _apply_gesture_template,
111
+ "analyze_mix": _analyze_mix,
112
+ "get_master_spectrum": _get_master_spectrum,
113
+ "get_emotional_arc": _get_emotional_arc,
114
+ "get_motif_graph": _get_motif_graph,
115
+ "generate_m4l_effect": _generate_m4l_effect,
116
+ "install_m4l_device": _install_m4l_device,
117
+ "list_genexpr_templates": _list_genexpr_templates,
46
118
  }