livepilot 1.10.4 → 1.10.6

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 (74) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +148 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +6 -6
  6. package/livepilot/.Codex-plugin/plugin.json +2 -2
  7. package/livepilot/.claude-plugin/plugin.json +2 -2
  8. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  9. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  10. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  11. package/livepilot/skills/livepilot-release/SKILL.md +5 -5
  12. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  13. package/m4l_device/livepilot_bridge.js +12 -1
  14. package/manifest.json +3 -3
  15. package/mcp_server/__init__.py +1 -1
  16. package/mcp_server/composer/sample_resolver.py +10 -6
  17. package/mcp_server/composer/tools.py +10 -6
  18. package/mcp_server/connection.py +6 -1
  19. package/mcp_server/creative_constraints/tools.py +9 -8
  20. package/mcp_server/experiment/engine.py +9 -5
  21. package/mcp_server/experiment/tools.py +9 -9
  22. package/mcp_server/hook_hunter/tools.py +14 -9
  23. package/mcp_server/m4l_bridge.py +11 -0
  24. package/mcp_server/memory/taste_graph.py +7 -2
  25. package/mcp_server/mix_engine/tools.py +8 -3
  26. package/mcp_server/musical_intelligence/tools.py +15 -10
  27. package/mcp_server/performance_engine/tools.py +6 -2
  28. package/mcp_server/preview_studio/tools.py +21 -15
  29. package/mcp_server/project_brain/tools.py +18 -10
  30. package/mcp_server/reference_engine/tools.py +7 -5
  31. package/mcp_server/runtime/capability_probe.py +10 -4
  32. package/mcp_server/runtime/tools.py +8 -2
  33. package/mcp_server/sample_engine/tools.py +394 -33
  34. package/mcp_server/semantic_moves/tools.py +5 -1
  35. package/mcp_server/server.py +10 -9
  36. package/mcp_server/services/motif_service.py +9 -3
  37. package/mcp_server/session_continuity/tools.py +7 -3
  38. package/mcp_server/session_continuity/tracker.py +9 -8
  39. package/mcp_server/song_brain/tools.py +17 -12
  40. package/mcp_server/splice_client/client.py +19 -6
  41. package/mcp_server/stuckness_detector/tools.py +8 -5
  42. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  43. package/mcp_server/tools/_agent_os_engine/critics.py +134 -0
  44. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  45. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  46. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  47. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  48. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  49. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  50. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  51. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  52. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  53. package/mcp_server/tools/_composition_engine/harmony.py +70 -0
  54. package/mcp_server/tools/_composition_engine/models.py +193 -0
  55. package/mcp_server/tools/_composition_engine/sections.py +371 -0
  56. package/mcp_server/tools/_perception_engine.py +18 -11
  57. package/mcp_server/tools/agent_os.py +23 -15
  58. package/mcp_server/tools/analyzer.py +166 -7
  59. package/mcp_server/tools/automation.py +6 -1
  60. package/mcp_server/tools/composition.py +25 -16
  61. package/mcp_server/tools/devices.py +10 -6
  62. package/mcp_server/tools/motif.py +7 -2
  63. package/mcp_server/tools/planner.py +6 -2
  64. package/mcp_server/tools/research.py +13 -10
  65. package/mcp_server/transition_engine/tools.py +6 -1
  66. package/mcp_server/translation_engine/tools.py +8 -6
  67. package/mcp_server/wonder_mode/engine.py +8 -3
  68. package/mcp_server/wonder_mode/tools.py +29 -21
  69. package/package.json +2 -2
  70. package/remote_script/LivePilot/__init__.py +1 -1
  71. package/requirements.txt +6 -0
  72. package/livepilot.mcpb +0 -0
  73. package/mcp_server/tools/_agent_os_engine.py +0 -947
  74. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -0,0 +1,371 @@
