livepilot 1.18.2 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +248 -0
- package/README.md +7 -7
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_director/__init__.py +21 -0
- package/mcp_server/creative_director/compliance.py +263 -0
- package/mcp_server/creative_director/hybrid.py +429 -0
- package/mcp_server/creative_director/tools.py +135 -0
- package/mcp_server/experiment/baseline.py +138 -0
- package/mcp_server/experiment/engine.py +20 -0
- package/mcp_server/experiment/models.py +9 -1
- package/mcp_server/experiment/tools.py +22 -0
- package/mcp_server/server.py +1 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,253 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.19.0 — Experiment baseline + hybrid packet compilation (April 24 2026)
|
|
4
|
+
|
|
5
|
+
Minor version bump. Ships two of the three open items documented in
|
|
6
|
+
`docs/plans/v1.19-structural-plan.md`. Item C (full architectural
|
|
7
|
+
routing of director Phase 6 through `apply_semantic_move`) is
|
|
8
|
+
deferred to v1.20 per the plan's blast-radius rationale.
|
|
9
|
+
|
|
10
|
+
Both items shipped under strict TDD: 52 new unit tests, zero
|
|
11
|
+
regressions across the 2854-test suite. Both items live-tested in
|
|
12
|
+
production (real Ableton session, Live 12.4.0, 13 live-test
|
|
13
|
+
scenarios green).
|
|
14
|
+
|
|
15
|
+
### Item A — Experiment baseline transport snapshot/restore
|
|
16
|
+
|
|
17
|
+
Live-verified in v1.18.0 Test 8: running a 3-branch experiment
|
|
18
|
+
sequentially produced inconsistent `before_snapshot` values
|
|
19
|
+
because playback position, mute/solo/arm, and playing-clip state
|
|
20
|
+
drifted across branches. `undo()` reverts command history but
|
|
21
|
+
doesn't guarantee transport state is identical when each branch's
|
|
22
|
+
`before_snapshot` fires. Track_meters[0].level values of 0.764 /
|
|
23
|
+
0.000 / 0.873 across three branches rendered the before/after
|
|
24
|
+
comparisons meaningless.
|
|
25
|
+
|
|
26
|
+
Fix — snapshot-and-restore pattern, experiment-level:
|
|
27
|
+
|
|
28
|
+
- NEW `mcp_server/experiment/baseline.py` — `BaselineTransportState`
|
|
29
|
+
dataclass + `capture_baseline(ableton)` +
|
|
30
|
+
`restore_baseline(ableton, baseline, stabilize_ms=300)`.
|
|
31
|
+
Captures `is_playing`, `song_time`, and per-track
|
|
32
|
+
`mute`/`solo`/`arm` via a single `get_session_info` round-trip.
|
|
33
|
+
Restore issues `stop_playback` → per-track
|
|
34
|
+
`set_track_mute`/`set_track_solo`/`set_track_arm` → 300 ms
|
|
35
|
+
stabilize sleep. Per-track failures are logged, not fatal (a
|
|
36
|
+
single flaky track never aborts restore for the rest).
|
|
37
|
+
- `ExperimentSet` gains a `baseline_transport: Optional[BaselineTransportState]`
|
|
38
|
+
field. `to_dict()` surfaces it when populated.
|
|
39
|
+
- `engine.prepare_for_next_branch(ableton, baseline, stabilize_ms)`
|
|
40
|
+
— thin wrapper called by `run_experiment` between branches.
|
|
41
|
+
No-op when baseline is None (first branch).
|
|
42
|
+
- `run_experiment` captures the baseline once before the branch
|
|
43
|
+
loop starts, stashes it on the experiment, and calls
|
|
44
|
+
`prepare_for_next_branch` before every branch after the first.
|
|
45
|
+
Capture failure logs + degrades to None (pre-v1.19 behavior).
|
|
46
|
+
|
|
47
|
+
**Stabilize window defaults to 300 ms** — midpoint of plan §2's
|
|
48
|
+
200-500 ms empirical range. Per-branch overhead stayed at
|
|
49
|
+
~1.04 s amortized under live 5-branch testing (well under the
|
|
50
|
+
plan's 2-second-per-branch success criterion target).
|
|
51
|
+
|
|
52
|
+
**Live evidence of state preservation:** 5-branch test with two
|
|
53
|
+
mutations on track 0 "Dub Chord" (pan -0.35 by `widen_stereo`,
|
|
54
|
+
then volume 0.4 by `darken_without_losing_width`) returned the
|
|
55
|
+
track to identical pre-experiment state (arm=true, mute=false,
|
|
56
|
+
solo=false) after every branch cycle.
|
|
57
|
+
|
|
58
|
+
Known limitations (accepted per plan §2):
|
|
59
|
+
- Automation drift is not frozen — deeper refactor out of scope.
|
|
60
|
+
- Send values + device parameters mutated outside a branch's own
|
|
61
|
+
steps fall back to `undo()` alone — no explicit restore.
|
|
62
|
+
- Transport position is NOT re-seeked; `song_time` is captured
|
|
63
|
+
but unused (stopping is enough).
|
|
64
|
+
|
|
65
|
+
21 unit tests added: capture (transport fields, empty tracks,
|
|
66
|
+
missing-field defaults, epoch-ms timestamp), restore (command
|
|
67
|
+
sequence, per-track mute/solo/arm restoration, stabilize sleep
|
|
68
|
+
with monkey-patched time.sleep, flaky-track resilience,
|
|
69
|
+
return-track arm skip), `ExperimentSet.baseline_transport`
|
|
70
|
+
(default None, to_dict surfacing/omission), engine helper
|
|
71
|
+
(None no-op, delegation), tool-level wiring (`run_experiment`
|
|
72
|
+
populates baseline once + idempotent on second run).
|
|
73
|
+
|
|
74
|
+
### Item B — Hybrid concept packet compilation
|
|
75
|
+
|
|
76
|
+
Pre-v1.19 the director handled "Basic Channel meets Dilla swing"
|
|
77
|
+
via LLM ad-hoc reasoning — no explicit rule for contradictions
|
|
78
|
+
(e.g., Gas deprioritizes rhythmic, Dilla emphasizes rhythmic;
|
|
79
|
+
what survives the hybrid?). v1.18.0 Test 7 verified plausible
|
|
80
|
+
output but entirely improvisational, with no guarantee either
|
|
81
|
+
source packet's `avoid` list or tempo constraints would persist.
|
|
82
|
+
|
|
83
|
+
Fix — explicit merge algorithm with canonical rules per plan §3:
|
|
84
|
+
|
|
85
|
+
- NEW `mcp_server/creative_director/hybrid.py` —
|
|
86
|
+
`compile_hybrid_brief(packet_ids, weights=None)` loads concept
|
|
87
|
+
packets from `livepilot/skills/livepilot-core/references/concepts/`
|
|
88
|
+
and applies merge rules:
|
|
89
|
+
* `sonic_identity` / `avoid` / `reach_for.*` / `*_idioms` /
|
|
90
|
+
`sample_roles` / `dimensions_in_scope`: UNION, deduplicated,
|
|
91
|
+
first-packet order preserved.
|
|
92
|
+
* `dimensions_deprioritized` / `move_family_bias.deprioritize`:
|
|
93
|
+
INTERSECTION — only deprioritize if ALL packets agree.
|
|
94
|
+
Safer default: one packet's ignored dimension shouldn't
|
|
95
|
+
starve another packet's wanted one.
|
|
96
|
+
* `move_family_bias.favor`: INTERSECTION when non-empty
|
|
97
|
+
(hybrid focuses where both agree), UNION fallback with
|
|
98
|
+
warning when empty.
|
|
99
|
+
* `evaluation_bias.target_dimensions`: WEIGHTED AVERAGE
|
|
100
|
+
(default uniform; override via `weights`).
|
|
101
|
+
* `evaluation_bias.protect`: MAX per dimension (stricter
|
|
102
|
+
floor wins).
|
|
103
|
+
* `novelty_budget_default`: MAX (hybrid asks skew
|
|
104
|
+
exploratory).
|
|
105
|
+
* `tempo_hint`: NEAREST-OVERLAP — intersect overlapping
|
|
106
|
+
ranges, else midpoint + `disjoint: true` flag + warning.
|
|
107
|
+
|
|
108
|
+
- NEW MCP tool `compile_hybrid_brief` in
|
|
109
|
+
`mcp_server/creative_director/tools.py` (tool count 428 → 429).
|
|
110
|
+
Accepts packet IDs as filename stems (`"basic-channel"`),
|
|
111
|
+
aliases (`"dilla"`), or packet `id` values
|
|
112
|
+
(`"dub_techno__basic_channel"`). Returns ValueError as an
|
|
113
|
+
error-dict response (doesn't raise).
|
|
114
|
+
|
|
115
|
+
- NEW reference doc
|
|
116
|
+
`livepilot/skills/livepilot-creative-director/references/hybrid-compilation.md`
|
|
117
|
+
— canonical merge-rule table, output shape, interop notes,
|
|
118
|
+
guidance for handling the `warnings` list.
|
|
119
|
+
|
|
120
|
+
- Director SKILL.md Phase 1 — explicit guidance to call
|
|
121
|
+
`compile_hybrid_brief` when the user names 2+ references,
|
|
122
|
+
with a mandate to surface any `warnings` entries (don't
|
|
123
|
+
silently average disjoint tempos).
|
|
124
|
+
|
|
125
|
+
- Output exposes merged `avoid` also as `anti_patterns` alias
|
|
126
|
+
for drop-in compat with `check_brief_compliance` (v1.18.3).
|
|
127
|
+
Live interop test: Basic Channel × J Dilla hybrid correctly
|
|
128
|
+
flagged a Hi Gain boost via `check_brief_compliance`.
|
|
129
|
+
|
|
130
|
+
31 unit tests added: packet loading (stem / alias / id /
|
|
131
|
+
underscore-to-hyphen normalization / missing), input validation
|
|
132
|
+
(min 2 packets / missing packet / weights length mismatch),
|
|
133
|
+
UNION rules (avoid / sonic_identity / reach_for /
|
|
134
|
+
dimensions_in_scope), INTERSECTION rules (deprioritized
|
|
135
|
+
dimensions / `move_family_bias.deprioritize` /
|
|
136
|
+
`move_family_bias.favor` non-empty case / UNION fallback with
|
|
137
|
+
warning), WEIGHTED AVERAGE (default + custom weights), MAX rules
|
|
138
|
+
(protect / novelty_budget), tempo_hint (overlap intersection /
|
|
139
|
+
disjoint midpoint with warning), 3+ packet composition, output
|
|
140
|
+
metadata (`type` / `source_packets` / hybrid name /
|
|
141
|
+
`locked_dimensions=[]` / warnings list), and interoperability
|
|
142
|
+
(hybrid brief passed through `check_brief_compliance`).
|
|
143
|
+
|
|
144
|
+
### Live test coverage (13 scenarios)
|
|
145
|
+
|
|
146
|
+
Item B: BC × Dilla (disjoint tempos) · BC × Villalobos
|
|
147
|
+
(overlapping tempos, NO disjoint flag) · alias + spaced-name
|
|
148
|
+
resolution · invalid packet error · 3-packet hybrid
|
|
149
|
+
(BC + Dilla + Villalobos) · weighted average 75/25 · genre ×
|
|
150
|
+
artist (ambient × basinski, tempo=0 case) · full hybrid brief
|
|
151
|
+
→ `check_brief_compliance` interop (quantize_clip flagged).
|
|
152
|
+
|
|
153
|
+
Item A: 3-branch experiment (all snapshots populated, ranking
|
|
154
|
+
produced) · 5-branch experiment (1.04s/branch amortized
|
|
155
|
+
overhead) · state preservation under 2 mutations on track 0
|
|
156
|
+
(Dub Chord) across 5-branch cycle · `discard_experiment` cleanup.
|
|
157
|
+
|
|
158
|
+
### Known gaps deferred to v1.19.1
|
|
159
|
+
|
|
160
|
+
- `experiment.baseline_transport` populated internally but not
|
|
161
|
+
surfaced through `compare_experiments` response. 3-line fix
|
|
162
|
+
for operator visibility; not a correctness issue.
|
|
163
|
+
- `warnings` message rounds tempo midpoint to int display (128
|
|
164
|
+
BPM) while range returned is exact (125-130, centered 127.5).
|
|
165
|
+
Two rounding conventions. Cosmetic.
|
|
166
|
+
- `weights` in response show full float precision
|
|
167
|
+
(`0.3333333333333333`) instead of rounding to 4 dp like
|
|
168
|
+
`target_dimensions` already does. Cosmetic.
|
|
169
|
+
|
|
170
|
+
### Still open for v1.20 (Item C from the plan)
|
|
171
|
+
|
|
172
|
+
- Route director's Phase 6 execution through `apply_semantic_move`
|
|
173
|
+
/ `create_experiment + commit_experiment` so the action ledger
|
|
174
|
+
populates automatically and anti-repetition becomes reliable.
|
|
175
|
+
Doc-level fix shipped in v1.18.1; architectural fix deferred
|
|
176
|
+
to v1.20 per plan §5 blast-radius rationale. Requires 5-10
|
|
177
|
+
new semantic_moves to cover current Phase 6 patterns
|
|
178
|
+
(return-chain builds, multi-param device presets, chord
|
|
179
|
+
source loading, send routing, etc.).
|
|
180
|
+
|
|
181
|
+
Test suite: 2854 pass, 1 skipped (from 2792 pre-v1.19). Zero
|
|
182
|
+
regressions. sync_metadata --check clean.
|
|
183
|
+
|
|
184
|
+
## 1.18.3 — Brief compliance runtime check (#7 + #8) (April 24 2026)
|
|
185
|
+
|
|
186
|
+
Third v1.18.x patch. Bundles two Known Issues items (#7 + #8) that
|
|
187
|
+
shared the same "check tool args against brief constraints" machinery.
|
|
188
|
+
|
|
189
|
+
### Fix
|
|
190
|
+
|
|
191
|
+
- **#7 Packet `avoid` list runtime enforcement.**
|
|
192
|
+
- **#8 `locked_dimensions` runtime enforcement.**
|
|
193
|
+
|
|
194
|
+
Both were advisory-only pre-v1.18.3: the director SKILL.md documented
|
|
195
|
+
the hard-filter rules but no runtime machinery verified compliance.
|
|
196
|
+
This release ships a **stateless pure check function** in a new
|
|
197
|
+
`mcp_server/creative_director` module, exposed as the MCP tool
|
|
198
|
+
`check_brief_compliance(brief, tool_name, tool_args)`.
|
|
199
|
+
|
|
200
|
+
**Usage**: director's Phase 6 calls the tool before each risky
|
|
201
|
+
execution (EQ parameters, filter settings, new scene creation, clip
|
|
202
|
+
note editing, send routing, etc.). The tool returns
|
|
203
|
+
`{"ok": bool, "violations": [...]}`. Violations are reports, not
|
|
204
|
+
automatic blocks — the director surfaces them to the user and offers
|
|
205
|
+
three paths: adjust, override-for-this-turn, or pick a different tool.
|
|
206
|
+
|
|
207
|
+
**Detection strategy — best-effort heuristic**, not semantic
|
|
208
|
+
understanding:
|
|
209
|
+
|
|
210
|
+
- anti_pattern matching via keyword tokens + parameter-name heuristics
|
|
211
|
+
(e.g., pattern "bright top-end" + Hi Gain positive value → fires)
|
|
212
|
+
- locked_dimension matching via tool → dimension map
|
|
213
|
+
(e.g., structural lock + create_scene → fires)
|
|
214
|
+
|
|
215
|
+
### Infrastructure
|
|
216
|
+
|
|
217
|
+
- NEW module `mcp_server/creative_director/` with compliance.py +
|
|
218
|
+
tools.py
|
|
219
|
+
- NEW MCP tool `check_brief_compliance` (tool count 427 → 428,
|
|
220
|
+
domain count 52 → 53)
|
|
221
|
+
- Director SKILL.md Phase 6 now documents the check + the
|
|
222
|
+
three-path violation-response protocol
|
|
223
|
+
- Full session-state active-brief storage is deferred to v1.19;
|
|
224
|
+
v1.18.3 is stateless (caller passes brief each time)
|
|
225
|
+
|
|
226
|
+
### Tests added
|
|
227
|
+
|
|
228
|
+
- `test_compliance_check_detects_anti_pattern_violation` (BC packet
|
|
229
|
+
+ Hi Gain boost → violation)
|
|
230
|
+
- `test_compliance_check_detects_locked_dimension_violation`
|
|
231
|
+
(structural lock + create_scene → violation)
|
|
232
|
+
- `test_compliance_check_passes_compliant_call` (no false positives)
|
|
233
|
+
- `test_compliance_check_empty_brief_permissive` (fresh session
|
|
234
|
+
safety)
|
|
235
|
+
|
|
236
|
+
Test suite: 2792 pass, 1 skipped. Zero regressions.
|
|
237
|
+
|
|
238
|
+
### Still open for v1.19 (3 items)
|
|
239
|
+
|
|
240
|
+
- Experiment state continuity between branches (architectural —
|
|
241
|
+
transport-state locking needed)
|
|
242
|
+
- Hybrid-packet compilation algorithm (union/intersection logic for
|
|
243
|
+
multi-packet refs like "Basic Channel meets Dilla")
|
|
244
|
+
- Full architectural fix for #3 (route director Phase 6 through
|
|
245
|
+
`apply_semantic_move` / `commit_experiment` — replaces the
|
|
246
|
+
doc-level fix shipped in v1.18.1)
|
|
247
|
+
|
|
248
|
+
These are v1.19 scope — each needs new architectural decisions and
|
|
249
|
+
infrastructure unsuitable for patch releases.
|
|
250
|
+
|
|
3
251
|
## 1.18.2 — Wonder cold-start + tie-break + genre catalog closure (April 24 2026)
|
|
4
252
|
|
|
5
253
|
Second patch in the v1.18.x series. Three items from the v1.18.0/v1.18.1
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
An agentic production system for Ableton Live 12.<br>
|
|
20
|
-
|
|
20
|
+
429 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
<br>
|
|
@@ -80,8 +80,8 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
80
80
|
│ └─────────────────┼──────────────────┘ │
|
|
81
81
|
│ ▼ │
|
|
82
82
|
│ ┌─────────────────┐ │
|
|
83
|
-
│ │
|
|
84
|
-
│ │
|
|
83
|
+
│ │ 429 MCP Tools │ │
|
|
84
|
+
│ │ 53 domains │ │
|
|
85
85
|
│ └────────┬────────┘ │
|
|
86
86
|
│ │ │
|
|
87
87
|
│ Remote Script ──┤── TCP 9878 │
|
|
@@ -121,7 +121,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
121
121
|
|
|
122
122
|
## The Intelligence Layer
|
|
123
123
|
|
|
124
|
-
12 engines sit on top of the
|
|
124
|
+
12 engines sit on top of the 429 tools. They give the AI musical judgment, not just musical execution.
|
|
125
125
|
|
|
126
126
|
### SongBrain — What the Song Is
|
|
127
127
|
|
|
@@ -173,7 +173,7 @@ Every engine follows: **measure before → act → measure after → compare**.
|
|
|
173
173
|
|
|
174
174
|
## Tools
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
429 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
|
|
177
177
|
|
|
178
178
|
<br>
|
|
179
179
|
|
|
@@ -362,7 +362,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
|
|
|
362
362
|
| Creative Constraints | 5 | constraint activation, reference-inspired variants |
|
|
363
363
|
| Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
|
|
364
364
|
|
|
365
|
-
> **[View all
|
|
365
|
+
> **[View all 429 tools →](docs/manual/tool-catalog.md)**
|
|
366
366
|
|
|
367
367
|
<br>
|
|
368
368
|
|
|
@@ -589,7 +589,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
|
|
|
589
589
|
|
|
590
590
|
| Document | What's inside |
|
|
591
591
|
|----------|---------------|
|
|
592
|
-
| [Manual](docs/manual/index.md) | Complete reference: architecture, all
|
|
592
|
+
| [Manual](docs/manual/index.md) | Complete reference: architecture, all 429 tools, workflows |
|
|
593
593
|
| [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
|
|
594
594
|
| [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
|
|
595
595
|
| [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.19.0"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Creative Director — v1.18.3+ runtime compliance check for brief constraints.
|
|
2
|
+
|
|
3
|
+
The livepilot-creative-director skill compiles a Creative Brief inline in
|
|
4
|
+
each creative turn. The brief's `anti_patterns` and `locked_dimensions`
|
|
5
|
+
fields were previously advisory — no runtime machinery verified that
|
|
6
|
+
intended tool calls respected them.
|
|
7
|
+
|
|
8
|
+
This module ships the minimum-effective enforcement layer: a pure
|
|
9
|
+
check function `check_brief_compliance(brief, tool_name, tool_args)`
|
|
10
|
+
that returns {"ok": bool, "violations": [...]}. Director's Phase 6
|
|
11
|
+
calls it before each risky tool execution. Violations don't block
|
|
12
|
+
execution automatically — the director reports them to the user, who
|
|
13
|
+
can override or abandon.
|
|
14
|
+
|
|
15
|
+
Full session-state active-brief storage + automatic interception is a
|
|
16
|
+
v1.19 scope item.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .compliance import check_brief_compliance
|
|
20
|
+
|
|
21
|
+
__all__ = ["check_brief_compliance"]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Brief compliance check — v1.18.3 pure function for anti-pattern and
|
|
2
|
+
locked-dimension enforcement.
|
|
3
|
+
|
|
4
|
+
Pure computation: no I/O, no session state. Caller passes the brief
|
|
5
|
+
dict and the intended tool call; the function returns a report of
|
|
6
|
+
violations. Director's Phase 6 calls this before each risky tool call.
|
|
7
|
+
|
|
8
|
+
Design principles:
|
|
9
|
+
|
|
10
|
+
1. **Best-effort heuristic, not semantic understanding.** anti_patterns
|
|
11
|
+
in the brief are prose. The checker does keyword-token matching
|
|
12
|
+
against humanized tool-call descriptions. Won't catch every
|
|
13
|
+
violation (e.g., "too muddy" → EQ cut at 300 Hz requires more
|
|
14
|
+
intelligence than substring match). Does catch obvious ones
|
|
15
|
+
(e.g., "bright top-end" → Hi Gain boost).
|
|
16
|
+
|
|
17
|
+
2. **Never block — always report.** The return format is a violations
|
|
18
|
+
list with human-readable reason + suggestion. The caller (director)
|
|
19
|
+
decides whether to proceed, ask the user, or abandon. Hard-blocking
|
|
20
|
+
at this layer would crash under false positives.
|
|
21
|
+
|
|
22
|
+
3. **Empty brief passes everything.** A brief without anti_patterns or
|
|
23
|
+
locked_dimensions returns ok=True for all calls — we don't want a
|
|
24
|
+
fresh session to be hostile to experimentation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Tool → dimension mapping ────────────────────────────────────────
|
|
34
|
+
# Maps MCP tool names to the creative dimension they primarily affect.
|
|
35
|
+
# Used for locked_dimensions enforcement. A tool can map to None
|
|
36
|
+
# (dimension-agnostic — e.g., undo, get_session_info) in which case no
|
|
37
|
+
# locked-dimension check applies.
|
|
38
|
+
|
|
39
|
+
_STRUCTURAL_TOOLS = frozenset({
|
|
40
|
+
"create_scene", "delete_scene", "duplicate_scene", "set_scene_name",
|
|
41
|
+
"set_scene_tempo", "set_scene_color", "fire_scene",
|
|
42
|
+
"set_clip_follow_action", "set_scene_follow_action",
|
|
43
|
+
"capture_and_insert_scene",
|
|
44
|
+
"create_arrangement_clip", "create_native_arrangement_clip",
|
|
45
|
+
"force_arrangement", "plan_arrangement",
|
|
46
|
+
"transform_section", "refresh_repeated_section",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
_RHYTHMIC_TOOLS = frozenset({
|
|
50
|
+
"add_notes", "modify_notes", "remove_notes", "transpose_notes",
|
|
51
|
+
"duplicate_notes", "add_arrangement_notes", "modify_arrangement_notes",
|
|
52
|
+
"remove_arrangement_notes", "quantize_clip",
|
|
53
|
+
"assign_clip_groove", "set_groove_params", "set_song_groove_amount",
|
|
54
|
+
"apply_gesture_template",
|
|
55
|
+
"generate_euclidean_rhythm", "layer_euclidean_rhythms",
|
|
56
|
+
"generate_countermelody", "transform_motif",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
_TIMBRAL_TOOLS = frozenset({
|
|
60
|
+
"set_device_parameter", "batch_set_parameters",
|
|
61
|
+
"load_browser_item", "find_and_load_device",
|
|
62
|
+
"load_device_by_uri", "insert_device", "delete_device", "move_device",
|
|
63
|
+
"toggle_device", "copy_device_state",
|
|
64
|
+
"install_m4l_device",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
_SPATIAL_TOOLS = frozenset({
|
|
68
|
+
"set_track_send", "set_track_pan", "set_track_volume",
|
|
69
|
+
"set_master_volume",
|
|
70
|
+
"create_return_track",
|
|
71
|
+
"set_track_routing",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tool_to_dimension(tool_name: str, tool_args: dict) -> str | None:
|
|
76
|
+
"""Map an MCP tool call to its primary creative dimension.
|
|
77
|
+
|
|
78
|
+
Returns one of {"structural", "rhythmic", "timbral", "spatial"} or
|
|
79
|
+
None if the tool is dimension-agnostic.
|
|
80
|
+
|
|
81
|
+
Some tools (e.g., load_browser_item) are timbral EXCEPT when loading
|
|
82
|
+
a device on a return track, which is spatial. The heuristic resolves
|
|
83
|
+
this via track_index: negative indices indicate return tracks.
|
|
84
|
+
"""
|
|
85
|
+
if tool_name in _STRUCTURAL_TOOLS:
|
|
86
|
+
return "structural"
|
|
87
|
+
if tool_name in _RHYTHMIC_TOOLS:
|
|
88
|
+
return "rhythmic"
|
|
89
|
+
if tool_name in _SPATIAL_TOOLS:
|
|
90
|
+
return "spatial"
|
|
91
|
+
if tool_name in _TIMBRAL_TOOLS:
|
|
92
|
+
# load_browser_item on a return track is spatial, not timbral —
|
|
93
|
+
# loading Echo/Reverb/Auto Filter on a return is send-chain work.
|
|
94
|
+
track_index = tool_args.get("track_index")
|
|
95
|
+
if isinstance(track_index, int) and track_index < 0 and track_index != -1000:
|
|
96
|
+
return "spatial"
|
|
97
|
+
return "timbral"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Anti-pattern token matching ────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
# Phrases that describe parameter changes indicative of "bright" moves
|
|
104
|
+
_BRIGHTENING_PARAM_KEYWORDS = ("hi gain", "hi freq", "high gain", "brightness",
|
|
105
|
+
"treble", "presence", "air")
|
|
106
|
+
# Phrases indicative of "aggressive transient" moves
|
|
107
|
+
_TRANSIENT_BOOST_KEYWORDS = ("transient", "attack", "punch", "snappy")
|
|
108
|
+
# Phrases indicative of "sidechain" moves
|
|
109
|
+
_SIDECHAIN_KEYWORDS = ("sidechain", "envelope follower", "ducking")
|
|
110
|
+
# Phrases indicative of "quantization" moves
|
|
111
|
+
_QUANTIZE_KEYWORDS = ("quantize", "snap", "full-grid", "perfectly quantized")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _humanize_tool_call(tool_name: str, tool_args: dict) -> str:
|
|
115
|
+
"""Produce a prose description of a tool call for keyword matching.
|
|
116
|
+
|
|
117
|
+
Best-effort — the output is not structured, just a lowercased string
|
|
118
|
+
that concatenates the tool name + notable arg values.
|
|
119
|
+
"""
|
|
120
|
+
parts = [tool_name]
|
|
121
|
+
for key, val in (tool_args or {}).items():
|
|
122
|
+
if isinstance(val, (str, int, float, bool)):
|
|
123
|
+
parts.append(f"{key}={val}")
|
|
124
|
+
return " ".join(parts).lower()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _anti_pattern_matches(pattern: str, tool_name: str, tool_args: dict) -> bool:
|
|
128
|
+
"""Decide whether an anti_pattern phrase flags this tool call.
|
|
129
|
+
|
|
130
|
+
Strategy: lowercase the pattern, pull content keywords, match against
|
|
131
|
+
the humanized call + known parameter-name heuristics.
|
|
132
|
+
"""
|
|
133
|
+
pattern_lower = pattern.lower()
|
|
134
|
+
call_desc = _humanize_tool_call(tool_name, tool_args)
|
|
135
|
+
|
|
136
|
+
# Direct substring match — cheapest first
|
|
137
|
+
# Split pattern into words, check if any significant word appears
|
|
138
|
+
# in the call description
|
|
139
|
+
stopwords = {"the", "a", "an", "and", "or", "of", "to", "in", "on", "at", "with", "for", "/", "-"}
|
|
140
|
+
pattern_tokens = [w.strip("—-./,:;") for w in re.split(r"\s+", pattern_lower)]
|
|
141
|
+
pattern_tokens = [w for w in pattern_tokens if w and w not in stopwords and len(w) >= 3]
|
|
142
|
+
|
|
143
|
+
# Check parameter-name heuristics for common anti-patterns
|
|
144
|
+
if any(kw in pattern_lower for kw in ("bright", "top-end", "top end", "highs")):
|
|
145
|
+
param_name = str(tool_args.get("parameter_name", "")).lower()
|
|
146
|
+
value = tool_args.get("value")
|
|
147
|
+
if any(bright_kw in param_name for bright_kw in _BRIGHTENING_PARAM_KEYWORDS):
|
|
148
|
+
# Boosting (positive value on a gain parameter) is the violation
|
|
149
|
+
if isinstance(value, (int, float)) and value > 0:
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
if any(kw in pattern_lower for kw in ("transient", "aggressive transient",
|
|
153
|
+
"transient-heavy", "crisp")):
|
|
154
|
+
param_name = str(tool_args.get("parameter_name", "")).lower()
|
|
155
|
+
if any(t_kw in param_name for t_kw in _TRANSIENT_BOOST_KEYWORDS):
|
|
156
|
+
value = tool_args.get("value")
|
|
157
|
+
if isinstance(value, (int, float)) and value > 0.5:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
if any(kw in pattern_lower for kw in ("sidechain", "pumping")):
|
|
161
|
+
if tool_name == "compressor_set_sidechain":
|
|
162
|
+
return True
|
|
163
|
+
param_name = str(tool_args.get("parameter_name", "")).lower()
|
|
164
|
+
if any(sc_kw in param_name for sc_kw in _SIDECHAIN_KEYWORDS):
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
if any(kw in pattern_lower for kw in ("full-grid", "full grid",
|
|
168
|
+
"quantized", "perfectly quantized")):
|
|
169
|
+
if tool_name == "quantize_clip":
|
|
170
|
+
# Quantize to tight grid (1/16 or finer, strong amount) is the violation
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
# Fallback: token-level substring match
|
|
174
|
+
for token in pattern_tokens:
|
|
175
|
+
if token in call_desc:
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Public API ──────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def check_brief_compliance(
|
|
184
|
+
brief: dict,
|
|
185
|
+
tool_name: str,
|
|
186
|
+
tool_args: dict | None = None,
|
|
187
|
+
) -> dict:
|
|
188
|
+
"""Check whether a tool call complies with the active creative brief.
|
|
189
|
+
|
|
190
|
+
brief: compiled Creative Brief dict (may contain anti_patterns,
|
|
191
|
+
locked_dimensions, reference_anchors, etc.).
|
|
192
|
+
tool_name: the MCP tool about to be called.
|
|
193
|
+
tool_args: the dict of arguments.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
{
|
|
197
|
+
"ok": bool,
|
|
198
|
+
"violations": [
|
|
199
|
+
{
|
|
200
|
+
"rule": "anti_pattern" | "locked_dimension",
|
|
201
|
+
"detail": <pattern or dimension string>,
|
|
202
|
+
"reason": "...",
|
|
203
|
+
"suggestion": "...",
|
|
204
|
+
},
|
|
205
|
+
...
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Empty brief (no anti_patterns, no locked_dimensions) always returns
|
|
210
|
+
ok=True with empty violations list. Best-effort heuristic — not
|
|
211
|
+
semantic understanding. Caller (director Phase 6) decides whether
|
|
212
|
+
to proceed, surface to user, or abandon.
|
|
213
|
+
"""
|
|
214
|
+
tool_args = tool_args or {}
|
|
215
|
+
violations: list[dict[str, Any]] = []
|
|
216
|
+
|
|
217
|
+
# 1. Check anti_patterns
|
|
218
|
+
anti_patterns = brief.get("anti_patterns", []) or []
|
|
219
|
+
for pattern in anti_patterns:
|
|
220
|
+
if not isinstance(pattern, str) or not pattern.strip():
|
|
221
|
+
continue
|
|
222
|
+
if _anti_pattern_matches(pattern, tool_name, tool_args):
|
|
223
|
+
violations.append({
|
|
224
|
+
"rule": "anti_pattern",
|
|
225
|
+
"detail": pattern,
|
|
226
|
+
"reason": (
|
|
227
|
+
f"Tool call '{tool_name}' appears to violate the "
|
|
228
|
+
f"anti_pattern '{pattern}' from the active brief."
|
|
229
|
+
),
|
|
230
|
+
"suggestion": (
|
|
231
|
+
"Either (a) adjust the call to avoid this pattern, "
|
|
232
|
+
"(b) ask the user to explicitly override this "
|
|
233
|
+
"specific anti_pattern, or (c) pick a different "
|
|
234
|
+
"tool that achieves the creative goal without "
|
|
235
|
+
"triggering the avoid rule."
|
|
236
|
+
),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
# 2. Check locked_dimensions
|
|
240
|
+
locked_dims = brief.get("locked_dimensions", []) or []
|
|
241
|
+
tool_dimension = _tool_to_dimension(tool_name, tool_args)
|
|
242
|
+
if tool_dimension and tool_dimension in locked_dims:
|
|
243
|
+
violations.append({
|
|
244
|
+
"rule": "locked_dimension",
|
|
245
|
+
"detail": tool_dimension,
|
|
246
|
+
"reason": (
|
|
247
|
+
f"Tool '{tool_name}' touches the '{tool_dimension}' "
|
|
248
|
+
f"dimension which the user explicitly locked in this "
|
|
249
|
+
f"brief."
|
|
250
|
+
),
|
|
251
|
+
"suggestion": (
|
|
252
|
+
f"User locked this dimension. Either (a) surface the "
|
|
253
|
+
f"conflict and ask the user to unlock, or (b) pick a "
|
|
254
|
+
f"tool that operates on a different dimension. "
|
|
255
|
+
f"Available unlocked dimensions: "
|
|
256
|
+
f"{sorted(set(['structural', 'rhythmic', 'timbral', 'spatial']) - set(locked_dims))}."
|
|
257
|
+
),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"ok": not violations,
|
|
262
|
+
"violations": violations,
|
|
263
|
+
}
|