livepilot 1.9.14 → 1.9.16

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 (108) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +82 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +8 -8
  6. package/livepilot/.Codex-plugin/plugin.json +2 -2
  7. package/livepilot/.claude-plugin/plugin.json +2 -2
  8. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  9. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  10. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  11. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  12. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  13. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  14. package/m4l_device/livepilot_bridge.js +32 -15
  15. package/mcp_server/__init__.py +1 -1
  16. package/mcp_server/connection.py +24 -2
  17. package/mcp_server/curves.py +14 -6
  18. package/mcp_server/evaluation/__init__.py +1 -0
  19. package/mcp_server/evaluation/fabric.py +575 -0
  20. package/mcp_server/evaluation/feature_extractors.py +84 -0
  21. package/mcp_server/evaluation/policy.py +67 -0
  22. package/mcp_server/evaluation/tools.py +53 -0
  23. package/mcp_server/m4l_bridge.py +9 -1
  24. package/mcp_server/memory/__init__.py +11 -2
  25. package/mcp_server/memory/anti_memory.py +78 -0
  26. package/mcp_server/memory/promotion.py +94 -0
  27. package/mcp_server/memory/session_memory.py +108 -0
  28. package/mcp_server/memory/taste_memory.py +158 -0
  29. package/mcp_server/memory/technique_store.py +27 -18
  30. package/mcp_server/memory/tools.py +112 -0
  31. package/mcp_server/mix_engine/__init__.py +1 -0
  32. package/mcp_server/mix_engine/critics.py +299 -0
  33. package/mcp_server/mix_engine/models.py +152 -0
  34. package/mcp_server/mix_engine/planner.py +103 -0
  35. package/mcp_server/mix_engine/state_builder.py +316 -0
  36. package/mcp_server/mix_engine/tools.py +220 -0
  37. package/mcp_server/performance_engine/__init__.py +1 -0
  38. package/mcp_server/performance_engine/models.py +148 -0
  39. package/mcp_server/performance_engine/planner.py +267 -0
  40. package/mcp_server/performance_engine/safety.py +165 -0
  41. package/mcp_server/performance_engine/tools.py +183 -0
  42. package/mcp_server/project_brain/__init__.py +6 -0
  43. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  44. package/mcp_server/project_brain/automation_graph.py +72 -0
  45. package/mcp_server/project_brain/builder.py +123 -0
  46. package/mcp_server/project_brain/capability_graph.py +64 -0
  47. package/mcp_server/project_brain/models.py +282 -0
  48. package/mcp_server/project_brain/refresh.py +86 -0
  49. package/mcp_server/project_brain/role_graph.py +103 -0
  50. package/mcp_server/project_brain/session_graph.py +51 -0
  51. package/mcp_server/project_brain/tools.py +144 -0
  52. package/mcp_server/reference_engine/__init__.py +1 -0
  53. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  54. package/mcp_server/reference_engine/models.py +105 -0
  55. package/mcp_server/reference_engine/profile_builder.py +149 -0
  56. package/mcp_server/reference_engine/tactic_router.py +117 -0
  57. package/mcp_server/reference_engine/tools.py +236 -0
  58. package/mcp_server/runtime/__init__.py +1 -0
  59. package/mcp_server/runtime/action_ledger.py +117 -0
  60. package/mcp_server/runtime/action_ledger_models.py +91 -0
  61. package/mcp_server/runtime/action_tools.py +57 -0
  62. package/mcp_server/runtime/capability_state.py +219 -0
  63. package/mcp_server/runtime/safety_kernel.py +339 -0
  64. package/mcp_server/runtime/safety_tools.py +42 -0
  65. package/mcp_server/runtime/tools.py +67 -0
  66. package/mcp_server/server.py +17 -0
  67. package/mcp_server/sound_design/__init__.py +1 -0
  68. package/mcp_server/sound_design/critics.py +297 -0
  69. package/mcp_server/sound_design/models.py +147 -0
  70. package/mcp_server/sound_design/planner.py +104 -0
  71. package/mcp_server/sound_design/tools.py +297 -0
  72. package/mcp_server/tools/_agent_os_engine.py +947 -0
  73. package/mcp_server/tools/_composition_engine.py +1530 -0
  74. package/mcp_server/tools/_conductor.py +199 -0
  75. package/mcp_server/tools/_conductor_budgets.py +222 -0
  76. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  77. package/mcp_server/tools/_form_engine.py +416 -0
  78. package/mcp_server/tools/_motif_engine.py +351 -0
  79. package/mcp_server/tools/_planner_engine.py +516 -0
  80. package/mcp_server/tools/_research_engine.py +542 -0
  81. package/mcp_server/tools/_research_provider.py +185 -0
  82. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  83. package/mcp_server/tools/agent_os.py +448 -0
  84. package/mcp_server/tools/analyzer.py +18 -0
  85. package/mcp_server/tools/automation.py +25 -10
  86. package/mcp_server/tools/composition.py +645 -0
  87. package/mcp_server/tools/devices.py +15 -1
  88. package/mcp_server/tools/midi_io.py +3 -1
  89. package/mcp_server/tools/motif.py +104 -0
  90. package/mcp_server/tools/planner.py +144 -0
  91. package/mcp_server/tools/research.py +223 -0
  92. package/mcp_server/tools/tracks.py +21 -6
  93. package/mcp_server/tools/transport.py +10 -2
  94. package/mcp_server/transition_engine/__init__.py +6 -0
  95. package/mcp_server/transition_engine/archetypes.py +167 -0
  96. package/mcp_server/transition_engine/critics.py +340 -0
  97. package/mcp_server/transition_engine/models.py +90 -0
  98. package/mcp_server/transition_engine/tools.py +291 -0
  99. package/mcp_server/translation_engine/__init__.py +5 -0
  100. package/mcp_server/translation_engine/critics.py +297 -0
  101. package/mcp_server/translation_engine/models.py +27 -0
  102. package/mcp_server/translation_engine/tools.py +108 -0
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +1 -1
  105. package/remote_script/LivePilot/arrangement.py +21 -3
  106. package/remote_script/LivePilot/clips.py +22 -6
  107. package/remote_script/LivePilot/notes.py +9 -1
  108. package/remote_script/LivePilot/server.py +6 -6