1
+ """Part of the _composition_engine package — extracted from the single-file engine.
2
+
3
+ Pure-computation core, no external deps. Callers should import from the package
4
+ facade (e.g. `from mcp_server.tools._composition_engine import X`), which
5
+ re-exports everything from 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 enum import Enum
13
+ from typing import Any, Optional
14
+
15
+ from .models import SectionType, RoleType, SectionNode, PhraseUnit, RoleNode
16
+
17
+ _SECTION_NAME_PATTERNS: list[tuple[str, SectionType]] = [
18
+ (r"intro", SectionType.INTRO),
19
+ (r"verse|vrs", SectionType.VERSE),
20
+ (r"pre[\s\-]?chorus", SectionType.PRE_CHORUS),
21
+ (r"chorus|hook|chrs", SectionType.CHORUS),
22
+ (r"build|riser|tension", SectionType.BUILD),
23
+ (r"drop|main|peak", SectionType.DROP),
24
+ (r"bridge|brg", SectionType.BRIDGE),
25
+ (r"break(?:down)?|strip", SectionType.BREAKDOWN),
26
+ (r"outro|end|fade", SectionType.OUTRO),
27
+ (r"loop", SectionType.LOOP),
28
+ ]
29
+
30
+ def _infer_section_type_from_name(name: str) -> tuple[SectionType, float]:
31
+ """Infer section type from a scene or clip name. Returns (type, confidence)."""
32
+ lower = name.lower().strip()
33
+ for pattern, stype in _SECTION_NAME_PATTERNS:
34
+ if re.search(pattern, lower):
35
+ return stype, 0.85
36
+ return SectionType.UNKNOWN, 0.0
37
+
38
+ def _infer_section_type_from_energy(
39
+ energy: float, density: float, position_ratio: float, total_sections: int,
40
+ ) -> tuple[SectionType, float]:
41
+ """Infer section type from energy/density/position heuristics."""
42
+ # Position-based heuristics
43
+ if position_ratio < 0.1 and density < 0.4:
44
+ return SectionType.INTRO, 0.6
45
+ if position_ratio > 0.9 and density < 0.4:
46
+ return SectionType.OUTRO, 0.6
47
+
48
+ # Energy-based heuristics
49
+ if energy > 0.8 and density > 0.7:
50
+ return SectionType.DROP, 0.5
51
+ if energy < 0.3 and density < 0.3:
52
+ return SectionType.BREAKDOWN, 0.5
53
+ if 0.4 <= energy <= 0.7:
54
+ return SectionType.VERSE, 0.4
55
+
56
+ return SectionType.UNKNOWN, 0.0
57
+
58
+ def build_section_graph_from_scenes(
59
+ scenes: list[dict],
60
+ clip_matrix: list[list[dict]],
61
+ track_count: int,
62
+ beats_per_bar: int = 4,
63
+ ) -> list[SectionNode]:
64
+ """Build section graph from session view scenes.
65
+
66
+ scenes: list of {index, name, tempo, color_index}
67
+ clip_matrix: [scene_index][track_index] = {state, name, ...} or None
68
+ """
69
+ sections = []
70
+ # Estimate bar positions: each scene is a section, assume 8-bar default
71
+ # unless clips provide length info
72
+ current_bar = 0
73
+
74
+ for i, scene in enumerate(scenes):
75
+ scene_name = scene.get("name", "")
76
+ if not scene_name.strip():
77
+ continue # Skip unnamed empty scenes
78
+
79
+ # Count active tracks in this scene
80
+ active_tracks = []
81
+ if i < len(clip_matrix):
82
+ for t_idx in range(min(track_count, len(clip_matrix[i]))):
83
+ slot = clip_matrix[i][t_idx]
84
+ if slot and slot.get("state") in ("playing", "stopped", "triggered"):
85
+ if slot.get("has_clip", True):
86
+ active_tracks.append(t_idx)
87
+
88
+ density = len(active_tracks) / max(track_count, 1)
89
+
90
+ # Estimate section length (default 32 beats = 8 bars)
91
+ section_length_bars = 8
92
+ start_bar = current_bar
93
+ end_bar = start_bar + section_length_bars
94
+
95
+ # Infer type from name first, then energy/position
96
+ stype, confidence = _infer_section_type_from_name(scene_name)
97
+ if stype == SectionType.UNKNOWN:
98
+ total = len([s for s in scenes if s.get("name", "").strip()])
99
+ position_ratio = i / max(total - 1, 1) if total > 1 else 0.5
100
+ stype, confidence = _infer_section_type_from_energy(
101
+ energy=density, density=density,
102
+ position_ratio=position_ratio, total_sections=total,
103
+ )
104
+
105
+ sections.append(SectionNode(
106
+ section_id=f"sec_{i:02d}",
107
+ start_bar=start_bar,
108
+ end_bar=end_bar,
109
+ section_type=stype,
110
+ confidence=confidence,
111
+ energy=density, # density as energy proxy
112
+ density=density,
113
+ tracks_active=active_tracks,
114
+ name=scene_name,
115
+ ))
116
+ current_bar = end_bar
117
+
118
+ return sections
119
+
120
+ def build_section_graph_from_arrangement(
121
+ arrangement_clips: dict[int, list[dict]],
122
+ track_count: int,
123
+ beats_per_bar: int = 4,
124
+ ) -> list[SectionNode]:
125
+ """Build section graph from arrangement view clips.
126
+
127
+ arrangement_clips: {track_index: [{start_time, end_time, length, name}, ...]}
128
+ """
129
+ if not arrangement_clips:
130
+ return []
131
+
132
+ # Collect all time boundaries
133
+ boundaries: set[float] = set()
134
+ for clips in arrangement_clips.values():
135
+ for clip in clips:
136
+ boundaries.add(clip.get("start_time", 0))
137
+ boundaries.add(clip.get("end_time", clip.get("start_time", 0) + clip.get("length", 0)))
138
+
139
+ sorted_bounds = sorted(boundaries)
140
+ if len(sorted_bounds) < 2:
141
+ return []
142
+
143
+ sections = []
144
+ for i in range(len(sorted_bounds) - 1):
145
+ start_beat = sorted_bounds[i]
146
+ end_beat = sorted_bounds[i + 1]
147
+ if end_beat - start_beat < beats_per_bar:
148
+ continue # Skip very short segments
149
+
150
+ start_bar = int(start_beat / beats_per_bar)
151
+ end_bar = int(end_beat / beats_per_bar)
152
+ if end_bar <= start_bar:
153
+ continue
154
+
155
+ # Count active tracks in this time range
156
+ active_tracks = []
157
+ for t_idx, clips in arrangement_clips.items():
158
+ for clip in clips:
159
+ clip_start = clip.get("start_time", 0)
160
+ clip_end = clip.get("end_time", clip_start + clip.get("length", 0))
161
+ if clip_start < end_beat and clip_end > start_beat:
162
+ active_tracks.append(t_idx)
163
+ break
164
+
165
+ density = len(active_tracks) / max(track_count, 1)
166
+ total_sections = len(sorted_bounds) - 1
167
+ position_ratio = i / max(total_sections - 1, 1) if total_sections > 1 else 0.5
168
+
169
+ stype, confidence = _infer_section_type_from_energy(
170
+ energy=density, density=density,
171
+ position_ratio=position_ratio, total_sections=total_sections,
172
+ )
173
+
174
+ sections.append(SectionNode(
175
+ section_id=f"arr_{i:02d}",
176
+ start_bar=start_bar,
177
+ end_bar=end_bar,
178
+ section_type=stype,
179
+ confidence=confidence,
180
+ energy=density,
181
+ density=density,
182
+ tracks_active=active_tracks,
183
+ ))
184
+
185
+ return sections
186
+
187
+ def detect_phrases(
188
+ section: SectionNode,
189
+ notes_by_track: dict[int, list[dict]],
190
+ default_phrase_length: int = 4,
191
+ beats_per_bar: int = 4,
192
+ ) -> list[PhraseUnit]:
193
+ """Detect phrase boundaries within a section from note data.
194
+
195
+ Uses note density changes and gap detection to find phrase boundaries.
196
+ Falls back to regular grid (4 or 8 bar phrases).
197
+ """
198
+ section_length = section.length_bars()
199
+ if section_length <= 0:
200
+ return []
201
+
202
+ # Aggregate all notes into a bar-level density map
203
+ bar_densities: dict[int, int] = {}
204
+ for bar in range(section.start_bar, section.end_bar):
205
+ bar_densities[bar] = 0
206
+
207
+ for track_notes in notes_by_track.values():
208
+ for note in track_notes:
209
+ start_beat = note.get("start_time", 0)
210
+ note_bar = section.start_bar + int(start_beat / beats_per_bar)
211
+ if section.start_bar <= note_bar < section.end_bar:
212
+ bar_densities[note_bar] = bar_densities.get(note_bar, 0) + 1
213
+
214
+ # Find phrase boundaries using density drops (gaps)
215
+ boundaries = [section.start_bar]
216
+ bars = sorted(bar_densities.keys())
217
+
218
+ for i in range(1, len(bars)):
219
+ prev_density = bar_densities.get(bars[i - 1], 0)
220
+ curr_density = bar_densities.get(bars[i], 0)
221
+
222
+ # A phrase boundary is where density drops significantly or a gap exists
223
+ if prev_density > 0 and curr_density == 0:
224
+ boundaries.append(bars[i])
225
+ elif (bars[i] - section.start_bar) % default_phrase_length == 0:
226
+ # Regular grid fallback
227
+ if bars[i] not in boundaries:
228
+ boundaries.append(bars[i])
229
+
230
+ boundaries.append(section.end_bar)
231
+ boundaries = sorted(set(boundaries))
232
+
233
+ # Build phrases from boundaries
234
+ phrases = []
235
+ for i in range(len(boundaries) - 1):
236
+ start = boundaries[i]
237
+ end = boundaries[i + 1]
238
+ if end <= start:
239
+ continue
240
+
241
+ # Calculate note density for this phrase
242
+ total_notes = sum(bar_densities.get(b, 0) for b in range(start, end))
243
+ phrase_bars = end - start
244
+ density = total_notes / max(phrase_bars, 1)
245
+
246
+ # Cadence strength: higher if the last bar has lower density (resolution)
247
+ last_bar_density = bar_densities.get(end - 1, 0)
248
+ avg_density = density
249
+ cadence = max(0.0, min(1.0, 1.0 - (last_bar_density / max(avg_density, 0.1)))) if avg_density > 0 else 0.3
250
+
251
+ phrases.append(PhraseUnit(
252
+ phrase_id=f"{section.section_id}_phr_{i:02d}",
253
+ section_id=section.section_id,
254
+ start_bar=start,
255
+ end_bar=end,
256
+ cadence_strength=round(cadence, 3),
257
+ note_density=round(density, 2),
258
+ has_variation=False, # Computed later by phrase critic
259
+ ))
260
+
261
+ # Mark variation: compare adjacent phrase densities
262
+ for i in range(1, len(phrases)):
263
+ density_diff = abs(phrases[i].note_density - phrases[i - 1].note_density)
264
+ if density_diff > 1.0:
265
+ phrases[i].has_variation = True
266
+
267
+ return phrases
268
+
269
+ _ROLE_NAME_HINTS: list[tuple[str, RoleType]] = [
270
+ (r"kick|bd|bass\s*drum", RoleType.KICK_ANCHOR),
271
+ (r"sub\s*bass|sub|bass", RoleType.BASS_ANCHOR),
272
+ (r"lead|melody|mel|hook|synth\s*lead", RoleType.LEAD),
273
+ (r"pad|atmosphere|atmo|ambient|drone|chord|keys", RoleType.HARMONY_BED),
274
+ (r"h(?:i)?[\s\-]?hat|hh|hat|perc|percussion|clap|snare|rim", RoleType.RHYTHMIC_TEXTURE),
275
+ (r"fx|sfx|riser|sweep|noise|texture|tape", RoleType.TEXTURE_WASH),
276
+ (r"resamp|bounce|bus|group|master|return", RoleType.UTILITY),
277
+ ]
278
+
279
+ def infer_role_for_track(
280
+ track_name: str,
281
+ notes: list[dict],
282
+ device_class: str = "",
283
+ beats_per_bar: int = 4,
284
+ ) -> tuple[RoleType, float, bool]:
285
+ """Infer a track's role from name, notes, and device class.
286
+
287
+ Returns (role, confidence, is_foreground).
288
+ """
289
+ # 1. Name-based inference (highest confidence)
290
+ lower_name = track_name.lower().strip()
291
+ for pattern, role in _ROLE_NAME_HINTS:
292
+ if re.search(pattern, lower_name):
293
+ foreground = role in (RoleType.LEAD, RoleType.HOOK, RoleType.KICK_ANCHOR)
294
+ return role, 0.80, foreground
295
+
296
+ # 2. Device-class inference
297
+ dc = device_class.lower()
298
+ if "drumgroup" in dc or "drum" in dc:
299
+ return RoleType.RHYTHMIC_TEXTURE, 0.70, False
300
+ if "simpler" in dc and not notes:
301
+ return RoleType.TEXTURE_WASH, 0.50, False
302
+
303
+ # 3. Note-based inference
304
+ if not notes:
305
+ return RoleType.UNKNOWN, 0.0, False
306
+
307
+ # Analyze pitch register and density
308
+ pitches = [n.get("pitch", 60) for n in notes]
309
+ durations = [n.get("duration", 0.5) for n in notes]
310
+ avg_pitch = sum(pitches) / len(pitches)
311
+ avg_duration = sum(durations) / len(durations)
312
+ note_count = len(notes)
313
+
314
+ # Sub-bass register (< MIDI 48 = C3)
315
+ if avg_pitch < 48:
316
+ return RoleType.BASS_ANCHOR, 0.65, False
317
+
318
+ # Very long sustained notes → harmony bed
319
+ if avg_duration > 4.0:
320
+ return RoleType.HARMONY_BED, 0.60, False
321
+
322
+ # Dense short notes → rhythmic or lead
323
+ if avg_duration < 0.5 and note_count > 8:
324
+ if avg_pitch > 60:
325
+ return RoleType.LEAD, 0.55, True
326
+ return RoleType.RHYTHMIC_TEXTURE, 0.55, False
327
+
328
+ # Medium density, mid register → could be hook or lead
329
+ if 55 <= avg_pitch <= 80 and 0.5 <= avg_duration <= 2.0:
330
+ return RoleType.HOOK, 0.45, True
331
+
332
+ return RoleType.UNKNOWN, 0.3, False
333
+
334
+ def build_role_graph(
335
+ sections: list[SectionNode],
336
+ track_data: list[dict],
337
+ notes_by_section_track: dict[str, dict[int, list[dict]]],
338
+ ) -> list[RoleNode]:
339
+ """Build role graph: what each track does in each section.
340
+
341
+ track_data: [{index, name, devices: [{class_name, ...}]}]
342
+ notes_by_section_track: {section_id: {track_index: [notes]}}
343
+ """
344
+ roles = []
345
+ for section in sections:
346
+ for track in track_data:
347
+ t_idx = track.get("index", 0)
348
+ if t_idx not in section.tracks_active:
349
+ continue
350
+
351
+ t_name = track.get("name", "")
352
+ devices = track.get("devices", [])
353
+ device_class = devices[0].get("class_name", "") if devices else ""
354
+
355
+ section_notes = notes_by_section_track.get(section.section_id, {}).get(t_idx, [])
356
+
357
+ role, confidence, foreground = infer_role_for_track(
358
+ t_name, section_notes, device_class,
359
+ )
360
+
361
+ roles.append(RoleNode(
362
+ track_index=t_idx,
363
+ track_name=t_name,
364
+ section_id=section.section_id,
365
+ role=role,
366
+ confidence=confidence,
367
+ foreground=foreground,
368
+ ))
369
+
370
+ return roles
371
+
@@ -11,7 +11,9 @@ import tempfile
11
11
  from typing import Any
12
12
 
13
13
  import numpy as np
14
+ import logging
14
15
 
16
+ logger = logging.getLogger(__name__)
15
17
 
16
18
  # ---------------------------------------------------------------------------
17
19
  # Constants
@@ -34,11 +36,11 @@ BAND_EDGES: dict[str, tuple[float, float]] = {
34
36
  "air_16khz": (8000.0, 20000.0),
35
37
  }
36
38
 
37
-
38
39
  # ---------------------------------------------------------------------------
39
40
  # Internal helpers
40
41
  # ---------------------------------------------------------------------------
41
42
 
43
+
42
44
  def _load_audio(file_path: str) -> tuple[np.ndarray, int]:
43
45
  """Load an audio file as (ndarray, sample_rate). Ensures stereo output."""
44
46
  if not os.path.exists(file_path):
@@ -67,7 +69,8 @@ def _normalize_to_lufs(
67
69
  os.close(tmp_fd)
68
70
  try:
69
71
  sf.write(tmp_path, normalized, sr)
70
- except Exception:
72
+ except Exception as exc:
73
+ logger.debug("_normalize_to_lufs failed: %s", exc)
71
74
  # Clean up on failure to avoid orphan files
72
75
  try:
73
76
  os.unlink(tmp_path)
@@ -76,11 +79,11 @@ def _normalize_to_lufs(
76
79
  raise
77
80
  return tmp_path
78
81
 
79
-
80
82
  # ---------------------------------------------------------------------------
81
83
  # True-peak helper
82
84
  # ---------------------------------------------------------------------------
83
85
 
86
+
84
87
  def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
85
88
  """Estimate EBU R128 true peak via 4x oversampling.
