livepilot 1.26.2 → 1.27.1

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 (69) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +34 -14
  3. package/bin/livepilot.js +4 -2
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +2 -2
  6. package/livepilot/.claude-plugin/plugin.json +2 -2
  7. package/livepilot/skills/livepilot-core/SKILL.md +12 -12
  8. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +2 -1
  9. package/livepilot/skills/livepilot-core/references/overview.md +6 -4
  10. package/livepilot/skills/livepilot-core/references/sound-design.md +5 -2
  11. package/livepilot/skills/livepilot-creative-director/SKILL.md +36 -0
  12. package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +1 -1
  13. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +15 -9
  14. package/livepilot/skills/livepilot-release/SKILL.md +7 -5
  15. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  16. package/m4l_device/livepilot_bridge.js +1 -1
  17. package/mcp_server/__init__.py +1 -1
  18. package/mcp_server/atlas/__init__.py +20 -1
  19. package/mcp_server/atlas/tools.py +83 -22
  20. package/mcp_server/composer/full/apply.py +9 -0
  21. package/mcp_server/composer/full/layer_planner.py +16 -6
  22. package/mcp_server/creative_constraints/engine.py +46 -0
  23. package/mcp_server/experiment/engine.py +10 -3
  24. package/mcp_server/grader/client.py +30 -2
  25. package/mcp_server/grader/tools.py +8 -2
  26. package/mcp_server/hook_hunter/analyzer.py +15 -2
  27. package/mcp_server/m4l_bridge.py +19 -21
  28. package/mcp_server/memory/taste_graph.py +16 -0
  29. package/mcp_server/memory/technique_store.py +23 -3
  30. package/mcp_server/mix_engine/state_builder.py +4 -2
  31. package/mcp_server/musical_intelligence/detectors.py +11 -2
  32. package/mcp_server/musical_intelligence/tools.py +15 -2
  33. package/mcp_server/preview_studio/engine.py +30 -1
  34. package/mcp_server/project_brain/tools.py +56 -52
  35. package/mcp_server/reference_engine/tools.py +22 -2
  36. package/mcp_server/runtime/capability_probe.py +1 -1
  37. package/mcp_server/runtime/capability_state.py +66 -9
  38. package/mcp_server/runtime/live_version.py +45 -8
  39. package/mcp_server/runtime/safety_kernel.py +11 -0
  40. package/mcp_server/runtime/session_kernel.py +7 -0
  41. package/mcp_server/runtime/tools.py +324 -22
  42. package/mcp_server/sample_engine/critics.py +7 -3
  43. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  44. package/mcp_server/sample_engine/tools.py +53 -21
  45. package/mcp_server/song_brain/builder.py +17 -1
  46. package/mcp_server/sound_design/tools.py +1 -1
  47. package/mcp_server/splice_client/http_bridge.py +43 -1
  48. package/mcp_server/splice_client/models.py +7 -3
  49. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  50. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  51. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  52. package/mcp_server/tools/_composition_engine/models.py +6 -0
  53. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  54. package/mcp_server/tools/analyzer.py +38 -1
  55. package/mcp_server/tools/clips.py +5 -4
  56. package/mcp_server/tools/composition.py +5 -1
  57. package/mcp_server/tools/midi_io.py +40 -1
  58. package/mcp_server/tools/transport.py +1 -1
  59. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  60. package/mcp_server/user_corpus/runner.py +7 -1
  61. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  62. package/mcp_server/user_corpus/tools.py +45 -9
  63. package/mcp_server/wonder_mode/tools.py +66 -27
  64. package/package.json +2 -2
  65. package/remote_script/LivePilot/__init__.py +1 -1
  66. package/remote_script/LivePilot/server.py +23 -3
  67. package/remote_script/LivePilot/version_detect.py +3 -0
  68. package/requirements.txt +4 -4
  69. package/server.json +3 -3
@@ -28,20 +28,20 @@ Run this checklist EVERY time the user says "update everything", "push", "releas
28
28
 
29
29
  ## 2. Tool Count (must ALL match)
30
30
 
31
- Current: **465 tools across 56 domains**.
31
+ Current: **467 tools across 56 domains**.
32
32
  Spectral/analyzer (bridge-only): **38**. The remaining tool surface works without the bridge or degrades gracefully. Backed by 32 bridge commands.
