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
@@ -12,7 +12,9 @@ from fastmcp import Context
12
12
  from ..server import mcp
13
13
  from . import builder
14
14
  from .models import SongBrain
15
+ import logging
15
16
 
17
+ logger = logging.getLogger(__name__)
16
18
 
17
19
  # Module-level fallback for consumers without ctx.
18
20
  # Prefer ctx.lifespan_context["current_brain"] when ctx is available.
@@ -67,7 +69,8 @@ def _fetch_session_data(ctx: Context) -> dict:
67
69
 
68
70
  try:
69
71
  data["session_info"] = ableton.send_command("get_session_info", {})
70
- except Exception:
72
+ except Exception as exc:
73
+ logger.debug("_fetch_session_data failed: %s", exc)
71
74
  data["session_info"] = {"tempo": 120.0, "track_count": 0}
72
75
 
73
76
  try:
@@ -78,22 +81,23 @@ def _fetch_session_data(ctx: Context) -> dict:
78
81
  zip(matrix.get("scenes", []), matrix.get("matrix", []))
79
82
  )
80
83
  ]
81
- except Exception:
82
- pass
84
+ except Exception as exc:
85
+ logger.debug("_fetch_session_data failed: %s", exc)
83
86
 
84
87
  try:
85
88
  info = data["session_info"]
86
89
  tracks_list = info.get("tracks", [])
87
90
  data["tracks"] = tracks_list if isinstance(tracks_list, list) else []
88
- except Exception:
89
- pass
91
+ except Exception as exc:
92
+ logger.debug("_fetch_session_data failed: %s", exc)
90
93
 
91
94
  # Motif data — via shared motif service (pure-Python, not TCP)
92
95
  try:
93
96
  from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
94
97
  notes_by_track = fetch_notes_from_ableton(ableton, data.get("tracks", []))
95
98
  data["motif_data"] = get_motif_data(notes_by_track)
96
- except Exception:
99
+ except Exception as exc:
100
+ logger.debug("_fetch_session_data failed: %s", exc)
97
101
  pass # Motif graph requires notes in clips; empty is valid
98
102
 
99
103
  # Composition analysis — from musical intelligence detectors (pure computation)
@@ -106,8 +110,8 @@ def _fetch_session_data(ctx: Context) -> dict:
106
110
  "sections": [p.to_dict() for p in purposes],
107
111
  "emotional_arc": arc.to_dict(),
108
112
  }
109
- except Exception:
110
- pass
113
+ except Exception as exc:
114
+ logger.debug("_fetch_session_data failed: %s", exc)
111
115
 
112
116
  # Role graph — from semantic move resolvers (pure computation, no I/O)
113
117
  try:
@@ -118,8 +122,8 @@ def _fetch_session_data(ctx: Context) -> dict:
118
122
  role = infer_role(name)
119
123
  roles[name] = {"index": track.get("index", 0), "role": role}
120
124
  data["role_graph"] = roles
121
- except Exception:
122
- pass
125
+ except Exception as exc:
126
+ logger.debug("_fetch_session_data failed: %s", exc)
123
127
 
124
128
  # Recent moves — from session-scoped action ledger
125
129
  try:
@@ -128,8 +132,8 @@ def _fetch_session_data(ctx: Context) -> dict:
128
132
  if isinstance(ledger, SessionLedger):
129
133
  recent = ledger.get_recent_moves(limit=10)
130
134
  data["recent_moves"] = [e.to_dict() for e in recent]
131
- except Exception:
132
- pass
135
+ except Exception as exc:
136
+ logger.debug("_fetch_session_data failed: %s", exc)
133
137
 
134
138
  return data
135
139
 
@@ -152,6 +156,7 @@ def build_song_brain(ctx: Context) -> dict:
152
156
 
153
157
  # Capability reporting — what data was actually available
154
158
  from ..runtime.capability import build_capability