86
89
 
@@ -95,11 +98,11 @@ def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
95
98
  peak_linear = float(np.max(np.abs(oversampled)))
96
99
  return float(20.0 * np.log10(max(peak_linear, 1e-10)))
97
100
 
98
-
99
101
  # ---------------------------------------------------------------------------
100
102
  # compute_loudness
101
103
  # ---------------------------------------------------------------------------
102
104
 
105
+
103
106
  def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
104
107
  """Analyze integrated loudness (LUFS), true peak, RMS, LRA, and streaming compliance.
105
108
 
@@ -151,7 +154,8 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
151
154
  try:
152
155
  st = meter.integrated_loudness(window)
153
156
  short_term_raw.append(float(st) if np.isfinite(st) else SILENCE_FLOOR)
154
- except Exception:
157
+ except Exception as exc:
158
+ logger.debug("compute_loudness failed: %s", exc)
155
159
  short_term_raw.append(SILENCE_FLOOR)
156
160
  pos += hop_samples
157
161
 
@@ -197,11 +201,11 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
197
201
 
198
202
  return result
199
203
 
200
-
201
204
  # ---------------------------------------------------------------------------
202
205
  # compute_spectral
203
206
  # ---------------------------------------------------------------------------
204
207
 
208
+
205
209
  def compute_spectral(
206
210
  file_path: str,
207
211
  n_fft: int = 2048,
@@ -289,11 +293,11 @@ def compute_spectral(
289
293
  "band_balance": band_balance,
290
294
  }
291
295
 
292
-
293
296
  # ---------------------------------------------------------------------------
294
297
  # compare_to_reference
295
298
  # ---------------------------------------------------------------------------
296
299
 
300
+
297
301
  def compare_to_reference(
298
302
  mix_path: str,
299
303
  reference_path: str,
@@ -412,11 +416,11 @@ def compare_to_reference(
412
416
  "suggestions": suggestions,
413
417
  }
414
418
 
415
-
416
419
  # ---------------------------------------------------------------------------
417
420
  # read_audio_metadata
418
421
  # ---------------------------------------------------------------------------
419
422
 
423
+
420
424
  def read_audio_metadata(file_path: str) -> dict[str, Any]:
421
425
  """Read audio file metadata using mutagen (tags) and soundfile (format info).
