livepilot 1.23.6 → 1.25.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 +107 -0
- package/README.md +60 -14
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +17 -3
- package/mcp_server/atlas/explore_tools.py +332 -0
- package/mcp_server/atlas/tools.py +161 -0
- package/mcp_server/audit/__init__.py +6 -0
- package/mcp_server/audit/checks.py +618 -0
- package/mcp_server/audit/tools.py +232 -0
- package/mcp_server/composer/branch_producer.py +5 -2
- package/mcp_server/composer/develop/__init__.py +19 -0
- package/mcp_server/composer/develop/apply.py +217 -0
- package/mcp_server/composer/develop/brief_builder.py +269 -0
- package/mcp_server/composer/develop/seed_introspector.py +195 -0
- package/mcp_server/composer/engine.py +15 -521
- package/mcp_server/composer/fast/__init__.py +62 -0
- package/mcp_server/composer/fast/apply.py +533 -0
- package/mcp_server/composer/fast/brief_builder.py +1479 -0
- package/mcp_server/composer/fast/tier_classification.py +159 -0
- package/mcp_server/composer/framework/__init__.py +0 -0
- package/mcp_server/composer/framework/applier.py +179 -0
- package/mcp_server/composer/framework/artist_loader.py +63 -0
- package/mcp_server/composer/framework/atlas_resolver.py +554 -0
- package/mcp_server/composer/framework/brief.py +79 -0
- package/mcp_server/composer/framework/event_lexicon.py +71 -0
- package/mcp_server/composer/framework/genre_loader.py +77 -0
- package/mcp_server/composer/framework/intent_source.py +137 -0
- package/mcp_server/composer/framework/knowledge_pack.py +140 -0
- package/mcp_server/composer/framework/plan_compiler.py +10 -0
- package/mcp_server/composer/full/__init__.py +10 -0
- package/mcp_server/composer/full/apply.py +1139 -0
- package/mcp_server/composer/full/brief_builder.py +227 -0
- package/mcp_server/composer/full/engine.py +541 -0
- package/mcp_server/composer/full/layer_planner.py +491 -0
- package/mcp_server/composer/layer_planner.py +19 -465
- package/mcp_server/composer/sample_resolver.py +80 -7
- package/mcp_server/composer/tools.py +626 -28
- package/mcp_server/server.py +1 -0
- package/mcp_server/splice_client/client.py +7 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +172 -7
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/mcp_server/tools/browser.py +102 -19
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""42-event structural lexicon — vocabulary of named primitives the agent
|
|
2
|
+
can schedule at phrase boundaries. Used by full + develop mode briefs.
|
|
3
|
+
|
|
4
|
+
Categories: filter, drums, layer, riser, dynamics, harmonic, space.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
EVENT_LEXICON: list[dict] = [
|
|
12
|
+
# Filter gestures (5)
|
|
13
|
+
{"name": "hpf_sweep_up", "category": "filter", "description": "HPF rises 80 Hz → 1-2 kHz over 16-32 bars before drop"},
|
|
14
|
+
{"name": "lpf_close_slow", "category": "filter", "description": "LPF closes gradually over 64-128 bars (ambient texture removal)"},
|
|
15
|
+
{"name": "filter_open_snap", "category": "filter", "description": "Sudden filter opening on bar 1 of new section (drop arrival)"},
|
|
16
|
+
{"name": "band_pass_sweep", "category": "filter", "description": "Band-pass center sweeps up over 8 bars (microhouse)"},
|
|
17
|
+
{"name": "filter_wobble_increase", "category": "filter", "description": "LFO-driven filter mod depth increases over 8 bars"},
|
|
18
|
+
# Drum / Rhythm events (11)
|
|
19
|
+
{"name": "kick_dropout", "category": "drums", "description": "Kick muted for 8-16 bars before reintroduction"},
|
|
20
|
+
{"name": "kick_first_entry", "category": "drums", "description": "Kick appears for the first time after percussion-only intro"},
|
|
21
|
+
{"name": "snare_roll_final_bars", "category": "drums", "description": "Snare roll accelerating over bars 15-16 of 16-bar phrase"},
|
|
22
|
+
{"name": "hat_layer_entry", "category": "drums", "description": "Additional hat layer enters (closed → open, or second hat sample)"},
|
|
23
|
+
{"name": "perc_dropout", "category": "drums", "description": "All percussion except kick removed for 4-8 bars (tension)"},
|
|
24
|
+
{"name": "full_drum_dropout", "category": "drums", "description": "All drums including kick removed (breakdown)"},
|
|
25
|
+
{"name": "drum_reintroduction", "category": "drums", "description": "Drums return after full dropout at full energy"},
|
|
26
|
+
{"name": "clap_add", "category": "drums", "description": "Clap or snare enters where only kick existed before"},
|
|
27
|
+
{"name": "ghost_density_increase", "category": "drums", "description": "Ghost note velocity raised across all hats"},
|
|
28
|
+
{"name": "and_of_4_snare", "category": "drums", "description": "Extra snare hit on 'and of 4' on bar 16 (common fill marker)"},
|
|
29
|
+
{"name": "ride_cymbal_add", "category": "drums", "description": "Ride cymbal enters (jazz/DnB open feel)"},
|
|
30
|
+
# Layer entry / exit (9)
|
|
31
|
+
{"name": "layer_fade_in", "category": "layer", "description": "Track volume automation 0 → nominal over 4-8 bars"},
|
|
32
|
+
{"name": "layer_fade_out", "category": "layer", "description": "Track volume nominal → 0 over 8-16 bars"},
|
|
33
|
+
{"name": "layer_hard_cut_in", "category": "layer", "description": "Track unmutes on bar 1 of new section"},
|
|
34
|
+
{"name": "layer_hard_cut_out", "category": "layer", "description": "Track mutes on bar 1 of section change"},
|
|
35
|
+
{"name": "melody_first_entry", "category": "layer", "description": "Lead/melody track introduces for the first time"},
|
|
36
|
+
{"name": "texture_swell", "category": "layer", "description": "Atmosphere/pad rises from inaudible to audible over 32 bars"},
|
|
37
|
+
{"name": "sub_bass_entry", "category": "layer", "description": "Sub-bass track appears for first time"},
|
|
38
|
+
{"name": "pad_strip_to_silence", "category": "layer", "description": "Pad/harmonic layer removed for breakdown isolation"},
|
|
39
|
+
{"name": "vocal_chop_entry", "category": "layer", "description": "Vocal chop track enters"},
|
|
40
|
+
# Riser / Fall events (5)
|
|
41
|
+
{"name": "riser_swell", "category": "riser", "description": "Riser sample triggered at bar 13 of 16-bar phrase (arrives at bar 17)"},
|
|
42
|
+
{"name": "reverse_cymbal", "category": "riser", "description": "Reverse cymbal swell timed to arrive on bar 1 of next section"},
|
|
43
|
+
{"name": "noise_swell", "category": "riser", "description": "White noise volume −inf → 0 dB over 8 bars then cut on drop"},
|
|
44
|
+
{"name": "pitch_riser", "category": "riser", "description": "Sample or synth pitch automating upward over 4-8 bars"},
|
|
45
|
+
{"name": "downlifter", "category": "riser", "description": "Reverse riser / falling pitch for energy drop or breakdown arrival"},
|
|
46
|
+
# Sidechain / Dynamics (4)
|
|
47
|
+
{"name": "sidechain_activate", "category": "dynamics", "description": "Sidechain compressor (pad vs. kick) switches on at section start"},
|
|
48
|
+
{"name": "sidechain_deactivate", "category": "dynamics", "description": "Sidechain removed in breakdown, restoring pad sustain"},
|
|
49
|
+
{"name": "compressor_release_lengthen", "category": "dynamics", "description": "Compressor release extended for swelling effect"},
|
|
50
|
+
{"name": "drum_bus_saturation_increase", "category": "dynamics", "description": "Saturation drive increases during a build"},
|
|
51
|
+
# Harmonic / Melodic (5)
|
|
52
|
+
{"name": "chord_change", "category": "harmonic", "description": "Harmonic progression advances to next chord"},
|
|
53
|
+
{"name": "motif_restatement", "category": "harmonic", "description": "Primary hook returns after absence (ABAB form)"},
|
|
54
|
+
{"name": "motif_inversion", "category": "harmonic", "description": "Inverted motif (IDM/classical variation)"},
|
|
55
|
+
{"name": "motif_fragmentation", "category": "harmonic", "description": "Motif shortened to 1-2 notes, repeated (pre-climax)"},
|
|
56
|
+
{"name": "half_time_shift", "category": "harmonic", "description": "Note durations doubled (groove drops to half-time feel)"},
|
|
57
|
+
# Space / Reverb (3)
|
|
58
|
+
{"name": "reverb_send_increase", "category": "space", "description": "Send to reverb return increases over 4-8 bars"},
|
|
59
|
+
{"name": "dub_throw", "category": "space", "description": "Single 1-beat send spike to delay/reverb"},
|
|
60
|
+
{"name": "full_silence_bar", "category": "space", "description": "1-2 bars of total silence (highest-impact transition tool)"},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_event_lexicon(category: Optional[str] = None) -> list[dict]:
|
|
65
|
+
"""Return the lexicon, optionally filtered by category.
|
|
66
|
+
|
|
67
|
+
category: 'filter', 'drums', 'layer', 'riser', 'dynamics', 'harmonic', 'space', or None for all.
|
|
68
|
+
"""
|
|
69
|
+
if category is None:
|
|
70
|
+
return list(EVENT_LEXICON)
|
|
71
|
+
return [ev for ev in EVENT_LEXICON if ev["category"] == category]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Markdown parser for livepilot/skills/livepilot-core/references/genre-vocabularies.md.
|
|
2
|
+
|
|
3
|
+
Genre entries are demarcated by `## genre_name` headers. Each entry contains
|
|
4
|
+
descriptive content (kick character, bass register, harmonic palette, etc.).
|
|
5
|
+
We parse opportunistically — capture the raw markdown block per genre name,
|
|
6
|
+
let the agent reason on the content rather than over-structuring it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_GENRE_CACHE: Optional[dict[str, dict]] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _genre_md_path() -> Optional[Path]:
|
|
19
|
+
"""Locate genre-vocabularies.md by walking up from this file."""
|
|
20
|
+
here = Path(__file__).resolve()
|
|
21
|
+
for parent in here.parents:
|
|
22
|
+
candidate = parent / "livepilot" / "skills" / "livepilot-core" / "references" / "genre-vocabularies.md"
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_genre_md() -> dict[str, dict]:
|
|
29
|
+
"""Parse the markdown into {genre_name_lowered: {raw: text, ...}}.
|
|
30
|
+
|
|
31
|
+
Cached after first call.
|
|
32
|
+
"""
|
|
33
|
+
global _GENRE_CACHE
|
|
34
|
+
if _GENRE_CACHE is not None:
|
|
35
|
+
return _GENRE_CACHE
|
|
36
|
+
path = _genre_md_path()
|
|
37
|
+
if path is None:
|
|
38
|
+
_GENRE_CACHE = {}
|
|
39
|
+
return _GENRE_CACHE
|
|
40
|
+
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
# Genres are demarcated by `## name` (or `### name`) — the structure
|
|
43
|
+
# may vary, so handle both. Use a regex split.
|
|
44
|
+
genres: dict[str, dict] = {}
|
|
45
|
+
sections = re.split(r"^##+\s+(.+?)\s*$", text, flags=re.MULTILINE)
|
|
46
|
+
# Re.split returns [pre, name1, body1, name2, body2, ...]
|
|
47
|
+
for i in range(1, len(sections), 2):
|
|
48
|
+
name = sections[i].strip()
|
|
49
|
+
if not name or name.lower() in ("vocabulary", "how to read this", "table of contents"):
|
|
50
|
+
continue
|
|
51
|
+
body = sections[i + 1] if i + 1 < len(sections) else ""
|
|
52
|
+
# Lowercase + underscore normalize the key
|
|
53
|
+
key = name.lower().replace(" ", "_").replace("-", "_")
|
|
54
|
+
genres[key] = {"name": name, "raw_md": body.strip()[:2000]} # cap to keep brief size sane
|
|
55
|
+
|
|
56
|
+
_GENRE_CACHE = genres
|
|
57
|
+
return _GENRE_CACHE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_genre_context(genre: str) -> dict:
|
|
61
|
+
"""Load descriptive context for a parsed genre.
|
|
62
|
+
|
|
63
|
+
genre: parsed genre slug (techno, microhouse, ambient, etc.)
|
|
64
|
+
|
|
65
|
+
Returns a dict with content if found, or {"status": "no_match", ...} if not.
|
|
66
|
+
"""
|
|
67
|
+
if not genre:
|
|
68
|
+
return {"status": "no_genre_provided"}
|
|
69
|
+
genres = _load_genre_md()
|
|
70
|
+
key = genre.lower().replace(" ", "_").replace("-", "_")
|
|
71
|
+
if key in genres:
|
|
72
|
+
return genres[key]
|
|
73
|
+
# Try fuzzy: genre might be sub-genre matching a key contained in entry names
|
|
74
|
+
for k, v in genres.items():
|
|
75
|
+
if key in k or k in key:
|
|
76
|
+
return v
|
|
77
|
+
return {"status": "no_match", "parsed_genre": genre}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Abstract source of CompositionIntent — prompt-derived, session-derived, or hybrid.
|
|
2
|
+
|
|
3
|
+
PromptSource: parses a free-text prompt via prompt_parser.parse_prompt
|
|
4
|
+
SessionSource: introspects the live Ableton session via the MCP tool layer
|
|
5
|
+
HybridSource: combines both — session wins on tempo/key, prompt wins on genre/mood
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IntentSource(ABC):
|
|
18
|
+
"""Abstract base — produces a CompositionIntent-shaped dict for any compose mode.
|
|
19
|
+
|
|
20
|
+
Returned dict shape (CompositionIntent fields):
|
|
21
|
+
{
|
|
22
|
+
"genre": str,
|
|
23
|
+
"mood": str,
|
|
24
|
+
"tempo": int,
|
|
25
|
+
"key": str,
|
|
26
|
+
"sub_genre": str,
|
|
27
|
+
"descriptors": list,
|
|
28
|
+
"explicit_elements": list,
|
|
29
|
+
"energy": float,
|
|
30
|
+
"layer_count": int,
|
|
31
|
+
"duration_bars": int,
|
|
32
|
+
# ... see prompt_parser.CompositionIntent for full list
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def parse(self, ctx: Any) -> dict:
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PromptSource(IntentSource):
|
|
42
|
+
"""Parses a free-text prompt via prompt_parser.parse_prompt."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, prompt: str):
|
|
45
|
+
self.prompt = prompt
|
|
46
|
+
|
|
47
|
+
def parse(self, ctx: Any) -> dict:
|
|
48
|
+
# Lazy import to avoid circular deps at framework-package import time
|
|
49
|
+
from ..prompt_parser import parse_prompt
|
|
50
|
+
intent = parse_prompt(self.prompt)
|
|
51
|
+
# CompositionIntent has its own to_dict(); prefer it, else fall back to asdict
|
|
52
|
+
if hasattr(intent, "to_dict"):
|
|
53
|
+
return intent.to_dict()
|
|
54
|
+
if hasattr(intent, "__dataclass_fields__"):
|
|
55
|
+
from dataclasses import asdict
|
|
56
|
+
return asdict(intent)
|
|
57
|
+
if isinstance(intent, dict):
|
|
58
|
+
return intent
|
|
59
|
+
# Fallback for unexpected return type
|
|
60
|
+
return {"raw_intent": str(intent)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SessionSource(IntentSource):
|
|
64
|
+
"""Introspects the live Ableton session via MCP tools.
|
|
65
|
+
|
|
66
|
+
Reads session tempo, key, time signature, and per-track content.
|
|
67
|
+
Returns the same CompositionIntent shape as PromptSource so downstream
|
|
68
|
+
consumers don't care which source was used.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, scene_index: int = 0):
|
|
72
|
+
self.scene_index = scene_index
|
|
73
|
+
|
|
74
|
+
def parse(self, ctx: Any) -> dict:
|
|
75
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
76
|
+
if ableton is None:
|
|
77
|
+
logger.warning("SessionSource.parse: ableton client not in ctx — returning empty intent")
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
session = ableton.send_command("get_session_info", {})
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logger.warning("SessionSource.parse: get_session_info failed: %s", exc)
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
intent: dict = {
|
|
87
|
+
"tempo": float(session.get("tempo", 120.0)),
|
|
88
|
+
"scene_index": self.scene_index,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Time signature
|
|
92
|
+
sig_num = session.get("signature_numerator")
|
|
93
|
+
sig_den = session.get("signature_denominator")
|
|
94
|
+
if sig_num and sig_den:
|
|
95
|
+
intent["time_signature"] = f"{sig_num}/{sig_den}"
|
|
96
|
+
|
|
97
|
+
# Try to read song scale (Live 12.4 song-scale API)
|
|
98
|
+
try:
|
|
99
|
+
scale_result = ableton.send_command("get_song_scale", {})
|
|
100
|
+
if scale_result and not scale_result.get("error"):
|
|
101
|
+
intent["key"] = scale_result.get("root_note") or scale_result.get("key")
|
|
102
|
+
intent["scale_mode"] = scale_result.get("scale_name") or scale_result.get("mode")
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logger.debug("SessionSource.parse: get_song_scale unavailable: %s", exc)
|
|
105
|
+
|
|
106
|
+
return intent
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HybridSource(IntentSource):
|
|
110
|
+
"""Combines prompt + session intent.
|
|
111
|
+
|
|
112
|
+
Merge rule:
|
|
113
|
+
- tempo: session wins (live truth) if present, else prompt
|
|
114
|
+
- key/scale_mode: session wins if present, else prompt
|
|
115
|
+
- genre/mood: prompt wins (creative directive)
|
|
116
|
+
- other fields: prompt wins, session fills gaps
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, prompt: str, scene_index: int = 0):
|
|
120
|
+
self.prompt = prompt
|
|
121
|
+
self.scene_index = scene_index
|
|
122
|
+
|
|
123
|
+
def parse(self, ctx: Any) -> dict:
|
|
124
|
+
prompt_intent = PromptSource(self.prompt).parse(ctx)
|
|
125
|
+
session_intent = SessionSource(self.scene_index).parse(ctx)
|
|
126
|
+
|
|
127
|
+
merged = dict(prompt_intent) # start with prompt as base
|
|
128
|
+
# Session-wins overrides
|
|
129
|
+
for session_authoritative in ("tempo", "key", "scale_mode", "time_signature"):
|
|
130
|
+
session_val = session_intent.get(session_authoritative)
|
|
131
|
+
if session_val is not None:
|
|
132
|
+
merged[session_authoritative] = session_val
|
|
133
|
+
# Carry session-only fields the prompt doesn't have
|
|
134
|
+
for k, v in session_intent.items():
|
|
135
|
+
if k not in merged:
|
|
136
|
+
merged[k] = v
|
|
137
|
+
return merged
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""KnowledgePack — assembles the vocabulary fields each compose mode's brief carries.
|
|
2
|
+
|
|
3
|
+
For full mode: rich (event_lexicon, genre_context, artist_context,
|
|
4
|
+
atlas_anchors, atlas_candidates_per_role, manual_snippets).
|
|
5
|
+
For fast mode: subset (no event_lexicon — fast mode is loop-sketch scope).
|
|
6
|
+
For develop mode: subset overlapping with full.
|
|
7
|
+
|
|
8
|
+
v1.25 adds `atlas_anchors` — cohort + role-anchored URIs from
|
|
9
|
+
atlas_pack_aware_compose, populated when atlas + brief_text are provided.
|
|
10
|
+
The agent uses `atlas_explore` / `atlas_audition` / `atlas_substitute`
|
|
11
|
+
mid-design to dig past anchors when needed (hybrid knowledge surface).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from .event_lexicon import EVENT_LEXICON, get_event_lexicon
|
|
21
|
+
from .genre_loader import load_genre_context
|
|
22
|
+
from .artist_loader import load_artist_context
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KnowledgePack:
|
|
28
|
+
"""Central knowledge assembly for compose-mode briefs."""
|
|
29
|
+
|
|
30
|
+
def build(
|
|
31
|
+
self,
|
|
32
|
+
intent: dict,
|
|
33
|
+
mode: str = "full",
|
|
34
|
+
*,
|
|
35
|
+
atlas: Any = None,
|
|
36
|
+
ableton: Any = None,
|
|
37
|
+
ctx: Any = None,
|
|
38
|
+
brief_text: str = "",
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Build the knowledge fields for a brief.
|
|
41
|
+
|
|
42
|
+
intent: parsed CompositionIntent dict with at least 'genre', possibly 'sub_genre'.
|
|
43
|
+
May also carry 'artists' (list of producer names).
|
|
44
|
+
mode: 'fast' | 'full' | 'develop'
|
|
45
|
+
atlas: AtlasManager instance (optional — when None, atlas_anchors is None)
|
|
46
|
+
ableton: ableton client (optional — reserved for v1.25.x browser fallback)
|
|
47
|
+
ctx: lifespan context (optional — reserved for taste_profile / recent_uris)
|
|
48
|
+
brief_text: the original prompt string (required for atlas_anchors)
|
|
49
|
+
|
|
50
|
+
Returns a dict — pass directly to brief.knowledge OR spread into brief top-level.
|
|
51
|
+
"""
|
|
52
|
+
# Genre context — descriptive vocabulary for the parsed style
|
|
53
|
+
genre = intent.get("sub_genre") or intent.get("genre") or ""
|
|
54
|
+
genre_ctx = load_genre_context(genre) if genre else {}
|
|
55
|
+
|
|
56
|
+
# Artist context — populated only if prompt mentioned producers
|
|
57
|
+
artist_names = intent.get("artists") or []
|
|
58
|
+
artist_ctx = load_artist_context(artist_names)
|
|
59
|
+
|
|
60
|
+
knowledge: dict[str, Any] = {
|
|
61
|
+
"genre_context": genre_ctx,
|
|
62
|
+
"artist_context": artist_ctx,
|
|
63
|
+
"atlas_candidates_per_role": {}, # legacy field — empty in v1.25 (replaced by atlas_anchors)
|
|
64
|
+
"atlas_anchors": None, # populated below for full mode when atlas available
|
|
65
|
+
"manual_snippets": {},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Event lexicon — full mode only (loop sketch doesn't design form)
|
|
69
|
+
if mode in ("full", "develop"):
|
|
70
|
+
knowledge["event_lexicon"] = list(EVENT_LEXICON)
|
|
71
|
+
else:
|
|
72
|
+
knowledge["event_lexicon"] = []
|
|
73
|
+
|
|
74
|
+
# Atlas anchors — full mode only, requires atlas + brief_text. Best-effort:
|
|
75
|
+
# any failure path silently leaves anchors=None and the brief still works.
|
|
76
|
+
if mode == "full" and atlas is not None and brief_text:
|
|
77
|
+
try:
|
|
78
|
+
from .atlas_resolver import AtlasResolver
|
|
79
|
+
resolver = AtlasResolver(
|
|
80
|
+
atlas=atlas,
|
|
81
|
+
ableton=ableton,
|
|
82
|
+
taste_profile=_safe_get_taste_profile(ctx),
|
|
83
|
+
recent_uris=_safe_get_recent_uris(ctx),
|
|
84
|
+
)
|
|
85
|
+
mood = _extract_mood(intent)
|
|
86
|
+
anchors = resolver.resolve_anchors(
|
|
87
|
+
brief_text=brief_text,
|
|
88
|
+
genre=genre,
|
|
89
|
+
mood=mood,
|
|
90
|
+
artist_refs=artist_names,
|
|
91
|
+
)
|
|
92
|
+
knowledge["atlas_anchors"] = asdict(anchors)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
logger.debug("KnowledgePack.build: atlas_anchors unavailable: %s", exc)
|
|
95
|
+
|
|
96
|
+
return knowledge
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_mood(intent: dict) -> str:
|
|
103
|
+
"""Derive a mood string from the parsed intent.
|
|
104
|
+
|
|
105
|
+
Combines mood + descriptors fields when present. Falls back to "" so
|
|
106
|
+
the resolver's mood-overlap boost simply doesn't fire (rather than
|
|
107
|
+
matching against junk).
|
|
108
|
+
"""
|
|
109
|
+
parts: list[str] = []
|
|
110
|
+
for key in ("mood", "descriptors", "modifiers"):
|
|
111
|
+
val = intent.get(key)
|
|
112
|
+
if isinstance(val, str) and val:
|
|
113
|
+
parts.append(val)
|
|
114
|
+
elif isinstance(val, (list, tuple)):
|
|
115
|
+
parts.extend(str(v) for v in val if v)
|
|
116
|
+
return " ".join(parts).strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _safe_get_taste_profile(ctx: Any) -> Optional[dict]:
|
|
120
|
+
"""Best-effort taste profile fetch. Returns None on any failure."""
|
|
121
|
+
if ctx is None:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
# The taste graph tools live under mcp_server/tools/agent_os.py;
|
|
125
|
+
# importing here would create a cycle, so leave as None for now.
|
|
126
|
+
# v1.25.x will wire in get_taste_profile() once the cycle is broken.
|
|
127
|
+
return None
|
|
128
|
+
except Exception:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _safe_get_recent_uris(ctx: Any) -> Optional[set[str]]:
|
|
133
|
+
"""Best-effort recent-URIs fetch (§7 #2 anti-repeat). Returns None on failure."""
|
|
134
|
+
if ctx is None:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
# Same cycle concern as _safe_get_taste_profile — defer to v1.25.x.
|
|
138
|
+
return None
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""PlanCompiler — turns a Brief-derived plan into a Remote Script step sequence."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PlanCompiler:
|
|
6
|
+
"""Phase 1 stub — full impl in Phase 2 onwards."""
|
|
7
|
+
|
|
8
|
+
def compile(self, brief: Any) -> list:
|
|
9
|
+
"""Return list of step dicts. Stub returns empty."""
|
|
10
|
+
return []
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Full compose mode — currently deterministic engine; will become two-phase
|
|
2
|
+
LLM-creative in v1.24 Phase 3 (BUG-FULL-MODE-18 fix).
|
|
3
|
+
|
|
4
|
+
Phase 1 of v1.24 just moves the existing code here without behavior change.
|
|
5
|
+
"""
|
|
6
|
+
from .engine import ComposerEngine, CompositionResult
|
|
7
|
+
from .apply import apply_full_plan
|
|
8
|
+
from .brief_builder import build_full_brief
|
|
9
|
+
|
|
10
|
+
__all__ = ["ComposerEngine", "CompositionResult", "apply_full_plan", "build_full_brief"]
|