livepilot 1.9.13 → 1.9.15
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/AGENTS.md +3 -3
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +2 -1
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +214 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +162 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +80 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +235 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +64 -0
- package/mcp_server/server.py +23 -1
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +18 -3
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/requirements.txt +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Reference profile builders — construct ReferenceProfile from various sources.
|
|
2
|
+
|
|
3
|
+
Pure functions, zero I/O.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .models import ReferenceProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Audio Reference ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_audio_reference_profile(comparison_data: dict) -> ReferenceProfile:
|
|
15
|
+
"""Build a ReferenceProfile from compare_to_reference output.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
comparison_data: dict returned by perception engine's
|
|
19
|
+
compare_to_reference (keys: reference_lufs, centroid_delta_hz,
|
|
20
|
+
stereo_width_ref, band_deltas, suggestions, etc.)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
ReferenceProfile with source_type="audio".
|
|
24
|
+
"""
|
|
25
|
+
band_deltas = comparison_data.get("band_deltas", {})
|
|
26
|
+
|
|
27
|
+
# Reconstruct approximate reference spectral contour from band deltas.
|
|
28
|
+
# The deltas are (mix - ref), so ref bands are conceptually the baseline.
|
|
29
|
+
spectral_contour: dict = {
|
|
30
|
+
"band_balance": band_deltas,
|
|
31
|
+
"centroid_delta_hz": comparison_data.get("centroid_delta_hz", 0.0),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
width_depth: dict = {
|
|
35
|
+
"stereo_width": comparison_data.get("stereo_width_ref", 0.0),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Extract loudness posture
|
|
39
|
+
loudness = comparison_data.get("reference_lufs", comparison_data.get("ref_lufs", 0.0))
|
|
40
|
+
|
|
41
|
+
return ReferenceProfile(
|
|
42
|
+
source_type="audio",
|
|
43
|
+
loudness_posture=float(loudness),
|
|
44
|
+
spectral_contour=spectral_contour,
|
|
45
|
+
width_depth=width_depth,
|
|
46
|
+
density_arc=[], # audio comparison doesn't provide density
|
|
47
|
+
section_pacing=[], # not available from offline comparison
|
|
48
|
+
harmonic_character="", # would need chroma analysis
|
|
49
|
+
transition_tendencies=[],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Style Reference ───────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile:
|
|
57
|
+
"""Build a ReferenceProfile from style tactic data.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
style_tactics: list of StyleTactic.to_dict() entries from the
|
|
61
|
+
research engine's get_style_tactics.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
ReferenceProfile with source_type="style".
|
|
65
|
+
"""
|
|
66
|
+
if not style_tactics:
|
|
67
|
+
return ReferenceProfile(source_type="style")
|
|
68
|
+
|
|
69
|
+
# Aggregate arrangement patterns into section_pacing
|
|
70
|
+
section_pacing: list[dict] = []
|
|
71
|
+
transition_tendencies: list[str] = []
|
|
72
|
+
device_names: list[str] = []
|
|
73
|
+
|
|
74
|
+
for tactic in style_tactics:
|
|
75
|
+
# Arrangement patterns -> section pacing
|
|
76
|
+
for pattern in tactic.get("arrangement_patterns", []):
|
|
77
|
+
section_pacing.append({
|
|
78
|
+
"label": pattern,
|
|
79
|
+
"source": tactic.get("artist_or_genre", "unknown"),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
# Automation gestures -> transition tendencies
|
|
83
|
+
for gesture in tactic.get("automation_gestures", []):
|
|
84
|
+
if gesture not in transition_tendencies:
|
|
85
|
+
transition_tendencies.append(gesture)
|
|
86
|
+
|
|
87
|
+
# Collect device names for harmonic character hints
|
|
88
|
+
for dev in tactic.get("device_chain", []):
|
|
89
|
+
name = dev.get("name", "")
|
|
90
|
+
if name and name not in device_names:
|
|
91
|
+
device_names.append(name)
|
|
92
|
+
|
|
93
|
+
# Infer harmonic character from device chain
|
|
94
|
+
harmonic_character = _infer_harmonic_character(device_names)
|
|
95
|
+
|
|
96
|
+
# Estimate density from arrangement pattern count
|
|
97
|
+
density_arc = _estimate_density_from_patterns(style_tactics)
|
|
98
|
+
|
|
99
|
+
return ReferenceProfile(
|
|
100
|
+
source_type="style",
|
|
101
|
+
loudness_posture=0.0, # style doesn't specify loudness
|
|
102
|
+
spectral_contour={}, # style doesn't specify spectrum
|
|
103
|
+
width_depth={},
|
|
104
|
+
density_arc=density_arc,
|
|
105
|
+
section_pacing=section_pacing,
|
|
106
|
+
harmonic_character=harmonic_character,
|
|
107
|
+
transition_tendencies=transition_tendencies,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Internal helpers ──────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _infer_harmonic_character(device_names: list[str]) -> str:
|
|
115
|
+
"""Heuristic: infer harmonic character from common device names."""
|
|
116
|
+
lower_names = [d.lower() for d in device_names]
|
|
117
|
+
|
|
118
|
+
if any("reverb" in n for n in lower_names):
|
|
119
|
+
if any("filter" in n for n in lower_names):
|
|
120
|
+
return "atmospheric_filtered"
|
|
121
|
+
return "spacious"
|
|
122
|
+
if any("saturator" in n or "overdrive" in n or "amp" in n for n in lower_names):
|
|
123
|
+
return "warm_harmonic"
|
|
124
|
+
if any("operator" in n or "wavetable" in n for n in lower_names):
|
|
125
|
+
return "synthetic"
|
|
126
|
+
return "neutral"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
|
|
130
|
+
"""Heuristic: estimate a density arc from arrangement patterns.
|
|
131
|
+
|
|
132
|
+
More patterns / longer structures suggest higher density.
|
|
133
|
+
"""
|
|
134
|
+
if not style_tactics:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
densities: list[float] = []
|
|
138
|
+
for tactic in style_tactics:
|
|
139
|
+
patterns = tactic.get("arrangement_patterns", [])
|
|
140
|
+
# Simple heuristic: 1-2 patterns = sparse, 3+ = dense
|
|
141
|
+
n = len(patterns)
|
|
142
|
+
if n == 0:
|
|
143
|
+
densities.append(0.2)
|
|
144
|
+
elif n <= 2:
|
|
145
|
+
densities.append(0.4)
|
|
146
|
+
else:
|
|
147
|
+
densities.append(min(0.9, 0.3 + n * 0.15))
|
|
148
|
+
|
|
149
|
+
return densities
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tactic router — map gaps to target engines and rank tactics.
|
|
2
|
+
|
|
3
|
+
Pure functions, zero I/O.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .models import GapEntry, GapReport, ReferencePlan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Domain -> Engine mapping ───────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
_DOMAIN_ENGINE_MAP: dict[str, str] = {
|
|
14
|
+
"spectral": "mix_engine",
|
|
15
|
+
"loudness": "mix_engine",
|
|
16
|
+
"width": "mix_engine",
|
|
17
|
+
"density": "composition",
|
|
18
|
+
"pacing": "composition",
|
|
19
|
+
"harmonic": "composition",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Routing ────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def route_to_engines(gap_report: GapReport) -> list[dict]:
|
|
27
|
+
"""Map each relevant gap to a target engine with priority.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
list of {domain, engine, delta, tactic, priority} dicts,
|
|
31
|
+
sorted by priority (highest first).
|
|
32
|
+
"""
|
|
33
|
+
routes: list[dict] = []
|
|
34
|
+
|
|
35
|
+
for gap in gap_report.relevant_gaps:
|
|
36
|
+
engine = _DOMAIN_ENGINE_MAP.get(gap.domain, "mix_engine")
|
|
37
|
+
priority = _compute_priority(gap)
|
|
38
|
+
|
|
39
|
+
routes.append({
|
|
40
|
+
"domain": gap.domain,
|
|
41
|
+
"engine": engine,
|
|
42
|
+
"delta": gap.delta,
|
|
43
|
+
"tactic": gap.suggested_tactic,
|
|
44
|
+
"priority": priority,
|
|
45
|
+
"identity_warning": gap.identity_warning,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Sort by priority descending
|
|
49
|
+
routes.sort(key=lambda r: -r["priority"])
|
|
50
|
+
return routes
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_reference_plan(gap_report: GapReport) -> ReferencePlan:
|
|
54
|
+
"""Build a full ReferencePlan from a gap report.
|
|
55
|
+
|
|
56
|
+
Combines routing with ranked tactics and target engine list.
|
|
57
|
+
"""
|
|
58
|
+
routes = route_to_engines(gap_report)
|
|
59
|
+
|
|
60
|
+
# Ranked tactics: each route is a tactic entry
|
|
61
|
+
ranked_tactics = [
|
|
62
|
+
{
|
|
63
|
+
"rank": i + 1,
|
|
64
|
+
"domain": r["domain"],
|
|
65
|
+
"tactic": r["tactic"],
|
|
66
|
+
"engine": r["engine"],
|
|
67
|
+
"priority": r["priority"],
|
|
68
|
+
"identity_warning": r["identity_warning"],
|
|
69
|
+
}
|
|
70
|
+
for i, r in enumerate(routes)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Unique target engines in priority order
|
|
74
|
+
seen: set[str] = set()
|
|
75
|
+
target_engines: list[str] = []
|
|
76
|
+
for r in routes:
|
|
77
|
+
if r["engine"] not in seen:
|
|
78
|
+
target_engines.append(r["engine"])
|
|
79
|
+
seen.add(r["engine"])
|
|
80
|
+
|
|
81
|
+
return ReferencePlan(
|
|
82
|
+
gap_report=gap_report,
|
|
83
|
+
ranked_tactics=ranked_tactics,
|
|
84
|
+
target_engines=target_engines,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Priority scoring ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compute_priority(gap: GapEntry) -> float:
|
|
92
|
+
"""Compute routing priority for a gap.
|
|
93
|
+
|
|
94
|
+
Higher = more urgent. Identity-warned gaps get deprioritized
|
|
95
|
+
to avoid flattening the project.
|
|
96
|
+
"""
|
|
97
|
+
base = abs(gap.delta)
|
|
98
|
+
|
|
99
|
+
# Normalize different domains to comparable scales
|
|
100
|
+
scale = _DOMAIN_SCALE.get(gap.domain, 1.0)
|
|
101
|
+
normalized = min(base * scale, 1.0)
|
|
102
|
+
|
|
103
|
+
# Penalize identity-risky gaps
|
|
104
|
+
if gap.identity_warning:
|
|
105
|
+
normalized *= 0.5
|
|
106
|
+
|
|
107
|
+
return round(normalized, 3)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_DOMAIN_SCALE: dict[str, float] = {
|
|
111
|
+
"spectral": 10.0, # band deltas are small (0.001-0.1)
|
|
112
|
+
"loudness": 0.1, # LUFS deltas are large (1-10)
|
|
113
|
+
"width": 5.0, # width deltas moderate (0.01-0.3)
|
|
114
|
+
"density": 2.0, # 0-1 range
|
|
115
|
+
"pacing": 0.2, # section count deltas
|
|
116
|
+
"harmonic": 1.0, # binary (0 or 1)
|
|
117
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Reference Engine MCP tools — 3 tools for reference-aware production intelligence.
|
|
2
|
+
|
|
3
|
+
Each tool fetches data from Ableton/perception via the shared connection,
|
|
4
|
+
then delegates to pure-computation modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from ..server import mcp
|
|
12
|
+
from ..tools._research_engine import get_style_tactics
|
|
13
|
+
from .profile_builder import build_audio_reference_profile, build_style_reference_profile
|
|
14
|
+
from .gap_analyzer import analyze_gaps, classify_gap_relevance, detect_identity_warnings
|
|
15
|
+
from .tactic_router import build_reference_plan
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Helpers ────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) -> dict:
|
|
22
|
+
"""Run compare_to_reference via the perception engine."""
|
|
23
|
+
from ..tools._perception_engine import compare_to_reference
|
|
24
|
+
|
|
25
|
+
if not mix_path:
|
|
26
|
+
return {"error": "No mix_path provided — bounce your project first and pass the path", "code": "INVALID_PARAM"}
|
|
27
|
+
|
|
28
|
+
return compare_to_reference(mix_path, reference_path, normalize=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
32
|
+
"""Build a lightweight project snapshot for gap analysis."""
|
|
33
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
34
|
+
|
|
35
|
+
snapshot: dict = {
|
|
36
|
+
"loudness": 0.0,
|
|
37
|
+
"spectral": {},
|
|
38
|
+
"width": 0.0,
|
|
39
|
+
"density": 0.0,
|
|
40
|
+
"pacing": [],
|
|
41
|
+
"harmonic_character": "",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Try to get master RMS / loudness
|
|
45
|
+
try:
|
|
46
|
+
rms_result = ableton.send_command("get_master_rms", {})
|
|
47
|
+
rms = rms_result.get("rms", 0.0) if isinstance(rms_result, dict) else 0.0
|
|
48
|
+
# Approximate LUFS from RMS (rough heuristic)
|
|
49
|
+
if rms > 0:
|
|
50
|
+
import math
|
|
51
|
+
snapshot["loudness"] = round(20 * math.log10(max(rms, 1e-10)), 2)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# Try to get spectrum data
|
|
56
|
+
try:
|
|
57
|
+
spectrum = ableton.send_command("get_master_spectrum", {})
|
|
58
|
+
if isinstance(spectrum, dict):
|
|
59
|
+
snapshot["spectral"] = spectrum
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Try to get session info for pacing / density
|
|
64
|
+
try:
|
|
65
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
66
|
+
track_count = session_info.get("track_count", 0)
|
|
67
|
+
scene_count = session_info.get("scene_count", 0)
|
|
68
|
+
# Rough density estimate
|
|
69
|
+
snapshot["density"] = min(1.0, track_count / 20.0)
|
|
70
|
+
snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
return snapshot
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── MCP Tools ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def build_reference_profile(
|
|
82
|
+
ctx: Context,
|
|
83
|
+
reference_path: str = "",
|
|
84
|
+
mix_path: str = "",
|
|
85
|
+
style: str = "",
|
|
86
|
+
) -> dict:
|
|
87
|
+
"""Build a reference profile from an audio file or style/genre name.
|
|
88
|
+
|
|
89
|
+
Provide either reference_path (for audio comparison) or style
|
|
90
|
+
(for style tactic lookup). If both are provided, audio takes priority.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
reference_path: Absolute path to a reference audio file (.wav, .flac, .aiff).
|
|
94
|
+
mix_path: Absolute path to your bounced mix file (required for audio comparison).
|
|
95
|
+
style: Artist or genre name (e.g. "burial", "techno", "lo-fi").
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
ReferenceProfile as dict with source_type, loudness_posture,
|
|
99
|
+
spectral_contour, width_depth, density_arc, section_pacing,
|
|
100
|
+
harmonic_character, transition_tendencies.
|
|
101
|
+
"""
|
|
102
|
+
if reference_path:
|
|
103
|
+
comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
|
|
104
|
+
if "error" in comparison:
|
|
105
|
+
return comparison
|
|
106
|
+
profile = build_audio_reference_profile(comparison)
|
|
107
|
+
return profile.to_dict()
|
|
108
|
+
|
|
109
|
+
if style:
|
|
110
|
+
tactics = get_style_tactics(style)
|
|
111
|
+
if not tactics:
|
|
112
|
+
return {
|
|
113
|
+
"error": f"No style tactics found for '{style}'",
|
|
114
|
+
"code": "NOT_FOUND",
|
|
115
|
+
}
|
|
116
|
+
tactic_dicts = [t.to_dict() for t in tactics]
|
|
117
|
+
profile = build_style_reference_profile(tactic_dicts)
|
|
118
|
+
return profile.to_dict()
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"error": "Provide either reference_path or style",
|
|
122
|
+
"code": "INVALID_PARAM",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def analyze_reference_gaps(
|
|
128
|
+
ctx: Context,
|
|
129
|
+
reference_path: str = "",
|
|
130
|
+
mix_path: str = "",
|
|
131
|
+
style: str = "",
|
|
132
|
+
goal_dimensions: str = "",
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Analyze gaps between your project and a reference.
|
|
135
|
+
|
|
136
|
+
Computes deltas across spectral, loudness, width, density, pacing,
|
|
137
|
+
and harmonic domains. Flags which gaps are relevant and which
|
|
138
|
+
would destroy your project's identity if closed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
reference_path: Absolute path to a reference audio file.
|
|
142
|
+
mix_path: Absolute path to your bounced mix file (required for audio comparison).
|
|
143
|
+
style: Artist or genre name for style-based comparison.
|
|
144
|
+
goal_dimensions: Comma-separated domains to focus on
|
|
145
|
+
(e.g. "spectral,width"). Empty = all domains.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
GapReport as dict with gaps, relevant_gaps, identity_warnings,
|
|
149
|
+
and overall_distance.
|
|
150
|
+
"""
|
|
151
|
+
# Build reference profile
|
|
152
|
+
if reference_path:
|
|
153
|
+
comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
|
|
154
|
+
if "error" in comparison:
|
|
155
|
+
return comparison
|
|
156
|
+
profile = build_audio_reference_profile(comparison)
|
|
157
|
+
elif style:
|
|
158
|
+
tactics = get_style_tactics(style)
|
|
159
|
+
if not tactics:
|
|
160
|
+
return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
|
|
161
|
+
tactic_dicts = [t.to_dict() for t in tactics]
|
|
162
|
+
profile = build_style_reference_profile(tactic_dicts)
|
|
163
|
+
else:
|
|
164
|
+
return {"error": "Provide either reference_path or style", "code": "INVALID_PARAM"}
|
|
165
|
+
|
|
166
|
+
# Build project snapshot
|
|
167
|
+
snapshot = _fetch_project_snapshot(ctx)
|
|
168
|
+
|
|
169
|
+
# Analyze gaps
|
|
170
|
+
gap_report = analyze_gaps(snapshot, profile)
|
|
171
|
+
|
|
172
|
+
# Reclassify relevance if user specified goal dimensions
|
|
173
|
+
if goal_dimensions:
|
|
174
|
+
dims = [d.strip() for d in goal_dimensions.split(",") if d.strip()]
|
|
175
|
+
for gap in gap_report.gaps:
|
|
176
|
+
gap.relevant = classify_gap_relevance(gap, dims)
|
|
177
|
+
|
|
178
|
+
return gap_report.to_dict()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mcp.tool()
|
|
182
|
+
def plan_reference_moves(
|
|
183
|
+
ctx: Context,
|
|
184
|
+
reference_path: str = "",
|
|
185
|
+
mix_path: str = "",
|
|
186
|
+
style: str = "",
|
|
187
|
+
goal_dimensions: str = "",
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Plan concrete moves to close reference gaps.
|
|
190
|
+
|
|
191
|
+
Builds a reference profile, analyzes gaps, then routes each gap
|
|
192
|
+
to the appropriate engine (mix_engine or composition) with
|
|
193
|
+
ranked tactics and identity warnings.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
reference_path: Absolute path to a reference audio file.
|
|
197
|
+
mix_path: Absolute path to your bounced mix file (required for audio comparison).
|
|
198
|
+
style: Artist or genre name for style-based comparison.
|
|
199
|
+
goal_dimensions: Comma-separated domains to focus on.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
ReferencePlan as dict with gap_report, ranked_tactics,
|
|
203
|
+
and target_engines.
|
|
204
|
+
"""
|
|
205
|
+
# Build reference profile
|
|
206
|
+
if reference_path:
|
|
207
|
+
comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
|
|
208
|
+
if "error" in comparison:
|
|
209
|
+
return comparison
|
|
210
|
+
profile = build_audio_reference_profile(comparison)
|
|
211
|
+
elif style:
|
|
212
|
+
tactics = get_style_tactics(style)
|
|
213
|
+
if not tactics:
|
|
214
|
+
return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
|
|
215
|
+
tactic_dicts = [t.to_dict() for t in tactics]
|
|
216
|
+
profile = build_style_reference_profile(tactic_dicts)
|
|
217
|
+
else:
|
|
218
|
+
return {"error": "Provide either reference_path or style", "code": "INVALID_PARAM"}
|
|
219
|
+
|
|
220
|
+
# Build project snapshot
|
|
221
|
+
snapshot = _fetch_project_snapshot(ctx)
|
|
222
|
+
|
|
223
|
+
# Analyze gaps
|
|
224
|
+
gap_report = analyze_gaps(snapshot, profile)
|
|
225
|
+
|
|
226
|
+
# Reclassify relevance if user specified goal dimensions
|
|
227
|
+
if goal_dimensions:
|
|
228
|
+
dims = [d.strip() for d in goal_dimensions.split(",") if d.strip()]
|
|
229
|
+
for gap in gap_report.gaps:
|
|
230
|
+
gap.relevant = classify_gap_relevance(gap, dims)
|
|
231
|
+
|
|
232
|
+
# Build plan
|
|
233
|
+
plan = build_reference_plan(gap_report)
|
|
234
|
+
|
|
235
|
+
return plan.to_dict()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime subsystem — capability state, action ledger."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""SessionLedger — in-memory store for semantic moves.
|
|
2
|
+
|
|
3
|
+
Pure Python, zero I/O. The ledger tracks every agent-initiated mutation
|
|
4
|
+
as a *semantic move* that can be debugged, undone, or promoted to memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .action_ledger_models import LedgerEntry, UndoGroup
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionLedger:
|
|
16
|
+
"""Per-session record of semantic moves."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._entries: OrderedDict[str, LedgerEntry] = OrderedDict()
|
|
20
|
+
|
|
21
|
+
# ── lifecycle ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def start_move(
|
|
24
|
+
self,
|
|
25
|
+
engine: str,
|
|
26
|
+
move_class: str,
|
|
27
|
+
intent: str,
|
|
28
|
+
undo_scope: str = "micro",
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Create a new LedgerEntry and return its id."""
|
|
31
|
+
entry = LedgerEntry(
|
|
32
|
+
engine=engine,
|
|
33
|
+
move_class=move_class,
|
|
34
|
+
intent=intent,
|
|
35
|
+
undo_scope=undo_scope,
|
|
36
|
+
)
|
|
37
|
+
self._entries[entry.id] = entry
|
|
38
|
+
return entry.id
|
|
39
|
+
|
|
40
|
+
def append_action(
|
|
41
|
+
self, entry_id: str, tool_name: str, summary: str
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Add a tool-call record to an existing entry."""
|
|
44
|
+
entry = self._entries.get(entry_id)
|
|
45
|
+
if entry is None:
|
|
46
|
+
raise KeyError(f"No ledger entry with id {entry_id!r}")
|
|
47
|
+
entry.actions.append({"tool": tool_name, "summary": summary})
|
|
48
|
+
|
|
49
|
+
def set_before_refs(self, entry_id: str, refs: dict) -> None:
|
|
50
|
+
entry = self._entries.get(entry_id)
|
|
51
|
+
if entry is None:
|
|
52
|
+
raise KeyError(f"No ledger entry with id {entry_id!r}")
|
|
53
|
+
entry.before_refs = refs
|
|
54
|
+
|
|
55
|
+
def set_after_refs(self, entry_id: str, refs: dict) -> None:
|
|
56
|
+
entry = self._entries.get(entry_id)
|
|
57
|
+
if entry is None:
|
|
58
|
+
raise KeyError(f"No ledger entry with id {entry_id!r}")
|
|
59
|
+
entry.after_refs = refs
|
|
60
|
+
|
|
61
|
+
def finalize_move(
|
|
62
|
+
self,
|
|
63
|
+
entry_id: str,
|
|
64
|
+
kept: bool = True,
|
|
65
|
+
score: float = 0.0,
|
|
66
|
+
memory_candidate: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Seal a move with its evaluation outcome."""
|
|
69
|
+
entry = self._entries.get(entry_id)
|
|
70
|
+
if entry is None:
|
|
71
|
+
raise KeyError(f"No ledger entry with id {entry_id!r}")
|
|
72
|
+
entry.kept = kept
|
|
73
|
+
entry.score = score
|
|
74
|
+
entry.memory_candidate = memory_candidate
|
|
75
|
+
|
|
76
|
+
# ── queries ────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def get_entry(self, entry_id: str) -> Optional[LedgerEntry]:
|
|
79
|
+
return self._entries.get(entry_id)
|
|
80
|
+
|
|
81
|
+
def get_last_move(self) -> Optional[LedgerEntry]:
|
|
82
|
+
if not self._entries:
|
|
83
|
+
return None
|
|
84
|
+
# OrderedDict preserves insertion order — last item is newest
|
|
85
|
+
return next(reversed(self._entries.values()))
|
|
86
|
+
|
|
87
|
+
def get_recent_moves(
|
|
88
|
+
self,
|
|
89
|
+
limit: int = 10,
|
|
90
|
+
engine: Optional[str] = None,
|
|
91
|
+
kept: Optional[bool] = None,
|
|
92
|
+
) -> list[LedgerEntry]:
|
|
93
|
+
"""Return recent moves, newest first, with optional filters."""
|
|
94
|
+
results: list[LedgerEntry] = []
|
|
95
|
+
for entry in reversed(self._entries.values()):
|
|
96
|
+
if engine is not None and entry.engine != engine:
|
|
97
|
+
continue
|
|
98
|
+
if kept is not None and entry.kept != kept:
|
|
99
|
+
continue
|
|
100
|
+
results.append(entry)
|
|
101
|
+
if len(results) >= limit:
|
|
102
|
+
break
|
|
103
|
+
return results
|
|
104
|
+
|
|
105
|
+
def get_memory_candidates(self) -> list[LedgerEntry]:
|
|
106
|
+
"""Return all entries flagged as memory promotion candidates."""
|
|
107
|
+
return [e for e in self._entries.values() if e.memory_candidate]
|
|
108
|
+
|
|
109
|
+
def get_undo_groups(self) -> list[UndoGroup]:
|
|
110
|
+
"""Group entries by undo_scope."""
|
|
111
|
+
groups: dict[str, list[str]] = {}
|
|
112
|
+
for entry in self._entries.values():
|
|
113
|
+
groups.setdefault(entry.undo_scope, []).append(entry.id)
|
|
114
|
+
return [
|
|
115
|
+
UndoGroup(scope=scope, entry_ids=ids)
|
|
116
|
+
for scope, ids in groups.items()
|
|
117
|
+
]
|