livepilot 1.10.5 → 1.10.7
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/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Part of the _agent_os_engine package — extracted from the single-file engine.
|
|
2
|
+
|
|
3
|
+
Pure-computation core. Callers should import from the package facade
|
|
4
|
+
(`from mcp_server.tools._agent_os_engine import X`), which re-exports from
|
|
5
|
+
these sub-modules.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from .models import TechniqueCard
|
|
15
|
+
|
|
16
|
+
def build_technique_card_from_outcome(outcome: dict) -> Optional[TechniqueCard]:
|
|
17
|
+
"""Extract a technique card from a successful outcome.
|
|
18
|
+
|
|
19
|
+
Only produces a card if the outcome was kept and had meaningful improvement.
|
|
20
|
+
"""
|
|
21
|
+
if not outcome.get("kept", False):
|
|
22
|
+
return None
|
|
23
|
+
if outcome.get("score", 0) < 0.6:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
gv = outcome.get("goal_vector", {})
|
|
27
|
+
move = outcome.get("move", {})
|
|
28
|
+
dim_changes = outcome.get("dimension_changes", {})
|
|
29
|
+
|
|
30
|
+
# Build problem description from goal
|
|
31
|
+
targets = gv.get("targets", {})
|
|
32
|
+
if not targets:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
top_dim = max(targets.items(), key=lambda x: x[1])[0] if targets else "general"
|
|
36
|
+
problem = f"Improve {top_dim} in production"
|
|
37
|
+
|
|
38
|
+
# Build method from move
|
|
39
|
+
method = move.get("name", "unknown technique")
|
|
40
|
+
if isinstance(move.get("actions"), list):
|
|
41
|
+
method = " → ".join(move["actions"])
|
|
42
|
+
|
|
43
|
+
# Build verification from dimension changes
|
|
44
|
+
verification = []
|
|
45
|
+
for dim, change in dim_changes.items():
|
|
46
|
+
if isinstance(change, dict) and change.get("delta", 0) > 0:
|
|
47
|
+
verification.append(f"{dim} should improve (was +{change['delta']:.3f})")
|
|
48
|
+
|
|
49
|
+
return TechniqueCard(
|
|
50
|
+
problem=problem,
|
|
51
|
+
context=list(gv.get("tags", [])) if isinstance(gv.get("tags"), list) else [],
|
|
52
|
+
devices=move.get("devices", []) if isinstance(move.get("devices"), list) else [],
|
|
53
|
+
method=method,
|
|
54
|
+
verification=verification,
|
|
55
|
+
evidence={"score": outcome.get("score", 0), "in_session_tested": True},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Background Technique Mining (Round 3) ───────────────────────────
|
|
60
|
+
def should_mine_technique(
|
|
61
|
+
outcome: dict,
|
|
62
|
+
existing_techniques: Optional[list[dict]] = None,
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""Determine if an outcome is novel enough to auto-create a technique card.
|
|
65
|
+
|
|
66
|
+
Returns True if:
|
|
67
|
+
- Score > 0.7 (high quality)
|
|
68
|
+
- At least one dimension improved by > 0.15
|
|
69
|
+
- No similar technique already exists in memory
|
|
70
|
+
"""
|
|
71
|
+
if not outcome.get("kept", False):
|
|
72
|
+
return False
|
|
73
|
+
if outcome.get("score", 0) < 0.7:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# Check for meaningful dimension improvement
|
|
77
|
+
dim_changes = outcome.get("dimension_changes", {})
|
|
78
|
+
has_significant_improvement = False
|
|
79
|
+
for dim, change in dim_changes.items():
|
|
80
|
+
delta = change.get("delta", 0) if isinstance(change, dict) else 0
|
|
81
|
+
if delta > 0.15:
|
|
82
|
+
has_significant_improvement = True
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if not has_significant_improvement:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# Check for novelty — don't create duplicate techniques
|
|
89
|
+
if existing_techniques:
|
|
90
|
+
move = outcome.get("move", {})
|
|
91
|
+
move_name = move.get("name", "") if isinstance(move, dict) else ""
|
|
92
|
+
if move_name:
|
|
93
|
+
for tech in existing_techniques:
|
|
94
|
+
payload = tech.get("payload", {})
|
|
95
|
+
existing_method = payload.get("method", "")
|
|
96
|
+
if move_name.lower() in existing_method.lower():
|
|
97
|
+
return False # Similar technique already exists
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def mine_technique_from_outcome(outcome: dict) -> Optional[TechniqueCard]:
|
|
102
|
+
"""Extract a technique card from a high-quality outcome.
|
|
103
|
+
|
|
104
|
+
This is the "background mining" — when the agent detects a novel
|
|
105
|
+
approach that worked well, it auto-creates a technique card for future use.
|
|
106
|
+
"""
|
|
107
|
+
if not outcome.get("kept", False):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
gv = outcome.get("goal_vector", {})
|
|
111
|
+
move = outcome.get("move", {})
|
|
112
|
+
dim_changes = outcome.get("dimension_changes", {})
|
|
113
|
+
score = outcome.get("score", 0)
|
|
114
|
+
|
|
115
|
+
# Build problem description
|
|
116
|
+
targets = gv.get("targets", {})
|
|
117
|
+
if targets:
|
|
118
|
+
top_dims = sorted(targets.items(), key=lambda x: -x[1])[:2]
|
|
119
|
+
problem = f"Improve {' and '.join(d for d, _ in top_dims)}"
|
|
120
|
+
else:
|
|
121
|
+
problem = "General production improvement"
|
|
122
|
+
|
|
123
|
+
# Build method
|
|
124
|
+
move_name = move.get("name", "unknown") if isinstance(move, dict) else str(move)
|
|
125
|
+
actions = move.get("actions", []) if isinstance(move, dict) else []
|
|
126
|
+
if isinstance(actions, list) and actions:
|
|
127
|
+
method = f"{move_name}: {' → '.join(str(a) for a in actions)}"
|
|
128
|
+
else:
|
|
129
|
+
method = move_name
|
|
130
|
+
|
|
131
|
+
# Build verification from what actually improved
|
|
132
|
+
verification = []
|
|
133
|
+
for dim, change in dim_changes.items():
|
|
134
|
+
if isinstance(change, dict) and change.get("delta", 0) > 0.05:
|
|
135
|
+
verification.append(
|
|
136
|
+
f"{dim} should improve (observed +{change['delta']:.3f})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Devices used
|
|
140
|
+
devices = move.get("devices", []) if isinstance(move, dict) else []
|
|
141
|
+
if not isinstance(devices, list):
|
|
142
|
+
devices = []
|
|
143
|
+
|
|
144
|
+
return TechniqueCard(
|
|
145
|
+
problem=problem,
|
|
146
|
+
context=list(gv.get("tags", [])) if isinstance(gv.get("tags"), list) else [],
|
|
147
|
+
devices=devices,
|
|
148
|
+
method=method,
|
|
149
|
+
verification=verification,
|
|
150
|
+
evidence={
|
|
151
|
+
"score": score,
|
|
152
|
+
"in_session_tested": True,
|
|
153
|
+
"auto_mined": True,
|
|
154
|
+
"dimension_improvements": {
|
|
155
|
+
dim: change.get("delta", 0)
|
|
156
|
+
for dim, change in dim_changes.items()
|
|
157
|
+
if isinstance(change, dict) and change.get("delta", 0) > 0
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Part of the _agent_os_engine package — extracted from the single-file engine.
|
|
2
|
+
|
|
3
|
+
Pure-computation core. Callers should import from the package facade
|
|
4
|
+
(`from mcp_server.tools._agent_os_engine import X`), which re-exports from
|
|
5
|
+
these sub-modules.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from .models import QUALITY_DIMENSIONS, MEASURABLE_PROXIES, VALID_MODES, VALID_RESEARCH_MODES, _ROLE_PATTERNS, GoalVector, WorldModel
|
|
15
|
+
|
|
16
|
+
def validate_goal_vector(
|
|
17
|
+
request_text: str,
|
|
18
|
+
targets: dict[str, float],
|
|
19
|
+
protect: dict[str, float],
|
|
20
|
+
mode: str,
|
|
21
|
+
aggression: float,
|
|
22
|
+
research_mode: str,
|
|
23
|
+
) -> GoalVector:
|
|
24
|
+
"""Validate and construct a GoalVector. Raises ValueError on invalid input."""
|
|
25
|
+
if not request_text or not request_text.strip():
|
|
26
|
+
raise ValueError("request_text cannot be empty")
|
|
27
|
+
|
|
28
|
+
# Validate dimensions
|
|
29
|
+
for dim in targets:
|
|
30
|
+
if dim not in QUALITY_DIMENSIONS:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Unknown target dimension '{dim}'. "
|
|
33
|
+
f"Valid: {sorted(QUALITY_DIMENSIONS)}"
|
|
34
|
+
)
|
|
35
|
+
for dim in protect:
|
|
36
|
+
if dim not in QUALITY_DIMENSIONS:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Unknown protect dimension '{dim}'. "
|
|
39
|
+
f"Valid: {sorted(QUALITY_DIMENSIONS)}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Validate weights are non-negative
|
|
43
|
+
for dim, w in targets.items():
|
|
44
|
+
if w < 0.0:
|
|
45
|
+
raise ValueError(f"Target weight for '{dim}' must be >= 0.0, got {w}")
|
|
46
|
+
for dim, w in protect.items():
|
|
47
|
+
if not 0.0 <= w <= 1.0:
|
|
48
|
+
raise ValueError(f"Protect threshold for '{dim}' must be 0.0-1.0, got {w}")
|
|
49
|
+
|
|
50
|
+
if mode not in VALID_MODES:
|
|
51
|
+
raise ValueError(f"mode must be one of {sorted(VALID_MODES)}, got '{mode}'")
|
|
52
|
+
if research_mode not in VALID_RESEARCH_MODES:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"research_mode must be one of {sorted(VALID_RESEARCH_MODES)}, "
|
|
55
|
+
f"got '{research_mode}'"
|
|
56
|
+
)
|
|
57
|
+
if not 0.0 <= aggression <= 1.0:
|
|
58
|
+
raise ValueError(f"aggression must be 0.0-1.0, got {aggression}")
|
|
59
|
+
|
|
60
|
+
# Normalize target weights to sum to ~1.0 if they don't already
|
|
61
|
+
total = sum(targets.values())
|
|
62
|
+
if targets and total > 0:
|
|
63
|
+
if abs(total - 1.0) > 0.01:
|
|
64
|
+
targets = {k: v / total for k, v in targets.items()}
|
|
65
|
+
|
|
66
|
+
return GoalVector(
|
|
67
|
+
request_text=request_text.strip(),
|
|
68
|
+
targets=targets,
|
|
69
|
+
protect=protect,
|
|
70
|
+
mode=mode,
|
|
71
|
+
aggression=aggression,
|
|
72
|
+
research_mode=research_mode,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def infer_track_role(track_name: str) -> str:
|
|
76
|
+
"""Infer a track's musical role from its name. Returns 'unknown' if no match."""
|
|
77
|
+
name_lower = track_name.lower().strip()
|
|
78
|
+
for pattern, role in _ROLE_PATTERNS:
|
|
79
|
+
if re.search(pattern, name_lower):
|
|
80
|
+
return role
|
|
81
|
+
return "unknown"
|
|
82
|
+
|
|
83
|
+
def build_world_model_from_data(
|
|
84
|
+
session_info: dict,
|
|
85
|
+
spectrum: Optional[dict] = None,
|
|
86
|
+
rms: Optional[dict] = None,
|
|
87
|
+
detected_key: Optional[dict] = None,
|
|
88
|
+
flucoma_status: Optional[dict] = None,
|
|
89
|
+
track_infos: Optional[list[dict]] = None,
|
|
90
|
+
) -> WorldModel:
|
|
91
|
+
"""Assemble a WorldModel from raw tool outputs.
|
|
92
|
+
|
|
93
|
+
All parameters are optional — the model degrades gracefully when
|
|
94
|
+
analyzer data is unavailable.
|
|
95
|
+
"""
|
|
96
|
+
# Topology
|
|
97
|
+
tracks = session_info.get("tracks", [])
|
|
98
|
+
topology = {
|
|
99
|
+
"tempo": session_info.get("tempo"),
|
|
100
|
+
"time_signature": f"{session_info.get('signature_numerator', 4)}/{session_info.get('signature_denominator', 4)}",
|
|
101
|
+
"track_count": session_info.get("track_count", 0),
|
|
102
|
+
"return_count": session_info.get("return_track_count", 0),
|
|
103
|
+
"scene_count": session_info.get("scene_count", 0),
|
|
104
|
+
"is_playing": session_info.get("is_playing", False),
|
|
105
|
+
"tracks": [
|
|
106
|
+
{
|
|
107
|
+
"index": t.get("index"),
|
|
108
|
+
"name": t.get("name", ""),
|
|
109
|
+
"has_midi": t.get("has_midi_input", False),
|
|
110
|
+
"has_audio": t.get("has_audio_input", False),
|
|
111
|
+
"mute": t.get("mute", False),
|
|
112
|
+
"solo": t.get("solo", False),
|
|
113
|
+
"arm": t.get("arm", False),
|
|
114
|
+
}
|
|
115
|
+
for t in tracks
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Track roles
|
|
120
|
+
track_roles = {}
|
|
121
|
+
for t in tracks:
|
|
122
|
+
idx = t.get("index", 0)
|
|
123
|
+
name = t.get("name", "")
|
|
124
|
+
track_roles[idx] = infer_track_role(name)
|
|
125
|
+
|
|
126
|
+
# Sonic state (None if analyzer unavailable)
|
|
127
|
+
sonic = None
|
|
128
|
+
if spectrum and spectrum.get("bands"):
|
|
129
|
+
sonic = {
|
|
130
|
+
"spectrum": spectrum.get("bands", {}),
|
|
131
|
+
"rms": rms.get("rms") if rms else None,
|
|
132
|
+
"peak": rms.get("peak") if rms else None,
|
|
133
|
+
"key": detected_key.get("key") if detected_key else None,
|
|
134
|
+
"scale": detected_key.get("scale") if detected_key else None,
|
|
135
|
+
"key_confidence": detected_key.get("confidence") if detected_key else None,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Technical state
|
|
139
|
+
analyzer_available = spectrum is not None and bool(spectrum.get("bands"))
|
|
140
|
+
flucoma_available = (
|
|
141
|
+
flucoma_status is not None
|
|
142
|
+
and flucoma_status.get("flucoma_available", False)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Check plugin health from track_infos if provided
|
|
146
|
+
unhealthy_devices = []
|
|
147
|
+
if track_infos:
|
|
148
|
+
for ti in track_infos:
|
|
149
|
+
for dev in ti.get("devices", []):
|
|
150
|
+
flags = dev.get("health_flags", [])
|
|
151
|
+
if "opaque_or_failed_plugin" in flags:
|
|
152
|
+
unhealthy_devices.append({
|
|
153
|
+
"track": ti.get("index"),
|
|
154
|
+
"device": dev.get("name"),
|
|
155
|
+
"flag": "opaque_or_failed_plugin",
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
technical = {
|
|
159
|
+
"analyzer_available": analyzer_available,
|
|
160
|
+
"flucoma_available": flucoma_available,
|
|
161
|
+
"unhealthy_devices": unhealthy_devices,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return WorldModel(
|
|
165
|
+
topology=topology,
|
|
166
|
+
sonic=sonic,
|
|
167
|
+
technical=technical,
|
|
168
|
+
track_roles=track_roles,
|
|
169
|
+
)
|
|
170
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Composition engine — structural + musical intelligence for arrangement.
|
|
2
|
+
|
|
3
|
+
This package replaces the former single-file `_composition_engine.py`.
|
|
4
|
+
The public surface is unchanged — callers import the same names:
|
|
5
|
+
|
|
6
|
+
from mcp_server.tools._composition_engine import (
|
|
7
|
+
SectionType, RoleType, GestureIntent,
|
|
8
|
+
build_section_graph_from_scenes, detect_phrases,
|
|
9
|
+
run_form_critic, plan_gesture, ...,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
Internal organization:
|
|
13
|
+
models.py — Enums and dataclasses (shared types)
|
|
14
|
+
sections.py — Section graph, phrase grid, role inference
|
|
15
|
+
critics.py — Form / identity / phrase / transition / emotional / cross-section
|
|
16
|
+
gestures.py — Gesture planner and template library
|
|
17
|
+
harmony.py — Harmony field construction
|
|
18
|
+
analysis.py — Composition evaluation, outcomes, taste model, constants
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .models import (
|
|
23
|
+
SectionType, RoleType, GestureIntent,
|
|
24
|
+
SectionNode, PhraseUnit, RoleNode, CompositionIssue,
|
|
25
|
+
GesturePlan, CompositionAnalysis, HarmonyField,
|
|
26
|
+
)
|
|
27
|
+
from .sections import (
|
|
28
|
+
build_section_graph_from_scenes,
|
|
29
|
+
build_section_graph_from_arrangement,
|
|
30
|
+
detect_phrases,
|
|
31
|
+
infer_role_for_track,
|
|
32
|
+
build_role_graph,
|
|
33
|
+
)
|
|
34
|
+
from .critics import (
|
|
35
|
+
run_form_critic,
|
|
36
|
+
run_section_identity_critic,
|
|
37
|
+
run_phrase_critic,
|
|
38
|
+
run_transition_critic,
|
|
39
|
+
run_emotional_arc_critic,
|
|
40
|
+
run_cross_section_critic,
|
|
41
|
+
)
|
|
42
|
+
from .gestures import (
|
|
43
|
+
GESTURE_TEMPLATES,
|
|
44
|
+
plan_gesture,
|
|
45
|
+
resolve_gesture_template,
|
|
46
|
+
)
|
|
47
|
+
from .harmony import build_harmony_field, harmonic_score
|
|
48
|
+
from .analysis import (
|
|
49
|
+
COMPOSITION_DIMENSIONS,
|
|
50
|
+
analyze_section_outcomes,
|
|
51
|
+
evaluate_composition_move,
|
|
52
|
+
build_composition_taste_model,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"SectionType", "RoleType", "GestureIntent",
|
|
57
|
+
"SectionNode", "PhraseUnit", "RoleNode", "CompositionIssue",
|
|
58
|
+
"GesturePlan", "CompositionAnalysis", "HarmonyField",
|
|
59
|
+
"build_section_graph_from_scenes", "build_section_graph_from_arrangement",
|
|
60
|
+
"detect_phrases", "infer_role_for_track", "build_role_graph",
|
|
61
|
+
"run_form_critic", "run_section_identity_critic", "run_phrase_critic",
|
|
62
|
+
"run_transition_critic", "run_emotional_arc_critic", "run_cross_section_critic",
|
|
63
|
+
"GESTURE_TEMPLATES", "plan_gesture", "resolve_gesture_template",
|
|
64
|
+
"build_harmony_field", "harmonic_score",
|
|
65
|
+
"COMPOSITION_DIMENSIONS", "analyze_section_outcomes",
|
|
66
|
+
"evaluate_composition_move", "build_composition_taste_model",
|
|
67
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Part of the _composition_engine package — extracted from the single-file engine.
|
|
2
|
+
|
|
3
|
+
Pure-computation core, no external deps. Callers should import from the package
|
|
4
|
+
facade (e.g. `from mcp_server.tools._composition_engine import X`), which
|
|
5
|
+
re-exports everything from these sub-modules.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .models import SectionNode, CompositionIssue, CompositionAnalysis
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Section Outcome Analysis (Round 2) ────────────────────────────────
|
|
19
|
+
def analyze_section_outcomes(
|
|
20
|
+
outcomes: list[dict],
|
|
21
|
+
) -> dict:
|
|
22
|
+
"""Analyze composition outcomes grouped by section type.
|
|
23
|
+
|
|
24
|
+
outcomes: list of composition_outcome payloads
|
|
25
|
+
Returns: {section_type: {move_name: {avg_score, count, keep_rate}}}
|
|
26
|
+
"""
|
|
27
|
+
by_section: dict[str, list[dict]] = {}
|
|
28
|
+
|
|
29
|
+
for o in outcomes:
|
|
30
|
+
section_type = o.get("section_type", "unknown")
|
|
31
|
+
by_section.setdefault(section_type, []).append(o)
|
|
32
|
+
|
|
33
|
+
result = {}
|
|
34
|
+
for stype, section_outcomes in by_section.items():
|
|
35
|
+
move_stats: dict[str, dict] = {}
|
|
36
|
+
for o in section_outcomes:
|
|
37
|
+
move = o.get("move_name", "unknown")
|
|
38
|
+
stats = move_stats.setdefault(move, {"scores": [], "kept": 0, "total": 0})
|
|
39
|
+
stats["scores"].append(o.get("score", 0))
|
|
40
|
+
stats["total"] += 1
|
|
41
|
+
if o.get("kept", False):
|
|
42
|
+
stats["kept"] += 1
|
|
43
|
+
|
|
44
|
+
result[stype] = {
|
|
45
|
+
move: {
|
|
46
|
+
"avg_score": round(sum(s["scores"]) / len(s["scores"]), 3) if s["scores"] else 0,
|
|
47
|
+
"count": s["total"],
|
|
48
|
+
"keep_rate": round(s["kept"] / s["total"], 3) if s["total"] > 0 else 0,
|
|
49
|
+
}
|
|
50
|
+
for move, s in move_stats.items()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"section_types": list(result.keys()),
|
|
55
|
+
"outcomes_by_section": result,
|
|
56
|
+
"total_outcomes": sum(len(v) for v in by_section.values()),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Composition Evaluation ────────────────────────────────────────────
|
|
61
|
+
COMPOSITION_DIMENSIONS = frozenset({
|
|
62
|
+
"section_clarity", "phrase_completion", "narrative_pacing",
|
|
63
|
+
"transition_strength", "orchestration_clarity", "tension_release",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
def evaluate_composition_move(
|
|
67
|
+
before_issues: list[CompositionIssue],
|
|
68
|
+
after_issues: list[CompositionIssue],
|
|
69
|
+
target_dimensions: dict[str, float],
|
|
70
|
+
protect: dict[str, float],
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""Evaluate whether a composition move improved the arrangement.
|
|
73
|
+
|
|
74
|
+
Compares issue counts and severities before and after.
|
|
75
|
+
Returns: {score, keep_change, issue_delta, notes}
|
|
76
|
+
"""
|
|
77
|
+
notes: list[str] = []
|
|
78
|
+
|
|
79
|
+
# Count issues by type before and after
|
|
80
|
+
before_count = len(before_issues)
|
|
81
|
+
after_count = len(after_issues)
|
|
82
|
+
issue_delta = before_count - after_count
|
|
83
|
+
|
|
84
|
+
# Severity-weighted improvement
|
|
85
|
+
before_severity = sum(i.severity for i in before_issues)
|
|
86
|
+
after_severity = sum(i.severity for i in after_issues)
|
|
87
|
+
severity_improvement = before_severity - after_severity
|
|
88
|
+
|
|
89
|
+
# Score: positive improvement = good
|
|
90
|
+
if before_count > 0:
|
|
91
|
+
improvement_ratio = severity_improvement / max(before_severity, 0.01)
|
|
92
|
+
else:
|
|
93
|
+
improvement_ratio = 0.0 if after_count == 0 else -0.5
|
|
94
|
+
|
|
95
|
+
# Normalize to 0-1 score
|
|
96
|
+
score = max(0.0, min(1.0, 0.5 + improvement_ratio * 0.5))
|
|
97
|
+
|
|
98
|
+
# Keep/undo decision
|
|
99
|
+
keep_change = True
|
|
100
|
+
|
|
101
|
+
if severity_improvement < 0:
|
|
102
|
+
keep_change = False
|
|
103
|
+
notes.append(f"WORSE: total severity increased by {-severity_improvement:.2f}")
|
|
104
|
+
|
|
105
|
+
if after_count > before_count + 1:
|
|
106
|
+
keep_change = False
|
|
107
|
+
notes.append(f"NEW ISSUES: {after_count - before_count} new issues introduced")
|
|
108
|
+
|
|
109
|
+
if score < 0.40:
|
|
110
|
+
keep_change = False
|
|
111
|
+
notes.append(f"SCORE: {score:.3f} below 0.40 threshold")
|
|
112
|
+
|
|
113
|
+
if keep_change and severity_improvement > 0:
|
|
114
|
+
notes.append(f"IMPROVED: resolved {issue_delta} issue(s), severity reduced by {severity_improvement:.2f}")
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"score": round(score, 4),
|
|
118
|
+
"keep_change": keep_change,
|
|
119
|
+
"issue_delta": issue_delta,
|
|
120
|
+
"before_issue_count": before_count,
|
|
121
|
+
"after_issue_count": after_count,
|
|
122
|
+
"severity_improvement": round(severity_improvement, 4),
|
|
123
|
+
"notes": notes,
|
|
124
|
+
"consecutive_undo_hint": not keep_change,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Composition Taste Model (Round 4) ───────────────────────────────
|
|
129
|
+
def build_composition_taste_model(
|
|
130
|
+
section_outcomes: list[dict],
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Build per-section-type preferences from composition outcome history.
|
|
133
|
+
|
|
134
|
+
Aggregates section outcomes to learn: what density, foreground count,
|
|
135
|
+
and move types does this user prefer for each section type?
|
|
136
|
+
|
|
137
|
+
Returns: {section_type: {preferred_density, preferred_foreground_count,
|
|
138
|
+
top_moves, sample_size}}
|
|
139
|
+
"""
|
|
140
|
+
if not section_outcomes:
|
|
141
|
+
return {"section_types": {}, "sample_size": 0}
|
|
142
|
+
|
|
143
|
+
by_type: dict[str, list[dict]] = {}
|
|
144
|
+
for o in section_outcomes:
|
|
145
|
+
stype = o.get("section_type", "unknown")
|
|
146
|
+
by_type.setdefault(stype, []).append(o)
|
|
147
|
+
|
|
148
|
+
preferences: dict[str, dict] = {}
|
|
149
|
+
for stype, outcomes in by_type.items():
|
|
150
|
+
kept = [o for o in outcomes if o.get("kept", False)]
|
|
151
|
+
densities = [o.get("density", 0.5) for o in kept if "density" in o]
|
|
152
|
+
fg_counts = [o.get("foreground_count", 1) for o in kept if "foreground_count" in o]
|
|
153
|
+
|
|
154
|
+
# Tally move types
|
|
155
|
+
move_counts: dict[str, int] = {}
|
|
156
|
+
for o in kept:
|
|
157
|
+
move = o.get("move_name", "unknown")
|
|
158
|
+
move_counts[move] = move_counts.get(move, 0) + 1
|
|
159
|
+
|
|
160
|
+
top_moves = sorted(move_counts.items(), key=lambda x: -x[1])[:3]
|
|
161
|
+
|
|
162
|
+
preferences[stype] = {
|
|
163
|
+
"preferred_density": round(sum(densities) / len(densities), 2) if densities else 0.5,
|
|
164
|
+
"preferred_foreground_count": round(sum(fg_counts) / len(fg_counts), 1) if fg_counts else 1.0,
|
|
165
|
+
"top_moves": [{"move": m, "count": c} for m, c in top_moves],
|
|
166
|
+
"keep_rate": round(len(kept) / len(outcomes), 3) if outcomes else 0,
|
|
167
|
+
"sample_size": len(outcomes),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"section_types": preferences,
|
|
172
|
+
"sample_size": sum(len(v) for v in by_type.values()),
|
|
173
|
+
}
|
|
174
|
+
|