422
426
 
@@ -451,6 +455,7 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
451
455
  has_artwork = False
452
456
  try:
453
457
  import mutagen
458
+
454
459
  audio = mutagen.File(file_path)
455
460
  if audio is not None:
456
461
  for key, value in audio.tags.items() if audio.tags else []:
@@ -459,8 +464,9 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
459
464
  str_val = str(value)
460
465
  if len(str_val) < 2048:
461
466
  tags[str(key)] = str_val
462
- except Exception:
463
- pass
467
+ except Exception as exc:
468
+ logger.debug("read_audio_metadata failed: %s", exc)
469
+
464
470
  # Detect artwork (common tag names)
465
471
  artwork_keys = {"APIC", "covr", "METADATA_BLOCK_PICTURE", "artwork"}
466
472
  if audio.tags:
@@ -468,7 +474,8 @@ def read_audio_metadata(file_path: str) -> dict[str, Any]:
468
474
  if any(k in str(key) for k in artwork_keys):
469
475
  has_artwork = True
470
476
  break
471
- except Exception:
477
+ except Exception as exc:
478
+ logger.debug("read_audio_metadata failed: %s", exc)
472
479
  pass # mutagen can't parse — use soundfile info only
473
480
 
474
481
  return {
@@ -10,6 +10,7 @@ These tools power the Agent OS cyclical loop:
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import logging
13
14
  from typing import Optional
14
15
 
15
16
  from fastmcp import Context
@@ -18,6 +19,8 @@ from ..server import mcp
18
19
  from ..memory.technique_store import TechniqueStore
19
20
  from . import _agent_os_engine as engine
20
21
 
22
+ logger = logging.getLogger(__name__)
23
+
21
24
  _memory_store = TechniqueStore()
22
25
 
23
26
 
@@ -125,8 +128,9 @@ def build_world_model(ctx: Context) -> dict:
125
128
  "track_index": track["index"]
126
129
  })
