livepilot 1.27.0 → 1.27.1

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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -6
  3. package/bin/livepilot.js +4 -2
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +1 -1
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/full/apply.py +9 -0
  16. package/mcp_server/composer/full/layer_planner.py +16 -6
  17. package/mcp_server/creative_constraints/engine.py +46 -0
  18. package/mcp_server/experiment/engine.py +10 -3
  19. package/mcp_server/grader/client.py +30 -2
  20. package/mcp_server/grader/tools.py +8 -2
  21. package/mcp_server/hook_hunter/analyzer.py +15 -2
  22. package/mcp_server/m4l_bridge.py +19 -21
  23. package/mcp_server/memory/taste_graph.py +16 -0
  24. package/mcp_server/memory/technique_store.py +23 -3
  25. package/mcp_server/mix_engine/state_builder.py +4 -2
  26. package/mcp_server/musical_intelligence/detectors.py +11 -2
  27. package/mcp_server/musical_intelligence/tools.py +15 -2
  28. package/mcp_server/preview_studio/engine.py +30 -1
  29. package/mcp_server/project_brain/tools.py +56 -52
  30. package/mcp_server/reference_engine/tools.py +22 -2
  31. package/mcp_server/runtime/live_version.py +27 -8
  32. package/mcp_server/runtime/safety_kernel.py +11 -0
  33. package/mcp_server/sample_engine/critics.py +7 -3
  34. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  35. package/mcp_server/sample_engine/tools.py +53 -21
  36. package/mcp_server/song_brain/builder.py +17 -1
  37. package/mcp_server/sound_design/tools.py +1 -1
  38. package/mcp_server/splice_client/http_bridge.py +43 -1
  39. package/mcp_server/splice_client/models.py +7 -3
  40. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  41. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  42. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  43. package/mcp_server/tools/_composition_engine/models.py +6 -0
  44. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  45. package/mcp_server/tools/clips.py +5 -4
  46. package/mcp_server/tools/composition.py +5 -1
  47. package/mcp_server/tools/midi_io.py +40 -1
  48. package/mcp_server/tools/transport.py +1 -1
  49. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  50. package/mcp_server/user_corpus/runner.py +7 -1
  51. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  52. package/mcp_server/user_corpus/tools.py +45 -9
  53. package/mcp_server/wonder_mode/tools.py +66 -27
  54. package/package.json +1 -1
  55. package/remote_script/LivePilot/__init__.py +1 -1
  56. package/remote_script/LivePilot/server.py +23 -3
  57. package/requirements.txt +1 -1
  58. package/server.json +2 -2
@@ -17,11 +17,14 @@ session state, returning the same shape used by `mcp_server/audit/checks.py`:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import logging
20
21
  from typing import Any, Callable, Iterable
21
22
 
22
23
  from mcp_server.audit import checks as audit_checks
23
24
  from mcp_server.audit.checks import infer_role
24
25
 
26
+ _logger = logging.getLogger(__name__)
27
+
25
28
 
26
29
  _TRACK_COUNT_WARN = 8
27
30
  _TRACK_COUNT_FAIL = 12
