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,185 @@
|
|
|
1
|
+
"""Research Provider Router — explicit provider ladder with mode-based routing.
|
|
2
|
+
|
|
3
|
+
Pure Python, zero I/O. Determines which research providers to query based on
|
|
4
|
+
the research mode (targeted, deep, background_mining) and provider availability.
|
|
5
|
+
|
|
6
|
+
Design: spec at docs/specs/2026-04-08-phase2-4-roadmap.md, Round 3.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Provider Definitions ────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ResearchProvider:
|
|
20
|
+
"""A single research data source with cost and priority metadata."""
|
|
21
|
+
|
|
22
|
+
name: str # "session_evidence", "local_docs", "memory", etc.
|
|
23
|
+
available: bool
|
|
24
|
+
priority: int # 1=highest
|
|
25
|
+
cost: str # "free", "low", "medium", "high"
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return asdict(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PROVIDER_LADDER: list[ResearchProvider] = [
|
|
32
|
+
ResearchProvider("session_evidence", True, 1, "free"),
|
|
33
|
+
ResearchProvider("local_docs", True, 2, "free"),
|
|
34
|
+
ResearchProvider("memory", True, 3, "free"),
|
|
35
|
+
ResearchProvider("user_references", False, 4, "low"),
|
|
36
|
+
ResearchProvider("structured_connectors", False, 5, "medium"),
|
|
37
|
+
ResearchProvider("web", False, 6, "high"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Which providers each mode is allowed to use
|
|
41
|
+
_MODE_ALLOWED: dict[str, set[str]] = {
|
|
42
|
+
"targeted": {"session_evidence", "local_docs", "memory"},
|
|
43
|
+
"deep": {
|
|
44
|
+
"session_evidence", "local_docs", "memory",
|
|
45
|
+
"user_references", "structured_connectors", "web",
|
|
46
|
+
},
|
|
47
|
+
"background_mining": {"session_evidence", "memory"},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_VALID_MODES = set(_MODE_ALLOWED.keys())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Provider Selection ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def get_available_providers(
|
|
56
|
+
capability_state: Optional[dict] = None,
|
|
57
|
+
) -> list[ResearchProvider]:
|
|
58
|
+
"""Return providers that are currently available.
|
|
59
|
+
|
|
60
|
+
capability_state: optional dict of provider_name -> bool overrides.
|
|
61
|
+
"""
|
|
62
|
+
result: list[ResearchProvider] = []
|
|
63
|
+
overrides = capability_state or {}
|
|
64
|
+
|
|
65
|
+
for p in PROVIDER_LADDER:
|
|
66
|
+
available = overrides.get(p.name, p.available)
|
|
67
|
+
result.append(ResearchProvider(
|
|
68
|
+
name=p.name,
|
|
69
|
+
available=available,
|
|
70
|
+
priority=p.priority,
|
|
71
|
+
cost=p.cost,
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def route_research(
|
|
78
|
+
query: str,
|
|
79
|
+
mode: str,
|
|
80
|
+
providers: Optional[list[ResearchProvider]] = None,
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""Determine which providers to query based on mode.
|
|
83
|
+
|
|
84
|
+
Returns: {
|
|
85
|
+
mode, query, providers_to_query: [provider dicts],
|
|
86
|
+
skipped: [provider dicts with reason],
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
if mode not in _VALID_MODES:
|
|
90
|
+
return {
|
|
91
|
+
"error": f"invalid mode {mode!r}, must be one of {sorted(_VALID_MODES)}",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if providers is None:
|
|
95
|
+
providers = get_available_providers()
|
|
96
|
+
|
|
97
|
+
allowed_names = _MODE_ALLOWED[mode]
|
|
98
|
+
|
|
99
|
+
to_query: list[dict] = []
|
|
100
|
+
skipped: list[dict] = []
|
|
101
|
+
|
|
102
|
+
for p in sorted(providers, key=lambda x: x.priority):
|
|
103
|
+
if p.name not in allowed_names:
|
|
104
|
+
skipped.append({**p.to_dict(), "reason": f"not allowed in {mode} mode"})
|
|
105
|
+
elif not p.available:
|
|
106
|
+
skipped.append({**p.to_dict(), "reason": "provider not available"})
|
|
107
|
+
else:
|
|
108
|
+
to_query.append(p.to_dict())
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"mode": mode,
|
|
112
|
+
"query": query,
|
|
113
|
+
"providers_to_query": to_query,
|
|
114
|
+
"skipped": skipped,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Research Outcome Feedback ───────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class ResearchOutcomeFeedback:
|
|
122
|
+
"""Track whether research results were actually useful."""
|
|
123
|
+
|
|
124
|
+
research_id: str
|
|
125
|
+
technique_card_id: str
|
|
126
|
+
applied: bool
|
|
127
|
+
move_kept: bool
|
|
128
|
+
score: float # 0-1
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict:
|
|
131
|
+
return asdict(self)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ResearchFeedbackStore:
|
|
135
|
+
"""In-memory store for research effectiveness tracking."""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self._entries: list[ResearchOutcomeFeedback] = []
|
|
139
|
+
|
|
140
|
+
def record(self, feedback: ResearchOutcomeFeedback) -> dict:
|
|
141
|
+
"""Record research feedback. Returns summary."""
|
|
142
|
+
self._entries.append(feedback)
|
|
143
|
+
return {
|
|
144
|
+
"recorded": feedback.to_dict(),
|
|
145
|
+
"total_feedback": len(self._entries),
|
|
146
|
+
"effectiveness": self._effectiveness(),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _effectiveness(self) -> dict:
|
|
150
|
+
"""Compute aggregate effectiveness stats."""
|
|
151
|
+
if not self._entries:
|
|
152
|
+
return {"applied_rate": 0.0, "kept_rate": 0.0, "avg_score": 0.0, "count": 0}
|
|
153
|
+
|
|
154
|
+
applied = sum(1 for e in self._entries if e.applied)
|
|
155
|
+
kept = sum(1 for e in self._entries if e.move_kept)
|
|
156
|
+
avg_score = sum(e.score for e in self._entries) / len(self._entries)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"applied_rate": round(applied / len(self._entries), 3),
|
|
160
|
+
"kept_rate": round(kept / len(self._entries), 3),
|
|
161
|
+
"avg_score": round(avg_score, 3),
|
|
162
|
+
"count": len(self._entries),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def get_effectiveness(self) -> dict:
|
|
166
|
+
"""Public access to effectiveness stats."""
|
|
167
|
+
return self._effectiveness()
|
|
168
|
+
|
|
169
|
+
def get_all(self) -> list[dict]:
|
|
170
|
+
"""Return all feedback entries."""
|
|
171
|
+
return [e.to_dict() for e in self._entries]
|
|
172
|
+
|
|
173
|
+
def to_dict(self) -> dict:
|
|
174
|
+
return {
|
|
175
|
+
"feedback": self.get_all(),
|
|
176
|
+
"effectiveness": self._effectiveness(),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def record_research_feedback(feedback: ResearchOutcomeFeedback) -> dict:
|
|
181
|
+
"""Standalone function to create a feedback record dict."""
|
|
182
|
+
return {
|
|
183
|
+
"feedback": feedback.to_dict(),
|
|
184
|
+
"timestamp_ms": int(time.time() * 1000),
|
|
185
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Snapshot Normalizer — canonical input normalization for all evaluators.
|
|
2
|
+
|
|
3
|
+
Ensures analyzer outputs are in a consistent schema regardless of
|
|
4
|
+
which tool produced them. All evaluators should consume normalized
|
|
5
|
+
snapshots, never raw tool outputs.
|
|
6
|
+
|
|
7
|
+
Design: AGENT_OS_PHASE0_HARDENING_PLAN.md, section 3.2
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_sonic_snapshot(
|
|
17
|
+
raw: Optional[dict],
|
|
18
|
+
source: str = "unknown",
|
|
19
|
+
) -> Optional[dict]:
|
|
20
|
+
"""Normalize a raw analyzer/perception output into canonical snapshot form.
|
|
21
|
+
|
|
22
|
+
Accepts both {"bands": {...}} and {"spectrum": {...}} shapes.
|
|
23
|
+
Returns None if input is empty or None.
|
|
24
|
+
|
|
25
|
+
Canonical form:
|
|
26
|
+
{
|
|
27
|
+
"spectrum": {band: value, ...},
|
|
28
|
+
"rms": float or None,
|
|
29
|
+
"peak": float or None,
|
|
30
|
+
"detected_key": str or None,
|
|
31
|
+
"source": str,
|
|
32
|
+
"normalized_at_ms": int,
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
if not raw or not isinstance(raw, dict):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
bands = raw.get("spectrum") or raw.get("bands")
|
|
39
|
+
if not bands:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"spectrum": bands,
|
|
44
|
+
"rms": raw.get("rms"),
|
|
45
|
+
"peak": raw.get("peak"),
|
|
46
|
+
"detected_key": raw.get("key") or raw.get("detected_key"),
|
|
47
|
+
"source": source,
|
|
48
|
+
"normalized_at_ms": int(time.time() * 1000),
|
|
49
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Agent OS V1 MCP tools — goal compilation, world model, and evaluation.
|
|
2
|
+
|
|
3
|
+
3 tools that connect the pure-computation engine (_agent_os_engine.py) to the
|
|
4
|
+
live Ableton session via the existing MCP infrastructure.
|
|
5
|
+
|
|
6
|
+
These tools power the Agent OS cyclical loop:
|
|
7
|
+
compile_goal_vector → build_world_model → [agent acts] → evaluate_move
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from fastmcp import Context
|
|
16
|
+
|
|
17
|
+
from ..server import mcp
|
|
18
|
+
from . import _agent_os_engine as engine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_ableton(ctx: Context):
|
|
22
|
+
return ctx.lifespan_context["ableton"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_spectral(ctx: Context):
|
|
26
|
+
return ctx.lifespan_context.get("spectral")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_json_param(value, name: str) -> dict:
|
|
30
|
+
"""Parse a dict, JSON string, or None parameter."""
|
|
31
|
+
if value is None:
|
|
32
|
+
return {}
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(value)
|
|
36
|
+
except json.JSONDecodeError as exc:
|
|
37
|
+
raise ValueError(f"Invalid JSON in {name}: {exc}") from exc
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return value
|
|
40
|
+
raise ValueError(f"{name} must be a dict or JSON string")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── compile_goal_vector ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
def compile_goal_vector(
|
|
48
|
+
ctx: Context,
|
|
49
|
+
request_text: str,
|
|
50
|
+
targets: dict | str,
|
|
51
|
+
protect: dict | str = "{}",
|
|
52
|
+
mode: str = "improve",
|
|
53
|
+
aggression: float = 0.5,
|
|
54
|
+
research_mode: str = "none",
|
|
55
|
+
) -> dict:
|
|
56
|
+
"""Compile a user request into a validated GoalVector.
|
|
57
|
+
|
|
58
|
+
The agent interprets the user's natural language into quality dimensions,
|
|
59
|
+
then this tool validates the schema and normalizes weights.
|
|
60
|
+
|
|
61
|
+
targets: dict of dimension → weight (e.g., {"punch": 0.4, "weight": 0.3, "energy": 0.3}).
|
|
62
|
+
Weights are normalized to sum to 1.0.
|
|
63
|
+
protect: dict of dimension → minimum threshold (e.g., {"clarity": 0.8}).
|
|
64
|
+
If a dimension drops below this value after a move, the move is undone.
|
|
65
|
+
mode: observe | improve | explore | finish | diagnose
|
|
66
|
+
aggression: 0.0 (subtle) to 1.0 (bold)
|
|
67
|
+
research_mode: none | targeted | deep
|
|
68
|
+
|
|
69
|
+
Valid dimensions: energy, punch, weight, density, brightness, warmth,
|
|
70
|
+
width, depth, motion, contrast, clarity, cohesion, groove, tension,
|
|
71
|
+
novelty, polish, emotion.
|
|
72
|
+
"""
|
|
73
|
+
targets_dict = _parse_json_param(targets, "targets")
|
|
74
|
+
protect_dict = _parse_json_param(protect, "protect")
|
|
75
|
+
|
|
76
|
+
gv = engine.validate_goal_vector(
|
|
77
|
+
request_text=request_text,
|
|
78
|
+
targets=targets_dict,
|
|
79
|
+
protect=protect_dict,
|
|
80
|
+
mode=mode,
|
|
81
|
+
aggression=float(aggression),
|
|
82
|
+
research_mode=research_mode,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"goal_vector": gv.to_dict(),
|
|
87
|
+
"measurable_dimensions": [
|
|
88
|
+
d for d in gv.targets if d in engine.MEASURABLE_PROXIES
|
|
89
|
+
],
|
|
90
|
+
"unmeasurable_dimensions": [
|
|
91
|
+
d for d in gv.targets if d not in engine.MEASURABLE_PROXIES
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── build_world_model ─────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def build_world_model(ctx: Context) -> dict:
|
|
101
|
+
"""Build a WorldModel snapshot of the current Ableton session.
|
|
102
|
+
|
|
103
|
+
Reads session info, spectral data (if analyzer available), per-track
|
|
104
|
+
device health, and infers track roles from names. Degrades gracefully
|
|
105
|
+
if M4L Analyzer is not loaded.
|
|
106
|
+
|
|
107
|
+
Returns topology (tracks, devices, scenes), sonic state (spectrum, RMS, key),
|
|
108
|
+
technical state (analyzer/FluCoMa availability, plugin health), and
|
|
109
|
+
inferred track roles.
|
|
110
|
+
"""
|
|
111
|
+
ableton = _get_ableton(ctx)
|
|
112
|
+
spectral = _get_spectral(ctx)
|
|
113
|
+
|
|
114
|
+
# Fetch session info (always available)
|
|
115
|
+
session_info = ableton.send_command("get_session_info")
|
|
116
|
+
|
|
117
|
+
# Fetch per-track device info for plugin health checks (I2 fix)
|
|
118
|
+
track_infos = []
|
|
119
|
+
for track in session_info.get("tracks", []):
|
|
120
|
+
try:
|
|
121
|
+
ti = ableton.send_command("get_track_info", {
|
|
122
|
+
"track_index": track["index"]
|
|
123
|
+
})
|
|
124
|
+
track_infos.append(ti)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass # Skip tracks that fail — don't block world model build
|
|
127
|
+
|
|
128
|
+
# Fetch spectral data (may be unavailable)
|
|
129
|
+
spectrum = None
|
|
130
|
+
rms = None
|
|
131
|
+
detected_key = None
|
|
132
|
+
flucoma_status = None
|
|
133
|
+
|
|
134
|
+
if spectral and spectral.is_connected:
|
|
135
|
+
spec_data = spectral.get("spectrum")
|
|
136
|
+
if spec_data:
|
|
137
|
+
spectrum = {"bands": spec_data["value"]}
|
|
138
|
+
|
|
139
|
+
rms_data = spectral.get("rms")
|
|
140
|
+
if rms_data:
|
|
141
|
+
rms = rms_data["value"] if isinstance(rms_data["value"], dict) else {"rms": rms_data["value"]}
|
|
142
|
+
|
|
143
|
+
key_data = spectral.get("key")
|
|
144
|
+
if key_data:
|
|
145
|
+
detected_key = key_data["value"] if isinstance(key_data["value"], dict) else {"key": key_data["value"]}
|
|
146
|
+
|
|
147
|
+
flucoma_data = spectral.get("flucoma_status")
|
|
148
|
+
if flucoma_data:
|
|
149
|
+
flucoma_status = flucoma_data["value"] if isinstance(flucoma_data["value"], dict) else {}
|
|
150
|
+
else:
|
|
151
|
+
flucoma_status = {"flucoma_available": False}
|
|
152
|
+
|
|
153
|
+
# Build model
|
|
154
|
+
wm = engine.build_world_model_from_data(
|
|
155
|
+
session_info=session_info,
|
|
156
|
+
spectrum=spectrum,
|
|
157
|
+
rms=rms,
|
|
158
|
+
detected_key=detected_key,
|
|
159
|
+
flucoma_status=flucoma_status,
|
|
160
|
+
track_infos=track_infos,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Run critics with all-dimensions stub goal to surface all issues.
|
|
164
|
+
# The agent should filter these against its actual GoalVector.
|
|
165
|
+
goal_stub = engine.GoalVector(
|
|
166
|
+
request_text="world_model_build",
|
|
167
|
+
targets={d: 1.0 / len(engine.QUALITY_DIMENSIONS) for d in engine.QUALITY_DIMENSIONS},
|
|
168
|
+
mode="observe",
|
|
169
|
+
)
|
|
170
|
+
sonic_issues = engine.run_sonic_critic(wm.sonic, goal_stub, wm.track_roles)
|
|
171
|
+
technical_issues = engine.run_technical_critic(wm.technical)
|
|
172
|
+
|
|
173
|
+
# Round 1: Wire structural critic (composition engine) into world model
|
|
174
|
+
structural_issues = []
|
|
175
|
+
try:
|
|
176
|
+
from . import _composition_engine as comp_engine
|
|
177
|
+
# Build lightweight section graph for structural analysis
|
|
178
|
+
scenes = session_info.get("scenes", [])
|
|
179
|
+
track_count = session_info.get("track_count", 0)
|
|
180
|
+
clip_matrix = []
|
|
181
|
+
try:
|
|
182
|
+
matrix_data = ableton.send_command("get_scene_matrix")
|
|
183
|
+
clip_matrix = matrix_data.get("matrix", [])
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
|
|
188
|
+
structural_issues = comp_engine.run_form_critic(sections)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass # Composition engine unavailable — degrade gracefully
|
|
191
|
+
|
|
192
|
+
result = wm.to_dict()
|
|
193
|
+
result["issues"] = {
|
|
194
|
+
"sonic": [i.to_dict() for i in sonic_issues],
|
|
195
|
+
"technical": [i.to_dict() for i in technical_issues],
|
|
196
|
+
"structural": [i.to_dict() for i in structural_issues],
|
|
197
|
+
"total_count": len(sonic_issues) + len(technical_issues) + len(structural_issues),
|
|
198
|
+
"note": "Issues are unfiltered — filter against your GoalVector targets before acting.",
|
|
199
|
+
}
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── evaluate_move ─────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@mcp.tool()
|
|
207
|
+
def evaluate_move(
|
|
208
|
+
ctx: Context,
|
|
209
|
+
goal_vector: dict | str,
|
|
210
|
+
before_snapshot: dict | str,
|
|
211
|
+
after_snapshot: dict | str,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""Evaluate whether a production move improved the mix toward the goal.
|
|
214
|
+
|
|
215
|
+
Takes before/after sonic snapshots and the active GoalVector.
|
|
216
|
+
Returns a score and keep/undo recommendation.
|
|
217
|
+
|
|
218
|
+
Snapshots should contain: spectrum (8-band dict), rms, peak.
|
|
219
|
+
Get these from get_master_spectrum + get_master_rms before and after
|
|
220
|
+
making changes.
|
|
221
|
+
|
|
222
|
+
Hard rules enforce undo when:
|
|
223
|
+
- No measurable improvement (delta <= 0)
|
|
224
|
+
- Protected dimension dropped below its threshold or by > 0.15
|
|
225
|
+
- Total score < 0.40
|
|
226
|
+
|
|
227
|
+
When all target dimensions are unmeasurable (e.g., groove, tension),
|
|
228
|
+
the tool defers keep/undo to the agent's musical judgment.
|
|
229
|
+
|
|
230
|
+
Returns consecutive_undo_hint=true when keep_change=false — the agent
|
|
231
|
+
should track consecutive undos and switch to observe mode after 3.
|
|
232
|
+
"""
|
|
233
|
+
gv_dict = _parse_json_param(goal_vector, "goal_vector")
|
|
234
|
+
before = _parse_json_param(before_snapshot, "before_snapshot")
|
|
235
|
+
after = _parse_json_param(after_snapshot, "after_snapshot")
|
|
236
|
+
|
|
237
|
+
# I6 fix: validate the GoalVector to catch malformed input
|
|
238
|
+
gv = engine.validate_goal_vector(
|
|
239
|
+
request_text=gv_dict.get("request_text", "evaluate"),
|
|
240
|
+
targets=gv_dict.get("targets", {}),
|
|
241
|
+
protect=gv_dict.get("protect", {}),
|
|
242
|
+
mode=gv_dict.get("mode", "improve"),
|
|
243
|
+
aggression=float(gv_dict.get("aggression", 0.5)),
|
|
244
|
+
research_mode=gv_dict.get("research_mode", "none"),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return engine.compute_evaluation_score(gv, before, after)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── analyze_outcomes (Round 1) ────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@mcp.tool()
|
|
254
|
+
def analyze_outcomes(
|
|
255
|
+
ctx: Context,
|
|
256
|
+
limit: int = 50,
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Analyze accumulated outcome memories to identify user taste patterns.
|
|
259
|
+
|
|
260
|
+
Reads outcome-type memories from the technique library and returns:
|
|
261
|
+
- keep_rate: what percentage of moves does this user keep?
|
|
262
|
+
- dimension_success: which quality dimensions improve most often?
|
|
263
|
+
- common_kept_moves: which action types work best?
|
|
264
|
+
- common_undone_moves: which action types fail most?
|
|
265
|
+
- taste_vector: inferred dimension preferences from history
|
|
266
|
+
|
|
267
|
+
Use this before choosing moves to align with user taste.
|
|
268
|
+
The more outcomes stored (via memory_learn type="outcome"),
|
|
269
|
+
the better the taste analysis becomes.
|
|
270
|
+
"""
|
|
271
|
+
ableton = _get_ableton(ctx)
|
|
272
|
+
|
|
273
|
+
# Fetch outcome memories
|
|
274
|
+
try:
|
|
275
|
+
memory_result = ableton.send_command("memory_list", {
|
|
276
|
+
"type": "outcome",
|
|
277
|
+
"limit": limit,
|
|
278
|
+
"sort_by": "updated_at",
|
|
279
|
+
})
|
|
280
|
+
techniques = memory_result.get("techniques", [])
|
|
281
|
+
except Exception:
|
|
282
|
+
techniques = []
|
|
283
|
+
|
|
284
|
+
# Extract payloads from techniques
|
|
285
|
+
outcomes = []
|
|
286
|
+
for t in techniques:
|
|
287
|
+
payload = t.get("payload", {})
|
|
288
|
+
if isinstance(payload, dict):
|
|
289
|
+
outcomes.append(payload)
|
|
290
|
+
|
|
291
|
+
return engine.analyze_outcome_history(outcomes)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── get_technique_card (Round 2) ──────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.tool()
|
|
298
|
+
def get_technique_card(
|
|
299
|
+
ctx: Context,
|
|
300
|
+
query: str,
|
|
301
|
+
limit: int = 5,
|
|
302
|
+
) -> dict:
|
|
303
|
+
"""Search for technique cards — structured production recipes.
|
|
304
|
+
|
|
305
|
+
Technique cards are reusable recipes saved from successful production
|
|
306
|
+
outcomes. Each card has: problem, context, devices, method, verification.
|
|
307
|
+
|
|
308
|
+
query: search term (e.g., "wider pad", "punchy kick", "sidechain bass")
|
|
309
|
+
limit: max results
|
|
310
|
+
"""
|
|
311
|
+
ableton = _get_ableton(ctx)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
memory_result = ableton.send_command("memory_recall", {
|
|
315
|
+
"query": query,
|
|
316
|
+
"type": "technique_card",
|
|
317
|
+
"limit": limit,
|
|
318
|
+
})
|
|
319
|
+
techniques = memory_result.get("techniques", [])
|
|
320
|
+
except Exception:
|
|
321
|
+
techniques = []
|
|
322
|
+
|
|
323
|
+
cards = []
|
|
324
|
+
for t in techniques:
|
|
325
|
+
payload = t.get("payload", {})
|
|
326
|
+
if isinstance(payload, dict):
|
|
327
|
+
cards.append({
|
|
328
|
+
"id": t.get("id"),
|
|
329
|
+
"name": t.get("name"),
|
|
330
|
+
"card": payload,
|
|
331
|
+
"rating": t.get("rating", 0),
|
|
332
|
+
"replay_count": t.get("replay_count", 0),
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"query": query,
|
|
337
|
+
"cards": cards,
|
|
338
|
+
"count": len(cards),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── get_taste_profile (Round 4) ────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@mcp.tool()
|
|
346
|
+
def get_taste_profile(
|
|
347
|
+
ctx: Context,
|
|
348
|
+
limit: int = 50,
|
|
349
|
+
) -> dict:
|
|
350
|
+
"""Get the user's production taste profile from outcome history.
|
|
351
|
+
|
|
352
|
+
Analyzes kept vs undone moves to identify: preferred dimensions,
|
|
353
|
+
avoided dimensions, taste vector weights, and overall keep rate.
|
|
354
|
+
Use this to understand what this user values in production.
|
|
355
|
+
|
|
356
|
+
limit: how many outcomes to analyze (default: 50)
|
|
357
|
+
|
|
358
|
+
Returns: {taste_vector, preferred_dimensions, avoided_dimensions,
|
|
359
|
+
keep_rate, sample_size}
|
|
360
|
+
"""
|
|
361
|
+
ableton = _get_ableton(ctx)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
memory_result = ableton.send_command("memory_list", {
|
|
365
|
+
"type": "outcome",
|
|
366
|
+
"limit": limit,
|
|
367
|
+
"sort_by": "updated_at",
|
|
368
|
+
})
|
|
369
|
+
techniques = memory_result.get("techniques", [])
|
|
370
|
+
except Exception:
|
|
371
|
+
techniques = []
|
|
372
|
+
|
|
373
|
+
outcomes = [t.get("payload", {}) for t in techniques if isinstance(t.get("payload"), dict)]
|
|
374
|
+
|
|
375
|
+
return engine.get_taste_profile(outcomes)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── get_turn_budget (Conductor Budget) ────────────────────────────
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@mcp.tool()
|
|
382
|
+
def get_turn_budget(
|
|
383
|
+
ctx: Context,
|
|
384
|
+
mode: str = "improve",
|
|
385
|
+
aggression: float = 0.5,
|
|
386
|
+
) -> dict:
|
|
387
|
+
"""Get a resource budget for the current agent turn.
|
|
388
|
+
|
|
389
|
+
Returns six resource pools that prevent overcommitting:
|
|
390
|
+
- latency_ms: time budget for this turn
|
|
391
|
+
- risk_points: how much risk is allowed (0-1)
|
|
392
|
+
- novelty_points: how much novelty is allowed (0-1)
|
|
393
|
+
- change_count: max production moves this turn
|
|
394
|
+
- undo_count: max consecutive undos before switching to observe
|
|
395
|
+
- research_calls: max research lookups this turn
|
|
396
|
+
|
|
397
|
+
mode: observe | improve | explore | finish | diagnose | performance
|
|
398
|
+
- observe: very low risk, zero changes, read-only
|
|
399
|
+
- improve: balanced defaults
|
|
400
|
+
- explore: high novelty, high risk, more moves
|
|
401
|
+
- finish: conservative, low novelty, few changes
|
|
402
|
+
- diagnose: zero changes, research-focused
|
|
403
|
+
- performance: very low latency, minimal risk
|
|
404
|
+
aggression: 0.0 (subtle) to 1.0 (bold) — scales risk and change limits
|
|
405
|
+
|
|
406
|
+
Use spend functions via the conductor to track consumption during the turn.
|
|
407
|
+
"""
|
|
408
|
+
from . import _conductor_budgets as budgets
|
|
409
|
+
|
|
410
|
+
budget = budgets.create_budget(mode=mode, aggression=float(aggression))
|
|
411
|
+
summary = budgets.get_budget_summary(budget)
|
|
412
|
+
summary["budget"] = budget.to_dict()
|
|
413
|
+
summary["mode"] = mode
|
|
414
|
+
summary["aggression"] = float(aggression)
|
|
415
|
+
return summary
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── route_request (Conductor) ──────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@mcp.tool()
|
|
422
|
+
def route_request(
|
|
423
|
+
ctx: Context,
|
|
424
|
+
request: str,
|
|
425
|
+
) -> dict:
|
|
426
|
+
"""Route a production request to the right engine(s).
|
|
427
|
+
|
|
428
|
+
Analyzes natural language to determine which engines should handle
|
|
429
|
+
the request, in what priority order, with what entry tools.
|
|
430
|
+
|
|
431
|
+
request: what the user wants (e.g., "make this punchier", "turn the
|
|
432
|
+
loop into a song", "make it sound like Burial")
|
|
433
|
+
|
|
434
|
+
Returns: routing plan with engine priorities, entry tools, and
|
|
435
|
+
capability requirements.
|
|
436
|
+
"""
|
|
437
|
+
from . import _conductor as conductor
|
|
438
|
+
|
|
439
|
+
plan = conductor.classify_request(request)
|
|
440
|
+
return plan.to_dict()
|
|
@@ -609,6 +609,24 @@ async def capture_audio(
|
|
|
609
609
|
filename,
|
|
610
610
|
timeout=float(duration_seconds + 10),
|
|
611
611
|
)
|
|
612
|
+
|
|
613
|
+
# Move captured file from M4L device directory to CAPTURE_DIR
|
|
614
|
+
if result.get("ok") and result.get("file_path"):
|
|
615
|
+
src = result["file_path"]
|
|
616
|
+
# Try common extensions the bridge might produce
|
|
617
|
+
for ext in ("", ".aiff", ".wav", ".aif"):
|
|
618
|
+
src_path = src + ext if not src.endswith(ext) else src
|
|
619
|
+
if os.path.isfile(src_path):
|
|
620
|
+
dst_name = os.path.basename(src_path)
|
|
621
|
+
dst_path = os.path.join(CAPTURE_DIR, dst_name)
|
|
622
|
+
try:
|
|
623
|
+
import shutil
|
|
624
|
+
shutil.move(src_path, dst_path)
|
|
625
|
+
result["file_path"] = dst_path
|
|
626
|
+
except OSError:
|
|
627
|
+
pass # Leave in original location if move fails
|
|
628
|
+
break
|
|
629
|
+
|
|
612
630
|
return result
|
|
613
631
|
|
|
614
632
|
|