127
130
  track_infos.append(ti)
128
- except Exception:
129
- pass # Skip tracks that fail — don't block world model build
131
+ except Exception as exc:
132
+ # Skip tracks that fail — don't block world model build
133
+ logger.debug("world-model track %s skipped: %s", track.get("index"), exc)
130
134
 
131
135
  # Fetch spectral data (may be unavailable)
132
136
  spectrum = None
@@ -184,13 +188,14 @@ def build_world_model(ctx: Context) -> dict:
184
188
  try:
185
189
  matrix_data = ableton.send_command("get_scene_matrix")
186
190
  clip_matrix = matrix_data.get("matrix", [])
187
- except Exception:
188
- pass
191
+ except Exception as exc:
192
+ logger.debug("scene_matrix fetch for structural critic skipped: %s", exc)
189
193
 
190
194
  sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
191
195
  structural_issues = comp_engine.run_form_critic(sections)
192
- except Exception:
193
- pass # Composition engine unavailable — degrade gracefully
196
+ except Exception as exc:
197
+ # Composition engine unavailable — degrade gracefully
198
+ logger.warning("structural critic unavailable: %s", exc)
194
199
 
195
200
  result = wm.to_dict()
196
201
  result["issues"] = {
@@ -276,7 +281,8 @@ def analyze_outcomes(
276
281
  techniques = _memory_store.list_techniques(
277
282
  type_filter="outcome", sort_by="updated_at", limit=limit,
278
283
  )
279
- except Exception:
284
+ except Exception as exc:
285
+ logger.warning("analyze_outcomes list_techniques failed: %s", exc)
280
286
  techniques = []
281
287
 
282
288
  # Extract payloads from full technique records
@@ -288,8 +294,8 @@ def analyze_outcomes(
288
294
  payload = full.get("payload", {})
289
295
  if isinstance(payload, dict):
290
296
  outcomes.append(payload)
291
- except Exception:
292
- pass
297
+ except Exception as exc:
298
+ logger.debug("outcome payload %s skipped: %s", t.get("id"), exc)
293
299
 
294
300
  return engine.analyze_outcome_history(outcomes)
295
301
 
@@ -316,7 +322,8 @@ def get_technique_card(
316
322
  techniques = _memory_store.search(
317
323
  query=query, type_filter="technique_card", limit=limit,
318
324
  )
319
- except Exception:
325
+ except Exception as exc:
326
+ logger.warning("technique_card search(%r) failed: %s", query, exc)
320
327
  techniques = []
321
328
 
322
329
  cards = []
@@ -333,8 +340,8 @@ def get_technique_card(
333
340
  "rating": t.get("rating", 0),
334
341
  "replay_count": t.get("replay_count", 0),
335
342
  })
336
- except Exception:
337
- pass
343
+ except Exception as exc:
344
+ logger.debug("technique_card %s payload skipped: %s", t.get("id"), exc)
338
345
 
339
346
  return {
340
347
  "query": query,
@@ -367,7 +374,8 @@ def get_taste_profile(
367
374
  techniques = _memory_store.list_techniques(
368
375
  type_filter="outcome", sort_by="updated_at", limit=limit,
369
376
  )
370
- except Exception:
377
+ except Exception as exc:
378
+ logger.warning("taste_profile list_techniques failed: %s", exc)
371
379
  techniques = []
372
380
 
373
381
  outcomes = []
@@ -377,8 +385,8 @@ def get_taste_profile(
377
385
  payload = full.get("payload", {})
378
386
  if isinstance(payload, dict):
379
387
  outcomes.append(payload)
380
- except Exception:
381
- pass
388
+ except Exception as exc:
389
+ logger.debug("taste_profile outcome %s skipped: %s", t.get("id"), exc)
382
390
 
383
391
  return engine.get_taste_profile(outcomes)
384
392