livepilot 1.27.0 → 1.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +26 -7
- package/bin/livepilot.js +86 -23
- package/installer/install.js +21 -12
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +8 -8
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +20 -1
- package/mcp_server/atlas/tools.py +83 -22
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/full/apply.py +9 -0
- package/mcp_server/composer/full/layer_planner.py +16 -6
- package/mcp_server/composer/tools.py +12 -6
- package/mcp_server/connection.py +2 -1
- package/mcp_server/creative_constraints/engine.py +46 -0
- package/mcp_server/experiment/engine.py +10 -3
- package/mcp_server/grader/client.py +30 -2
- package/mcp_server/grader/tools.py +8 -2
- package/mcp_server/hook_hunter/analyzer.py +15 -2
- package/mcp_server/m4l_bridge.py +145 -54
- package/mcp_server/memory/taste_graph.py +16 -0
- package/mcp_server/memory/technique_store.py +23 -3
- package/mcp_server/mix_engine/state_builder.py +4 -2
- package/mcp_server/musical_intelligence/detectors.py +11 -2
- package/mcp_server/musical_intelligence/tools.py +15 -2
- package/mcp_server/preview_studio/engine.py +30 -1
- package/mcp_server/project_brain/tools.py +56 -52
- package/mcp_server/reference_engine/tools.py +22 -2
- package/mcp_server/runtime/execution_router.py +6 -0
- package/mcp_server/runtime/live_version.py +27 -8
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/safety_kernel.py +11 -0
- package/mcp_server/sample_engine/critics.py +7 -3
- package/mcp_server/sample_engine/slice_workflow.py +3 -2
- package/mcp_server/sample_engine/tools.py +53 -21
- package/mcp_server/server.py +1 -1
- package/mcp_server/song_brain/builder.py +17 -1
- package/mcp_server/sound_design/tools.py +1 -1
- package/mcp_server/splice_client/http_bridge.py +43 -1
- package/mcp_server/splice_client/models.py +7 -3
- package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
- package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
- package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
- package/mcp_server/tools/_composition_engine/models.py +6 -0
- package/mcp_server/tools/_composition_engine/sections.py +4 -0
- package/mcp_server/tools/clips.py +5 -4
- package/mcp_server/tools/composition.py +5 -1
- package/mcp_server/tools/midi_io.py +40 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
- package/mcp_server/user_corpus/runner.py +7 -1
- package/mcp_server/user_corpus/scanners/amxd.py +24 -13
- package/mcp_server/user_corpus/tools.py +45 -9
- package/mcp_server/wonder_mode/tools.py +66 -27
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/server.py +23 -3
- package/remote_script/LivePilot/tracks.py +11 -5
- package/requirements.txt +1 -1
- package/server.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
#
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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":
|
|
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": [
|
|
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
|
|
|
@@ -1402,8 +1402,12 @@ def build_creative_brief(
|
|
|
1402
1402
|
" — those are filtered out before the brief reaches you.\n"
|
|
1403
1403
|
"5. **TIER-1: Fire each search in `recommended_searches` BEFORE designing\n"
|
|
1404
1404
|
" that role.** Each entry gives you a (tool, query) pair — call the named\n"
|
|
1405
|
-
" Ableton Knowledge MCP tool
|
|
1406
|
-
"
|
|
1405
|
+
" Ableton Knowledge MCP tool with the `mcp__Ableton_Knowledge__` prefix\n"
|
|
1406
|
+
" (e.g. search_live_manual → mcp__Ableton_Knowledge__search_live_manual).\n"
|
|
1407
|
+
" Most queries hit the Live manual or video tutorials; capture 1 useful\n"
|
|
1408
|
+
" snippet per role and apply it. The Ableton Knowledge MCP is OPTIONAL —\n"
|
|
1409
|
+
" if any mcp__Ableton_Knowledge__* tool is unavailable or returns nothing,\n"
|
|
1410
|
+
" skip it and proceed using the creative guidance alone.\n"
|
|
1407
1411
|
"6. **TIER-2: If `reference_artist` is set**, also fire each search in\n"
|
|
1408
1412
|
" `reference_searches` and design USING that artist's signature techniques.\n"
|
|
1409
1413
|
"7. For each layer: pick ONE uri from instruments_by_role[role], design\n"
|
|
@@ -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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
|
@@ -378,12 +378,18 @@ def consult_ableton_knowledge(
|
|
|
378
378
|
template, and surfaces sources alongside the answer.
|
|
379
379
|
|
|
380
380
|
Examples:
|
|
381
|
-
consult_ableton_knowledge("
|
|
382
|
-
→
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
381
|
+
consult_ableton_knowledge("what does the Saturator Drive knob do?")
|
|
382
|
+
→ intent: device
|
|
383
|
+
→ plan: [search_live_manual("what does the Saturator Drive knob do?"),
|
|
384
|
+
search_videos("what does the Saturator Drive knob do?"),
|
|
385
|
+
search_transcripts("what does the Saturator Drive knob do?")]
|
|
386
|
+
+ synthesis template
|
|
387
|
+
consult_ableton_knowledge("how do I make my kick punchier?", {"current_genre": "techno"})
|
|
388
|
+
→ intent: sound_design
|
|
389
|
+
→ plan: [search_transcripts("techno how do I make my kick punchier?"),
|
|
390
|
+
search_videos("techno how do I make my kick punchier? tutorial"),
|
|
391
|
+
search_knowledge_base("how do I make my kick punchier?")]
|
|
392
|
+
+ synthesis template
|
|
387
393
|
|
|
388
394
|
session_context (optional): {
|
|
389
395
|
"current_genre": "techno",
|
package/mcp_server/connection.py
CHANGED
|
@@ -258,7 +258,8 @@ class AbletonConnection:
|
|
|
258
258
|
needs_retry = fresh_connect and _is_single_client_state_error(response)
|
|
259
259
|
|
|
260
260
|
if needs_retry:
|
|
261
|
-
self.
|
|
261
|
+
with self._lock:
|
|
262
|
+
self.disconnect()
|
|
262
263
|
time.sleep(SINGLE_CLIENT_RETRY_DELAY)
|
|
263
264
|
with self._lock:
|
|
264
265
|
self.connect()
|
|
@@ -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
|
|
251
|
-
#
|
|
252
|
-
for _ in range(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|