33
33
 
34
34
  Verify: `grep -rc "@mcp.tool" mcp_server/tools/ | grep -v ":0" | awk -F: '{sum+=$2} END{print sum}'`
35
35
 
36
36
  Files that reference tool count:
37
- - [ ] `README.md` — header ("465 tools. 56 domains"), bridge section ("38 spectral/analyzer tools require bridge")
37
+ - [ ] `README.md` — header ("467 tools. 56 domains"), bridge section ("38 spectral/analyzer tools require bridge")
38
38
  - [ ] `package.json` → `"description"`
39
39
  - [ ] `server.json` → `"description"`
40
40
  - [ ] `manifest.json` → `"description"`
41
41
  - [ ] `livepilot/.Codex-plugin/plugin.json` → `"description"` (primary Codex manifest)
42
42
  - [ ] `livepilot/.claude-plugin/plugin.json` → `"description"` (must match Codex plugin)
43
43
  - [ ] `.claude-plugin/marketplace.json` → `"description"`
44
- - [ ] `CLAUDE.md` → "465 tools across 56 domains"
44
+ - [ ] `CLAUDE.md` → "467 tools across 56 domains"
45
45
  - [ ] `CONTRIBUTING.md` → tool count in intro
46
46
  - [ ] `livepilot/skills/livepilot-core/SKILL.md` — tool/domain count
47
47
  - [ ] `livepilot/skills/livepilot-core/references/overview.md` — tool/domain count
@@ -85,6 +85,8 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
85
85
 
86
86
  ## 6. Plugin Cache
87
87
 
88
+ - [ ] `python3 scripts/verify_codex_plugin_sync.py` passes after `npx livepilot --install-codex-plugin`
89
+ - [ ] `python3 scripts/verify_plugin_sync.py` passes after Claude plugin cache/mirror sync
88
90
  - [ ] `~/.claude/plugins/cache/dreamrec-LivePilot/livepilot/` has current version directory
89
91
  - [ ] Old version directories removed
90
92
  - [ ] `~/.claude/plugins/installed_plugins.json` → `livepilot@dreamrec-LivePilot` entry: update `version`, `installPath`, `gitCommitSha`, `lastUpdated`
@@ -98,7 +100,7 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
98
100
 
99
101
  - [ ] `README.md` — features match current capabilities, "Coming" section is accurate
100
102
  - [ ] `docs/manual/getting-started.md` — install instructions current
101
- - [ ] `docs/manual/tool-reference.md` — all 56 domains listed, all 465 tools present
103
+ - [ ] `docs/manual/tool-reference.md` — all 56 domains listed, all 467 tools present
102
104
  - [ ] `docs/M4L_BRIDGE.md` — architecture accurate, core/bridge counts correct
103
105
 
104
106
  ## 9. Derived Artifacts
@@ -147,5 +149,5 @@ Current: **56 domains**: transport, tracks, clips, notes, devices, scenes, mixin
147
149
  ## Quick Verify Command
148
150
 
149
151
  ```bash
150
- echo "=== Versions ===" && grep -h '"version"' package.json server.json manifest.json livepilot/.Codex-plugin/plugin.json livepilot/.claude-plugin/plugin.json .claude-plugin/marketplace.json | head -8 && grep __version__ mcp_server/__init__.py remote_script/LivePilot/__init__.py && echo "=== Tool count ===" && grep -rc "@mcp.tool" mcp_server/ | grep -v ":0" | awk -F: '{sum+=$2} END{print "Total:", sum}' && echo "=== Tests ===" && python3 -m pytest tests/ -q 2>&1 | tail -1
152
+ echo "=== Versions ===" && grep -h '"version"' package.json server.json manifest.json livepilot/.Codex-plugin/plugin.json livepilot/.claude-plugin/plugin.json .claude-plugin/marketplace.json | head -8 && grep __version__ mcp_server/__init__.py remote_script/LivePilot/__init__.py && echo "=== Metadata ===" && python3 scripts/sync_metadata.py --check && echo "=== Plugin sync ===" && python3 scripts/verify_codex_plugin_sync.py --quiet && python3 scripts/verify_plugin_sync.py --quiet && echo "=== Tests ===" && python3 -m pytest tests/ -q 2>&1 | tail -1
151
153
  ```
