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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Hybrid concept packet compilation (v1.19 Item B).
|
|
2
|
+
|
|
3
|
+
When the user says "Basic Channel meets Dilla swing" or
|
|
4
|
+
"Villalobos but sparse like Gas", the director needs to merge
|
|
5
|
+
two (or more) concept packets into a single brief. Pre-v1.19
|
|
6
|
+
this was LLM ad-hoc reasoning with no guarantees about
|
|
7
|
+
contradiction handling.
|
|
8
|
+
|
|
9
|
+
``compile_hybrid_brief(packet_ids, weights=None)`` loads the
|
|
10
|
+
named packets from
|
|
11
|
+
``livepilot/skills/livepilot-core/references/concepts/`` and
|
|
12
|
+
merges them per the rules in
|
|
13
|
+
``docs/plans/v1.19-structural-plan.md §3``.
|
|
14
|
+
|
|
15
|
+
Design invariants:
|
|
16
|
+
|
|
17
|
+
1. **UNION** the descriptive fields (sonic_identity, avoid,
|
|
18
|
+
reach_for.*, *_idioms) — hybrids describe the envelope of
|
|
19
|
+
BOTH sources, not the intersection.
|
|
20
|
+
2. **INTERSECTION** the deprioritization fields
|
|
21
|
+
(dimensions_deprioritized, move_family_bias.deprioritize) —
|
|
22
|
+
a hybrid only deprioritizes something if BOTH sources agree
|
|
23
|
+
it should be deprioritized. Otherwise the other packet is
|
|
24
|
+
asking for it and the hybrid must honor that.
|
|
25
|
+
3. **INTERSECTION (with UNION fallback + warning)** for
|
|
26
|
+
move_family_bias.favor — hybrids focus where both packets
|
|
27
|
+
agree when possible; when they don't overlap at all, fall
|
|
28
|
+
back to UNION but warn (the hybrid spans more families
|
|
29
|
+
than either source intends).
|
|
30
|
+
4. **MAX** for stricter-wins fields (protect floors,
|
|
31
|
+
novelty_budget_default).
|
|
32
|
+
5. **WEIGHTED AVERAGE** for continuous blends
|
|
33
|
+
(target_dimensions weights).
|
|
34
|
+
6. **NEAREST-OVERLAP** for tempo_hint — intersect when ranges
|
|
35
|
+
overlap; warn and use midpoint when they don't.
|
|
36
|
+
7. **Surface ambiguity** — all warnings go on the ``warnings``
|
|
37
|
+
list so the caller (director) can read them back to the
|
|
38
|
+
user.
|
|
39
|
+
|
|
40
|
+
Output is a dict that is structurally compatible with
|
|
41
|
+
:func:`mcp_server.creative_director.compliance.check_brief_compliance`:
|
|
42
|
+
the merged ``avoid`` list is also exposed as ``anti_patterns``,
|
|
43
|
+
and ``locked_dimensions`` defaults to ``[]`` (hybrids don't lock
|
|
44
|
+
dimensions by default — that's a per-turn choice).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import pathlib
|
|
51
|
+
from typing import Iterable, Optional
|
|
52
|
+
|
|
53
|
+
import yaml
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Resolve the concepts root relative to this file. Layout:
|
|
59
|
+
# mcp_server/creative_director/hybrid.py
|
|
60
|
+
# livepilot/skills/livepilot-core/references/concepts/
|
|
61
|
+
# Three parents up from this file → repo root.
|
|
62
|
+
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
63
|
+
_CONCEPTS_ROOT = (
|
|
64
|
+
_REPO_ROOT / "livepilot" / "skills" / "livepilot-core"
|
|
65
|
+
/ "references" / "concepts"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Packet loader ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize(s: str) -> str:
|
|
73
|
+
"""Lowercase, hyphenate whitespace and underscores for lookup."""
|
|
74
|
+
return s.strip().lower().replace("_", "-").replace(" ", "-")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_packet(packet_id: str) -> Optional[dict]:
|
|
78
|
+
"""Load a concept packet by filename stem, alias, or packet.id.
|
|
79
|
+
|
|
80
|
+
Resolution order (first hit wins):
|
|
81
|
+
1. Normalize the given id (lowercase, underscores → hyphens).
|
|
82
|
+
2. Try ``artists/<norm>.yaml`` then ``genres/<norm>.yaml``.
|
|
83
|
+
3. If still not found, scan all packets and match on ``id``
|
|
84
|
+
or any alias (normalized).
|
|
85
|
+
4. Return None on miss.
|
|
86
|
+
"""
|
|
87
|
+
norm = _normalize(packet_id)
|
|
88
|
+
|
|
89
|
+
for subdir in ("artists", "genres"):
|
|
90
|
+
candidate = _CONCEPTS_ROOT / subdir / f"{norm}.yaml"
|
|
91
|
+
if candidate.exists():
|
|
92
|
+
try:
|
|
93
|
+
return yaml.safe_load(candidate.read_text())
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.debug("load_packet parse failed for %s: %s", candidate, exc)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Fallback: scan for alias / id match
|
|
99
|
+
for subdir in ("artists", "genres"):
|
|
100
|
+
subpath = _CONCEPTS_ROOT / subdir
|
|
101
|
+
if not subpath.is_dir():
|
|
102
|
+
continue
|
|
103
|
+
for p in sorted(subpath.glob("*.yaml")):
|
|
104
|
+
try:
|
|
105
|
+
d = yaml.safe_load(p.read_text())
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.debug("load_packet scan-parse failed for %s: %s", p, exc)
|
|
108
|
+
continue
|
|
109
|
+
if not isinstance(d, dict):
|
|
110
|
+
continue
|
|
111
|
+
if d.get("id") == packet_id:
|
|
112
|
+
return d
|
|
113
|
+
aliases = [_normalize(a) for a in (d.get("aliases") or []) if isinstance(a, str)]
|
|
114
|
+
if norm in aliases:
|
|
115
|
+
return d
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Merge helpers ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _union_preserve_order(lists: Iterable[Iterable[str]]) -> list[str]:
|
|
124
|
+
seen: set = set()
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
for lst in lists:
|
|
127
|
+
for item in (lst or []):
|
|
128
|
+
if item not in seen:
|
|
129
|
+
seen.add(item)
|
|
130
|
+
out.append(item)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _intersection_preserve_order(
|
|
135
|
+
lists: list[list[str]], reference_order: list[str],
|
|
136
|
+
) -> list[str]:
|
|
137
|
+
"""Intersect across all lists; ordering follows ``reference_order``
|
|
138
|
+
(typically the first packet's list)."""
|
|
139
|
+
if not lists:
|
|
140
|
+
return []
|
|
141
|
+
sets = [set(lst or []) for lst in lists]
|
|
142
|
+
intersection = sets[0]
|
|
143
|
+
for s in sets[1:]:
|
|
144
|
+
intersection = intersection & s
|
|
145
|
+
return [item for item in (reference_order or []) if item in intersection]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── Core compile function (packet-level, no disk I/O) ───────────────────────
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _compile_from_packets(
|
|
152
|
+
packets: list[dict],
|
|
153
|
+
packet_ids: list[str],
|
|
154
|
+
weights: Optional[list[float]] = None,
|
|
155
|
+
) -> dict:
|
|
156
|
+
"""Compile a hybrid brief from already-loaded packet dicts.
|
|
157
|
+
|
|
158
|
+
Public callers should use :func:`compile_hybrid_brief`. This split
|
|
159
|
+
exists so tests can inject synthetic packets (e.g., to force an
|
|
160
|
+
empty favor-intersection and exercise the UNION fallback).
|
|
161
|
+
"""
|
|
162
|
+
if len(packets) < 2:
|
|
163
|
+
raise ValueError("Hybrid requires at least 2 packets")
|
|
164
|
+
if weights is not None and len(weights) != len(packets):
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"weights length ({len(weights)}) must match packets "
|
|
167
|
+
f"length ({len(packets)})"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if weights is None:
|
|
171
|
+
weights = [1.0 / len(packets)] * len(packets)
|
|
172
|
+
else:
|
|
173
|
+
total = sum(weights) or 1.0
|
|
174
|
+
weights = [w / total for w in weights]
|
|
175
|
+
|
|
176
|
+
warnings: list[str] = []
|
|
177
|
+
|
|
178
|
+
# ── UNION fields ─────────────────────────────────────────────────────
|
|
179
|
+
sonic_identity = _union_preserve_order(
|
|
180
|
+
p.get("sonic_identity") or [] for p in packets
|
|
181
|
+
)
|
|
182
|
+
avoid = _union_preserve_order(p.get("avoid") or [] for p in packets)
|
|
183
|
+
rhythm_idioms = _union_preserve_order(p.get("rhythm_idioms") or [] for p in packets)
|
|
184
|
+
harmony_idioms = _union_preserve_order(p.get("harmony_idioms") or [] for p in packets)
|
|
185
|
+
arrangement_idioms = _union_preserve_order(
|
|
186
|
+
p.get("arrangement_idioms") or [] for p in packets
|
|
187
|
+
)
|
|
188
|
+
texture_idioms = _union_preserve_order(p.get("texture_idioms") or [] for p in packets)
|
|
189
|
+
sample_roles = _union_preserve_order(p.get("sample_roles") or [] for p in packets)
|
|
190
|
+
dimensions_in_scope = _union_preserve_order(
|
|
191
|
+
p.get("dimensions_in_scope") or [] for p in packets
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
reach_for = {
|
|
195
|
+
"instruments": _union_preserve_order(
|
|
196
|
+
(p.get("reach_for") or {}).get("instruments") or [] for p in packets
|
|
197
|
+
),
|
|
198
|
+
"effects": _union_preserve_order(
|
|
199
|
+
(p.get("reach_for") or {}).get("effects") or [] for p in packets
|
|
200
|
+
),
|
|
201
|
+
"packs": _union_preserve_order(
|
|
202
|
+
(p.get("reach_for") or {}).get("packs") or [] for p in packets
|
|
203
|
+
),
|
|
204
|
+
"utilities": _union_preserve_order(
|
|
205
|
+
(p.get("reach_for") or {}).get("utilities") or [] for p in packets
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# ── INTERSECTION fields (safety defaults — be cautious) ─────────────
|
|
210
|
+
# deprioritize only if ALL packets agree → a hybrid with one packet
|
|
211
|
+
# asking for rhythmic must NOT deprioritize rhythmic just because the
|
|
212
|
+
# other packet's aesthetic does.
|
|
213
|
+
dimensions_deprioritized = _intersection_preserve_order(
|
|
214
|
+
[p.get("dimensions_deprioritized") or [] for p in packets],
|
|
215
|
+
packets[0].get("dimensions_deprioritized") or [],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
deprioritize = _intersection_preserve_order(
|
|
219
|
+
[(p.get("move_family_bias") or {}).get("deprioritize") or []
|
|
220
|
+
for p in packets],
|
|
221
|
+
(packets[0].get("move_family_bias") or {}).get("deprioritize") or [],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# ── favor: INTERSECTION preferred, UNION fallback with warning ──────
|
|
225
|
+
favor_lists = [
|
|
226
|
+
(p.get("move_family_bias") or {}).get("favor") or [] for p in packets
|
|
227
|
+
]
|
|
228
|
+
favor_intersection = _intersection_preserve_order(
|
|
229
|
+
favor_lists, favor_lists[0],
|
|
230
|
+
)
|
|
231
|
+
if favor_intersection:
|
|
232
|
+
favor = favor_intersection
|
|
233
|
+
else:
|
|
234
|
+
favor = _union_preserve_order(favor_lists)
|
|
235
|
+
warnings.append(
|
|
236
|
+
"move_family_bias.favor intersection was empty — falling back "
|
|
237
|
+
"to UNION. Hybrid plans may span more families than either "
|
|
238
|
+
"source packet intends; prioritize explicit user framing."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ── Numeric rules ───────────────────────────────────────────────────
|
|
242
|
+
# target_dimensions: WEIGHTED AVERAGE
|
|
243
|
+
all_dim_keys: set = set()
|
|
244
|
+
for p in packets:
|
|
245
|
+
td = (p.get("evaluation_bias") or {}).get("target_dimensions") or {}
|
|
246
|
+
all_dim_keys.update(td.keys())
|
|
247
|
+
target_dimensions: dict[str, float] = {}
|
|
248
|
+
for dim in sorted(all_dim_keys):
|
|
249
|
+
accum = 0.0
|
|
250
|
+
for w, p in zip(weights, packets):
|
|
251
|
+
td = (p.get("evaluation_bias") or {}).get("target_dimensions") or {}
|
|
252
|
+
val = td.get(dim, 0.0)
|
|
253
|
+
try:
|
|
254
|
+
accum += float(w) * float(val)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
continue
|
|
257
|
+
if accum > 0:
|
|
258
|
+
target_dimensions[dim] = round(accum, 4)
|
|
259
|
+
|
|
260
|
+
# protect: MAX per dimension (stricter wins)
|
|
261
|
+
all_protect_keys: set = set()
|
|
262
|
+
for p in packets:
|
|
263
|
+
pr = (p.get("evaluation_bias") or {}).get("protect") or {}
|
|
264
|
+
all_protect_keys.update(pr.keys())
|
|
265
|
+
protect: dict[str, float] = {}
|
|
266
|
+
for dim in sorted(all_protect_keys):
|
|
267
|
+
values = []
|
|
268
|
+
for p in packets:
|
|
269
|
+
pr = (p.get("evaluation_bias") or {}).get("protect") or {}
|
|
270
|
+
val = pr.get(dim, 0.0)
|
|
271
|
+
try:
|
|
272
|
+
values.append(float(val))
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
continue
|
|
275
|
+
if values:
|
|
276
|
+
protect[dim] = max(values)
|
|
277
|
+
|
|
278
|
+
# novelty_budget_default: MAX (hybrids lean exploratory)
|
|
279
|
+
novelty_values: list[float] = []
|
|
280
|
+
for p in packets:
|
|
281
|
+
nb = p.get("novelty_budget_default")
|
|
282
|
+
if nb is None:
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
novelty_values.append(float(nb))
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
continue
|
|
288
|
+
novelty_budget = max(novelty_values) if novelty_values else 0.5
|
|
289
|
+
|
|
290
|
+
# ── tempo_hint: NEAREST-OVERLAP ─────────────────────────────────────
|
|
291
|
+
tempo_ranges: list[tuple[float, float, str]] = []
|
|
292
|
+
for p in packets:
|
|
293
|
+
th = p.get("tempo_hint") or {}
|
|
294
|
+
lo, hi = th.get("min"), th.get("max")
|
|
295
|
+
if lo is None or hi is None:
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
tempo_ranges.append((float(lo), float(hi), p.get("name", "")))
|
|
299
|
+
except (TypeError, ValueError):
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
tempo_hint: Optional[dict]
|
|
303
|
+
if not tempo_ranges:
|
|
304
|
+
tempo_hint = None
|
|
305
|
+
elif len(tempo_ranges) == 1:
|
|
306
|
+
lo, hi, _ = tempo_ranges[0]
|
|
307
|
+
tempo_hint = {"min": lo, "max": hi, "time_signature": "4/4"}
|
|
308
|
+
else:
|
|
309
|
+
overlap_lo = max(r[0] for r in tempo_ranges)
|
|
310
|
+
overlap_hi = min(r[1] for r in tempo_ranges)
|
|
311
|
+
if overlap_lo <= overlap_hi:
|
|
312
|
+
tempo_hint = {
|
|
313
|
+
"min": overlap_lo, "max": overlap_hi,
|
|
314
|
+
"time_signature": "4/4",
|
|
315
|
+
}
|
|
316
|
+
else:
|
|
317
|
+
# Disjoint ranges — compute gap midpoint, surface warning.
|
|
318
|
+
# The gap is between the highest range-max and the lowest
|
|
319
|
+
# range-min that exceeds it. For 2 ranges this is
|
|
320
|
+
# (max of all his, min of all los). For 3+ ranges this still
|
|
321
|
+
# reads as "the gap in the middle of the sorted range set".
|
|
322
|
+
sorted_ranges = sorted(tempo_ranges, key=lambda r: r[0])
|
|
323
|
+
gap_lo = max(r[1] for r in sorted_ranges if r[0] < sorted_ranges[-1][0])
|
|
324
|
+
gap_hi = sorted_ranges[-1][0]
|
|
325
|
+
midpoint = (gap_lo + gap_hi) / 2.0
|
|
326
|
+
tempo_hint = {
|
|
327
|
+
"min": midpoint - 2.5,
|
|
328
|
+
"max": midpoint + 2.5,
|
|
329
|
+
"time_signature": "4/4",
|
|
330
|
+
"disjoint": True,
|
|
331
|
+
}
|
|
332
|
+
range_desc = "; ".join(
|
|
333
|
+
f"{name or 'packet'} {lo:.0f}-{hi:.0f}"
|
|
334
|
+
for lo, hi, name in tempo_ranges
|
|
335
|
+
)
|
|
336
|
+
warnings.append(
|
|
337
|
+
f"Tempo ranges don't overlap ({range_desc}) — defaulting "
|
|
338
|
+
f"to midpoint {midpoint:.0f} BPM. Specify which anchor "
|
|
339
|
+
f"you want or pick a single packet."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# ── Output ───────────────────────────────────────────────────────────
|
|
343
|
+
names = [p.get("name") or pid for p, pid in zip(packets, packet_ids)]
|
|
344
|
+
hybrid_name = " × ".join(names)
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"type": "hybrid",
|
|
348
|
+
"source_packets": list(packet_ids),
|
|
349
|
+
"weights": list(weights),
|
|
350
|
+
"name": hybrid_name,
|
|
351
|
+
"sonic_identity": sonic_identity,
|
|
352
|
+
"reach_for": reach_for,
|
|
353
|
+
"avoid": avoid,
|
|
354
|
+
# Alias for compatibility with check_brief_compliance, which reads
|
|
355
|
+
# "anti_patterns". The semantics are identical — "avoid" at the
|
|
356
|
+
# packet layer, "anti_patterns" at the brief layer.
|
|
357
|
+
"anti_patterns": list(avoid),
|
|
358
|
+
"rhythm_idioms": rhythm_idioms,
|
|
359
|
+
"harmony_idioms": harmony_idioms,
|
|
360
|
+
"arrangement_idioms": arrangement_idioms,
|
|
361
|
+
"texture_idioms": texture_idioms,
|
|
362
|
+
"sample_roles": sample_roles,
|
|
363
|
+
"evaluation_bias": {
|
|
364
|
+
"target_dimensions": target_dimensions,
|
|
365
|
+
"protect": protect,
|
|
366
|
+
},
|
|
367
|
+
"move_family_bias": {
|
|
368
|
+
"favor": favor,
|
|
369
|
+
"deprioritize": deprioritize,
|
|
370
|
+
},
|
|
371
|
+
"dimensions_in_scope": dimensions_in_scope,
|
|
372
|
+
"dimensions_deprioritized": dimensions_deprioritized,
|
|
373
|
+
# Hybrids do not lock dimensions by default — locking is a per-turn
|
|
374
|
+
# user choice (e.g., "don't touch structure"). Included here for
|
|
375
|
+
# compat with check_brief_compliance which reads this field.
|
|
376
|
+
"locked_dimensions": [],
|
|
377
|
+
"novelty_budget_default": novelty_budget,
|
|
378
|
+
"tempo_hint": tempo_hint,
|
|
379
|
+
"warnings": warnings,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def compile_hybrid_brief(
|
|
387
|
+
packet_ids: list[str],
|
|
388
|
+
weights: Optional[list[float]] = None,
|
|
389
|
+
) -> dict:
|
|
390
|
+
"""Merge N concept packets into a single hybrid brief.
|
|
391
|
+
|
|
392
|
+
packet_ids: filename stems (``'basic-channel'``), aliases
|
|
393
|
+
(``'dilla'``), or packet ``id`` values (``'dub_techno__basic_channel'``).
|
|
394
|
+
At least 2 required.
|
|
395
|
+
weights: optional per-packet weighting for the target_dimensions
|
|
396
|
+
weighted-average step. If None, uniform weights are used.
|
|
397
|
+
Must match ``packet_ids`` length when provided. Normalized to
|
|
398
|
+
sum to 1.0 internally.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
ValueError: on fewer than 2 packets, an unresolvable packet id,
|
|
402
|
+
or a weights-length mismatch.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
A dict structurally compatible with the packet schema plus:
|
|
406
|
+
- ``type``: always ``"hybrid"``
|
|
407
|
+
- ``source_packets``: ``packet_ids`` echoed back
|
|
408
|
+
- ``weights``: normalized weights
|
|
409
|
+
- ``name``: ``"Packet A × Packet B"`` for user-facing display
|
|
410
|
+
- ``anti_patterns``: alias of ``avoid`` (compat with
|
|
411
|
+
``check_brief_compliance``)
|
|
412
|
+
- ``locked_dimensions``: empty by default (hybrids don't lock)
|
|
413
|
+
- ``warnings``: list of human-readable ambiguity notes (tempo
|
|
414
|
+
disjunction, empty favor intersection fallback, etc.). Empty
|
|
415
|
+
when all merge rules resolved cleanly.
|
|
416
|
+
"""
|
|
417
|
+
packets: list[dict] = []
|
|
418
|
+
missing: list[str] = []
|
|
419
|
+
for pid in packet_ids:
|
|
420
|
+
p = load_packet(pid)
|
|
421
|
+
if p is None:
|
|
422
|
+
missing.append(pid)
|
|
423
|
+
else:
|
|
424
|
+
packets.append(p)
|
|
425
|
+
|
|
426
|
+
if missing:
|
|
427
|
+
raise ValueError(f"Packets not found: {missing}")
|
|
428
|
+
|
|
429
|
+
return _compile_from_packets(packets, list(packet_ids), weights=weights)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Creative Director MCP tools — v1.18.3+ brief compliance.
|
|
2
|
+
|
|
3
|
+
Exposes `check_brief_compliance` as an MCP tool so the
|
|
4
|
+
`livepilot-creative-director` skill can call it before each risky
|
|
5
|
+
Phase 6 tool execution. Caller passes the compiled brief dict + the
|
|
6
|
+
intended tool call; the tool returns a violations report.
|
|
7
|
+
|
|
8
|
+
Stateless by design: no session storage of the active brief. The
|
|
9
|
+
director passes the brief each time. Full session-state active-brief
|
|
10
|
+
storage is v1.19 scope.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from fastmcp import Context
|
|
18
|
+
|
|
19
|
+
from ..server import mcp
|
|
20
|
+
from .compliance import check_brief_compliance as _check_brief_compliance
|
|
21
|
+
from .hybrid import compile_hybrid_brief as _compile_hybrid_brief
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def check_brief_compliance(
|
|
26
|
+
ctx: Context,
|
|
27
|
+
brief: dict,
|
|
28
|
+
tool_name: str,
|
|
29
|
+
tool_args: Optional[dict] = None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Check whether an intended tool call complies with the active creative brief.
|
|
32
|
+
|
|
33
|
+
v1.18.3 #7 + #8 runtime enforcement for the director's anti_patterns
|
|
34
|
+
and locked_dimensions brief fields. Call this BEFORE executing any
|
|
35
|
+
risky tool from director's Phase 6 — especially when the brief has
|
|
36
|
+
non-empty anti_patterns or locked_dimensions.
|
|
37
|
+
|
|
38
|
+
brief: the compiled Creative Brief dict. May contain anti_patterns
|
|
39
|
+
(list of prose phrases), locked_dimensions (list of:
|
|
40
|
+
structural/rhythmic/timbral/spatial), reference_anchors, etc.
|
|
41
|
+
tool_name: the MCP tool name you're about to call.
|
|
42
|
+
tool_args: dict of arguments you'll pass to that tool.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
{
|
|
46
|
+
"ok": bool,
|
|
47
|
+
"violations": [
|
|
48
|
+
{
|
|
49
|
+
"rule": "anti_pattern" | "locked_dimension",
|
|
50
|
+
"detail": <the anti_pattern phrase OR the locked dimension>,
|
|
51
|
+
"reason": "Why this call appears to violate the brief",
|
|
52
|
+
"suggestion": "What to do about it",
|
|
53
|
+
},
|
|
54
|
+
...
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Violations are NEVER automatic blocks — they're reports. The
|
|
59
|
+
director decides whether to proceed, surface to user, or abandon.
|
|
60
|
+
Empty brief (no anti_patterns, no locked_dimensions) always
|
|
61
|
+
returns ok=True.
|
|
62
|
+
|
|
63
|
+
Best-effort keyword heuristic, NOT semantic understanding. Will
|
|
64
|
+
miss subtle violations (e.g., 'too muddy' → 300 Hz cut needs
|
|
65
|
+
judgment this checker doesn't have). Will catch obvious ones
|
|
66
|
+
(e.g., 'bright top-end' → Hi Gain positive boost).
|
|
67
|
+
"""
|
|
68
|
+
result = _check_brief_compliance(
|
|
69
|
+
brief=brief,
|
|
70
|
+
tool_name=tool_name,
|
|
71
|
+
tool_args=tool_args or {},
|
|
72
|
+
)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def compile_hybrid_brief(
|
|
78
|
+
ctx: Context,
|
|
79
|
+
packet_ids: list,
|
|
80
|
+
weights: Optional[list] = None,
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""Merge 2+ concept packets into a single hybrid brief (v1.19 Item B).
|
|
83
|
+
|
|
84
|
+
When the user says "Basic Channel meets Dilla swing" or
|
|
85
|
+
"Villalobos but sparse like Gas", the director needs an explicit
|
|
86
|
+
merge algorithm — not LLM ad-hoc reasoning. This tool loads the
|
|
87
|
+
named concept packets from
|
|
88
|
+
``livepilot/skills/livepilot-core/references/concepts/`` and merges
|
|
89
|
+
them per the rules in
|
|
90
|
+
``livepilot/skills/livepilot-creative-director/references/hybrid-compilation.md``.
|
|
91
|
+
|
|
92
|
+
Merge rule summary:
|
|
93
|
+
- ``sonic_identity`` / ``avoid`` / ``reach_for.*`` / ``*_idioms``:
|
|
94
|
+
UNION, deduplicated, first-packet order preserved.
|
|
95
|
+
- ``dimensions_deprioritized`` and
|
|
96
|
+
``move_family_bias.deprioritize``: INTERSECTION — only
|
|
97
|
+
deprioritize if ALL source packets do. Safer default for
|
|
98
|
+
hybrids where one packet may want what the other ignores.
|
|
99
|
+
- ``move_family_bias.favor``: INTERSECTION when non-empty
|
|
100
|
+
(hybrid focuses where both agree); UNION fallback otherwise
|
|
101
|
+
with a warning.
|
|
102
|
+
- ``evaluation_bias.target_dimensions``: WEIGHTED AVERAGE
|
|
103
|
+
(default uniform weights).
|
|
104
|
+
- ``evaluation_bias.protect``: MAX per dimension — stricter
|
|
105
|
+
floor wins.
|
|
106
|
+
- ``novelty_budget_default``: MAX (hybrids skew exploratory).
|
|
107
|
+
- ``tempo_hint``: NEAREST-OVERLAP — intersect overlapping
|
|
108
|
+
ranges, or warn + midpoint on disjoint ranges.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
packet_ids: list of ≥2 packet IDs. Accepts filename stems
|
|
112
|
+
(``"basic-channel"``), aliases (``"dilla"``), or packet ``id``
|
|
113
|
+
values (``"dub_techno__basic_channel"``).
|
|
114
|
+
weights: optional per-packet weights for the
|
|
115
|
+
``target_dimensions`` average. Must match ``packet_ids``
|
|
116
|
+
length. Normalized internally; defaults to uniform.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A brief dict structurally compatible with
|
|
120
|
+
``check_brief_compliance``. Exposes the merged ``avoid`` list
|
|
121
|
+
both as ``avoid`` (packet semantic) and ``anti_patterns``
|
|
122
|
+
(brief semantic). Includes a ``warnings`` list surfacing any
|
|
123
|
+
ambiguity the merge algorithm couldn't resolve cleanly.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError (surfaced as an error-dict response) on fewer than
|
|
127
|
+
2 packets, an unresolvable packet id, or a weights-length
|
|
128
|
+
mismatch.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
pid_list = [str(x) for x in (packet_ids or [])]
|
|
132
|
+
w_list = [float(x) for x in weights] if weights else None
|
|
133
|
+
return _compile_hybrid_brief(packet_ids=pid_list, weights=w_list)
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
return {"error": str(exc)}
|