livepilot 1.10.5 → 1.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +92 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -0,0 +1,158 @@
1
+ """Part of the _agent_os_engine package — extracted from the single-file engine.
2
+
3
+ Pure-computation core. Callers should import from the package facade
4
+ (`from mcp_server.tools._agent_os_engine import X`), which re-exports from
5
+ these sub-modules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Optional
13
+
14
+ from .models import Issue, GoalVector, WorldModel, MEASURABLE_PROXIES
15
+
16
+ def run_sonic_critic(
17
+ sonic: Optional[dict],
18
+ goal: GoalVector,
19
+ track_roles: dict,
20
+ ) -> list[Issue]:
21
+ """Run sonic heuristics against spectrum data. Returns issues that overlap
22
+ with the goal's target dimensions."""
23
+ if sonic is None:
24
+ return [Issue(
25
+ type="analyzer_unavailable",
26
+ critic="sonic",
27
+ severity=0.3,
28
+ confidence=1.0,
29
+ affected_dimensions=list(MEASURABLE_PROXIES.keys()),
30
+ evidence=["M4L Analyzer not connected or no audio playing"],
31
+ recommended_actions=["Load LivePilot_Analyzer on master", "Start playback"],
32
+ )]
33
+
34
+ issues = []
35
+ bands = sonic.get("spectrum", {})
36
+ rms = sonic.get("rms")
37
+ peak = sonic.get("peak")
38
+ target_dims = set(goal.targets.keys())
39
+
40
+ # BUG-B42: if every spectrum band is zero AND rms is zero, playback
41
+ # is stopped (or nothing is routing to master). Spectrum-based
42
+ # critics (weak_foundation, harsh_highs, low_mid_congestion, etc.)
43
+ # would fire on zero data, reporting "no bass!" when the real cause
44
+ # is "no audio". Short-circuit to a playback_required advisory so
45
+ # callers don't chase phantom mix issues during static inspection.
46
+ _all_bands = all(float(bands.get(b, 0) or 0) == 0 for b in
47
+ ("sub", "low", "low_mid", "mid", "high_mid", "high",
48
+ "presence", "air"))
49
+ _silent = _all_bands and (rms is None or float(rms or 0) == 0)
50
+ if _silent:
51
+ return [Issue(
52
+ type="playback_required",
53
+ critic="sonic",
54
+ severity=0.1,
55
+ confidence=1.0,
56
+ affected_dimensions=list(MEASURABLE_PROXIES.keys()),
57
+ evidence=["spectrum and RMS both zero — playback stopped or no signal"],
58
+ recommended_actions=[
59
+ "Start playback before calling build_world_model / "
60
+ "analyze_mix so spectrum-based critics can evaluate.",
61
+ ],
62
+ )]
63
+
64
+ # 1. Mud detection: low_mid congestion
65
+ low_mid = bands.get("low_mid", 0)
66
+ if low_mid > 0.7 and {"clarity", "weight", "warmth"} & target_dims:
67
+ issues.append(Issue(
68
+ type="low_mid_congestion",
69
+ critic="sonic",
70
+ severity=min(1.0, (low_mid - 0.7) * 3.3),
71
+ confidence=0.85,
72
+ affected_dimensions=["clarity", "weight"],
73
+ evidence=[f"low_mid band energy: {low_mid:.2f} (threshold: 0.7)"],
74
+ recommended_actions=["EQ cut 200-500Hz on muddiest track", "HPF on non-bass elements"],
75
+ ))
76
+
77
+ # 2. Weak sub
78
+ sub = bands.get("sub", 0)
79
+ has_bass = any(r in ("kick", "bass", "sub_bass") for r in track_roles.values())
80
+ if sub < 0.15 and has_bass and {"weight", "energy", "punch"} & target_dims:
81
+ issues.append(Issue(
82
+ type="weak_foundation",
83
+ critic="sonic",
84
+ severity=0.6,
85
+ confidence=0.75,
86
+ affected_dimensions=["weight", "energy"],
87
+ evidence=[f"sub band energy: {sub:.2f} with bass tracks present"],
88
+ recommended_actions=["Boost sub on kick/bass", "Check HPF not too aggressive"],
89
+ ))
90
+
91
+ # 3. Harsh top
92
+ high = bands.get("high", 0)
93
+ presence = bands.get("presence", 0)
94
+ if (high + presence) > 0.8 and {"brightness", "clarity", "warmth"} & target_dims:
95
+ issues.append(Issue(
96
+ type="harsh_highs",
97
+ critic="sonic",
98
+ severity=min(1.0, ((high + presence) - 0.8) * 2.5),
99
+ confidence=0.80,
100
+ affected_dimensions=["brightness", "clarity"],
101
+ evidence=[f"high+presence: {high + presence:.2f} (threshold: 0.8)"],
102
+ recommended_actions=["Reduce high shelf on brightest element", "Add subtle LP filter"],
103
+ ))
104
+
105
+ # 4. Low headroom
106
+ if rms is not None and rms > 0.9 and {"energy", "punch", "clarity"} & target_dims:
107
+ issues.append(Issue(
108
+ type="headroom_risk",
109
+ critic="sonic",
110
+ severity=min(1.0, (rms - 0.9) * 10),
111
+ confidence=0.90,
112
+ affected_dimensions=["energy", "clarity", "punch"],
113
+ evidence=[f"RMS: {rms:.3f} (threshold: 0.9)"],
114
+ recommended_actions=["Reduce master volume", "Lower loudest track", "Add limiter"],
115
+ ))
116
+
117
+ # 5. Flat dynamics (C1 fix: correct dB formula)
118
+ if rms is not None and peak is not None and rms > 0 and peak > 0:
119
+ crest_db = 20.0 * math.log10(peak / max(rms, 0.001))
120
+ if crest_db < 3.0 and {"punch", "energy", "contrast"} & target_dims:
121
+ issues.append(Issue(
122
+ type="dynamics_flat",
123
+ critic="sonic",
124
+ severity=0.5,
125
+ confidence=0.70,
126
+ affected_dimensions=["punch", "contrast"],
127
+ evidence=[f"crest factor: {crest_db:.1f} dB (threshold: 3 dB)"],
128
+ recommended_actions=["Reduce compression", "Add transient shaper", "Reduce limiter"],
129
+ ))
130
+
131
+ return issues
132
+
133
+ def run_technical_critic(technical: dict) -> list[Issue]:
134
+ """Check technical health of the session."""
135
+ issues = []
136
+
137
+ if not technical.get("analyzer_available", False):
138
+ issues.append(Issue(
139
+ type="analyzer_offline",
140
+ critic="technical",
141
+ severity=0.4,
142
+ confidence=1.0,
143
+ evidence=["LivePilot Analyzer not receiving data"],
144
+ recommended_actions=["Load LivePilot_Analyzer.amxd on master track"],
145
+ ))
146
+
147
+ for dev in technical.get("unhealthy_devices", []):
148
+ issues.append(Issue(
149
+ type="unhealthy_plugin",
150
+ critic="technical",
151
+ severity=0.7,
152
+ confidence=0.95,
153
+ evidence=[f"Track {dev['track']}: {dev['device']} — {dev['flag']}"],
154
+ recommended_actions=["Delete and replace with native Ableton device"],
155
+ ))
156
+
157
+ return issues
158
+
@@ -0,0 +1,206 @@
1
+ """Part of the _agent_os_engine package — extracted from the single-file engine.
2
+
3
+ Pure-computation core. Callers should import from the package facade
4
+ (`from mcp_server.tools._agent_os_engine import X`), which re-exports from
5
+ these sub-modules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Optional
13
+
14
+ from .models import QUALITY_DIMENSIONS, GoalVector, WorldModel, _clamp
15
+ from .taste import compute_taste_fit
16
+
17
+
18
+ # ── Evaluation Engine ─────────────────────────────────────────────────
19
+ # _clamp lives in .models — shared with taste.py to avoid circular imports.
20
+
21
+ def _extract_dimension_value(
22
+ sonic: dict,
23
+ dimension: str,
24
+ ) -> Optional[float]:
25
+ """Map a quality dimension to a measurable value from sonic data.
26
+
27
+ Returns None for unmeasurable dimensions (confidence=0.0 in Phase 1).
28
+ All returned values are clamped to 0.0-1.0 for consistent scoring.
29
+ """
30
+ if not sonic:
31
+ return None
32
+ # Accept both "spectrum" and "bands" keys — get_master_spectrum returns
33
+ # {"bands": {...}} while the evaluator historically expected {"spectrum": {...}}.
34
+ # Finding 2 fix: tolerate either shape so raw analyzer output works.
35
+ bands = sonic.get("spectrum") or sonic.get("bands")
36
+ if not bands:
37
+ return None
38
+ rms = sonic.get("rms")
39
+ peak = sonic.get("peak")
40
+
41
+ if dimension == "brightness":
42
+ high = bands.get("high", 0)
43
+ presence = bands.get("presence", 0)
44
+ return _clamp((high + presence) / 2.0)
45
+ elif dimension == "warmth":
46
+ return _clamp(bands.get("low_mid", 0))
47
+ elif dimension == "weight":
48
+ sub = bands.get("sub", 0)
49
+ low = bands.get("low", 0)
50
+ return _clamp((sub + low) / 2.0)
51
+ elif dimension == "clarity":
52
+ low_mid = bands.get("low_mid", 0)
53
+ return _clamp(1.0 - low_mid)
54
+ elif dimension == "density":
55
+ # Spectral flatness: geometric mean / arithmetic mean of band values.
56
+ # Higher = more evenly distributed energy (noise-like).
57
+ # Lower = more tonal (energy concentrated in few bands).
58
+ vals = [max(v, 1e-10) for v in bands.values() if isinstance(v, (int, float))]
59
+ if not vals:
60
+ return None
61
+ geo_mean = math.exp(sum(math.log(v) for v in vals) / len(vals))
62
+ arith_mean = sum(vals) / len(vals)
63
+ return _clamp(geo_mean / max(arith_mean, 1e-10))
64
+ elif dimension == "energy":
65
+ return _clamp(rms) if rms is not None else None
66
+ elif dimension == "punch":
67
+ if rms and peak and rms > 0:
68
+ crest_db = 20.0 * math.log10(max(peak / rms, 1.0))
69
+ # Normalize: 0 dB = 0.0, 20 dB = 1.0
70
+ return _clamp(crest_db / 20.0)
71
+ return None
72
+ else:
73
+ # Unmeasurable in Phase 1 (width, depth, motion, contrast,
74
+ # groove, tension, novelty, polish, emotion, cohesion)
75
+ return None
76
+
77
+ def compute_evaluation_score(
78
+ goal: GoalVector,
79
+ before_sonic: dict,
80
+ after_sonic: dict,
81
+ outcome_history: Optional[list[dict]] = None,
82
+ ) -> dict:
83
+ """Compute whether a move improved the mix toward the goal.
84
+
85
+ Returns:
86
+ {
87
+ "score": float (0-1),
88
+ "keep_change": bool,
89
+ "goal_progress": float (-1 to 1),
90
+ "collateral_damage": float (0-1),
91
+ "measurable_delta": float (-1 to 1),
92
+ "notes": list[str],
93
+ "dimension_changes": dict,
94
+ "consecutive_undo_hint": bool,
95
+ }
96
+ """
97
+ notes: list[str] = []
98
+ dimension_changes: dict[str, dict] = {}
99
+
100
+ # Compute per-dimension deltas
101
+ total_goal_progress = 0.0
102
+ measurable_count = 0
103
+
104
+ for dim, weight in goal.targets.items():
105
+ before_val = _extract_dimension_value(before_sonic, dim)
106
+ after_val = _extract_dimension_value(after_sonic, dim)
107
+
108
+ if before_val is not None and after_val is not None:
109
+ delta = after_val - before_val
110
+ dimension_changes[dim] = {
111
+ "before": round(before_val, 4),
112
+ "after": round(after_val, 4),
113
+ "delta": round(delta, 4),
114
+ }
115
+ total_goal_progress += delta * weight
116
+ measurable_count += 1
117
+ else:
118
+ notes.append(f"{dim}: not measurable in Phase 1 (confidence=0.0)")
119
+
120
+ # Check protected dimensions (C3 fix: use the actual threshold)
121
+ collateral_damage = 0.0
122
+ protection_violated = False
123
+
124
+ for dim, threshold in goal.protect.items():
125
+ before_val = _extract_dimension_value(before_sonic, dim)
126
+ after_val = _extract_dimension_value(after_sonic, dim)
127
+
128
+ if before_val is not None and after_val is not None:
129
+ drop = before_val - after_val
130
+ if drop > 0:
131
+ collateral_damage = max(collateral_damage, drop)
132
+ # Violation: value dropped below the user's threshold
133
+ if after_val < threshold:
134
+ protection_violated = True
135
+ notes.append(
136
+ f"PROTECTED dimension '{dim}' at {after_val:.3f}, "
137
+ f"below threshold {threshold:.3f}"
138
+ )
139
+ # Also flag large drops even if still above threshold
140
+ elif drop > 0.15:
141
+ protection_violated = True
142
+ notes.append(
143
+ f"PROTECTED dimension '{dim}' dropped by {drop:.3f} "
144
+ f"(absolute drop > 0.15)"
145
+ )
146
+
147
+ # Measurable delta (average improvement across measured dimensions)
148
+ measurable_delta = total_goal_progress / max(measurable_count, 1)
149
+
150
+ # Taste fit: how well does this move align with user preferences?
151
+ taste_fit = compute_taste_fit(goal, outcome_history) if outcome_history else 0.0
152
+
153
+ # Compute composite score (spec section 12.2)
154
+ goal_fit = _clamp(0.5 + total_goal_progress)
155
+ measurable_component = _clamp(0.5 + measurable_delta)
156
+ preservation = _clamp(1.0 - collateral_damage * 5)
157
+ confidence = measurable_count / max(len(goal.targets), 1)
158
+
159
+ score = (
160
+ 0.30 * goal_fit
161
+ + 0.25 * measurable_component
162
+ + 0.15 * preservation
163
+ + 0.10 * taste_fit
164
+ + 0.10 * confidence
165
+ + 0.10 * 1.0 # reversibility: 1.0 for undo-able moves
166
+ )
167
+
168
+ # Hard rules
169
+ keep_change = True
170
+
171
+ if measurable_count > 0 and measurable_delta <= 0:
172
+ keep_change = False
173
+ notes.append("HARD RULE: measurable delta <= 0 — no measurable improvement")
174
+
175
+ if protection_violated:
176
+ keep_change = False
177
+ notes.append("HARD RULE: protected dimension violated")
178
+
179
+ if score < 0.40:
180
+ keep_change = False
181
+ notes.append(f"HARD RULE: total score {score:.3f} < 0.40 threshold")
182
+
183
+ if measurable_count == 0 and not protection_violated:
184
+ # All TARGET dimensions unmeasurable AND no protection violations —
185
+ # defer keep/undo to the agent's musical judgment.
186
+ # IMPORTANT: protection violations still force undo even when
187
+ # targets are unmeasurable (Finding 1 fix).
188
+ keep_change = True
189
+ notes.append(
190
+ "No measurable target dimensions — deferring keep/undo to agent musical judgment"
191
+ )
192
+
193
+ return {
194
+ "score": round(score, 4),
195
+ "keep_change": keep_change,
196
+ "goal_progress": round(total_goal_progress, 4),
197
+ "collateral_damage": round(collateral_damage, 4),
198
+ "measurable_delta": round(measurable_delta, 4),
199
+ "measurable_dimensions": measurable_count,
200
+ "total_dimensions": len(goal.targets),
201
+ "dimension_changes": dimension_changes,
202
+ "notes": notes,
203
+ # I5: hint for the agent to track consecutive undos
204
+ "consecutive_undo_hint": not keep_change,
205
+ }
206
+
@@ -0,0 +1,132 @@
1
+ """Part of the _agent_os_engine package — extracted from the single-file engine.
2
+
3
+ Pure-computation core. Callers should import from the package facade
4
+ (`from mcp_server.tools._agent_os_engine import X`), which re-exports from
5
+ these sub-modules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Optional
13
+
14
+
15
+ # ── Shared utility ────────────────────────────────────────────────────
16
+ def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
17
+ """Clamp value to [lo, hi] range. Shared across evaluation + taste."""
18
+ return max(lo, min(hi, value))
19
+
20
+
21
+ # ── Quality Dimensions ────────────────────────────────────────────────
22
+ QUALITY_DIMENSIONS = frozenset({
23
+ "energy", "punch", "weight", "density", "brightness", "warmth",
24
+ "width", "depth", "motion", "contrast", "clarity", "cohesion",
25
+ "groove", "tension", "novelty", "polish", "emotion",
26
+ })
27
+
28
+ MEASURABLE_PROXIES: dict[str, str] = {
29
+ "brightness": "high + presence bands (averaged)",
30
+ "warmth": "low_mid band energy",
31
+ "weight": "sub + low bands (averaged)",
32
+ "clarity": "inverse of low_mid congestion",
33
+ "density": "spectral flatness (geometric/arithmetic mean ratio)",
34
+ "energy": "RMS level",
35
+ "punch": "crest factor in dB (20*log10(peak/rms))",
36
+ }
37
+
38
+ VALID_MODES = frozenset({"observe", "improve", "explore", "finish", "diagnose"})
39
+
40
+ VALID_RESEARCH_MODES = frozenset({"none", "targeted", "deep"})
41
+
42
+
43
+ # ── GoalVector ────────────────────────────────────────────────────────
44
+ @dataclass
45
+ class GoalVector:
46
+ """Compiled user intent as a machine-usable goal.
47
+
48
+ targets: dimension → weight (0-1). Weights should approximately sum to 1.0.
49
+ protect: dimension → minimum acceptable value (0-1). If a dimension drops
50
+ below this value after a move, the move is undone.
51
+ """
52
+ request_text: str
53
+ targets: dict[str, float] = field(default_factory=dict)
54
+ protect: dict[str, float] = field(default_factory=dict)
55
+ mode: str = "improve"
56
+ aggression: float = 0.5
57
+ research_mode: str = "none"
58
+
59
+ def to_dict(self) -> dict:
60
+ return asdict(self)
61
+
62
+ _ROLE_PATTERNS: list[tuple[str, str]] = [
63
+ (r"kick|bd|bass\s*drum", "kick"),
64
+ (r"snare|sd|snr", "snare"),
65
+ (r"clap|cp|hand\s*clap", "clap"),
66
+ (r"h(?:i)?[\s\-]?hat|hh|hat", "hihat"),
67
+ (r"perc|percussion|conga|bongo|shaker|tamb", "percussion"),
68
+ (r"sub\s*bass|sub", "sub_bass"),
69
+ (r"bass|low", "bass"),
70
+ (r"pad|atmosphere|atmo|ambient|drone", "pad"),
71
+ (r"lead|melody|mel|synth\s*lead", "lead"),
72
+ (r"chord|keys|piano|organ|rhodes", "chords"),
73
+ (r"vocal|vox|voice", "vocal"),
74
+ (r"fx|sfx|riser|sweep|noise|texture|tape", "texture"),
75
+ (r"string", "strings"),
76
+ (r"brass", "brass"),
77
+ (r"resamp|bounce|bus|group|master", "utility"),
78
+ ]
79
+
80
+ @dataclass
81
+ class WorldModel:
82
+ """Session state snapshot for critic analysis."""
83
+ topology: dict = field(default_factory=dict)
84
+ sonic: Optional[dict] = None
85
+ technical: dict = field(default_factory=dict)
86
+ track_roles: dict = field(default_factory=dict)
87
+
88
+ def to_dict(self) -> dict:
89
+ return asdict(self)
90
+
91
+
92
+ # ── Critics ───────────────────────────────────────────────────────────
93
+ @dataclass
94
+ class Issue:
95
+ """A diagnosed problem or opportunity."""
96
+ type: str
97
+ critic: str # "sonic" or "technical"
98
+ severity: float # 0.0-1.0
99
+ confidence: float # 0.0-1.0
100
+ affected_dimensions: list[str] = field(default_factory=list)
101
+ evidence: list[str] = field(default_factory=list)
102
+ recommended_actions: list[str] = field(default_factory=list)
103
+
104
+ def to_dict(self) -> dict:
105
+ return asdict(self)
106
+
107
+
108
+ # ── Technique Cards (Round 2) ─────────────────────────────────────────
109
+ @dataclass
110
+ class TechniqueCard:
111
+ """A structured, reusable production recipe — not just text."""
112
+ problem: str
113
+ context: list[str] = field(default_factory=list) # genre/style tags
114
+ devices: list[str] = field(default_factory=list) # what to load
115
+ method: str = "" # step-by-step instructions
116
+ verification: list[str] = field(default_factory=list) # what to check after
117
+ evidence: dict = field(default_factory=dict) # {sources, in_session_tested}
118
+
119
+ def to_dict(self) -> dict:
120
+ return asdict(self)
121
+
122
+ def to_memory_payload(self) -> dict:
123
+ """Convert to a payload suitable for memory_learn(type='technique_card')."""
124
+ return {
125
+ "problem": self.problem,
126
+ "context": self.context,
127
+ "devices": self.devices,
128
+ "method": self.method,
129
+ "verification": self.verification,
130
+ "evidence": self.evidence,
131
+ }
132
+
@@ -0,0 +1,192 @@
1
+ """Part of the _agent_os_engine package — extracted from the single-file engine.
2
+
3
+ Pure-computation core. Callers should import from the package facade
4
+ (`from mcp_server.tools._agent_os_engine import X`), which re-exports from
5
+ these sub-modules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Optional
13
+
14
+ from .models import QUALITY_DIMENSIONS, _clamp
15
+
16
+
17
+ # ── Outcome Memory Analysis (Round 1) ────────────────────────────────
18
+ def analyze_outcome_history(outcomes: list[dict]) -> dict:
19
+ """Analyze accumulated outcome memories to identify user taste patterns.
20
+
21
+ outcomes: list of outcome technique payloads from memory_list(type="outcome")
22
+ Returns taste analysis: keep rate, dimension success, inferred preferences.
23
+ """
24
+ if not outcomes:
25
+ return {
26
+ "total_outcomes": 0,
27
+ "keep_rate": 0.0,
28
+ "dimension_success": {},
29
+ "common_kept_moves": [],
30
+ "common_undone_moves": [],
31
+ "taste_vector": {},
32
+ "notes": ["No outcome history — use the evaluation loop to build taste data"],
33
+ }
34
+
35
+ total = len(outcomes)
36
+ kept = [o for o in outcomes if o.get("kept", False)]
37
+ undone = [o for o in outcomes if not o.get("kept", False)]
38
+ keep_rate = len(kept) / total
39
+
40
+ # Dimension success: average improvement per dimension when kept
41
+ dimension_success: dict[str, list[float]] = {}
42
+ for o in kept:
43
+ for dim, change in o.get("dimension_changes", {}).items():
44
+ delta = change.get("delta", 0) if isinstance(change, dict) else 0
45
+ dimension_success.setdefault(dim, []).append(delta)
46
+
47
+ avg_dimension_success = {
48
+ dim: round(sum(vals) / len(vals), 4)
49
+ for dim, vals in dimension_success.items()
50
+ if vals
51
+ }
52
+
53
+ # Common move types
54
+ kept_moves = {}
55
+ undone_moves = {}
56
+ for o in kept:
57
+ move_name = o.get("move", {}).get("name", "unknown") if isinstance(o.get("move"), dict) else "unknown"
58
+ kept_moves[move_name] = kept_moves.get(move_name, 0) + 1
59
+ for o in undone:
60
+ move_name = o.get("move", {}).get("name", "unknown") if isinstance(o.get("move"), dict) else "unknown"
61
+ undone_moves[move_name] = undone_moves.get(move_name, 0) + 1
62
+
63
+ common_kept = sorted(kept_moves.items(), key=lambda x: -x[1])[:5]
64
+ common_undone = sorted(undone_moves.items(), key=lambda x: -x[1])[:5]
65
+
66
+ # Taste vector: which dimensions does this user care about?
67
+ # Weight by how often each dimension appears in kept outcomes
68
+ taste_vector: dict[str, float] = {}
69
+ for o in kept:
70
+ gv = o.get("goal_vector", {})
71
+ targets = gv.get("targets", {}) if isinstance(gv, dict) else {}
72
+ for dim, weight in targets.items():
73
+ taste_vector[dim] = taste_vector.get(dim, 0) + weight
74
+
75
+ # Normalize
76
+ taste_total = sum(taste_vector.values())
77
+ if taste_total > 0:
78
+ taste_vector = {k: round(v / taste_total, 3) for k, v in taste_vector.items()}
79
+
80
+ notes = []
81
+ if keep_rate < 0.3:
82
+ notes.append(f"Low keep rate ({keep_rate:.0%}) — agent may be too aggressive")
83
+ if keep_rate > 0.8:
84
+ notes.append(f"High keep rate ({keep_rate:.0%}) — agent is well-calibrated or too conservative")
85
+
86
+ return {
87
+ "total_outcomes": total,
88
+ "kept": len(kept),
89
+ "undone": len(undone),
90
+ "keep_rate": round(keep_rate, 3),
91
+ "dimension_success": avg_dimension_success,
92
+ "common_kept_moves": [{"move": m, "count": c} for m, c in common_kept],
93
+ "common_undone_moves": [{"move": m, "count": c} for m, c in common_undone],
94
+ "taste_vector": taste_vector,
95
+ "notes": notes,
96
+ }
97
+
98
+
99
+ # ── Taste Model (Round 4) ────────────────────────────────────────────
100
+ def compute_taste_fit(
101
+ goal: GoalVector,
102
+ outcome_history: Optional[list[dict]] = None,
103
+ ) -> float:
104
+ """Compute how well a goal aligns with the user's accumulated taste preferences.
105
+
106
+ Analyzes outcome history to build a taste vector (which dimensions matter
107
+ most to this user), then scores the current goal's alignment.
108
+
109
+ Returns 0.0-1.0 where:
110
+ - 0.0 = no data or goal doesn't match taste
111
+ - 1.0 = goal perfectly aligns with user's demonstrated preferences
112
+ """
113
+ if not outcome_history:
114
+ return 0.0
115
+
116
+ # Build taste vector from kept outcomes
117
+ taste_vector: dict[str, float] = {}
118
+ total_kept = 0
119
+
120
+ for o in outcome_history:
121
+ if not o.get("kept", False):
122
+ continue
123
+ total_kept += 1
124
+ gv = o.get("goal_vector", {})
125
+ targets = gv.get("targets", {}) if isinstance(gv, dict) else {}
126
+ for dim, weight in targets.items():
127
+ taste_vector[dim] = taste_vector.get(dim, 0) + weight
128
+
129
+ if not taste_vector or total_kept == 0:
130
+ return 0.0
131
+
132
+ # Normalize taste vector
133
+ taste_total = sum(taste_vector.values())
134
+ if taste_total > 0:
135
+ taste_vector = {k: v / taste_total for k, v in taste_vector.items()}
136
+
137
+ # Score: how much does the current goal overlap with taste preferences?
138
+ # Dot product of normalized goal weights and taste weights
139
+ goal_targets = goal.targets
140
+ if not goal_targets:
141
+ return 0.0
142
+
143
+ goal_total = sum(goal_targets.values())
144
+ if goal_total <= 0:
145
+ return 0.0
146
+
147
+ overlap = 0.0
148
+ for dim, weight in goal_targets.items():
149
+ normalized_weight = weight / goal_total
150
+ taste_weight = taste_vector.get(dim, 0)
151
+ overlap += normalized_weight * taste_weight
152
+
153
+ # Scale: overlap is typically small (product of two normalized distributions)
154
+ # Amplify so that moderate overlap gives a meaningful score
155
+ return _clamp(overlap * 4.0)
156
+
157
+ def get_taste_profile(outcome_history: list[dict]) -> dict:
158
+ """Build a full taste profile from outcome history.
159
+
160
+ Returns: {taste_vector, preferred_dimensions, avoided_dimensions,
161
+ keep_rate, sample_size}
162
+ """
163
+ analysis = analyze_outcome_history(outcome_history)
164
+ taste_vector = analysis.get("taste_vector", {})
165
+
166
+ # Identify preferred and avoided dimensions
167
+ preferred = sorted(taste_vector.items(), key=lambda x: -x[1])[:5]
168
+ avoided_dims: dict[str, float] = {}
169
+ for o in outcome_history:
170
+ if o.get("kept", False):
171
+ continue # Only look at undone moves
172
+ gv = o.get("goal_vector", {})
173
+ targets = gv.get("targets", {}) if isinstance(gv, dict) else {}
174
+ for dim, weight in targets.items():
175
+ avoided_dims[dim] = avoided_dims.get(dim, 0) + weight
176
+
177
+ if avoided_dims:
178
+ avoid_total = sum(avoided_dims.values())
179
+ if avoid_total > 0:
180
+ avoided_dims = {k: v / avoid_total for k, v in avoided_dims.items()}
181
+
182
+ avoided = sorted(avoided_dims.items(), key=lambda x: -x[1])[:5]
183
+
184
+ return {
185
+ "taste_vector": taste_vector,
186
+ "preferred_dimensions": [{"dim": d, "weight": round(w, 3)} for d, w in preferred],
187
+ "avoided_dimensions": [{"dim": d, "weight": round(w, 3)} for d, w in avoided],
188
+ "keep_rate": analysis.get("keep_rate", 0),
189
+ "sample_size": analysis.get("total_outcomes", 0),
190
+ "notes": analysis.get("notes", []),
191
+ }
192
+