@@ -441,11 +444,21 @@ def _aggregate_per_track(
441
444
  try:
442
445
  result = check_fn(*args)
443
446
  except Exception as exc:
447
+ _logger.warning(
448
+ "grader check %s failed for criterion %r track %r (%s): %s",
449
+ getattr(check_fn, "__name__", repr(check_fn)),
450
+ criterion_id,
451
+ t.get("index"),
452
+ t.get("name"),
453
+ exc,
454
+ exc_info=True,
455
+ )
444
456
  per_track.append({
445
457
  "track_index": t.get("index"),
446
458
  "name": t.get("name"),
447
459
  "role": role,
448
460
  "severity": "n/a",
461
+ "errored": True,
449
462
  "summary": f"check failed: {type(exc).__name__}",
450
463
  "issues": [],
451
464
  "evidence": {},
@@ -459,15 +472,30 @@ def _aggregate_per_track(
459
472
  })
460
473
 
461
474
  actionable = [r for r in per_track if r["severity"] != "n/a"]
475
+ n_errored = sum(1 for r in per_track if r.get("errored"))
462
476
 
463
477
  if not actionable:
478
+ if n_errored:
479
+ # Every checkable track raised — do NOT masquerade an all-error
480
+ # sweep as a benign no-op pass. Surface it as a failure.
481
+ return {
482
+ "id": criterion_id,
483
+ "passed": False,
484
+ "severity": "fail",
485
+ "summary": (
486
+ f"{n_errored} track(s) errored during check; no track "
487
+ "produced a usable result"
488
+ ),
489
+ "issues": [],
490
+ "evidence": {"per_track": per_track, "errored": n_errored},
491
+ }
464
492
  return {
465
493
  "id": criterion_id,
466
494
  "passed": True,
467
495
  "severity": "n/a",
468
496
  "summary": "No checkable tracks (data missing or no applicable role)",
469
497
  "issues": [],
470
- "evidence": {"per_track": per_track},
498
+ "evidence": {"per_track": per_track, "errored": n_errored},
471
499
  }
472
500
 
473
501
  has_fail = any(r["severity"] == "fail" for r in actionable)
@@ -505,7 +533,7 @@ def _aggregate_per_track(
505
533
  "severity": rubric_severity,
506
534
  "summary": summary,
507
535
  "issues": issues,
508
- "evidence": {"per_track": per_track},
536
+ "evidence": {"per_track": per_track, "errored": n_errored},
509
537
  }
510
538
 
511
539
 
@@ -64,9 +64,15 @@ def _build_light_state(ctx: Context) -> dict[str, Any]:
64
64
  info = _safe_call(ableton, "get_track_info", {"track_index": idx})
65
65
  if not info:
66
66
  continue
67
- if info.get("is_foldable") and info.get("is_master"):
67
+ # Skip master/return tracks (real Remote Script field names are
68
+ # is_master_track / is_return_track, not is_master / is_return).
69
+ if info.get("is_master_track") or info.get("is_return_track"):
68
70
  continue
69
- if info.get("is_return"):
71
+ # Skip group containers — foldable group tracks hold no clips or
72
+ # devices of their own, so they inflate the §7.3 track count and
73
+ # (having a mixer volume but no role/ghost tag) trip the
74
+ # buried-track check. Only count real, content-bearing layers.
75
+ if info.get("is_foldable"):
70
76
  continue
71
77
  tracks.append({
72
78
  "index": idx,
@@ -108,8 +108,21 @@ def find_hook_candidates(
108
108
  for c in candidates:
109
109
  # Check if hook is present in payoff sections (via motif locations)
110
110
  if c.hook_type == "melodic" and motif_data:
111
- for motif in motif_data.get("motifs", []):
112
- if motif.get("name", "") in c.hook_id:
111
+ for idx, motif in enumerate(motif_data.get("motifs", [])):
112
+ # BUG-B61 fix: the old test `motif.get("name", "") in c.hook_id`
113
+ # was always True for real motif data, because the engine emits
114
+ # `motif_id` (not `name`) so .get("name","") returned "", and
115
+ # `"" in c.hook_id` is True for every candidate. That boosted
116
+ # every melodic candidate by every motif's recurrence. Rebuild
117
+ # the source motif's hook_id exactly as it was constructed above
118
+ # (motif_id -> name -> idxN fallback) and require an exact match
119
+ # so each candidate is boosted only by its own source motif.
120
+ identifier = (
121
+ motif.get("motif_id")
122
+ or motif.get("name")
123
+ or f"idx{idx}"
124
+ )
125
+ if f"motif_{identifier}" == c.hook_id:
113
126
  # Motif with high recurrence across sections = stronger hook
114
127
  c.memorability = min(1.0, c.memorability + motif.get("recurrence", 0) * 0.2)
115
128
 
@@ -497,6 +497,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
497
497
  self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
498
498
  self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
499
499
  self._chunk_id = 0
500
+ self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
500
501
  self._response_callback: Optional[asyncio.Future] = None
501
502
  self._capture_future: Optional[asyncio.Future] = None
502
503
  self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
@@ -733,29 +734,26 @@ class SpectralReceiver(asyncio.DatagramProtocol):
733
734
  way we never poison a prior sequence, and the problem surfaces in
734
735
  logs if it happens.
735
736
  """
736
- if index == 0:
737
+ # Only one response is chunked at a time: send_command serialises on
738
+ # _cmd_lock, so a single active reassembly bucket is sufficient and we
739
+ # do NOT need the first packet to be index 0. Accepting chunks in any
740
+ # order fixes the permanent-loss path where an index>0 chunk arriving
741
+ # before index 0 (UDP loopback reordering under load) used to split
742
+ # one response across two buckets that never completed.
743
+ active = self._chunks.get(self._chunk_key)
744
+ if active is None or active["total"] != total:
745
+ # No bucket open, OR a chunk with a different `total` arrived,
746
+ # meaning a new response started (e.g. the previous one timed out
747
+ # without ever completing). Evict any stale partial and open fresh.
748
+ if active is not None:
749
+ self._chunks.pop(self._chunk_key, None)
750
+ self._chunk_times.pop(self._chunk_key, None)
737
751
  self._chunk_id += 1
738
- key = str(self._chunk_id)
739
- self._chunks[key] = {"parts": {}, "total": total}
740
- self._chunk_times[key] = time.monotonic()
741
- else:
742
- key = str(self._chunk_id)
743
- if key not in self._chunks:
744
- # Out-of-order arrival. Start a new bucket rather than append
745
- # to the previous sequence's parts — that's the corruption
746
- # path. Log once so it's diagnosable.
747
- import sys
748
- print(
749
- f"LivePilot: chunk index={index}/{total} arrived before "
750
- f"index=0 — starting fresh bucket. UDP reordering on "
751
- f"loopback suggests system load.",
752
- file=sys.stderr,
753
- )
754
- self._chunk_id += 1
755
- key = str(self._chunk_id)
756
- self._chunks[key] = {"parts": {}, "total": total}
757
- self._chunk_times[key] = time.monotonic()
752
+ self._chunk_key = str(self._chunk_id)
753
+ self._chunks[self._chunk_key] = {"parts": {}, "total": total}
754
+ self._chunk_times[self._chunk_key] = time.monotonic()
758
755
 
756
+ key = self._chunk_key
759
757
  self._chunks[key]["parts"][index] = encoded
760
758
 
761
759
  if len(self._chunks[key]["parts"]) == total:
@@ -182,6 +182,14 @@ class TasteGraph:
182
182
  self.evidence_count += 1
183
183
  self.last_updated_ms = now
184
184
 
185
+ # Write-back to persistent store
186
+ if self._persistent_store is not None:
187
+ try:
188
+ self._persistent_store.record_device_use(device_name, positive)
189
+ except Exception as exc:
190
+ logger.debug("record_device_use failed: %s", exc)
191
+ pass # persistence is best-effort
192
+
185
193
  def update_novelty_from_experiment(
186
194
  self, chose_bold: bool, goal_mode: str = "improve",
187
195
  ) -> None:
@@ -200,6 +208,14 @@ class TasteGraph:
200
208
  new_val = max(0.0, current - 0.05)
201
209
  self.novelty_bands[goal_mode] = new_val
202
210
 
211
+ # Write-back to persistent store
212
+ if self._persistent_store is not None:
213
+ try:
214
+ self._persistent_store.update_novelty(chose_bold, goal_mode)
215
+ except Exception as exc:
216
+ logger.debug("update_novelty failed: %s", exc)
217
+ pass # persistence is best-effort
218
+
203
219
  # ── Ranking ──────────────────────────────────────────────────────
204
220
 
205
221
  def rank_moves(
@@ -31,6 +31,19 @@ class TechniqueStore:
31
31
  self._lock = threading.Lock()
32
32
  self._initialized = False
33
33
  self._data: dict = {"version": 1, "techniques": []}
34
+ # Signature (mtime_ns, size) of the file as we last loaded it. Lets
35
+ # multiple TechniqueStore instances pointing at the same file pick up
36
+ # each other's writes (reload-on-read) instead of caching stale data
37
+ # for the life of the process.
38
+ self._loaded_sig: Optional[tuple] = None
39
+
40
+ def _file_signature(self) -> Optional[tuple]:
41
+ """Return (mtime_ns, size) of the backing file, or None if absent."""
42
+ try:
43
+ st = self._file.stat()
44
+ except OSError:
45
+ return None
46
+ return (st.st_mtime_ns, st.st_size)
34
47
 
35
48
  def _ensure_initialized(self) -> None:
36
49
  """Lazily create directory and load data on first access.
@@ -40,12 +53,14 @@ class TechniqueStore:
40
53
  Thread-safe: uses double-checked locking to prevent concurrent
41
54
  callers from racing on initialization.
42
55
  """
43
- if self._initialized:
56
+ # Fast path: already initialized AND the file on disk has not changed
57
+ # since we last loaded it (no other instance has written).
58
+ if self._initialized and self._file_signature() == self._loaded_sig:
44
59
  return
45
60
  with self._lock:
46
61
  # Double-check after acquiring lock — another thread may have
47
- # initialized while we were waiting.
48
- if self._initialized:
62
+ # (re)loaded while we were waiting.
63
+ if self._initialized and self._file_signature() == self._loaded_sig:
49
64
  return
50
65
  try:
51
66
  self._base_dir.mkdir(parents=True, exist_ok=True)
@@ -58,10 +73,12 @@ class TechniqueStore:
58
73
  try:
59
74
  with open(self._file, "r") as f:
60
75
  self._data = json.load(f)
76
+ self._loaded_sig = self._file_signature()
61
77
  except (json.JSONDecodeError, ValueError):
62
78
  corrupt = self._file.with_suffix(".json.corrupt")
63
79
  self._file.rename(corrupt)
64
80
  self._data = {"version": 1, "techniques": []}
81
+ self._loaded_sig = None
65
82
  else:
66
83
  self._data = {"version": 1, "techniques": []}
67
84
  self._flush()
@@ -77,6 +94,9 @@ class TechniqueStore:
77
94
  f.flush()
78
95
  os.fsync(f.fileno())
79
96
  os.replace(str(tmp), str(self._file))
97
+ # Record the signature of our own write so this instance does not
98
+ # needlessly reload the data it already holds in memory.
99
+ self._loaded_sig = self._file_signature()
80
100
 
81
101
  # ── public API ───────────────────────────────────────────────
82
102
 
@@ -241,8 +241,10 @@ def build_dynamics_state(
241
241
 
242
242
  crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
243
243
 
244
- # Over-compressed when crest factor < 6 dB (flat dynamics)
245
- over_compressed = crest < 6.0
244
+ # Over-compressed band is 3-6 dB crest. Below 3 dB the signal is so flat
245
+ # that the dynamics critic should report the stronger `flat_dynamics`
246
+ # issue instead, which only fires when over_compressed is False.
247
+ over_compressed = 3.0 <= crest < 6.0
246
248
 
247
249
  # Headroom = distance from peak to 0 dBFS
248
250
  if peak_linear > 0:
@@ -134,9 +134,15 @@ def detect_repetition_fatigue(
134
134
  staleness = min(1.0, (reuse_count / total - 1) * 0.3) if total else 0
135
135
  report.section_staleness[name] = round(max(0, staleness), 3)
136
136
 
137
- # Overall fatigue level
137
+ # Overall fatigue level — saturating combine (probabilistic OR) so that
138
+ # adding more issues never *reduces* fatigue. A plain mean would let a
139
+ # cluster of low-severity issues dilute a single serious one.
138
140
  if report.issues:
139
- report.fatigue_level = min(1.0, sum(i["severity"] for i in report.issues) / max(1, len(report.issues)))
141
+ remaining_freshness = 1.0
142
+ for issue in report.issues:
143
+ severity = max(0.0, min(1.0, issue["severity"]))
144
+ remaining_freshness *= (1.0 - severity)
145
+ report.fatigue_level = min(1.0, 1.0 - remaining_freshness)
140
146
 
141
147
  # Recommendations
142
148
  if report.fatigue_level > 0.5:
@@ -337,6 +343,9 @@ def infer_section_purposes(
337
343
  if position < 0.15 and density < 0.5:
338
344
  purpose = "setup"
339
345
  confidence = 0.7
346
+ elif density >= 0.8 and density_delta >= 0:
347
+ purpose = "payoff"
348
+ confidence = 0.65
340
349
  elif density_delta > 0.2:
341
350
  purpose = "tension"
342
351
  confidence = 0.6
@@ -215,8 +215,21 @@ def compare_phrase_renders(
215
215
 
216
216
  critiques = []
217
217
  for path in file_paths:
218
- # Try to get cached analysis or run fresh
219
- critique = phrase_critic.analyze_phrase(target=target)
218
+ # Run offline analysis per file so each render gets a real critique
219
+ loudness_data = None
220
+ spectrum_data = None
221
+ try:
222
+ from ..tools._perception_engine import compute_loudness
223
+ loudness_data = compute_loudness(path, detail="full")
224
+ except Exception as exc:
225
+ logger.debug("compare_phrase_renders loudness failed for %s: %s", path, exc)
226
+ try:
227
+ from ..tools._perception_engine import compute_spectral
228
+ spectrum_data = compute_spectral(path)
229
+ except Exception as exc:
230
+ logger.debug("compare_phrase_renders spectral failed for %s: %s", path, exc)
231
+
232
+ critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
220
233
  critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
221
234
  critiques.append(critique)
222
235
 
@@ -33,6 +33,35 @@ def store_preview_set(ps: PreviewSet) -> None:
33
33
  del _preview_sets[oldest_key]
34
34
 
35
35
 
36
+ # Statuses that represent work a user (or caller) has already invested in:
37
+ # a compared ranking or a committed pick. A fresh request that hashes to the
38
+ # same set_id must NOT clobber these — it branches to a distinct id instead.
39
+ _PROTECTED_STATUSES = {"committed", "compared"}
40
+
41
+
42
+ def _resolve_set_id(base_id: str) -> str:
43
+ """Return a set_id that won't clobber a protected (committed/compared) set.
44
+
45
+ The base_id is a deterministic hash of request_text + kernel_id, so a
46
+ re-request reuses it by design. That reuse is fine while the existing set
47
+ is still 'pending'/'discarded' (nothing of value to lose). But if the
48
+ existing set under base_id has been compared or committed, overwriting it
49
+ would silently drop its rankings / committed pick. In that case we branch
50
+ to a distinct, still-deterministic id (base_id + a monotonic suffix) so the
51
+ protected set survives and the new set gets its own slot.
52
+ """
53
+ existing = _preview_sets.get(base_id)
54
+ if existing is None or existing.status not in _PROTECTED_STATUSES:
55
+ return base_id
56
+ suffix = 2
57
+ while True:
58
+ candidate = f"{base_id}_b{suffix}"
59
+ occupant = _preview_sets.get(candidate)
60
+ if occupant is None or occupant.status not in _PROTECTED_STATUSES:
61
+ return candidate
62
+ suffix += 1
63
+
64
+
36
65
  # ── Creation ──────────────────────────────────────────────────────
37
66
 
38
67
 
@@ -58,7 +87,7 @@ def create_preview_set(
58
87
  flags the resulting PreviewSet with ``degradation.is_degraded=True``
59
88
  so callers can tell a synthesized compile from a real one.
60
89
  """
61
- set_id = _compute_set_id(request_text, kernel_id)
90
+ set_id = _resolve_set_id(_compute_set_id(request_text, kernel_id))
62
91
  now = int(time.time() * 1000)
63
92
 
64
93
  moves = available_moves or []
@@ -84,17 +84,41 @@ def build_project_brain(ctx: Context) -> dict:
84
84
  except Exception as exc:
85
85
  logger.debug("build_project_brain failed: %s", exc)
86
86
 
87
- # 5b. Build notes_map for role inference.
88
- # Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
89
- # falls back to "assume all tracks active in every section" which destroys
90
- # section-scoped role confidence.
87
+ # 5b/5c. Single combined grid sweep for notes (role inference) and clip
88
+ # automation envelopes. Previously these were two separate
89
+ # N_tracks x N_scenes loops, each issuing a remote round-trip per slot
90
+ # including empty slots. We now consult the get_scene_matrix presence grid
91
+ # (already fetched at step 3 as clip_matrix) and skip slots that are
92
+ # positively empty, collapsing both sweeps into one pass over the grid.
91
93
  #
92
94
  # BUG-E1: section_id must match what build_section_graph_from_scenes emits.
93
95
  # The composition engine emits `sec_{i:02d}` using the RAW enumerate index
94
96
  # of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
95
97
  # ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
96
- # Our notes_map must mirror that or keys won't align.
98
+ # Both maps mirror that or keys won't align.
99
+ #
100
+ # clips_scanned is the denominator for coverage_pct (BUG-D2): it counts
101
+ # the slots we actually probed for envelopes. Slots skipped as empty are
102
+ # not counted, so coverage_pct stays "fraction of present clips automated".
103
+ def _slot_is_empty(s_idx: int, t_idx: int) -> bool:
104
+ """True only when the presence grid positively reports no clip.
105
+
106
+ Unknown / out-of-range / malformed cells return False so we still
107
+ issue the round-trip (safe fallback — never skip on ambiguity).
108
+ """
109
+ try:
110
+ cell = clip_matrix[s_idx][t_idx]
111
+ except (IndexError, TypeError, KeyError):
112
+ return False
113
+ if not isinstance(cell, dict):
114
+ return False
115
+ if cell.get("has_clip"):
116
+ return False
117
+ return cell.get("state") in ("empty", "missing")
118
+
97
119
  notes_map: dict[str, dict[int, list[dict]]] = {}
120
+ clip_automation: list[dict] = []
121
+ clips_scanned = 0
98
122
  try:
99
123
  for scene_idx, scene in enumerate(scenes or []):
100
124
  scene_name = str(scene.get("name", "")).strip()
@@ -105,6 +129,11 @@ def build_project_brain(ctx: Context) -> dict:
105
129
  per_track: dict[int, list[dict]] = {}
106
130
  for track in tracks:
107
131
  t_idx = track.get("index", 0)
132
+ if _slot_is_empty(scene_idx, t_idx):
133
+ continue # no clip in this slot — skip both round-trips
134
+ clips_scanned += 1
135
+
136
+ # Notes for role inference.
108
137
  try:
109
138
  notes_resp = ableton.send_command("get_notes", {
110
139
  "track_index": t_idx,
@@ -115,39 +144,9 @@ def build_project_brain(ctx: Context) -> dict:
115
144
  if notes:
116
145
  per_track[t_idx] = notes
117
146
  except Exception as exc:
118
- logger.debug("build_project_brain failed: %s", exc)
119
- # Individual note fetch failing is fine — continue with others
120
- continue
121
- if per_track:
122
- notes_map[section_id] = per_track
123
- except Exception as exc:
124
- logger.debug("build_project_brain failed: %s", exc)
125
- # Overall failure: empty map, degrade to "all tracks active" fallback
126
- notes_map = {}
147
+ logger.debug("build_project_brain notes fetch failed: %s", exc)
127
148
 
128
- # 5c. Scan clip automation across the session (BUG-E2).
129
- # Device-parameter is_automated flags only reflect whether a parameter
130
- # is mapped somewhere — they don't reveal clip envelopes. Ableton's
131
- # automation actually lives on each clip (session + arrangement). We
132
- # walk every clip slot that has a clip and ask get_clip_automation, then
133
- # aggregate into a flat list keyed by section.
134
- #
135
- # clips_scanned is the denominator for coverage_pct (BUG-D2) — it
136
- # counts how many (track, scene) slots we probed, regardless of
137
- # whether an envelope came back. Without this, a session with zero
138
- # automation would be indistinguishable from a session where we
139
- # failed to probe, which is exactly the ambiguity BUG-D2 flagged.
140
- clip_automation: list[dict] = []
141
- clips_scanned = 0
142
- try:
143
- for scene_idx, scene in enumerate(scenes or []):
144
- scene_name = str(scene.get("name", "")).strip()
145
- if not scene_name:
146
- continue
147
- section_id = f"sec_{scene_idx:02d}"
148
- for track in tracks:
149
- t_idx = track.get("index", 0)
150
- clips_scanned += 1
149
+ # Clip automation envelopes (BUG-E2).
151
150
  try:
152
151
  auto_resp = ableton.send_command("get_clip_automation", {
153
152
  "track_index": t_idx,
@@ -156,22 +155,27 @@ def build_project_brain(ctx: Context) -> dict:
156
155
  except Exception as exc:
157
156
  # No clip in slot, or remote script rejected — skip
158
157
  logger.debug("build_project_brain automation skip: %s", exc)
159
- continue
160
- if not isinstance(auto_resp, dict):
161
- continue
162
- envs = auto_resp.get("envelopes") or []
163
- for env in envs:
164
- clip_automation.append({
165
- "section_id": section_id,
166
- "track_index": t_idx,
167
- "track_name": track.get("name", ""),
168
- "clip_index": scene_idx,
169
- "parameter_name": env.get("parameter_name", ""),
170
- "parameter_type": env.get("parameter_type", ""),
171
- "device_name": env.get("device_name"),
172
- })
158
+ auto_resp = None
159
+ if isinstance(auto_resp, dict):
160
+ for env in (auto_resp.get("envelopes") or []):
161
+ clip_automation.append({
162
+ "section_id": section_id,
163
+ "track_index": t_idx,
164
+ "track_name": track.get("name", ""),
165
+ "clip_index": scene_idx,
166
+ "parameter_name": env.get("parameter_name", ""),
167
+ "parameter_type": env.get("parameter_type", ""),
168
+ "device_name": env.get("device_name"),
169
+ })
170
+
171
+ if per_track:
172
+ notes_map[section_id] = per_track
173
173
  except Exception as exc:
174
- logger.debug("build_project_brain automation scan failed: %s", exc)
174
+ logger.debug("build_project_brain grid sweep failed: %s", exc)
175
+ # Overall failure: empty maps, degrade to "all tracks active" fallback
176
+ notes_map = {}
177
+ clip_automation = []
178
+ clips_scanned = 0
175
179
 
176
180
  # 6. Probe capabilities (direct SpectralCache access, not TCP)
177
181
  analyzer_ok = False
@@ -71,14 +71,18 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
71
71
 
72
72
  spec_data = spectral.get("spectrum")
73
73
  if spec_data:
74
- snapshot["spectral"] = {"bands": spec_data["value"]}
74
+ # gap_analyzer.analyze_gaps reads proj_spectral["band_balance"]
75
+ # and the reference profile stores its bands under the same
76
+ # key, so the project spectrum MUST go under band_balance too
77
+ # (writing it under "bands" made every band read as 0.0).
78
+ snapshot["spectral"] = {"band_balance": spec_data["value"]}
75
79
  key_data = spectral.get("key")
76
80
  if key_data:
77
81
  snapshot["spectral"]["detected_key"] = key_data["value"]
78
82
  except Exception as exc:
79
83
  logger.debug("_fetch_project_snapshot failed: %s", exc)
80
84
 
81
- # Try to get session info for pacing / density
85
+ # Try to get session info for pacing / density / stereo width
82
86
  try:
83
87
  session_info = ableton.send_command("get_session_info", {})
84
88
  track_count = session_info.get("track_count", 0)
@@ -86,6 +90,22 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
86
90
  # Rough density estimate
87
91
  snapshot["density"] = min(1.0, track_count / 20.0)
88
92
  snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
93
+
94
+ # Estimate project stereo width from track pans (mirrors the
95
+ # pan-spread estimate in translation_engine/tools.py). Without this,
96
+ # snapshot["width"] stayed 0.0 and the width gap always reported the
97
+ # full reference width.
98
+ tracks = session_info.get("tracks", [])
99
+ if tracks:
100
+ def _get_pan(t: dict) -> float:
101
+ mixer = t.get("mixer")
102
+ if isinstance(mixer, dict):
103
+ return abs(mixer.get("panning", 0.0))
104
+ return abs(t.get("pan", 0.0))
105
+
106
+ pan_values = [_get_pan(t) for t in tracks if not t.get("muted", False)]
107
+ if pan_values:
108
+ snapshot["width"] = min(1.0, sum(pan_values) / max(len(pan_values), 1))
89
109
  except Exception as exc:
90
110
  logger.debug("_fetch_project_snapshot failed: %s", exc)
91
111
 
@@ -6,6 +6,7 @@ responses and exposes feature flags for tool routing.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import re
9
10
  from dataclasses import dataclass
10
11
 
11
12
 
@@ -19,21 +20,39 @@ class LiveVersionCapabilities:
19
20
 
20
21
  @classmethod
21
22
  def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
22
- """Parse '12.3.6' into a capabilities instance."""
23
- parts = version_str.split(".")
24
- major = int(parts[0]) if len(parts) > 0 else 12
25
- minor = int(parts[1]) if len(parts) > 1 else 0
26
- patch = int(parts[2]) if len(parts) > 2 else 0
23
+ """Parse '12.3.6' into a capabilities instance.
24
+
25
+ Tolerant of malformed input: extracts the leading integer of each
26
+ dotted component and falls back to the conservative floor
27
+ (major=12, minor=0, patch=0) for missing or non-numeric components.
28
+ Mirrors the Remote Script's degrade-to-(12,0,0) contract
29
+ (remote_script/LivePilot/version_detect.py) so a junk live_version
30
+ string never crashes get_capability_state / get_session_kernel /
31
+ --doctor.
32
+ """
33
+ def _component(parts: list[str], index: int, default: int) -> int:
34
+ if index >= len(parts):
35
+ return default
36
+ match = re.match(r"\s*(\d+)", parts[index])
37
+ return int(match.group(1)) if match else default
38
+
39
+ parts = (version_str or "").split(".")
40
+ major = _component(parts, 0, 12)
41
+ minor = _component(parts, 1, 0)
42
+ patch = _component(parts, 2, 0)
27
43
  return cls(major=major, minor=minor, patch=patch)
28
44
 
29
45
  @classmethod
30
46
  def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
31
47
  """Extract version from get_session_info response.
32
48
 
33
- Looks for 'live_version' field. Falls back to 12.0.0 if absent
34
- (pre-upgrade Remote Script).
49
+ Looks for 'live_version' field. Falls back to 12.0.0 if absent,
50
+ null, or empty (pre-upgrade Remote Script). Uses a falsy-guard
51
+ rather than a key-presence default so a present-but-empty value
52
+ degrades to the conservative floor, aligning with the sibling
53
+ readers in runtime/tools.py and runtime/capability_probe.py.
35
54
  """
36
- version_str = session_info.get("live_version", "12.0.0")
55
+ version_str = session_info.get("live_version") or "12.0.0"
37
56
  return cls.from_version_string(version_str)
38
57
 
39
58
  @property
@@ -56,6 +56,15 @@ CONFIRM_REQUIRED_ACTIONS: set[str] = {
56
56
  "replace_simpler_sample",
57
57
  }
58
58
 
59
+ # Names that LOOK read-only by prefix but actually mutate session state.
60
+ # These must NOT be classified as read-only — they are intersected against
61
+ # the prefix matcher below so a mutating verb never slips through a read
62
+ # prefix (e.g. ``find_and_load_device`` starts with ``find_`` but loads a
63
+ # device, a real mutation routed through REMOTE_COMMANDS).
64
+ _MUTATING_OVERRIDES: set[str] = {
65
+ "find_and_load_device",
66
+ }
67
+
59
68
  # Read-only prefixes — any action starting with one of these is a read.
60
69
  _READ_ONLY_PREFIXES = (
61
70
  "get_",
@@ -203,6 +212,8 @@ _WIDE_SCOPE_THRESHOLD = 5 # tracks
203
212
 
204
213
  def is_read_only_action(action: str) -> bool:
205
214
  """Return True if *action* is a non-mutating read/query."""
215
+ if action in _MUTATING_OVERRIDES:
216
+ return False
206
217
  if action in SAFE_ACTIONS:
207
218
  return True
208
219
  return action.startswith(_READ_ONLY_PREFIXES)