Binary file
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.26.2";
37
+ var VERSION = "1.27.1";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.26.2"
2
+ __version__ = "1.27.1"
@@ -7,9 +7,12 @@ suggestion, chain building, and device comparison.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import logging
10
11
  import os
11
12
  from typing import Any, Dict, List, Optional
12
13
 
14
+ logger = logging.getLogger(__name__)
15
+
13
16
 
14
17
  class AtlasManager:
15
18
  """In-memory device atlas with indexed lookups."""
@@ -47,10 +50,26 @@ class AtlasManager:
47
50
  dev_category = dev.get("category", "")
48
51
 
49
52
  if dev_id:
53
+ if dev_id in self._by_id and self._by_id[dev_id] is not dev:
54
+ logger.warning(
55
+ "atlas: duplicate device id %r shadows a prior entry "
56
+ "(name=%r); last-wins", dev_id, dev_name,
57
+ )
50
58
  self._by_id[dev_id] = dev
51
59
  if dev_name:
52
- self._by_name[dev_name.lower()] = dev
60
+ name_key = dev_name.lower()
61
+ if name_key in self._by_name and self._by_name[name_key] is not dev:
62
+ logger.warning(
63
+ "atlas: duplicate device name %r shadows a prior entry "
64
+ "(id=%r); last-wins", dev_name, dev_id,
65
+ )
66
+ self._by_name[name_key] = dev
53
67
  if dev_uri:
68
+ if dev_uri in self._by_uri and self._by_uri[dev_uri] is not dev:
69
+ logger.warning(
70
+ "atlas: duplicate device uri %r shadows a prior entry "
71
+ "(id=%r); last-wins", dev_uri, dev_id,
72
+ )
54
73
  self._by_uri[dev_uri] = dev
55
74
 
56
75
  # Category index
@@ -109,11 +109,14 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
109
109
  try:
110
110
  from .overlays import get_overlay_index
111
111
  idx = get_overlay_index()
112
- # Search all non-`packs` namespaces; `packs` is already in the bundled atlas
113
- for ns in idx.list_namespaces():
114
- if ns == "packs":
115
- continue
116
- overlay_results.extend(idx.search(query, namespace=ns, limit=limit))
112
+ # Single pass over the overlay index (namespace=None scans all
113
+ # namespaces at once), then drop `packs` client-side — `packs` is
114
+ # already covered by the bundled atlas. Avoids re-scanning the whole
115
+ # index once per namespace.
116
+ overlay_results = [
117
+ e for e in idx.search(query, namespace=None, limit=limit * 2)
118
+ if e.namespace != "packs"
119
+ ]
117
120
  except Exception: # noqa: BLE001 — never fail atlas_search over an overlay glitch
118
121
  pass
119
122
 
