livepilot 1.10.5 → 1.10.7

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 (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -14,6 +14,17 @@
14
14
  * - Chunk parameter reads: 4 per batch, 50ms delay
15
15
  * - Base64 encode all JSON responses
16
16
  * - Defer all LiveAPI operations via deferlow()
17
+ *
18
+ * OSC address convention:
19
+ * - OUTGOING (this file → server via udpsend): use WITH leading slash,
20
+ * e.g. outlet(0, "/response", encoded). The slash is part of the
21
+ * OSC address that udpsend packs into the packet.
22
+ * - INCOMING (server → this file via udpreceive): Max's udpreceive
23
+ * routes on the selector, so the server's address string must be
24
+ * "response"/"cmd" WITHOUT a leading slash (Max would otherwise
25
+ * treat the slash as a literal selector character). See
26
+ * mcp_server/m4l_bridge.py for the sending side and `_parse_osc`
27
+ * for the tolerant normalization.
17
28
  */
18
29
 
19
30
  autowatch = 1;
@@ -84,7 +95,7 @@ function anything() {
84
95
  function dispatch(cmd, args) {
85
96
  switch(cmd) {
86
97
  case "ping":
87
- send_response({"ok": true, "version": "1.10.5"});
98
+ send_response({"ok": true, "version": "1.10.6"});
88
99
  break;
89
100
  case "get_params":
90
101
  cmd_get_params(args);
@@ -172,6 +183,13 @@ function dispatch(cmd, args) {
172
183
  case "get_plugin_presets":
173
184
  cmd_get_plugin_presets(args);
174
185
  break;
186
+ // ── BUG-A2 / A3: deep-LOM properties not on the automatable surface ──
187
+ case "simpler_set_warp":
188
+ cmd_simpler_set_warp(args);
189
+ break;
190
+ case "compressor_set_sidechain":
191
+ cmd_compressor_set_sidechain(args);
192
+ break;
175
193
  default:
176
194
  send_response({"error": "Unknown command: " + cmd});
177
195
  }
@@ -630,8 +648,13 @@ function detect_key() {
630
648
  detected_key = note_names[best_key];
631
649
  detected_scale = best_scale;
632
650
 
633
- // Send to UI
634
- outlet(1, "key", detected_key + " " + detected_scale);
651
+ // Send to UI — use abbreviated scale ("min"/"maj") so text fits in the
652
+ // 72-pixel presentation widget, and pass a SINGLE symbol so Max's
653
+ // [route] + [prepend set] chain doesn't split atoms on the internal
654
+ // space. Max's [comment] displays whatever the `set` message carries.
655
+ var scale_abbr = (detected_scale === "minor") ? "min" : "maj";
656
+ var display = detected_key + " " + scale_abbr; // e.g., "D min"
657
+ outlet(1, "key", display);
635
658
  }
636
659
 
637
660
  function correlate(a, b) {
@@ -980,6 +1003,206 @@ function cmd_simpler_warp(args) {
980
1003
  }
981
1004
  }
982
1005
 
1006
+ // ── BUG-A2: Simpler warping property + warp_mode ─────────────────────
1007
+ //
1008
+ // Python's Remote Script ControlSurface API only exposes automatable
1009
+ // parameters. Simpler's `warping` and `warp_mode` live on the sample
1010
+ // child object (SimplerDevice.sample.*) — unreachable from the Python
1011
+ // side. Max JS LiveAPI can step INTO the sample child, so we do the
1012
+ // property write here and surface the result to the MCP server.
1013
+ //
1014
+ // args: [track_index, device_index, warp_on (0/1), warp_mode (-1 = leave alone, 0..6)]
1015
+ // warp_mode: 0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 6=Complex Pro
1016
+ //
1017
+ // Returns: {ok, warping, warp_mode} on success, {error} otherwise.
1018
+ function cmd_simpler_set_warp(args) {
1019
+ var track_idx = parseInt(args[0]);
1020
+ var device_idx = parseInt(args[1]);
1021
+ var warp_on = parseInt(args[2]);
1022
+ var warp_mode = args.length > 3 ? parseInt(args[3]) : -1;
1023
+
1024
+ var device_path = build_device_path(track_idx, device_idx);
1025
+ cursor_a.goto(device_path);
1026
+ if (cursor_a.id === 0) {
1027
+ send_response({"error": "Device not found at track " + track_idx + ", device " + device_idx});
1028
+ return;
1029
+ }
1030
+ if (cursor_a.get("class_name").toString() !== "OriginalSimpler") {
1031
+ send_response({"error": "Not a Simpler device (class is " + cursor_a.get("class_name") + ")"});
1032
+ return;
1033
+ }
1034
+
1035
+ // Step into the sample child — warping + warp_mode live there, not on
1036
+ // the device itself.
1037
+ try {
1038
+ cursor_a.goto(device_path + " sample");
1039
+ if (cursor_a.id === 0) {
1040
+ send_response({"error": "Simpler has no sample loaded (warping not applicable)"});
1041
+ return;
1042
+ }
1043
+ cursor_a.set("warping", warp_on ? 1 : 0);
1044
+ if (warp_on && warp_mode >= 0) {
1045
+ cursor_a.set("warp_mode", warp_mode);
1046
+ }
1047
+ // Read back so the caller can confirm the write landed
1048
+ var read_warping = parseInt(cursor_a.get("warping"));
1049
+ var read_warp_mode = parseInt(cursor_a.get("warp_mode"));
1050
+ send_response({
1051
+ "ok": true,
1052
+ "track_index": track_idx,
1053
+ "device_index": device_idx,
1054
+ "warping": read_warping,
1055
+ "warp_mode": read_warp_mode,
1056
+ });
1057
+ } catch(e) {
1058
+ send_response({"error": "simpler_set_warp failed: " + e.message});
1059
+ }
1060
+ }
1061
+
1062
+ // ── BUG-A3: Compressor sidechain input routing ───────────────────────
1063
+ //
1064
+ // Sidechain INPUT ROUTING is exposed as LiveAPI properties on the
1065
+ // Compressor device in Live 11+: sidechain_input_routing_type and
1066
+ // sidechain_input_routing_channel. They don't appear in the automatable
1067
+ // parameter list so the Python Remote Script can't reach them; Max JS
1068
+ // LiveAPI can.
1069
+ //
1070
+ // args: [track_index, device_index, routing_type, routing_channel]
1071
+ // routing_type: string — e.g. "1-Audio From" / track name / "Ext. In"
1072
+ // routing_channel: string — "Post FX" / "Pre FX" / "Post Mixer" / ...
1073
+ //
1074
+ // Returns: {ok, sidechain: {type, channel}} on success.
1075
+ // Older Live versions without these properties return a clean error.
1076
+ function cmd_compressor_set_sidechain(args) {
1077
+ var track_idx = parseInt(args[0]);
1078
+ var device_idx = parseInt(args[1]);
1079
+ var routing_type = String(args[2] || "");
1080
+ var routing_channel = String(args[3] || "");
1081
+
1082
+ var path = build_device_path(track_idx, device_idx);
1083
+ cursor_a.goto(path);
1084
+ if (cursor_a.id === 0) {
1085
+ send_response({"error": "Device not found at track " + track_idx + ", device " + device_idx});
1086
+ return;
1087
+ }
1088
+ var class_name = String(cursor_a.get("class_name"));
1089
+ if (class_name !== "Compressor2" && class_name !== "Compressor") {
1090
+ send_response({"error": "Not a Compressor device (class is " + class_name + ")"});
1091
+ return;
1092
+ }
1093
+
1094
+ // Helper: read a LiveAPI property that returns a JSON-serialized dict
1095
+ // or list. Max's `get()` wraps results in a single-element array,
1096
+ // and complex properties come back as JSON strings.
1097
+ function read_json_prop(name) {
1098
+ try {
1099
+ var raw = cursor_a.get(name);
1100
+ if (raw === null || raw === undefined) return null;
1101
+ if (Object.prototype.toString.call(raw) === "[object Array]" && raw.length === 1) {
1102
+ raw = raw[0];
1103
+ }
1104
+ if (typeof raw === "string") {
1105
+ try { return JSON.parse(raw); } catch(e) { return raw; }
1106
+ }
1107
+ return raw;
1108
+ } catch(e) {
1109
+ return null;
1110
+ }
1111
+ }
1112
+
1113
+ function find_by_name(list, name) {
1114
+ if (!list || !list.length || !name) return null;
1115
+ for (var i = 0; i < list.length; i++) {
1116
+ var entry = list[i];
1117
+ if (!entry) continue;
1118
+ var n = entry.display_name || entry.name;
1119
+ if (n === name) return entry;
1120
+ }
1121
+ return null;
1122
+ }
1123
+
1124
+ try {
1125
+ // Enable sidechain first — Live rejects routing writes on a
1126
+ // compressor with the sidechain disabled. Property is available
1127
+ // on Live 10+. Try/catch for legacy builds.
1128
+ try { cursor_a.set("sidechain_enabled", 1); } catch(e) {}
1129
+
1130
+ var debug = {};
1131
+
1132
+ // --- Routing TYPE (the source: "1-DRUMS", "Ext. In", "No Input", …)
1133
+ if (routing_type) {
1134
+ var types = read_json_prop("available_sidechain_input_routing_types");
1135
+ debug.requested_type = routing_type;
1136
+ debug.type_count = types && types.length ? types.length : 0;
1137
+ var t_match = find_by_name(types, routing_type);
1138
+ if (t_match && t_match.identifier !== undefined) {
1139
+ // LOM expects a RoutingType object; Max JS accepts a
1140
+ // JSON-encoded {identifier: N} for the `set`.
1141
+ cursor_a.set(
1142
+ "sidechain_input_routing_type",
1143
+ JSON.stringify({identifier: t_match.identifier})
1144
+ );
1145
+ debug.set_type = "ok (identifier=" + t_match.identifier + ")";
1146
+ } else {
1147
+ debug.set_type = "FAIL: no matching type";
1148
+ if (types) {
1149
+ debug.available_types = [];
1150
+ for (var i = 0; i < types.length; i++) {
1151
+ debug.available_types.push(types[i].display_name || types[i].name || "?");
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ // --- Routing CHANNEL (the tap point: "Post FX", "Pre FX", …)
1158
+ if (routing_channel) {
1159
+ var channels = read_json_prop("available_sidechain_input_routing_channels");
1160
+ debug.requested_channel = routing_channel;
1161
+ debug.channel_count = channels && channels.length ? channels.length : 0;
1162
+ var c_match = find_by_name(channels, routing_channel);
1163
+ if (c_match && c_match.identifier !== undefined) {
1164
+ cursor_a.set(
1165
+ "sidechain_input_routing_channel",
1166
+ JSON.stringify({identifier: c_match.identifier})
1167
+ );
1168
+ debug.set_channel = "ok (identifier=" + c_match.identifier + ")";
1169
+ } else {
1170
+ debug.set_channel = "FAIL: no matching channel";
1171
+ if (channels) {
1172
+ debug.available_channels = [];
1173
+ for (var j = 0; j < channels.length; j++) {
1174
+ debug.available_channels.push(channels[j].display_name || channels[j].name || "?");
1175
+ }
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ // Read back canonical display_name for confirmation
1181
+ var cur_type = read_json_prop("sidechain_input_routing_type");
1182
+ var cur_channel = read_json_prop("sidechain_input_routing_channel");
1183
+ var read_type_name = (cur_type && cur_type.display_name) || "";
1184
+ var read_channel_name = (cur_channel && cur_channel.display_name) || "";
1185
+
1186
+ send_response({
1187
+ "ok": true,
1188
+ "track_index": track_idx,
1189
+ "device_index": device_idx,
1190
+ "sidechain": {
1191
+ "type": read_type_name,
1192
+ "channel": read_channel_name,
1193
+ "enabled": 1
1194
+ },
1195
+ "debug": debug
1196
+ });
1197
+ } catch(e) {
1198
+ send_response({
1199
+ "error": "compressor_set_sidechain failed: " + e.message
1200
+ + " (this Live build may not expose sidechain_input_routing_* —"
1201
+ + " user must set routing manually)"
1202
+ });
1203
+ }
1204
+ }
1205
+
983
1206
  // ── Phase 2: Warp Markers ─────────────────────────────────────────────
984
1207
 
985
1208
  function cmd_get_warp_markers(args) {
package/manifest.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "livepilot",
4
4
  "display_name": "LivePilot — AI for Ableton Live",
5
- "version": "1.10.5",
6
- "description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with 320 AI-powered tools.",
7
- "long_description": "LivePilot is an agentic production system for Ableton Live 12. 320 tools across 43 domains — device atlas (1305 devices), sample intelligence (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, and 12 creative engines.\n\n**What it does:**\n- Creates MIDI clips with notes, chords, and rhythms\n- Loads instruments and effects via Device Atlas (1305 devices indexed)\n- Searches samples across Splice, Ableton browser, and filesystem\n- Plans compositions from text prompts with genre-aware layering\n- Slices samples with intent-based MIDI generation\n- Mixes with volume, panning, sends, and automation\n- Analyzes your mix with real-time spectral data (M4L bridge)\n- Diagnoses stuck sessions and generates creative rescue variants\n- Remembers your production style across sessions\n\n**How it works:**\nLivePilot installs a Remote Script in Ableton that communicates with the AI over a local TCP connection. Everything runs on your machine — no audio leaves your computer.",
5
+ "version": "1.10.7",
6
+ "description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with 323 AI-powered tools.",
7
+ "long_description": "LivePilot is an agentic production system for Ableton Live 12. 323 tools across 45 domains — device atlas (1305 devices), sample intelligence (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, and 12 creative engines.\n\n**What it does:**\n- Creates MIDI clips with notes, chords, and rhythms\n- Loads instruments and effects via Device Atlas (1305 devices indexed)\n- Searches samples across Splice, Ableton browser, and filesystem\n- Plans compositions from text prompts with genre-aware layering\n- Slices samples with intent-based MIDI generation\n- Mixes with volume, panning, sends, and automation\n- Analyzes your mix with real-time spectral data (M4L bridge)\n- Diagnoses stuck sessions and generates creative rescue variants\n- Remembers your production style across sessions\n\n**How it works:**\nLivePilot installs a Remote Script in Ableton that communicates with the AI over a local TCP connection. Everything runs on your machine — no audio leaves your computer.",
8
8
  "author": {
9
9
  "name": "Pilot Studio",
10
10
  "url": "https://github.com/dreamrec/LivePilot"
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.10.5"
2
+ __version__ = "1.10.7"
@@ -115,26 +115,70 @@ class AtlasManager:
115
115
  query_words = query_lower.split()
116
116
  results: List[Dict[str, Any]] = []
117
117
 
118
+ # BUG-B39: the real atlas scanner emits "instruments" /
119
+ # "audio_effects" but older callers and test fixtures sometimes
120
+ # pass the singular "instrument" / "effect". Build a tolerant
121
+ # category alias set so both forms work.
122
+ _CAT_ALIASES = {
123
+ "instrument": {"instrument", "instruments"},
124
+ "instruments": {"instrument", "instruments"},
125
+ "effect": {"effect", "effects", "audio_effects"},
126
+ "effects": {"effect", "effects", "audio_effects"},
127
+ "audio_effect": {"effect", "effects", "audio_effects",
128
+ "audio_effect"},
129
+ "audio_effects": {"effect", "effects", "audio_effects",
130
+ "audio_effect"},
131
+ }
132
+ allowed_cats = (
133
+ _CAT_ALIASES.get(category, {category})
134
+ if category != "all" else None
135
+ )
136
+
118
137
  for dev in self._devices:
119
138
  # Category filter
120
- if category != "all" and dev.get("category", "") != category:
139
+ if allowed_cats is not None and dev.get("category", "") not in allowed_cats:
121
140
  continue
122
141
 
123
142
  score = 0
124
143
  dev_name = dev.get("name", "")
125
144
  dev_name_lower = dev_name.lower()
126
145
 
127
- # Name scoring: 100pts exact, 50pts substring
146
+ # Name scoring. BUG-B41 fix: dropped weight dramatically
147
+ # (was 100 exact / 50 substring) so a device literally
148
+ # named "Bass" no longer blows past character-tag matches
149
+ # for a sonic query like "warm analog bass". An exact name
150
+ # match is still the strongest single signal, but a device
151
+ # with 2+ matching character-tags now beats a name-only
152
+ # accident.
128
153
  if dev_name_lower == query_lower:
129
- score += 100
154
+ score += 45 # was 100
130
155
  elif query_lower in dev_name_lower:
131
- score += 50
132
-
133
- # Tag scoring: 30pts per matching tag
134
- dev_tags = [t.lower() for t in dev.get("tags", [])]
156
+ score += 20 # was 50
157
+ else:
158
+ # Partial: any query word present in name — small signal
159
+ for word in query_words:
160
+ if len(word) >= 3 and word in dev_name_lower:
161
+ score += 5
162
+
163
+ # Tag scoring — prefer enriched character_tags.
164
+ # BUG-B40 / B41: also read character_tags so enriched devices
165
+ # actually compete with name-based matches.
166
+ dev_tags = [
167
+ t.lower() for t in (
168
+ dev.get("character_tags") or dev.get("tags", [])
169
+ )
170
+ ]
171
+ # BUG-B41: bumped to 35pts per tag so multi-tag matches beat
172
+ # a single substring-name match.
135
173
  for word in query_words:
136
174
  if word in dev_tags:
137
- score += 30
175
+ score += 35
176
+ # Partial tag match (word appears as substring in a tag)
177
+ else:
178
+ for tag in dev_tags:
179
+ if word in tag:
180
+ score += 10
181
+ break
138
182
 
139
183
  # Use case scoring: 25pts per match
140
184
  for use_case in dev.get("use_cases", []):
@@ -142,10 +186,11 @@ class AtlasManager:
142
186
  for word in query_words:
143
187
  if word in use_lower:
144
188
  score += 25
145
- break # one match per use_case
189
+ break
146
190
 
147
- # Genre scoring: 20pts primary, 10pts secondary
148
- genres = dev.get("genres", {})
191
+ # Genre scoring: 20pts primary, 10pts secondary.
192
+ # BUG-B40: also read genre_affinity (enriched field).
193
+ genres = dev.get("genre_affinity") or dev.get("genres", {}) or {}
149
194
  for genre in genres.get("primary", []):
150
195
  if query_lower in genre.lower() or genre.lower() in query_lower:
151
196
  score += 20
@@ -153,8 +198,11 @@ class AtlasManager:
153
198
  if query_lower in genre.lower() or genre.lower() in query_lower:
154
199
  score += 10
155
200
 
156
- # Description keyword scoring: 15pts
157
- description = dev.get("description", "").lower()
201
+ # Description keyword scoring: 15pts.
202
+ # BUG-B40: prefer sonic_description when present.
203
+ description = (
204
+ dev.get("sonic_description") or dev.get("description", "")
205
+ ).lower()
158
206
  for word in query_words:
159
207
  if len(word) >= 3 and word in description:
160
208
  score += 15
@@ -224,7 +272,13 @@ class AtlasManager:
224
272
  def chain_suggest(
225
273
  self, role: str, genre: str = ""
226
274
  ) -> Dict[str, Any]:
227
- """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad')."""
275
+ """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
276
+
277
+ BUG-B39 fix: the old code passed category="instrument" (singular)
278
+ and category="effect" to self.search(), but the atlas stores
279
+ devices with category="instruments" / "audio_effects" (plural).
280
+ Every filtered search missed and the chain came back empty.
281
+ """
228
282
  chain: List[Dict[str, Any]] = []
229
283
  position = 0
230
284
 
@@ -244,8 +298,8 @@ class AtlasManager:
244
298
  intent = instrument_intents.get(role_lower, role_lower)
245
299
  search_q = f"{intent} {genre}" if genre else intent
246
300
 
247
- # Find instrument
248
- instrument_candidates = self.search(search_q, category="instrument", limit=3)
301
+ # Find instrument — atlas category is "instruments" (plural)
302
+ instrument_candidates = self.search(search_q, category="instruments", limit=3)
249
303
  if instrument_candidates:
250
304
  best = instrument_candidates[0]["device"]
251
305
  chain.append({
@@ -255,7 +309,7 @@ class AtlasManager:
255
309
  })
256
310
  position += 1
257
311
 
258
- # Stage 2: Effects
312
+ # Stage 2: Effects — atlas category is "audio_effects"
259
313
  effect_stages = [
260
314
  ("eq", f"Shape the {role} tone"),
261
315
  ("compression", f"Control {role} dynamics"),
@@ -264,7 +318,9 @@ class AtlasManager:
264
318
 
265
319
  for effect_type, reason in effect_stages:
266
320
  effect_q = f"{effect_type} {genre}" if genre else effect_type
267
- effect_candidates = self.search(effect_q, category="effect", limit=2)
321
+ effect_candidates = self.search(
322
+ effect_q, category="audio_effects", limit=2,
323
+ )
268
324
  if effect_candidates:
269
325
  best = effect_candidates[0]["device"]
270
326
  chain.append({
@@ -295,37 +351,48 @@ class AtlasManager:
295
351
  return {"error": f"Device not found: {device_b}"}
296
352
 
297
353
  def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
354
+ # BUG-B40 fix: enriched atlas entries use character_tags /
355
+ # sonic_description / genre_affinity — the old _summarize
356
+ # looked for "tags" / "description" / "genres" which are
357
+ # the UN-enriched raw scanner fields. We prefer enriched
358
+ # fields, fall back to raw when enrichment is absent.
298
359
  return {
299
360
  "name": dev.get("name", ""),
300
361
  "category": dev.get("category", ""),
301
- "tags": dev.get("tags", []),
302
- "genres": dev.get("genres", {}),
362
+ "tags": dev.get("character_tags") or dev.get("tags", []),
363
+ "genres": dev.get("genre_affinity") or dev.get("genres", {}),
303
364
  "use_cases": dev.get("use_cases", []),
304
- "description": dev.get("description", ""),
365
+ "description": (
366
+ dev.get("sonic_description")
367
+ or dev.get("description", "")
368
+ ),
305
369
  "cpu_weight": dev.get("cpu_weight", "unknown"),
306
370
  "sweet_spot": dev.get("sweet_spot", ""),
371
+ "enriched": dev.get("enriched", False),
307
372
  }
308
373
 
309
374
  summary_a = _summarize(dev_a)
310
375
  summary_b = _summarize(dev_b)
311
376
 
312
- # Recommendation logic: score each for the role
377
+ # Recommendation logic: score each for the role.
378
+ # BUG-B40: scorer also reads the enriched field names.
313
379
  score_a = 0
314
380
  score_b = 0
315
381
  if role:
316
382
  role_lower = role.lower()
317
- # Check use_cases
318
383
  for uc in dev_a.get("use_cases", []):
319
384
  if role_lower in uc.lower():
320
385
  score_a += 20
321
386
  for uc in dev_b.get("use_cases", []):
322
387
  if role_lower in uc.lower():
323
388
  score_b += 20
324
- # Check tags
325
- for tag in dev_a.get("tags", []):
389
+ # Tag scoring — prefer character_tags (enriched)
390
+ a_tags = dev_a.get("character_tags") or dev_a.get("tags", [])
391
+ b_tags = dev_b.get("character_tags") or dev_b.get("tags", [])
392
+ for tag in a_tags:
326
393
  if role_lower in tag.lower():
327
394
  score_a += 10
328
- for tag in dev_b.get("tags", []):
395
+ for tag in b_tags:
329
396
  if role_lower in tag.lower():
330
397
  score_b += 10
331
398
 
@@ -45,6 +45,10 @@ from pathlib import Path
45
45
  from typing import Optional, Tuple
46
46
 
47
47
  from .layer_planner import LayerSpec
48
+ import logging
49
+
50
+ logger = logging.getLogger(__name__)
51
+
48
52
 
49
53
 
50
54
  _AUDIO_EXTENSIONS = (".wav", ".aif", ".aiff", ".flac")
@@ -216,9 +220,9 @@ async def _splice_resolve(
216
220
  query=layer.search_query,
217
221
  per_page=5,
218
222
  )
219
- except Exception:
223
+ except Exception as exc:
224
+ logger.debug("_splice_resolve failed: %s", exc)
220
225
  return None, "unresolved"
221
-
222
226
  samples = list(result.samples) if result and hasattr(result, "samples") else []
223
227
  if not samples:
224
228
  return None, "unresolved"
@@ -243,7 +247,8 @@ async def _splice_resolve(
243
247
  downloaded = await splice_client.download_sample(file_hash)
244
248
  if downloaded and Path(downloaded).exists():
245
249
  return downloaded, "splice_remote"
246
- except Exception:
250
+ except Exception as exc:
251
+ logger.debug("_splice_resolve failed: %s", exc)
247
252
  continue # try next hit
248
253
 
249
254
  return None, "unresolved"
@@ -286,7 +291,6 @@ async def resolve_sample_for_layer(
286
291
  lp = hit.get("file_path") if isinstance(hit, dict) else None
287
292
  if lp and Path(lp).exists():
288
293
  return lp, "browser"
289
- except Exception:
290
- pass
291
-
294
+ except Exception as exc:
295
+ logger.debug("resolve_sample_for_layer failed: %s", exc)
292
296
  return None, "unresolved"
@@ -14,6 +14,10 @@ from fastmcp import Context
14
14
  from ..server import mcp
15
15
  from .prompt_parser import parse_prompt
16
16
  from .engine import ComposerEngine
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
 
19
23
  # Singleton engine — stateless, safe to reuse
@@ -29,8 +33,8 @@ def _get_search_roots(ctx: Context) -> list:
29
33
  cfg = ctx.lifespan_context.get("sample_search_roots") if hasattr(ctx, "lifespan_context") else None
30
34
  if cfg:
31
35
  roots.extend(cfg)
32
- except Exception:
33
- pass
36
+ except Exception as exc:
37
+ logger.debug("_get_search_roots failed: %s", exc)
34
38
  return roots
35
39
 
36
40
 
@@ -52,7 +56,8 @@ async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int,
52
56
  try:
53
57
  info = await splice_client.get_credits()
54
58
  credits_remaining = getattr(info, "credits", None)
55
- except Exception:
59
+ except Exception as exc:
60
+ logger.debug("_credit_safety_prelude failed: %s", exc)
56
61
  credits_remaining = None
57
62
 
58
63
  if credits_remaining is None:
@@ -153,9 +158,8 @@ async def augment_with_samples(
153
158
  info = ableton.send_command("get_session_info", {})
154
159
  session_context["tempo"] = info.get("tempo", 120)
155
160
  session_context["track_count"] = info.get("track_count", 0)
156
- except Exception:
157
- pass
158
-
161
+ except Exception as exc:
162
+ logger.debug("augment_with_samples failed: %s", exc)
159
163
  result = await _engine.augment(
160
164
  request=request,
161
165
  max_credits=max_credits,
@@ -11,6 +11,10 @@ import time
11
11
  import uuid
12
12
  from collections import deque
13
13
  from typing import Optional
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
14
18
 
15
19
  CONNECT_TIMEOUT = 5
16
20
  RECV_TIMEOUT = 20
@@ -153,7 +157,8 @@ class AbletonConnection:
153
157
  """Send a ping and return True if a pong is received."""
154
158
  try:
155
159
  return self.send_command("ping").get("pong") is True
156
- except Exception:
160
+ except Exception as exc:
161
+ logger.debug("ping failed: %s", exc)
157
162
  return False
158
163
 
159
164
  def send_command(self, command_type: str, params: Optional[dict] = None) -> dict: