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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. 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