@@ -177,10 +180,13 @@ def atlas_search(ctx: Context, query: str, category: str = "all", limit: int = 1
177
180
 
178
181
 
179
182
  @mcp.tool()
180
- def atlas_device_info(ctx: Context, device_id: str) -> dict:
183
+ def atlas_device_info(ctx: Context, device_id: str, verbose: bool = True) -> dict:
181
184
  """Get complete atlas knowledge about a device — parameters, recipes, pairings, gotchas.
182
185
 
183
186
  device_id: the atlas ID or device name (e.g., "drift", "Compressor", "808_core_kit")
187
+ verbose: when True (default) return the full raw atlas record. Set False for a
188
+ compact summary (capped description, tag/technique counts) — useful
189
+ when you only need to identify the device, not read every recipe.
184
190
  """
185
191
  atlas = _get_atlas()
186
192
  if atlas is None:
@@ -189,7 +195,29 @@ def atlas_device_info(ctx: Context, device_id: str) -> dict:
189
195
  entry = atlas.lookup(device_id)
190
196
  if entry is None:
191
197
  return {"error": f"Device '{device_id}' not found in atlas", "suggestion": "Use atlas_search to find devices"}
192
- return entry
198
+ if verbose:
199
+ return entry
200
+ # Compact mode: the common case doesn't need the full multi-KB record.
201
+ # Truncate the longest free-text fields the way atlas_search already caps
202
+ # sonic_description, and report counts for list-heavy fields so the caller
203
+ # knows to re-request verbose for the full detail.
204
+ summary: dict = {
205
+ "id": entry.get("id", ""),
206
+ "name": entry.get("name", ""),
207
+ "uri": entry.get("uri", ""),
208
+ "category": entry.get("category", ""),
209
+ "enriched": bool(entry.get("enriched", False)),
210
+ "sonic_description": (
211
+ entry.get("sonic_description") or entry.get("description", "")
212
+ )[:_ATLAS_SEARCH_DESCRIPTION_CHAR_CAP],
213
+ "character_tags": (
214
+ entry.get("character_tags") or entry.get("tags", [])
215
+ )[:8],
216
+ "sweet_spot": str(entry.get("sweet_spot", ""))[:_ATLAS_SEARCH_DESCRIPTION_CHAR_CAP],
217
+ }
218
+ summary.update(_surface_enriched_fields(entry))
219
+ summary["verbose_available"] = True
220
+ return summary
193
221
 
194
222
 
195
223
  @mcp.tool()
@@ -258,20 +286,20 @@ def atlas_chain_suggest(ctx: Context, role: str, genre: str = "") -> dict:
258
286
  query_parts.append(genre)
259
287
  query = " ".join(query_parts)
260
288
 
261
- for ns in idx.list_namespaces():
262
- if ns == "packs":
263
- continue
264
- hits = idx.search(query, namespace=ns, limit=5)
265
- for entry in hits:
266
- overlay_suggestions.append({
289
+ hits = [
290
+ e for e in idx.search(query, namespace=None, limit=10)
291
+ if e.namespace != "packs"
292
+ ]
293
+ for entry in hits:
294
+ overlay_suggestions.append({
267
295
  "namespace": entry.namespace,
268
296
  "entity_id": entry.entity_id,
269
297
  "name": entry.name,
270
298
  "description": (entry.description or "")[:120],
271
299
  "tags": list(entry.tags)[:5],
272
- "source": f"user_overlay:{entry.namespace}",
273
- "note": "Load via search_browser or extension_atlas_get; no Live URI.",
274
- })
300
+ "source": f"user_overlay:{entry.namespace}",
301
+ "note": "Load via search_browser or extension_atlas_get; no Live URI.",
302
+ })
275
303
  except Exception: # noqa: BLE001 — never fail chain_suggest over an overlay glitch
276
304
  pass
277
305
 
@@ -662,14 +690,17 @@ def scan_full_library(
662
690
  if not force and os.path.exists(atlas_path):
663
691
  age = time.time() - os.path.getmtime(atlas_path)
664
692
  if age < 86400:
665
- # Reload if not already loaded
666
- import mcp_server.atlas as atlas_mod
667
- if atlas_mod._atlas_instance is None:
668
- atlas_mod._atlas_instance = AtlasManager(atlas_path)
693
+ # Use the public, thread-safe loader rather than poking the
694
+ # legacy `_atlas_instance` cell directly: get_atlas() loads
695
+ # lazily via the Singleton holder and is the only path the rest
696
+ # of the module actually reads back from. The old direct
697
+ # AtlasManager(atlas_path) construction wrote a cell get_atlas()
698
+ # never consults.
699
+ from . import get_atlas
669
700
  return {
670
701
  "status": "already_exists",
671
702
  "age_hours": round(age / 3600, 1),
672
- "device_count": atlas_mod._atlas_instance.device_count,
703
+ "device_count": get_atlas().device_count,
673
704
  "message": "Atlas is recent. Use force=True to rescan.",
674
705
  }
675
706
 
@@ -796,6 +827,34 @@ def _serialize_overlay_entry(entry) -> dict:
796
827
  }
797
828
 
798
829
 
830
+ _OVERLAY_DESCRIPTION_PREVIEW = 240
831
+
832
+
833
+ def _serialize_overlay_entry_lite(entry) -> dict:
834
+ """Lightweight search-result serializer: drops the full YAML `body`.
835
+
836
+ The `body` of an overlay entry can be ~17 KB; inlining it for every one of
837
+ up to `limit` matches makes a single extension_atlas_search return hundreds
838
+ of KB. The search path only needs identity + a description preview so the
839
+ caller can decide which entry to fetch in full via extension_atlas_get.
840
+ """
841
+ description = entry.description or ""
842
+ truncated = len(description) > _OVERLAY_DESCRIPTION_PREVIEW
843
+ return {
844
+ "namespace": entry.namespace,
845
+ "entity_type": entry.entity_type,
846
+ "entity_id": entry.entity_id,
847
+ "name": entry.name,
848
+ "description": (
849
+ description[:_OVERLAY_DESCRIPTION_PREVIEW] + "…"
850
+ if truncated else description
851
+ ),
852
+ "tags": entry.tags,
853
+ "artists": entry.artists,
854
+ "requires_box": entry.requires_box,
855
+ }
856
+
857
+
799
858
  @mcp.tool()
800
859
  def extension_atlas_search(ctx: Context, query: str,
801
860
  namespace: str = "",
@@ -822,7 +881,9 @@ def extension_atlas_search(ctx: Context, query: str,
822
881
  "namespace": namespace or None,
823
882
  "entity_type": entity_type or None,
824
883
  "count": len(matches),
825
- "results": [_serialize_overlay_entry(e) for e in matches],
884
+ "results": [_serialize_overlay_entry_lite(e) for e in matches],
885
+ "note": "Search results omit the full YAML body; call "
886
+ "extension_atlas_get(namespace, entity_id) for the complete entry.",
826
887
  }
827
888
 
828
889
 
@@ -719,6 +719,15 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
719
719
  continue
720
720
  else:
721
721
  track_index = int(track_index)
722
+ # BUG-FIX (P2 zombie-cleanup): a reused/existing track must also be
723
+ # registered as "applied". Otherwise the postflight zombie-cleanup
724
+ # (which excludes only applied_track_indices) sees a reused track
725
+ # that currently has no session clips / instrument — e.g. one given
726
+ # only arrangement clips — as an empty leftover and DELETES it.
727
+ # Reused tracks must never be eligible for cleanup, and postflight
728
+ # monitoring must be restored on them too.
729
+ if track_index >= 0 and track_index not in applied_track_indices:
730
+ applied_track_indices.append(track_index)
722
731
 
723
732
  # Optional instrument load
724
733
  instrument = track_spec.get("instrument") or {}
@@ -408,12 +408,22 @@ def plan_sections(
408
408
  Returns a list of dicts: {name, bars, layers, start_bar}.
409
409
  """
410
410
  if section_template is None:
411
- raise ValueError(
412
- "plan_sections() requires explicit section_template in v1.24+. "
413
- "SECTION_TEMPLATES was removed — the LLM provides form, not the "
414
- "framework. Pass a section_template list or use the v1.24 full-mode "
415
- "compose flow which is LLM-creative."
416
- )
411
+ # v1.24 removed the built-in SECTION_TEMPLATES registry (form is the
412
+ # LLM's job, not the framework's). But the deterministic scaffolding
413
+ # tools (augment_with_samples, get_composition_plan,
414
+ # propose_composer_branches) and wonder_mode still call this without a
415
+ # template. Degrade to a SINGLE full-length section containing every
416
+ # selected role — this is NOT a form template (no genre-keyed
417
+ # multi-section sequence, no module-level bar-count registry), it just
418
+ # lets those tools emit a working skeleton. Callers that want real form
419
+ # pass an explicit section_template.
420
+ roles = _select_roles(intent)
421
+ return [{
422
+ "name": "Full",
423
+ "bars": max(4, int(intent.duration_bars or 64)),
424
+ "layers": list(roles),
425
+ "start_bar": 0,
426
+ }]
417
427
 
418
428
  template = section_template
419
429
 
@@ -72,9 +72,28 @@ def validate_plan_against_constraints(
72
72
  session_info = session_info or {}
73
73
  violations: list[str] = []
74
74
  warnings: list[str] = []
75
+ unenforced: list[str] = []
75
76
 
76
77
  steps = plan.get("steps", [])
77
78
 
79
+ # Modes that carry no structural step-level rule (taste/heuristic only).
80
+ # They were previously skipped silently — surface them as warnings so a
81
+ # caller can't mistake "no violations" for "validated against this mode".
82
+ advisory_modes = {
83
+ "mood_shift_without_new_fx": (
84
+ "shift the mood with existing tools — adding effects is discouraged "
85
+ "but not auto-detected here"
86
+ ),
87
+ "make_it_stranger_but_keep_the_hook": (
88
+ "push novelty while preserving the hook — not enforceable from plan "
89
+ "steps alone"
90
+ ),
91
+ "club_translation_safe": (
92
+ "keep changes club/DJ-friendly — judged by mix/tempo character, not "
93
+ "individual steps"
94
+ ),
95
+ }
96
+
78
97
  for constraint in constraint_set.constraints:
79
98
  if constraint == "no_new_tracks":
80
99
  for step in steps:
@@ -96,10 +115,37 @@ def validate_plan_against_constraints(
96
115
  if step.get("action", "") in mix_actions:
97
116
  violations.append(f"Step modifies mix ({step['action']}) — violates arrangement_only")
98
117
 
118
+ elif constraint == "use_loaded_devices_only":
119
+ load_actions = {"load_browser_item", "insert_device", "find_and_load_device",
120
+ "load_device_by_uri", "load_sample_to_simpler",
121
+ "replace_simpler_sample", "insert_rack_chain"}
122
+ for step in steps:
123
+ if step.get("action", "") in load_actions:
124
+ violations.append(
125
+ f"Step loads a new device ({step['action']}) — violates use_loaded_devices_only"
126
+ )
127
+
128
+ elif constraint == "performance_safe_creative":
129
+ unsafe_actions = {"create_midi_track", "create_audio_track", "create_return_track",
130
+ "delete_track", "delete_device", "delete_clip", "delete_scene"}
131
+ for step in steps:
132
+ if step.get("action", "") in unsafe_actions:
133
+ violations.append(
134
+ f"Step is unsafe during live performance ({step['action']}) — "
135
+ f"violates performance_safe_creative"
136
+ )
137
+
138
+ elif constraint in advisory_modes:
139
+ unenforced.append(constraint)
140
+ warnings.append(
141
+ f"{constraint} is advisory: {advisory_modes[constraint]}"
142
+ )
143
+
99
144
  return {
100
145
  "valid": len(violations) == 0,
101
146
  "violations": violations,
102
147
  "warnings": warnings,
148
+ "unenforced_constraints": unenforced,
103
149
  "constraint_count": len(constraint_set.constraints),
104
150
  }
105
151
 
@@ -244,12 +244,19 @@ async def run_branch_async(
244
244
  ]
245
245
 
246
246
  steps_executed = sum(1 for r in exec_results if r.ok)
247
+ # Only remote_command steps land on Ableton's linear undo stack. Bridge
248
+ # (M4L/OSC) and mcp_tool mutations do NOT, so issuing one `undo` per
249
+ # successful step over-undoes and walks back unrelated prior user edits.
250
+ # Count undos from remote_command successes alone.
251
+ undo_count = sum(
252
+ 1 for r in exec_results if r.ok and r.backend == "remote_command"
253
+ )
247
254
  branch.executed_at_ms = int(time.time() * 1000)
248
255
  branch.after_snapshot = capture_fn()
249
256
 
250
- # Undo all successful steps back to checkpoint. Undo is a remote_command,
251
- # route it through the normal ableton.send_command path for simplicity.
252
- for _ in range(steps_executed):
257
+ # Undo only the remote_command steps back to checkpoint. Undo is itself a
258
+ # remote_command, routed through the normal ableton.send_command path.
259
+ for _ in range(undo_count):
253
260
  try:
254
261
  ableton.send_command("undo", {})
255
262
  except Exception as exc:
@@ -17,11 +17,14 @@ session state, returning the same shape used by `mcp_server/audit/checks.py`:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import logging
20
21
  from typing import Any, Callable, Iterable
21
22
 
22
23
  from mcp_server.audit import checks as audit_checks
23
24
  from mcp_server.audit.checks import infer_role
24
25
 
26
+ _logger = logging.getLogger(__name__)
27
+
25
28
 
26
29
  _TRACK_COUNT_WARN = 8
27
30
  _TRACK_COUNT_FAIL = 12
@@ -441,11 +444,21 @@ def _aggregate_per_track(
441
444
  try:
442
445
  result = check_fn(*args)
443
446
  except Exception as exc:
447
+ _logger.warning(
448
+ "grader check %s failed for criterion %r track %r (%s): %s",
449
+ getattr(check_fn, "__name__", repr(check_fn)),
450
+ criterion_id,
451
+ t.get("index"),
452
+ t.get("name"),
453
+ exc,
454
+ exc_info=True,
455
+ )
444
456
  per_track.append({
445
457
  "track_index": t.get("index"),
446
458
  "name": t.get("name"),
447
459
  "role": role,
448
460
  "severity": "n/a",
461
+ "errored": True,
449
462
  "summary": f"check failed: {type(exc).__name__}",
450
463
  "issues": [],
451
464
  "evidence": {},
@@ -459,15 +472,30 @@ def _aggregate_per_track(
459
472
  })
460
473
 
461
474
  actionable = [r for r in per_track if r["severity"] != "n/a"]
475
+ n_errored = sum(1 for r in per_track if r.get("errored"))
462
476
 
463
477
  if not actionable:
478
+ if n_errored:
479
+ # Every checkable track raised — do NOT masquerade an all-error
480
+ # sweep as a benign no-op pass. Surface it as a failure.
481
+ return {
482
+ "id": criterion_id,
483
+ "passed": False,
484
+ "severity": "fail",
485
+ "summary": (
486
+ f"{n_errored} track(s) errored during check; no track "
487
+ "produced a usable result"
488
+ ),
489
+ "issues": [],
490
+ "evidence": {"per_track": per_track, "errored": n_errored},
491
+ }
464
492
  return {
465
493
  "id": criterion_id,
466
494
  "passed": True,
467
495
  "severity": "n/a",
468
496
  "summary": "No checkable tracks (data missing or no applicable role)",
469
497
  "issues": [],
470
- "evidence": {"per_track": per_track},
498
+ "evidence": {"per_track": per_track, "errored": n_errored},
471
499
  }
472
500
 
473
501
  has_fail = any(r["severity"] == "fail" for r in actionable)
@@ -505,7 +533,7 @@ def _aggregate_per_track(
505
533
  "severity": rubric_severity,
506
534
  "summary": summary,
507
535
  "issues": issues,
508
- "evidence": {"per_track": per_track},
536
+ "evidence": {"per_track": per_track, "errored": n_errored},
509
537
  }
510
538
 
511
539
 
@@ -64,9 +64,15 @@ def _build_light_state(ctx: Context) -> dict[str, Any]:
64
64
  info = _safe_call(ableton, "get_track_info", {"track_index": idx})
65
65
  if not info:
66
66
  continue
67
- if info.get("is_foldable") and info.get("is_master"):
67
+ # Skip master/return tracks (real Remote Script field names are
68
+ # is_master_track / is_return_track, not is_master / is_return).
69
+ if info.get("is_master_track") or info.get("is_return_track"):
68
70
  continue
69
- if info.get("is_return"):
71
+ # Skip group containers — foldable group tracks hold no clips or
72
+ # devices of their own, so they inflate the §7.3 track count and
73
+ # (having a mixer volume but no role/ghost tag) trip the
74
+ # buried-track check. Only count real, content-bearing layers.
75
+ if info.get("is_foldable"):
70
76
  continue
71
77
  tracks.append({
72
78
  "index": idx,
@@ -108,8 +108,21 @@ def find_hook_candidates(
108
108
  for c in candidates:
109
109
  # Check if hook is present in payoff sections (via motif locations)
110
110
  if c.hook_type == "melodic" and motif_data:
111
- for motif in motif_data.get("motifs", []):
112
- if motif.get("name", "") in c.hook_id:
111
+ for idx, motif in enumerate(motif_data.get("motifs", [])):
112
+ # BUG-B61 fix: the old test `motif.get("name", "") in c.hook_id`
113
+ # was always True for real motif data, because the engine emits
114
+ # `motif_id` (not `name`) so .get("name","") returned "", and
115
+ # `"" in c.hook_id` is True for every candidate. That boosted
116
+ # every melodic candidate by every motif's recurrence. Rebuild
117
+ # the source motif's hook_id exactly as it was constructed above
118
+ # (motif_id -> name -> idxN fallback) and require an exact match
119
+ # so each candidate is boosted only by its own source motif.
120
+ identifier = (
121
+ motif.get("motif_id")
122
+ or motif.get("name")
123
+ or f"idx{idx}"
124
+ )
125
+ if f"motif_{identifier}" == c.hook_id:
113
126
  # Motif with high recurrence across sections = stronger hook
114
127
  c.memorability = min(1.0, c.memorability + motif.get("recurrence", 0) * 0.2)
115
128
 
@@ -497,6 +497,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
497
497
  self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
498
498
  self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
499
499
  self._chunk_id = 0
500
+ self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
500
501
  self._response_callback: Optional[asyncio.Future] = None
501
502
  self._capture_future: Optional[asyncio.Future] = None
502
503
  self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
@@ -733,29 +734,26 @@ class SpectralReceiver(asyncio.DatagramProtocol):
733
734
  way we never poison a prior sequence, and the problem surfaces in
734
735
  logs if it happens.
735
736
  """
736
- if index == 0:
737
+ # Only one response is chunked at a time: send_command serialises on
738
+ # _cmd_lock, so a single active reassembly bucket is sufficient and we
739
+ # do NOT need the first packet to be index 0. Accepting chunks in any
740
+ # order fixes the permanent-loss path where an index>0 chunk arriving
741
+ # before index 0 (UDP loopback reordering under load) used to split
742
+ # one response across two buckets that never completed.
743
+ active = self._chunks.get(self._chunk_key)
744
+ if active is None or active["total"] != total:
745
+ # No bucket open, OR a chunk with a different `total` arrived,
746
+ # meaning a new response started (e.g. the previous one timed out
747
+ # without ever completing). Evict any stale partial and open fresh.
748
+ if active is not None:
749
+ self._chunks.pop(self._chunk_key, None)
750
+ self._chunk_times.pop(self._chunk_key, None)
737
751
  self._chunk_id += 1
738
- key = str(self._chunk_id)
739
- self._chunks[key] = {"parts": {}, "total": total}
740
- self._chunk_times[key] = time.monotonic()
741
- else:
742
- key = str(self._chunk_id)
743
- if key not in self._chunks:
744
- # Out-of-order arrival. Start a new bucket rather than append
745
- # to the previous sequence's parts — that's the corruption
746
- # path. Log once so it's diagnosable.
747
- import sys
748
- print(
749
- f"LivePilot: chunk index={index}/{total} arrived before "
750
- f"index=0 — starting fresh bucket. UDP reordering on "
751
- f"loopback suggests system load.",
752
- file=sys.stderr,
753
- )
754
- self._chunk_id += 1
755
- key = str(self._chunk_id)
756
- self._chunks[key] = {"parts": {}, "total": total}
757
- self._chunk_times[key] = time.monotonic()
752
+ self._chunk_key = str(self._chunk_id)
753
+ self._chunks[self._chunk_key] = {"parts": {}, "total": total}
754
+ self._chunk_times[self._chunk_key] = time.monotonic()
758
755
 
756
+ key = self._chunk_key
759
757
  self._chunks[key]["parts"][index] = encoded
760
758
 
761
759
  if len(self._chunks[key]["parts"]) == total:
@@ -182,6 +182,14 @@ class TasteGraph:
182
182
  self.evidence_count += 1
183
183
  self.last_updated_ms = now
184
184
 
185
+ # Write-back to persistent store
186
+ if self._persistent_store is not None:
187
+ try:
188
+ self._persistent_store.record_device_use(device_name, positive)
189
+ except Exception as exc:
190
+ logger.debug("record_device_use failed: %s", exc)
191
+ pass # persistence is best-effort
192
+
185
193
  def update_novelty_from_experiment(
186
194
  self, chose_bold: bool, goal_mode: str = "improve",
187
195
  ) -> None:
@@ -200,6 +208,14 @@ class TasteGraph:
200
208
  new_val = max(0.0, current - 0.05)
201
209
  self.novelty_bands[goal_mode] = new_val
202
210
 
211
+ # Write-back to persistent store
212
+ if self._persistent_store is not None:
213
+ try:
214
+ self._persistent_store.update_novelty(chose_bold, goal_mode)
215
+ except Exception as exc:
216
+ logger.debug("update_novelty failed: %s", exc)
217
+ pass # persistence is best-effort
218
+
203
219
  # ── Ranking ──────────────────────────────────────────────────────
204
220
 
205
221
  def rank_moves(