@@ -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,448 @@
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 ..memory.technique_store import TechniqueStore
19
+ from . import _agent_os_engine as engine
20
+
21
+ _memory_store = TechniqueStore()
22
+
23
+
24
+ def _get_ableton(ctx: Context):
25
+ return ctx.lifespan_context["ableton"]
26
+
27
+
28
+ def _get_spectral(ctx: Context):
29
+ return ctx.lifespan_context.get("spectral")
30
+
31
+
32
+ def _parse_json_param(value, name: str) -> dict:
33
+ """Parse a dict, JSON string, or None parameter."""
34
+ if value is None:
35
+ return {}
36
+ if isinstance(value, str):
37
+ try:
38
+ return json.loads(value)
39
+ except json.JSONDecodeError as exc:
40
+ raise ValueError(f"Invalid JSON in {name}: {exc}") from exc
41
+ if isinstance(value, dict):
42
+ return value
43
+ raise ValueError(f"{name} must be a dict or JSON string")
44
+
45
+
46
+ # ── compile_goal_vector ───────────────────────────────────────────────
47
+
48
+
49
+ @mcp.tool()
50
+ def compile_goal_vector(
51
+ ctx: Context,
52
+ request_text: str,
53
+ targets: dict | str,
54
+ protect: dict | str = "{}",
55
+ mode: str = "improve",
56
+ aggression: float = 0.5,
57
+ research_mode: str = "none",
58
+ ) -> dict:
59
+ """Compile a user request into a validated GoalVector.
60
+
61
+ The agent interprets the user's natural language into quality dimensions,
62
+ then this tool validates the schema and normalizes weights.
63
+
64
+ targets: dict of dimension → weight (e.g., {"punch": 0.4, "weight": 0.3, "energy": 0.3}).
65
+ Weights are normalized to sum to 1.0.
66
+ protect: dict of dimension → minimum threshold (e.g., {"clarity": 0.8}).
67
+ If a dimension drops below this value after a move, the move is undone.
68
+ mode: observe | improve | explore | finish | diagnose
69
+ aggression: 0.0 (subtle) to 1.0 (bold)
70
+ research_mode: none | targeted | deep
71
+
72
+ Valid dimensions: energy, punch, weight, density, brightness, warmth,
73
+ width, depth, motion, contrast, clarity, cohesion, groove, tension,
74
+ novelty, polish, emotion.
75
+ """
76
+ targets_dict = _parse_json_param(targets, "targets")
77
+ protect_dict = _parse_json_param(protect, "protect")
78
+
79
+ gv = engine.validate_goal_vector(
80
+ request_text=request_text,
81
+ targets=targets_dict,
82
+ protect=protect_dict,
83
+ mode=mode,
84
+ aggression=float(aggression),
85
+ research_mode=research_mode,
86
+ )
87
+
88
+ return {
89
+ "goal_vector": gv.to_dict(),
90
+ "measurable_dimensions": [
91
+ d for d in gv.targets if d in engine.MEASURABLE_PROXIES
92
+ ],
93
+ "unmeasurable_dimensions": [
94
+ d for d in gv.targets if d not in engine.MEASURABLE_PROXIES
95
+ ],
96
+ }
97
+
98
+
99
+ # ── build_world_model ─────────────────────────────────────────────────
100
+
101
+
102
+ @mcp.tool()
103
+ def build_world_model(ctx: Context) -> dict:
104
+ """Build a WorldModel snapshot of the current Ableton session.
105
+
106
+ Reads session info, spectral data (if analyzer available), per-track
107
+ device health, and infers track roles from names. Degrades gracefully
108
+ if M4L Analyzer is not loaded.
109
+
110
+ Returns topology (tracks, devices, scenes), sonic state (spectrum, RMS, key),
111
+ technical state (analyzer/FluCoMa availability, plugin health), and
112
+ inferred track roles.
113
+ """
114
+ ableton = _get_ableton(ctx)
115
+ spectral = _get_spectral(ctx)
116
+
117
+ # Fetch session info (always available)
118
+ session_info = ableton.send_command("get_session_info")
119
+
120
+ # Fetch per-track device info for plugin health checks (I2 fix)
121
+ track_infos = []
122
+ for track in session_info.get("tracks", []):
123
+ try:
124
+ ti = ableton.send_command("get_track_info", {
125
+ "track_index": track["index"]
126
+ })
127
+ track_infos.append(ti)
128
+ except Exception:
129
+ pass # Skip tracks that fail — don't block world model build
130
+
131
+ # Fetch spectral data (may be unavailable)
132
+ spectrum = None
133
+ rms = None
134
+ detected_key = None
135
+ flucoma_status = None
136
+
137
+ if spectral and spectral.is_connected:
138
+ spec_data = spectral.get("spectrum")
139
+ if spec_data:
140
+ spectrum = {"bands": spec_data["value"]}
141
+
142
+ rms_data = spectral.get("rms")
143
+ if rms_data:
144
+ rms = rms_data["value"] if isinstance(rms_data["value"], dict) else {"rms": rms_data["value"]}
145
+
146
+ key_data = spectral.get("key")
147
+ if key_data:
148
+ detected_key = key_data["value"] if isinstance(key_data["value"], dict) else {"key": key_data["value"]}
149
+
150
+ flucoma_data = spectral.get("flucoma_status")
151
+ if flucoma_data:
152
+ flucoma_status = flucoma_data["value"] if isinstance(flucoma_data["value"], dict) else {}
153
+ else:
154
+ flucoma_status = {"flucoma_available": False}
155
+
156
+ # Build model
157
+ wm = engine.build_world_model_from_data(
158
+ session_info=session_info,
159
+ spectrum=spectrum,
160
+ rms=rms,
161
+ detected_key=detected_key,
162
+ flucoma_status=flucoma_status,
163
+ track_infos=track_infos,
164
+ )
165
+
166
+ # Run critics with all-dimensions stub goal to surface all issues.
167
+ # The agent should filter these against its actual GoalVector.
168
+ goal_stub = engine.GoalVector(
169
+ request_text="world_model_build",
170
+ targets={d: 1.0 / len(engine.QUALITY_DIMENSIONS) for d in engine.QUALITY_DIMENSIONS},
171
+ mode="observe",
172
+ )
173
+ sonic_issues = engine.run_sonic_critic(wm.sonic, goal_stub, wm.track_roles)
174
+ technical_issues = engine.run_technical_critic(wm.technical)
175
+
176
+ # Round 1: Wire structural critic (composition engine) into world model
177
+ structural_issues = []
178
+ try:
179
+ from . import _composition_engine as comp_engine
180
+ # Build lightweight section graph for structural analysis
181
+ scenes = session_info.get("scenes", [])
182
+ track_count = session_info.get("track_count", 0)
183
+ clip_matrix = []
184
+ try:
185
+ matrix_data = ableton.send_command("get_scene_matrix")
186
+ clip_matrix = matrix_data.get("matrix", [])
187
+ except Exception:
188
+ pass
189
+
190
+ sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
191
+ structural_issues = comp_engine.run_form_critic(sections)
192
+ except Exception:
193
+ pass # Composition engine unavailable — degrade gracefully
194
+
195
+ result = wm.to_dict()
196
+ result["issues"] = {
197
+ "sonic": [i.to_dict() for i in sonic_issues],
198
+ "technical": [i.to_dict() for i in technical_issues],
199
+ "structural": [i.to_dict() for i in structural_issues],
200
+ "total_count": len(sonic_issues) + len(technical_issues) + len(structural_issues),
201
+ "note": "Issues are unfiltered — filter against your GoalVector targets before acting.",
202
+ }
203
+ return result
204
+
205
+
206
+ # ── evaluate_move ─────────────────────────────────────────────────────
207
+
208
+
209
+ @mcp.tool()
210
+ def evaluate_move(
211
+ ctx: Context,
212
+ goal_vector: dict | str,
213
+ before_snapshot: dict | str,
214
+ after_snapshot: dict | str,
215
+ ) -> dict:
216
+ """Evaluate whether a production move improved the mix toward the goal.
217
+
218
+ Takes before/after sonic snapshots and the active GoalVector.
219
+ Returns a score and keep/undo recommendation.
220
+
221
+ Snapshots should contain: spectrum (8-band dict), rms, peak.
222
+ Get these from get_master_spectrum + get_master_rms before and after
223
+ making changes.
224
+
225
+ Hard rules enforce undo when:
226
+ - No measurable improvement (delta <= 0)
227
+ - Protected dimension dropped below its threshold or by > 0.15
228
+ - Total score < 0.40
229
+
230
+ When all target dimensions are unmeasurable (e.g., groove, tension),
231
+ the tool defers keep/undo to the agent's musical judgment.
232
+
233
+ Returns consecutive_undo_hint=true when keep_change=false — the agent
234
+ should track consecutive undos and switch to observe mode after 3.
235
+ """
236
+ gv_dict = _parse_json_param(goal_vector, "goal_vector")
237
+ before = _parse_json_param(before_snapshot, "before_snapshot")
238
+ after = _parse_json_param(after_snapshot, "after_snapshot")
239
+
240
+ # I6 fix: validate the GoalVector to catch malformed input
241
+ gv = engine.validate_goal_vector(
242
+ request_text=gv_dict.get("request_text", "evaluate"),
243
+ targets=gv_dict.get("targets", {}),
244
+ protect=gv_dict.get("protect", {}),
245
+ mode=gv_dict.get("mode", "improve"),
246
+ aggression=float(gv_dict.get("aggression", 0.5)),
247
+ research_mode=gv_dict.get("research_mode", "none"),
248
+ )
249
+
250
+ return engine.compute_evaluation_score(gv, before, after)
251
+
252
+
253
+ # ── analyze_outcomes (Round 1) ────────────────────────────────────────
254
+
255
+
256
+ @mcp.tool()
257
+ def analyze_outcomes(
258
+ ctx: Context,
259
+ limit: int = 50,
260
+ ) -> dict:
261
+ """Analyze accumulated outcome memories to identify user taste patterns.
262
+
263
+ Reads outcome-type memories from the technique library and returns:
264
+ - keep_rate: what percentage of moves does this user keep?
265
+ - dimension_success: which quality dimensions improve most often?
266
+ - common_kept_moves: which action types work best?
267
+ - common_undone_moves: which action types fail most?
268
+ - taste_vector: inferred dimension preferences from history
269
+
270
+ Use this before choosing moves to align with user taste.
271
+ The more outcomes stored (via memory_learn type="outcome"),
272
+ the better the taste analysis becomes.
273
+ """
274
+ # Fetch outcome memories directly from TechniqueStore
275
+ try:
276
+ techniques = _memory_store.list_techniques(
277
+ type_filter="outcome", sort_by="updated_at", limit=limit,
278
+ )
279
+ except Exception:
280
+ techniques = []
281
+
282
+ # Extract payloads from full technique records
283
+ outcomes = []
284
+ for t in techniques:
285
+ # list_techniques returns compact summaries; get full record for payload
286
+ try:
287
+ full = _memory_store.get(t["id"])
288
+ payload = full.get("payload", {})
289
+ if isinstance(payload, dict):
290
+ outcomes.append(payload)
291
+ except Exception:
292
+ pass
293
+
294
+ return engine.analyze_outcome_history(outcomes)
295
+
296
+
297
+ # ── get_technique_card (Round 2) ──────────────────────────────────────
298
+
299
+
300
+ @mcp.tool()
301
+ def get_technique_card(
302
+ ctx: Context,
303
+ query: str,
304
+ limit: int = 5,
305
+ ) -> dict:
306
+ """Search for technique cards — structured production recipes.
307
+
308
+ Technique cards are reusable recipes saved from successful production
309
+ outcomes. Each card has: problem, context, devices, method, verification.
310
+
311
+ query: search term (e.g., "wider pad", "punchy kick", "sidechain bass")
312
+ limit: max results
313
+ """
314
+ # Search technique cards directly from TechniqueStore
315
+ try:
316
+ techniques = _memory_store.search(
317
+ query=query, type_filter="technique_card", limit=limit,
318
+ )
319
+ except Exception:
320
+ techniques = []
321
+
322
+ cards = []
323
+ for t in techniques:
324
+ # search() returns summaries without payload; get full record
325
+ try:
326
+ full = _memory_store.get(t["id"])
327
+ payload = full.get("payload", {})
328
+ if isinstance(payload, dict):
329
+ cards.append({
330
+ "id": t.get("id"),
331
+ "name": t.get("name"),
332
+ "card": payload,
333
+ "rating": t.get("rating", 0),
334
+ "replay_count": t.get("replay_count", 0),
335
+ })
336
+ except Exception:
337
+ pass
338
+
339
+ return {
340
+ "query": query,
341
+ "cards": cards,
342
+ "count": len(cards),
343
+ }
344
+
345
+
346
+ # ── get_taste_profile (Round 4) ────────────────────────────────────
347
+
348
+
349
+ @mcp.tool()
350
+ def get_taste_profile(
351
+ ctx: Context,
352
+ limit: int = 50,
353
+ ) -> dict:
354
+ """Get the user's production taste profile from outcome history.
355
+
356
+ Analyzes kept vs undone moves to identify: preferred dimensions,
357
+ avoided dimensions, taste vector weights, and overall keep rate.
358
+ Use this to understand what this user values in production.
359
+
360
+ limit: how many outcomes to analyze (default: 50)
361
+
362
+ Returns: {taste_vector, preferred_dimensions, avoided_dimensions,
363
+ keep_rate, sample_size}
364
+ """
365
+ # Fetch outcome memories directly from TechniqueStore
366
+ try:
367
+ techniques = _memory_store.list_techniques(
368
+ type_filter="outcome", sort_by="updated_at", limit=limit,
369
+ )
370
+ except Exception:
371
+ techniques = []
372
+
373
+ outcomes = []
374
+ for t in techniques:
375
+ try:
376
+ full = _memory_store.get(t["id"])
377
+ payload = full.get("payload", {})
378
+ if isinstance(payload, dict):
379
+ outcomes.append(payload)
380
+ except Exception:
381
+ pass
382
+
383
+ return engine.get_taste_profile(outcomes)
384
+
385
+
386
+ # ── get_turn_budget (Conductor Budget) ────────────────────────────
387
+
388
+
389
+ @mcp.tool()
390
+ def get_turn_budget(
391
+ ctx: Context,
392
+ mode: str = "improve",
393
+ aggression: float = 0.5,
394
+ ) -> dict:
395
+ """Get a resource budget for the current agent turn.
396
+
397
+ Returns six resource pools that prevent overcommitting:
398
+ - latency_ms: time budget for this turn
399
+ - risk_points: how much risk is allowed (0-1)
400
+ - novelty_points: how much novelty is allowed (0-1)
401
+ - change_count: max production moves this turn
402
+ - undo_count: max consecutive undos before switching to observe
403
+ - research_calls: max research lookups this turn
404
+
405
+ mode: observe | improve | explore | finish | diagnose | performance
406
+ - observe: very low risk, zero changes, read-only
407
+ - improve: balanced defaults
408
+ - explore: high novelty, high risk, more moves
409
+ - finish: conservative, low novelty, few changes
410
+ - diagnose: zero changes, research-focused
411
+ - performance: very low latency, minimal risk
412
+ aggression: 0.0 (subtle) to 1.0 (bold) — scales risk and change limits
413
+
414
+ Use spend functions via the conductor to track consumption during the turn.
415
+ """
416
+ from . import _conductor_budgets as budgets
417
+
418
+ budget = budgets.create_budget(mode=mode, aggression=float(aggression))
419
+ summary = budgets.get_budget_summary(budget)
420
+ summary["budget"] = budget.to_dict()
421
+ summary["mode"] = mode
422
+ summary["aggression"] = float(aggression)
423
+ return summary
424
+
425
+
426
+ # ── route_request (Conductor) ──────────────────────────────────────
427
+
428
+
429
+ @mcp.tool()
430
+ def route_request(
431
+ ctx: Context,
432
+ request: str,
433
+ ) -> dict:
434
+ """Route a production request to the right engine(s).
435
+
436
+ Analyzes natural language to determine which engines should handle
437
+ the request, in what priority order, with what entry tools.
438
+
439
+ request: what the user wants (e.g., "make this punchier", "turn the
440
+ loop into a song", "make it sound like Burial")
441
+
442
+ Returns: routing plan with engine priorities, entry tools, and
443
+ capability requirements.
444
+ """
445
+ from . import _conductor as conductor
446
+
447
+ plan = conductor.classify_request(request)
448
+ return plan.to_dict()