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.
Files changed (48) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +60 -14
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/__init__.py +17 -3
  7. package/mcp_server/atlas/explore_tools.py +332 -0
  8. package/mcp_server/atlas/tools.py +161 -0
  9. package/mcp_server/audit/__init__.py +6 -0
  10. package/mcp_server/audit/checks.py +618 -0
  11. package/mcp_server/audit/tools.py +232 -0
  12. package/mcp_server/composer/branch_producer.py +5 -2
  13. package/mcp_server/composer/develop/__init__.py +19 -0
  14. package/mcp_server/composer/develop/apply.py +217 -0
  15. package/mcp_server/composer/develop/brief_builder.py +269 -0
  16. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  17. package/mcp_server/composer/engine.py +15 -521
  18. package/mcp_server/composer/fast/__init__.py +62 -0
  19. package/mcp_server/composer/fast/apply.py +533 -0
  20. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  21. package/mcp_server/composer/fast/tier_classification.py +159 -0
  22. package/mcp_server/composer/framework/__init__.py +0 -0
  23. package/mcp_server/composer/framework/applier.py +179 -0
  24. package/mcp_server/composer/framework/artist_loader.py +63 -0
  25. package/mcp_server/composer/framework/atlas_resolver.py +554 -0
  26. package/mcp_server/composer/framework/brief.py +79 -0
  27. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  28. package/mcp_server/composer/framework/genre_loader.py +77 -0
  29. package/mcp_server/composer/framework/intent_source.py +137 -0
  30. package/mcp_server/composer/framework/knowledge_pack.py +140 -0
  31. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  32. package/mcp_server/composer/full/__init__.py +10 -0
  33. package/mcp_server/composer/full/apply.py +1139 -0
  34. package/mcp_server/composer/full/brief_builder.py +227 -0
  35. package/mcp_server/composer/full/engine.py +541 -0
  36. package/mcp_server/composer/full/layer_planner.py +491 -0
  37. package/mcp_server/composer/layer_planner.py +19 -465
  38. package/mcp_server/composer/sample_resolver.py +80 -7
  39. package/mcp_server/composer/tools.py +626 -28
  40. package/mcp_server/server.py +1 -0
  41. package/mcp_server/splice_client/client.py +7 -0
  42. package/mcp_server/tools/_analyzer_engine/sample.py +172 -7
  43. package/mcp_server/tools/_planner_engine.py +25 -63
  44. package/mcp_server/tools/analyzer.py +10 -4
  45. package/mcp_server/tools/browser.py +102 -19
  46. package/package.json +2 -2
  47. package/remote_script/LivePilot/__init__.py +1 -1
  48. 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"]