159
+
155
160
  cap = build_capability(
156
161
  required=["session_info", "scenes", "tracks", "motif_data", "composition_analysis", "role_graph"],
157
162
  available={
@@ -182,6 +187,77 @@ def build_song_brain(ctx: Context) -> dict:
182
187
  }
183
188
 
184
189
 
190
+ def classify_energy_shape(arc: list[float]) -> dict:
191
+ """Classify the shape of an energy arc for user-facing explanation.
192
+
193
+ BUG-B13 fix: the old single-max-position classifier labeled
194
+ [0.7, 0.9, 0.9, 0.5, 0.6, 0.9, 0.4] (peaks at 1-2 AND 5) as
195
+ "front-loaded" because max()'s first occurrence is at index 1.
196
+ We now find ALL peaks above a dynamic threshold and classify by
197
+ count + distribution.
198
+
199
+ Returns {"shape": str, "peak_positions": list[int] | None}.
200
+ """
201
+ arc = [x for x in (arc or []) if x is not None]
202
+ if len(arc) < 3:
203
+ return {"shape": "short form — limited arc data", "peak_positions": None}
204
+
205
+ max_energy = max(arc)
206
+ arc_min = min(arc)
207
+ dynamic_mid = (arc_min + max_energy) / 2.0
208
+ peak_threshold = max(max_energy * 0.9, dynamic_mid)
209
+ peak_indices = [i for i, v in enumerate(arc) if v >= peak_threshold]
210
+
211
+ # Collapse runs of adjacent peak indices into their starting index —
212
+ # [1, 2, 5] has peaks at "position ~1" and "position 5", NOT three
213
+ # distinct peaks. Without this, front-loaded arcs where bars 0 and 1
214
+ # are both above threshold would misfire the dual-peak branch.
215
+ distinct_peaks: list[int] = []
216
+ for idx in peak_indices:
217
+ if not distinct_peaks or idx - distinct_peaks[-1] > 1:
218
+ distinct_peaks.append(idx)
219
+
220
+ n = len(arc)
221
+ first_third = {i for i in range(0, n // 3 + 1)}
222
+ last_third = {i for i in range(2 * n // 3, n)}
223
+ in_first = any(i in first_third for i in peak_indices)
224
+ in_last = any(i in last_third for i in peak_indices)
225
+ in_middle = any(
226
+ i not in first_third and i not in last_third
227
+ for i in peak_indices
228
+ )
229
+
230
+ # Plateau FIRST — when the dynamic range is narrow (<0.3) and most
231
+ # of the arc sits at/near the max, it's a plateau, not a multi-peak
232
+ # shape. Has to win over dual-peak so [0.7, 0.8, 0.8, 0.75, 0.8, …]
233
+ # doesn't get labeled "dual-peak at 2 and 6" when it's clearly flat.
234
+ if len(peak_indices) >= max(n - 2, 2) and (
235
+ max_energy - arc_min < 0.3
236
+ ):
237
+ shape = "plateau — sustained energy with limited dynamic range"
238
+ # Multi-peak: at least 2 DISTINCT peaks (after collapsing adjacent runs),
239
+ # separated by >= n/3 positions. Adjacent peaks are a single plateau, not two.
240
+ elif len(distinct_peaks) >= 2 and (
241
+ max(distinct_peaks) - min(distinct_peaks) >= max(n // 3, 2)
242
+ ):
243
+ shape = (
244
+ f"dual-peak — energy peaks at positions "
245
+ f"{distinct_peaks[0]+1} and {distinct_peaks[-1]+1}"
246
+ )
247
+ elif in_first and not in_middle and not in_last:
248
+ shape = "front-loaded — peaks early"
249
+ elif in_last and not in_first and not in_middle:
250
+ shape = "slow burn — builds to late peak"
251
+ elif in_middle and not in_first and not in_last:
252
+ shape = "centered arc — peaks in the middle"
253
+ else:
254
+ shape = (
255
+ f"mixed — peaks at positions "
256
+ f"{', '.join(str(i+1) for i in peak_indices)}"
257
+ )
258
+ return {"shape": shape, "peak_positions": peak_indices}
259
+
260
+
185
261
  @mcp.tool()
186
262
  def explain_song_identity(ctx: Context) -> dict:
187
263
  """Explain the current song's identity in human musical language.
@@ -223,20 +299,13 @@ def explain_song_identity(ctx: Context) -> dict:
223
299
  for s in brain.section_purposes
224
300
  ]
225
301
 
226
- # Energy shape
302
+ # Energy shape — BUG-B13 fix: dual-peak detection. See
303
+ # classify_energy_shape() for logic.
227
304
  if brain.energy_arc:
228
- arc = brain.energy_arc
229
- if len(arc) >= 3:
230
- peak_idx = arc.index(max(arc))
231
- peak_pct = peak_idx / max(len(arc) - 1, 1)
232
- if peak_pct < 0.3:
233
- explanation["energy_shape"] = "front-loaded — peaks early"
234
- elif peak_pct > 0.7:
235
- explanation["energy_shape"] = "slow burn — builds to late peak"
236
- else:
237
- explanation["energy_shape"] = "centered arc — peaks in the middle"
238
- else:
239
- explanation["energy_shape"] = "short form — limited arc data"
305
+ shape_info = classify_energy_shape(brain.energy_arc)
306
+ explanation["energy_shape"] = shape_info["shape"]
307
+ if shape_info["peak_positions"] is not None:
308
+ explanation["peak_positions"] = shape_info["peak_positions"]
240
309
 
241
310
  # Open questions
242
311
  if brain.open_questions:
@@ -195,6 +195,46 @@ def _fetch_sound_design_data(ctx: Context, track_index: int) -> dict:
195
195
  }
196
196
 
197
197
 
198
+ # BUG-B35: some roles are SUPPOSED to be simple. A kick, snare, or sub-bass
199
+ # patch with one block + a saturator is textbook electronic drum design —
200
+ # not "weak identity". Gate the "too_few_blocks" + "no_modulation_sources"
201
+ # critics on track role so we don't pester users about their perfectly
202
+ # serviceable DS Kick patches.
203
+ _SIMPLE_ROLE_TOKENS = (
204
+ "kick", "snare", "clap", "rim", "hat", "hihat", "hi-hat",
205
+ "drum", "drums", "perc", "percussion", "conga", "shaker",
206
+ "tambourine", "cowbell", "tom", "crash", "ride", "cymbal",
207
+ "808", "sub", "sub bass", "sub_bass",
208
+ )
209
+
210
+
211
+ def _is_simple_role_track(track_name: str) -> bool:
212
+ """True when the track name matches a role where simple patches are
213
+ the correct creative choice (kick/snare/drums/sub)."""
214
+ if not track_name:
215
+ return False
216
+ lowered = str(track_name).lower()
217
+ return any(tok in lowered for tok in _SIMPLE_ROLE_TOKENS)
218
+
219
+
220
+ _ROLE_SUPPRESSIBLE_ISSUES = frozenset({
221
+ "too_few_blocks",
222
+ "no_modulation_sources",
223
+ })
224
+
225
+
226
+ def _filter_role_appropriate_issues(
227
+ issues: list,
228
+ track_name: str,
229
+ ) -> list:
230
+ """Drop issues whose type is in _ROLE_SUPPRESSIBLE_ISSUES when the
231
+ track role (inferred from name) is one where simplicity is expected.
232
+ Issues pass through unchanged for pad / lead / synth / bass roles."""
233
+ if not _is_simple_role_track(track_name):
234
+ return issues
235
+ return [i for i in issues if i.issue_type not in _ROLE_SUPPRESSIBLE_ISSUES]
236
+
237
+
198
238
  # ── MCP Tools ────────────────────────────────────────────────────────
199
239
 
200
240
 
@@ -217,6 +257,10 @@ def analyze_sound_design(ctx: Context, track_index: int) -> dict:
217
257
  layers=layers,
218
258
  )
219
259
  issues = run_all_sound_design_critics(state)
260
+ # BUG-B35: gate role-sensitive critics by track name
261
+ issues = _filter_role_appropriate_issues(
262
+ issues, data["track_info"].get("name", "")
263
+ )
220
264
  moves = plan_sound_design_moves(issues, state)
221
265
 
222
266
  return {
@@ -246,6 +290,9 @@ def get_sound_design_issues(ctx: Context, track_index: int) -> dict:
246
290
  layers=layers,
247
291
  )
248
292
  issues = run_all_sound_design_critics(state)
293
+ issues = _filter_role_appropriate_issues(
294
+ issues, data["track_info"].get("name", "")
295
+ )
249
296
 
250
297
  return {
251
298
  "issues": [i.to_dict() for i in issues],
@@ -260,6 +307,11 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
260
307
  Runs critics and planner, returns sorted moves with
261
308
  estimated impact and risk scores.
262
309
 
310
+ BUG-B36 fix: when zero sound-design issues but sibling mix/
311
+ composition engines flag problems on the same track, returns a
312
+ `cross_engine_hint` pointing the user to the right tool instead
313
+ of silently reporting empty.
314
+
263
315
  Args:
264
316
  track_index: Index of the track to analyze.
265
317
  """
@@ -272,14 +324,73 @@ def plan_sound_design_move(ctx: Context, track_index: int) -> dict:
272
324
  layers=layers,
273
325
  )
274
326
  issues = run_all_sound_design_critics(state)
327
+ issues = _filter_role_appropriate_issues(
328
+ issues, data["track_info"].get("name", "")
329
+ )
275
330
  moves = plan_sound_design_moves(issues, state)
276
331
 
277
- return {
332
+ result: dict = {
278
333
  "moves": [m.to_dict() for m in moves],
279
334
  "move_count": len(moves),
280
335
  "issue_count": len(issues),
281
336
  }
282
337
 
338
+ # BUG-B36: when nothing to do on the sound-design side, probe sibling
339
+ # engines for issues on this track and emit a discoverability hint.
340
+ if not moves:
341
+ cross_hint = _cross_engine_hint_for_track(ctx, track_index)
342
+ if cross_hint:
343
+ result["cross_engine_hint"] = cross_hint
344
+
345
+ return result
346
+
347
+
348
+ def _cross_engine_hint_for_track(
349
+ ctx: Context, track_index: int,
350
+ ) -> Optional[str]:
351
+ """Look at mix issues for this track and emit a hint pointing to
352
+ the right sibling tool when sound-design has nothing to say.
353
+
354
+ Best-effort — any failure returns None so plan_sound_design_move
355
+ never breaks on telemetry hiccups. Calls into the mix-engine's
356
+ pure function directly (not an MCP round-trip) to avoid the
357
+ one-client-per-port contention.
358
+ """
359
+ try:
360
+ from ..mix_engine.critics import run_all_mix_critics
361
+ from ..mix_engine.state_builder import build_mix_state
362
+ from ..mix_engine.tools import _fetch_mix_data
363
+ data = _fetch_mix_data(ctx)
364
+ mix_state = build_mix_state(
365
+ session_info=data.get("session_info", {}),
366
+ track_infos=data.get("track_infos", []),
367
+ spectrum=data.get("spectrum"),
368
+ rms_data=data.get("rms_data"),
369
+ )
370
+ mix_issues = run_all_mix_critics(mix_state)
371
+ except Exception:
372
+ return None
373
+
374
+ if not mix_issues:
375
+ return None
376
+ track_issues = [
377
+ i for i in mix_issues
378
+ if getattr(i, "track_index", None) == track_index
379
+ ]
380
+ if not track_issues:
381
+ return None
382
+ top = max(
383
+ track_issues,
384
+ key=lambda i: float(getattr(i, "severity", 0) or 0),
385
+ )
386
+ issue_type = str(getattr(top, "issue_type", None) or getattr(top, "type", "issue"))
387
+ sev = float(getattr(top, "severity", 0) or 0)
388
+ return (
389
+ f"No sound-design issues on this track, but mix critic flagged "
390
+ f"'{issue_type}' (severity {sev:.2f}). "
391
+ f"Try plan_mix_move for the same track_index."
392
+ )
393
+
283
394
 
284
395
  @mcp.tool()
285
396
  def get_patch_model(ctx: Context, track_index: int) -> dict:
@@ -190,12 +190,25 @@ class SpliceGRPCClient:
190
190
  ) -> Optional[str]:
191
191
  """Download a sample by file_hash. Returns local path when complete.
192
192
 
193
- Costs 1 credit. Checks credit floor before downloading.
194
- Returns None on failure.
193
+ Costs 1 credit. Enforces CREDIT_HARD_FLOOR defensively refuses the
194
+ download (returns None) if completing it would leave the user at or
195
+ below the floor, regardless of what the caller requested. Callers
196
+ should still gate on `can_afford` upstream for UX, but this guard
197
+ closes the hole if a future caller forgets.
195
198
  """
196
199
  if not self.connected:
197
200
  return None
198
201
 
202
+ # Defensive floor guard — do not rely on callers alone.
203
+ can, remaining = await self.can_afford(1, budget=1)
204
+ if not can:
205
+ logger.warning(
206
+ "Splice download blocked by credit floor guard "
207
+ "(remaining=%s, floor=%s, file_hash=%s)",
208
+ remaining, CREDIT_HARD_FLOOR, file_hash,
209
+ )
210
+ return None
211
+
199
212
  pb2 = self._pb2
200
213
  try:
201
214
  # Trigger download
@@ -221,8 +234,8 @@ class SpliceGRPCClient:
221
234
  )
222
235
  if response.Sample.LocalPath:
223
236
  return response.Sample.LocalPath
224
- except Exception:
225
- pass
237
+ except Exception as exc:
238
+ logger.debug("_wait_for_download failed: %s", exc)
226
239
  await asyncio.sleep(0.5)
227
240
  logger.warning(f"Download timed out for {file_hash}")
228
241
  return None
@@ -304,9 +317,9 @@ class SpliceGRPCClient:
304
317
  try:
305
318
  await self.stub.SyncSounds(pb2.SyncSoundsRequest())
306
319
  return True
307
- except Exception:
320
+ except Exception as exc:
321
+ logger.debug("sync_sounds failed: %s", exc)
308
322
  return False
309
-
310
323
  # ── Connection Helpers ──────────────────────────────────────────
311
324
 
312
325
  def _read_port(self) -> Optional[int]:
@@ -20,14 +20,30 @@ def detect_stuckness(
20
20
  session_info: Optional[dict] = None,
21
21
  song_brain: Optional[dict] = None,
22
22
  section_count: int = 0,
23
+ state_signals: Optional[dict] = None,
23
24
  ) -> StucknessReport:
24
25
  """Detect whether the session is stuck.
25
26
 
26
27
  Analyzes action history for repeated undos, local tweaking,
27
28
  long loops without structural edits, and other stuckness signals.
29
+
30
+ BUG-B6 / B20 fix: also accepts `state_signals` — a dict of
31
+ current-session-state indicators that may reveal stuckness even
32
+ when the action ledger is empty. Known keys (all optional):
33
+ fatigue_level: float 0-1 (from detect_repetition_fatigue)
34
+ motif_overuse_count: int (motifs exceeding overuse threshold)
35
+ emotional_arc_issues: list of issue-type strings
36
+ transition_issues: int
37
+ support_too_loud: bool (from analyze_mix)
38
+ automation_density: float (0 = no clip automation anywhere)
39
+
40
+ When state_signals are provided, they contribute to confidence
41
+ alongside ledger-based signals (ledger weighting is still
42
+ dominant — ledger signals indicate active-user-is-stuck behavior).
28
43
  """
29
44
  session_info = session_info or {}
30
45
  song_brain = song_brain or {}
46
+ state_signals = state_signals or {}
31
47
  signals: list[StucknessSignal] = []
32
48
 
33
49
  # 1. Repeated undos
@@ -60,6 +76,10 @@ def detect_stuckness(
60
76
  if identity_signal:
61
77
  signals.append(identity_signal)
62
78
 
79
+ # 7. BUG-B6 / B20 — state critics
80
+ state_derived = _state_signals_to_signal_list(state_signals)
81
+ signals.extend(state_derived)
82
+
63
83
  # Compute overall confidence
64
84
  if not signals:
65
85
  return StucknessReport(confidence=0.0, level="flowing")
@@ -98,6 +118,76 @@ def detect_stuckness(
98
118
  # ── Signal checkers ───────────────────────────────────────────────
99
119
 
100
120
 
121
+ def _state_signals_to_signal_list(state: dict) -> list[StucknessSignal]:
122
+ """Convert a state_signals dict (from sibling critics) into
123
+ StucknessSignal entries. BUG-B6 / B20: previously the detector
124
+ ignored session state entirely — so a session with fatigue_level=0.93
125
+ but an empty action ledger always reported "flowing". Now we surface
126
+ state-based stuckness but keep the signals at a slightly lower
127
+ weight than ledger signals (ledger = active-user-is-stuck behavior,
128
+ state = project-shape-is-stuck).
129
+ """
130
+ out: list[StucknessSignal] = []
131
+ if not state:
132
+ return out
133
+
134
+ # Fatigue / repetition
135
+ fatigue = state.get("fatigue_level")
136
+ if isinstance(fatigue, (int, float)) and fatigue >= 0.6:
137
+ out.append(StucknessSignal(
138
+ signal_type="state_repetition_fatigue",
139
+ # Scale from 0.6-1.0 → 0.5-0.85 (sub-ledger weight)
140
+ strength=min(0.85, 0.5 + (fatigue - 0.6) * 0.875),
141
+ evidence=(
142
+ f"repetition fatigue at {fatigue:.2f} — clips/sections "
143
+ f"overused"
144
+ ),
145
+ ))
146
+
147
+ motif_overuse = state.get("motif_overuse_count", 0)
148
+ if isinstance(motif_overuse, int) and motif_overuse >= 3:
149
+ out.append(StucknessSignal(
150
+ signal_type="state_motif_overuse",
151
+ strength=min(0.7, 0.3 + motif_overuse * 0.1),
152
+ evidence=f"{motif_overuse} motifs flagged as overused",
153
+ ))
154
+
155
+ arc_issues = state.get("emotional_arc_issues") or []
156
+ if isinstance(arc_issues, (list, tuple)) and arc_issues:
157
+ out.append(StucknessSignal(
158
+ signal_type="state_emotional_arc",
159
+ strength=min(0.7, 0.3 + len(arc_issues) * 0.1),
160
+ evidence=(
161
+ f"emotional-arc issues: {', '.join(str(i) for i in arc_issues[:3])}"
162
+ ),
163
+ ))
164
+
165
+ transition_issues = state.get("transition_issues", 0)
166
+ if isinstance(transition_issues, int) and transition_issues >= 3:
167
+ out.append(StucknessSignal(
168
+ signal_type="state_transition_issues",
169
+ strength=min(0.7, 0.25 + transition_issues * 0.08),
170
+ evidence=f"{transition_issues} transition issues detected",
171
+ ))
172
+
173
+ if state.get("support_too_loud"):
174
+ out.append(StucknessSignal(
175
+ signal_type="state_mix_imbalance",
176
+ strength=0.35,
177
+ evidence="mix critic flagged a support element as too loud",
178
+ ))
179
+
180
+ auto_density = state.get("automation_density")
181
+ if isinstance(auto_density, (int, float)) and auto_density <= 0.05:
182
+ out.append(StucknessSignal(
183
+ signal_type="state_no_automation",
184
+ strength=0.35,
185
+ evidence="no clip automation detected — arrangement is static",
186
+ ))
187
+
188
+ return out
189
+
190
+
101
191
  def _check_repeated_undos(history: list[dict]) -> Optional[StucknessSignal]:
102
192
  """Check for repeated undone moves (kept=False in ledger entries)."""
103
193
  recent = history[-20:] if len(history) > 20 else history
@@ -11,6 +11,9 @@ from fastmcp import Context
11
11
 
12
12
  from ..server import mcp
13
13
  from . import detector
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
14
17
 
15
18
 
16
19
  def _get_ableton(ctx: Context):
@@ -30,8 +33,8 @@ def _get_action_history(ctx: Context) -> list[dict]:
30
33
  if isinstance(ledger, SessionLedger):
31
34
  recent = ledger.get_recent_moves(limit=20)
32
35
  return [e.to_dict() for e in recent]
33
- except Exception:
34
- pass
36
+ except Exception as exc:
37
+ logger.debug("_get_action_history failed: %s", exc)
35
38
  return []
36
39
 
37
40
 
@@ -45,9 +48,8 @@ def _get_session_and_brain(ctx: Context) -> tuple[dict, dict, int]:
45
48
  try:
46
49
  session_info = ableton.send_command("get_session_info", {})
47
50
  section_count = session_info.get("scene_count", 0)
48
- except Exception:
49
- pass
50
-
51
+ except Exception as exc:
52
+ logger.debug("_get_session_and_brain failed: %s", exc)
51
53
  try:
52
54
  from ..song_brain.tools import _current_brain
53
55
  if _current_brain is not None:
@@ -60,6 +62,43 @@ def _get_session_and_brain(ctx: Context) -> tuple[dict, dict, int]:
60
62
  return session_info, song_brain, section_count
61
63
 
62
64
 
65
+ def _gather_state_signals(ctx: Context, song_brain: dict) -> dict:
66
+ """BUG-B6 / B20: collect current-session-state stuckness indicators
67
+ that the detector can merge with ledger-based signals.
68
+
69
+ All lookups are best-effort — if a sibling module isn't available
70
+ or its data is stale, we omit the signal (don't guess).
71
+ """
72
+ signals: dict = {}
73
+
74
+ # Repetition fatigue from musical_intelligence.detectors
75
+ try:
76
+ from ..musical_intelligence.tools import _current_fatigue_cache # type: ignore
77
+ if isinstance(_current_fatigue_cache, dict):
78
+ fl = _current_fatigue_cache.get("fatigue_level")
79
+ if isinstance(fl, (int, float)):
80
+ signals["fatigue_level"] = float(fl)
81
+ overuse = _current_fatigue_cache.get("motif_overuse_count")
82
+ if isinstance(overuse, int):
83
+ signals["motif_overuse_count"] = overuse
84
+ except Exception as exc:
85
+ logger.debug("_gather_state_signals fatigue fetch failed: %s", exc)
86
+
87
+ # Emotional-arc issues directly from song brain (already fetched)
88
+ arc_issues = []
89
+ if isinstance(song_brain, dict):
90
+ oqs = song_brain.get("open_questions") or []
91
+ for q in oqs:
92
+ if isinstance(q, dict):
93
+ qtype = q.get("question_type") or q.get("type") or ""
94
+ if "arc" in str(qtype).lower() or "payoff" in str(qtype).lower():
95
+ arc_issues.append(qtype)
96
+ if arc_issues:
97
+ signals["emotional_arc_issues"] = arc_issues
98
+
99
+ return signals
100
+
101
+
63
102
  @mcp.tool()
64
103
  def detect_stuckness(ctx: Context) -> dict:
65
104
  """Detect whether the session is losing momentum.
@@ -77,12 +116,14 @@ def detect_stuckness(ctx: Context) -> dict:
77
116
  """
78
117
  history = _get_action_history(ctx)
79
118
  session_info, song_brain, section_count = _get_session_and_brain(ctx)
119
+ state_signals = _gather_state_signals(ctx, song_brain)
80
120
 
81
121
  report = detector.detect_stuckness(
82
122
  action_history=history,
83
123
  session_info=session_info,
84
124
  song_brain=song_brain,
85
125
  section_count=section_count,
126
+ state_signals=state_signals,
86
127
  )
87
128
 
88
129
  return report.to_dict()
@@ -108,12 +149,14 @@ def suggest_momentum_rescue(
108
149
 
109
150
  history = _get_action_history(ctx)
110
151
  session_info, song_brain, section_count = _get_session_and_brain(ctx)
152
+ state_signals = _gather_state_signals(ctx, song_brain)
111
153
 
112
154
  report = detector.detect_stuckness(
113
155
  action_history=history,
114
156
  session_info=session_info,
115
157
  song_brain=song_brain,
116
158
  section_count=section_count,
159
+ state_signals=state_signals,
117
160
  )
118
161
 
119
162
  if report.level == "flowing":
@@ -165,6 +208,7 @@ def start_rescue_workflow(
165
208
 
166
209
  # Build a rescue suggestion for this specific type
167
210
  from .models import StucknessReport
211
+
168
212
  report = StucknessReport(
169
213
  confidence=0.6,
170
214
  level="stuck",
@@ -0,0 +1,52 @@
1
+ """Agent OS engine — goal compilation, world model, evaluation.
2
+
3
+ This package replaces the former single-file `_agent_os_engine.py`.
4
+ Public surface unchanged — callers import the same names.
5
+
6
+ Internal organization:
7
+ models.py — Dataclasses + module-level constants
8
+ world_model.py — Goal validation, role inference, world-model build
9
+ critics.py — Sonic + technical critics
10
+ evaluation.py — Scoring, dimension extraction, clamp helpers
11
+ techniques.py — TechniqueCard mining + building
12
+ taste.py — Outcome analysis, taste fit, taste profile
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .models import (
17
+ QUALITY_DIMENSIONS, MEASURABLE_PROXIES,
18
+ VALID_MODES, VALID_RESEARCH_MODES,
19
+ GoalVector, WorldModel, Issue, TechniqueCard,
20
+ )
21
+ from .world_model import (
22
+ validate_goal_vector,
23
+ infer_track_role,
24
+ build_world_model_from_data,
25
+ )
26
+ from .critics import run_sonic_critic, run_technical_critic
27
+ from .evaluation import compute_evaluation_score, _extract_dimension_value
28
+ from .techniques import (
29
+ build_technique_card_from_outcome,
30
+ should_mine_technique,
31
+ mine_technique_from_outcome,
32
+ )
33
+ from .taste import (
34
+ analyze_outcome_history,
35
+ compute_taste_fit,
36
+ get_taste_profile,
37
+ )
38
+
39
+ __all__ = [
40
+ "QUALITY_DIMENSIONS", "MEASURABLE_PROXIES",
41
+ "VALID_MODES", "VALID_RESEARCH_MODES",
42
+ "GoalVector", "WorldModel", "Issue", "TechniqueCard",
43
+ "validate_goal_vector", "infer_track_role", "build_world_model_from_data",
44
+ "run_sonic_critic", "run_technical_critic",
45
+ "compute_evaluation_score",
46
+ "build_technique_card_from_outcome",
47
+ "should_mine_technique",
48
+ "mine_technique_from_outcome",
49
+ "analyze_outcome_history",
50
+ "compute_taste_fit",
51
+ "get_taste_profile",
52
+ ]