livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -124,3 +124,48 @@ def adjust_volume(current: float, delta_percent: float) -> float:
124
124
  """Adjust a volume by a percentage. delta_percent=5 means +5%."""
125
125
  new = current + (delta_percent / 100.0)
126
126
  return clamp_volume(new)
127
+
128
+
129
+ def compile_relative_volume(
130
+ current: Optional[float],
131
+ delta_percent: float,
132
+ *,
133
+ cap: Optional[float] = None,
134
+ floor: Optional[float] = None,
135
+ fallback: float,
136
+ ) -> float:
137
+ """Compile a bounded RELATIVE volume nudge from a track's current level.
138
+
139
+ P2-21 fix: mix/sound-design/transition compilers used to write an
140
+ ABSOLUTE volume regardless of what was already on the track — e.g.
141
+ "make it punchier" could turn a hot drum bus DOWN by blindly writing
142
+ a flat 0.75. This computes current + delta_percent% instead (via
143
+ adjust_volume), then clamps to an optional cap/floor so the move's
144
+ musical intent (push toward louder / pull toward quieter) still holds
145
+ even when starting from an unusual level.
146
+
147
+ `current` is None when the connected Remote Script predates the
148
+ `volume` field in get_session_info (see transport.py get_session_info)
149
+ — in that case this falls back to the historical absolute value so
150
+ the compiler degrades gracefully instead of crashing or guessing.
151
+
152
+ The cap/floor bound the SIZE of the nudge — they must never flip its
153
+ DIRECTION. A track already louder than `cap` must not get pulled back
154
+ down to the cap when the move means to push it UP (that would
155
+ reintroduce the P2-21 bug in a smaller way); symmetrically a track
156
+ already quieter than `floor` must not get nudged back UP when the
157
+ move means to pull it DOWN. So after clamping to [floor, cap], the
158
+ result is re-clamped against `current` in the direction of travel.
159
+ """
160
+ if current is None:
161
+ return fallback
162
+ target = adjust_volume(current, delta_percent)
163
+ if cap is not None:
164
+ target = min(target, cap)
165
+ if floor is not None:
166
+ target = max(target, floor)
167
+ if delta_percent > 0:
168
+ target = max(target, current)
169
+ elif delta_percent < 0:
170
+ target = min(target, current)
171
+ return clamp_volume(target)
@@ -33,11 +33,15 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
33
33
  idx = t["index"]
34
34
  name = t["name"]
35
35
 
36
- # Boost volume slightly for perceived warmth
36
+ # Boost volume slightly for perceived warmth — RELATIVE nudge
37
+ # (P2-21), capped so an already-hot track isn't pushed toward clip.
38
+ target = resolvers.compile_relative_volume(
39
+ t.get("volume"), 6, cap=0.80, fallback=0.65
40
+ )
37
41
  steps.append(CompiledStep(
38
42
  tool="set_track_volume",
39
- params={"track_index": idx, "volume": 0.65},
40
- description=f"Boost {name} slightly for warmth",
43
+ params={"track_index": idx, "volume": target},
44
+ description=f"Boost {name} to {target:.2f} for warmth",
41
45
  ))
42
46
 
43
47
  # Add reverb send for depth/warmth perception
@@ -138,11 +142,15 @@ def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
138
142
  idx = dt["index"]
139
143
  name = dt["name"]
140
144
 
141
- # Push volume for transient punch
145
+ # Push volume for transient punch — RELATIVE nudge (P2-21), capped
146
+ # so an already-hot drum bus isn't pushed toward clip.
147
+ target = resolvers.compile_relative_volume(
148
+ dt.get("volume"), 8, cap=0.85, fallback=0.75
149
+ )
142
150
  steps.append(CompiledStep(
143
151
  tool="set_track_volume",
144
- params={"track_index": idx, "volume": 0.75},
145
- description=f"Push {name} to 0.75 for transient punch",
152
+ params={"track_index": idx, "volume": target},
153
+ description=f"Push {name} to {target:.2f} for transient punch",
146
154
  ))
147
155
  descriptions.append(f"Push {name} for punch")
148
156
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import asyncio
11
12
  from typing import Optional
12
13
 
13
14
  from fastmcp import Context
@@ -323,7 +324,7 @@ async def apply_semantic_move(
323
324
 
324
325
  # Build a lightweight kernel from session info
325
326
  ableton = ctx.lifespan_context["ableton"]
326
- session_info = ableton.send_command("get_session_info")
327
+ session_info = await asyncio.to_thread(ableton.send_command, "get_session_info")
327
328
  kernel = {
328
329
  "session_info": session_info,
329
330
  "mode": mode,
@@ -394,7 +395,77 @@ async def apply_semantic_move(
394
395
  "ok": er.ok,
395
396
  })
396
397
 
397
- success_count = sum(1 for s in executed_steps if s["ok"])
398
+ # ── Verify-after playback guard ──────────────────────────────────────────
399
+ # get_track_meters returns is_playing=False + all-zero values when the
400
+ # transport is stopped. Counting such a step as "ok" inflates success_count
401
+ # with meaningless verification signal. Detect this pattern and annotate
402
+ # affected steps as verification_skipped so the caller knows the result
403
+ # cannot confirm that the move had the intended audible effect.
404
+ #
405
+ # Detection heuristic: step tool is get_track_meters or get_master_meters
406
+ # AND the returned result contains is_playing=False (or is_playing absent
407
+ # and all numeric meter values are exactly 0.0). We do not modify ok=True
408
+ # (the tool call itself succeeded) — we add a side-channel flag.
409
+ _METER_VERIFY_TOOLS = {"get_track_meters", "get_master_meters"}
410
+ meter_verify_skipped_count = 0
411
+ for es in executed_steps:
412
+ if es["tool"] not in _METER_VERIFY_TOOLS:
413
+ continue
414
+ if not es["ok"] or es["result"] is None:
415
+ continue
416
+ result_data = es["result"]
417
+ if not isinstance(result_data, dict):
418
+ continue
419
+ # is_playing key present and explicitly False → stopped transport.
420
+ # NOTE: the meter verify steps run through the remote_command path,
421
+ # whose handler returns the BARE meter shape with NO is_playing key
422
+ # (the MCP-wrapper that annotates is_playing is bypassed here). So we
423
+ # ALSO apply the all-zero-meters fallback when is_playing is absent.
424
+ is_playing_flag = result_data.get("is_playing")
425
+ skip = False
426
+ note = ""
427
+ if is_playing_flag is False:
428
+ skip = True
429
+ note = (
430
+ "Playback was stopped — meter values are zero; "
431
+ "verification deferred until transport is running"
432
+ )
433
+ elif is_playing_flag is None:
434
+ # Collect every present numeric meter value (track shape or
435
+ # single-track/master shape). If there IS at least one and they
436
+ # are ALL exactly 0.0, the transport is almost certainly stopped
437
+ # → unverifiable. Guard against an empty tracks list so "no
438
+ # tracks" is not mistaken for "stopped".
439
+ meter_vals = []
440
+ tracks = result_data.get("tracks")
441
+ if isinstance(tracks, list) and tracks:
442
+ for t in tracks:
443
+ if isinstance(t, dict):
444
+ for k in ("level", "left", "right"):
445
+ v = t.get(k)
446
+ if isinstance(v, (int, float)):
447
+ meter_vals.append(v)
448
+ else:
449
+ for k in ("level", "left", "right"):
450
+ v = result_data.get(k)
451
+ if isinstance(v, (int, float)):
452
+ meter_vals.append(v)
453
+ if meter_vals and all(v == 0.0 for v in meter_vals):
454
+ skip = True
455
+ note = (
456
+ "Meters all zero (transport likely stopped) — "
457
+ "verification deferred until audio is playing"
458
+ )
459
+ if skip:
460
+ es["verification_skipped"] = True
461
+ es["verification_note"] = note
462
+ meter_verify_skipped_count += 1
463
+
464
+ # success_count: tool calls that succeeded AND are NOT skipped verify steps
465
+ success_count = sum(
466
+ 1 for s in executed_steps
467
+ if s["ok"] and not s.get("verification_skipped", False)
468
+ )
398
469
  failure_count = sum(1 for s in executed_steps if not s["ok"])
399
470
 
400
471
  # store_purpose: writer
@@ -437,6 +508,13 @@ async def apply_semantic_move(
437
508
  result["execution_results"] = executed_steps
438
509
  result["success_count"] = success_count
439
510
  result["failure_count"] = failure_count
511
+ if meter_verify_skipped_count > 0:
512
+ result["verification_note"] = (
513
+ f"{meter_verify_skipped_count} meter verification step(s) skipped: "
514
+ "playback was stopped when meters were read — start transport before "
515
+ "re-running this move to get meaningful before/after confirmation"
516
+ )
517
+ result["verification_skipped_count"] = meter_verify_skipped_count
440
518
  if ledger_entry_id is not None:
441
519
  result["ledger_entry_id"] = ledger_entry_id
442
520
  return result
@@ -23,10 +23,15 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
23
23
  drums = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
24
24
 
25
25
  for dt in drums[:1]:
26
+ # RELATIVE nudge (P2-21), capped so an already-hot drum bus isn't
27
+ # pushed toward clip.
28
+ target = resolvers.compile_relative_volume(
29
+ dt.get("volume"), 8, cap=0.85, fallback=0.75
30
+ )
26
31
  steps.append(CompiledStep(
27
32
  tool="set_track_volume",
28
- params={"track_index": dt["index"], "volume": 0.75},
29
- description=f"Push {dt['name']} to 0.75 for forward drive",
33
+ params={"track_index": dt["index"], "volume": target},
34
+ description=f"Push {dt['name']} to {target:.2f} for forward drive",
30
35
  ))
31
36
  descriptions.append(f"Push {dt['name']} forward")
32
37
 
@@ -66,12 +71,16 @@ def _compile_open_chorus(move: SemanticMove, kernel: dict) -> CompiledPlan:
66
71
  melodic = resolvers.find_tracks_by_role(kernel, ["chords", "lead", "pad"])
67
72
  drums = resolvers.find_tracks_by_role(kernel, ["drums"])
68
73
 
69
- # Push all melodic tracks
74
+ # Push all melodic tracks — RELATIVE nudge (P2-21), capped so an
75
+ # already-hot track isn't pushed toward clip.
70
76
  for mt in melodic:
77
+ target = resolvers.compile_relative_volume(
78
+ mt.get("volume"), 8, cap=0.85, fallback=0.75
79
+ )
71
80
  steps.append(CompiledStep(
72
81
  tool="set_track_volume",
73
- params={"track_index": mt["index"], "volume": 0.75},
74
- description=f"Push {mt['name']} to 0.75 for chorus energy",
82
+ params={"track_index": mt["index"], "volume": target},
83
+ description=f"Push {mt['name']} to {target:.2f} for chorus energy",
75
84
  ))
76
85
  descriptions.append(f"Push {mt['name']}")
77
86
 
@@ -126,18 +135,26 @@ def _compile_create_breakdown(move: SemanticMove, kernel: dict) -> CompiledPlan:
126
135
  pads = resolvers.find_tracks_by_role(kernel, ["pad"])
127
136
 
128
137
  for dt in drums:
138
+ # RELATIVE nudge (P2-21), floored so an already-quiet drum bus
139
+ # doesn't get pulled to silence.
140
+ target = resolvers.compile_relative_volume(
141
+ dt.get("volume"), -15, floor=0.15, fallback=0.25
142
+ )
129
143
  steps.append(CompiledStep(
130
144
  tool="set_track_volume",
131
- params={"track_index": dt["index"], "volume": 0.25},
132
- description=f"Strip {dt['name']} to 0.25 for breakdown",
145
+ params={"track_index": dt["index"], "volume": target},
146
+ description=f"Strip {dt['name']} to {target:.2f} for breakdown",
133
147
  ))
134
148
  descriptions.append(f"Strip {dt['name']}")
135
149
 
136
150
  for bt in bass[:1]:
151
+ target = resolvers.compile_relative_volume(
152
+ bt.get("volume"), -12, floor=0.20, fallback=0.30
153
+ )
137
154
  steps.append(CompiledStep(
138
155
  tool="set_track_volume",
139
- params={"track_index": bt["index"], "volume": 0.30},
140
- description=f"Reduce {bt['name']} to 0.30",
156
+ params={"track_index": bt["index"], "volume": target},
157
+ description=f"Reduce {bt['name']} to {target:.2f}",
141
158
  ))
142
159
  descriptions.append(f"Reduce {bt['name']}")
143
160
 
@@ -10,6 +10,7 @@ from fastmcp import FastMCP, Context # noqa: F401
10
10
 
11
11
  from .connection import AbletonConnection
12
12
  from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge, MidiToolCache
13
+ from .persistence.taste_store import PersistentTasteStore
13
14
 
14
15
  # Logger must be defined before any function uses it — several module-level
15
16
  # helpers below (e.g. _master_has_livepilot_analyzer) call logger.debug on
@@ -133,7 +134,9 @@ async def _warm_analyzer_bridge(
133
134
  timeout: float = 3.0,
134
135
  ) -> None:
135
136
  """Give the analyzer stream a short startup window before first use."""
136
- if not _master_has_livepilot_analyzer(ableton):
137
+ # _master_has_livepilot_analyzer does a blocking TCP round-trip — run it
138
+ # off the event-loop thread so a slow Ableton can't stall startup.
139
+ if not await asyncio.to_thread(_master_has_livepilot_analyzer, ableton):
137
140
  return
138
141
 
139
142
  loop = asyncio.get_running_loop()
@@ -202,7 +205,10 @@ async def lifespan(server):
202
205
  # Degrade gracefully. The reconnect_bridge tool can retry later
203
206
  # if the other instance is stopped.
204
207
  import sys
205
- holder_info = _identify_port_holder(9880)
208
+ # _identify_port_holder runs two blocking subprocess.check_output
209
+ # calls (lsof + ps) — offload so a slow/busy host can't stall the
210
+ # event loop during startup, same as the neighboring lifespan calls.
211
+ holder_info = await asyncio.to_thread(_identify_port_holder, 9880)
206
212
  print(
207
213
  "LivePilot: UDP port 9880 already in use%s — "
208
214
  "analyzer/bridge tools unavailable at startup. "
@@ -223,7 +229,7 @@ async def lifespan(server):
223
229
  try:
224
230
  # BUG-A1: detect stale Remote Script installs early so the user
225
231
  # sees a clear message instead of cryptic "Unknown command type" errors.
226
- _check_remote_script_version(ableton)
232
+ await asyncio.to_thread(_check_remote_script_version, ableton)
227
233
  if bridge_state["transport"] is not None:
228
234
  await _warm_analyzer_bridge(ableton, spectral)
229
235
  # Bind per-project persistent store so creative threads and turn
@@ -231,7 +237,7 @@ async def lifespan(server):
231
237
  # through the tracker but never called — threads/turns were effectively
232
238
  # in-memory only. If Ableton isn't reachable yet, tools will lazy-bind
233
239
  # on first write via ensure_project_store_bound().
234
- _bind_session_continuity(ableton)
240
+ await asyncio.to_thread(_bind_session_continuity, ableton)
235
241
  yield {
236
242
  "ableton": ableton,
237
243
  "spectral": spectral,
@@ -240,6 +246,12 @@ async def lifespan(server):
240
246
  "_bridge_state": bridge_state,
241
247
  "mcp_dispatch": mcp_dispatch,
242
248
  "splice_client": splice_client,
249
+ # Persistent taste backing so dimension weights / anti-preferences
250
+ # survive a server restart (P2-29). Keyed "persistent_taste" to match
251
+ # the existing wonder/runtime/preview_studio setdefault callers so all
252
+ # tools share ONE instance. Only present on the live server; tests
253
+ # construct their own context without it → session-only taste.
254
+ "persistent_taste": PersistentTasteStore(),
243
255
  }
244
256
  finally:
245
257
  if bridge_state["transport"]:
@@ -362,7 +374,7 @@ def _get_all_tools():
362
374
  to the next path rather than exploding.
363
375
 
364
376
  WARNING: Accesses FastMCP private internals. Pinned to
365
- fastmcp>=3.3.1,<3.4.0 in requirements.txt. The startup self-test
377
+ fastmcp>=3.4.2,<3.5.0 in requirements.txt. The startup self-test
366
378
  (_assert_tool_registry_accessible) will fail loudly if every probe
367
379
  returns empty — better than silently returning [] and disabling
368
380
  schema coercion.
@@ -521,32 +533,22 @@ _patch_tool_schemas()
521
533
 
522
534
 
523
535
  # ─────────────────────────────────────────────────────────────────────────
524
- # v1.23.0: User-local atlas overlay boot hook.
536
+ # v1.23.0: User-local atlas overlay loading.
525
537
  #
526
- # Loads YAMLs from ~/.livepilot/atlas-overlays/<namespace>/ into the
538
+ # YAMLs from ~/.livepilot/atlas-overlays/<namespace>/ are loaded into the
527
539
  # module-level OverlayIndex singleton. The 3 extension_atlas_* tools
528
540
  # registered above resolve the singleton at REQUEST time (via the
529
- # get_overlay_index() accessor), so this load can happen after their
530
- # registration without ordering issues.
541
+ # get_overlay_index() accessor).
531
542
  #
532
- # Failures are logged but never abort boot server starts even if the
533
- # user has no overlays installed or has malformed YAMLs.
543
+ # v1.27.3 perf batch: population moved from an unconditional import-time
544
+ # call here (measured ~1.1s of a ~2s import with a populated overlay tree)
545
+ # to lazy first-access population inside get_overlay_index() itself (see
546
+ # _ensure_loaded() in mcp_server/atlas/overlays.py, guarded by a lock).
547
+ # Nothing in this codebase depends on the singleton being populated at
548
+ # import time — every caller resolves it via get_overlay_index() at request
549
+ # time — so this boot hook is no longer needed.
534
550
  # Spec: docs/superpowers/specs/2026-04-25-user-local-extensions-design.md §6.1
535
551
  # ─────────────────────────────────────────────────────────────────────────
536
- try:
537
- from .atlas.overlays import load_overlays
538
- _overlay_idx_at_boot = load_overlays()
539
- _overlay_count = len(_overlay_idx_at_boot.all_entries())
540
- if _overlay_count:
541
- logger.info(
542
- f"User-local overlays loaded: {_overlay_count} entries across "
543
- f"namespaces {_overlay_idx_at_boot.list_namespaces()}"
544
- )
545
- else:
546
- logger.debug("User-local overlays: none installed at "
547
- "~/.livepilot/atlas-overlays/")
548
- except Exception as e:
549
- logger.warning(f"User-local overlay load failed (non-fatal, server continues): {e}")
550
552
 
551
553
 
552
554
  def main():
@@ -97,20 +97,68 @@ def bind_project_store_from_session(session_info: dict) -> Optional[str]:
97
97
  logger.debug("bind_project_store_from_session: read failed: %s", exc)
98
98
  raw_threads, raw_turns = [], []
99
99
 
100
- _threads = {
100
+ # MERGE, don't overwrite. The whole reason a lazy/late bind exists is the
101
+ # startup bind couldn't reach Ableton — during that window the tracker
102
+ # accepted open_thread()/record_turn_resolution() with no store attached,
103
+ # so those entries live ONLY in _threads/_turns and were never flushed. A
104
+ # naive reassignment of _threads/_turns from disk silently discards them
105
+ # (data loss). Instead: disk is the truth for anything it already holds
106
+ # (id-keyed), and any in-memory entry whose id is absent on disk is an
107
+ # unpersisted survivor we keep AND flush so the next bind sees it on disk.
108
+ disk_threads = {
101
109
  t["thread_id"]: CreativeThread.from_dict(t)
102
110
  for t in raw_threads
103
111
  if isinstance(t, dict) and "thread_id" in t
104
112
  }
105
- _turns = [
113
+ unflushed_threads = [
114
+ thread for tid, thread in _threads.items()
115
+ if tid and tid not in disk_threads
116
+ ]
117
+ merged_threads = dict(disk_threads)
118
+ for thread in unflushed_threads:
119
+ merged_threads[thread.thread_id] = thread
120
+
121
+ disk_turn_ids = {
122
+ t["turn_id"] for t in raw_turns
123
+ if isinstance(t, dict) and t.get("turn_id")
124
+ }
125
+ disk_turns = [
106
126
  TurnResolution.from_dict(t)
107
127
  for t in raw_turns
108
128
  if isinstance(t, dict)
109
129
  ]
130
+ # Turns are append-only history: keep disk order, then append any
131
+ # in-memory turn whose id isn't already on disk (preserve insertion order).
132
+ unflushed_turns = [
133
+ turn for turn in _turns
134
+ if turn.turn_id and turn.turn_id not in disk_turn_ids
135
+ ]
136
+ merged_turns = disk_turns + unflushed_turns
137
+
138
+ _threads = merged_threads
139
+ _turns = merged_turns
110
140
  _project_store = store
141
+
142
+ # Persist the survivors now that a store is attached. We do this AFTER
143
+ # binding _project_store so a failure here doesn't leave the survivors
144
+ # invisible — they're already live in memory; this only writes them
145
+ # through to disk so a future restart/rebind keeps them.
146
+ for thread in unflushed_threads:
147
+ try:
148
+ store.save_thread(thread.to_dict())
149
+ except Exception as exc:
150
+ logger.debug("bind_project_store_from_session: thread flush failed: %s", exc)
151
+ for turn in unflushed_turns:
152
+ try:
153
+ store.save_turn(turn.to_dict())
154
+ except Exception as exc:
155
+ logger.debug("bind_project_store_from_session: turn flush failed: %s", exc)
156
+
111
157
  logger.info(
112
- "session_continuity: bound project %s (%d threads, %d turns restored)",
158
+ "session_continuity: bound project %s "
159
+ "(%d threads, %d turns; %d threads + %d turns merged from memory)",
113
160
  new_id, len(_threads), len(_turns),
161
+ len(unflushed_threads), len(unflushed_turns),
114
162
  )
115
163
  return new_id
116
164
 
@@ -46,7 +46,6 @@ def build_song_brain(
46
46
  role_graph = role_graph or {}
47
47
  recent_moves = recent_moves or []
48
48
 
49
- brain_id = _compute_brain_id(session_info, scenes)
50
49
  built_from: dict[str, bool] = {
51
50
  "session_info": True,
52
51
  "scenes": bool(scenes),
@@ -74,6 +73,13 @@ def build_song_brain(
74
73
 
75
74
  drift_risk = _estimate_drift_risk(recent_moves, sacred)
76
75
 
76
+ # Content-aware brain_id: computed AFTER identity_core/sections/energy_arc
77
+ # so it reflects actual musical content, not just track/scene counts.
78
+ # See _compute_brain_id docstring.
79
+ brain_id = _compute_brain_id(
80
+ session_info, scenes, identity_core, energy_arc, sections
81
+ )
82
+
77
83
  # Evidence-weighted confidence adjustment
78
84
  # Weights: motif=0.4, composition=0.2, role_graph=0.15, scenes=0.15, recent_moves=0.1
79
85
  evidence_weights = {
@@ -343,7 +349,7 @@ def _infer_section_purposes(
343
349
  # From composition analysis if available
344
350
  comp_sections = composition.get("sections", [])
345
351
  if comp_sections:
346
- for sec in comp_sections:
352
+ for i, sec in enumerate(comp_sections):
347
353
  name = str(sec.get("name", ""))
348
354
  # BUG-B12: skip empty placeholder sections that pollute the
349
355
  # energy_arc and section_purposes list. A section with no name
@@ -360,7 +366,11 @@ def _infer_section_purposes(
360
366
  or intent.lower() in _PAYOFF_INTENTS
361
367
  )
362
368
  sections.append(SectionPurpose(
363
- section_id=sec.get("id", name),
369
+ # Must be UNIQUE: _build_energy_arc / detect_identity_drift key
370
+ # before/after energy by section_id. A section with energy>0 but
371
+ # no "id" and an empty name would yield "" for two distinct
372
+ # sections, collapsing them in the dict and dropping one's drift.
373
+ section_id=sec.get("id") or name.strip() or f"section_{i}",
364
374
  label=sec.get("label", name),
365
375
  emotional_intent=intent,
366
376
  energy_level=sec.get("energy", 0.5),
@@ -608,11 +618,43 @@ def detect_identity_drift(
608
618
  # ── Helpers ───────────────────────────────────────────────────────
609
619
 
610
620
 
611
- def _compute_brain_id(session_info: dict, scenes: list[dict]) -> str:
612
- """Deterministic brain ID from session state."""
621
+ def _compute_brain_id(
622
+ session_info: dict,
623
+ scenes: list[dict],
624
+ identity_core: str = "",
625
+ energy_arc: Optional[list[float]] = None,
626
+ section_purposes: Optional[list[SectionPurpose]] = None,
627
+ ) -> str:
628
+ """Deterministic brain ID from session state AND musical content.
629
+
630
+ Previously hashed only {tempo, track_count, scene_count}. A note-only
631
+ edit (same track/scene counts, different melodic/harmonic/energy
632
+ content) produced the SAME brain_id every time. detect_identity_drift
633
+ keys its snapshot store by brain_id (song_brain/tools.py
634
+ _brain_snapshots), so an unchanged id meant every "after" build silently
635
+ overwrote its own "before" baseline in that dict and drift always read
636
+ 0.0 even when the song's actual identity had shifted.
637
+
638
+ Folding identity_core / energy_arc / section signatures in keeps the id
639
+ STABLE across identical builds (same inputs -> same id — still a pure,
640
+ deterministic function) while making it change whenever the song's
641
+ inferred content changes, even when track/scene counts don't.
642
+ """
643
+ section_sig = [
644
+ {
645
+ "section_id": s.section_id,
646
+ "energy_level": round(s.energy_level, 3),
647
+ "emotional_intent": s.emotional_intent,
648
+ "is_payoff": s.is_payoff,
649
+ }
650
+ for s in (section_purposes or [])
651
+ ]
613
652
  seed = json.dumps({
614
653
  "tempo": session_info.get("tempo"),
615
654
  "track_count": session_info.get("track_count"),
616
655
  "scene_count": len(scenes),
656
+ "identity_core": identity_core,
657
+ "energy_arc": [round(e, 3) for e in (energy_arc or [])],
658
+ "section_purposes": section_sig,
617
659
  }, sort_keys=True)
618
660
  return hashlib.sha256(seed.encode()).hexdigest()[:12]
@@ -7,6 +7,8 @@
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import threading
11
+
10
12
  from fastmcp import Context
11
13
 
12
14
  from ..runtime.degradation import DegradationInfo
@@ -24,6 +26,10 @@ _current_brain: SongBrain | None = None
24
26
  # Snapshot store: brain_id -> SongBrain, max 10 snapshots
25
27
  _brain_snapshots: dict[str, SongBrain] = {}
26
28
  _MAX_SNAPSHOTS = 10
29
+ # Guards _brain_snapshots store/evict — the MCP server can service concurrent
30
+ # tool calls, and store-then-evict was a read-modify-write on a shared dict
31
+ # with no synchronization (same race shape flagged in the persistence stores).
32
+ _snapshots_lock = threading.Lock()
27
33
 
28
34
 
29
35
  def _set_brain(ctx: Context, brain: SongBrain) -> None:
@@ -32,16 +38,24 @@ def _set_brain(ctx: Context, brain: SongBrain) -> None:
32
38
  _current_brain = brain
33
39
  ctx.lifespan_context["current_brain"] = brain
34
40
  # Save snapshot for later drift comparison
35
- _brain_snapshots[brain.brain_id] = brain
36
- # Evict oldest if over limit
37
- while len(_brain_snapshots) > _MAX_SNAPSHOTS:
38
- oldest_key = next(iter(_brain_snapshots))
39
- del _brain_snapshots[oldest_key]
41
+ with _snapshots_lock:
42
+ _brain_snapshots[brain.brain_id] = brain
43
+ # Evict oldest if over limit
44
+ while len(_brain_snapshots) > _MAX_SNAPSHOTS:
45
+ oldest_key = next(iter(_brain_snapshots))
46
+ del _brain_snapshots[oldest_key]
40
47
 
41
48
 
42
49
  def _get_snapshot(brain_id: str) -> SongBrain | None:
43
50
  """Retrieve a past brain snapshot by ID."""
44
- return _brain_snapshots.get(brain_id)
51
+ with _snapshots_lock:
52
+ return _brain_snapshots.get(brain_id)
53
+
54
+
55
+ def _snapshot_ids() -> list[str]:
56
+ """List currently held snapshot ids (lock-guarded snapshot for error reporting)."""
57
+ with _snapshots_lock:
58
+ return list(_brain_snapshots.keys())
45
59
 
46
60
 
47
61
  def _get_ableton(ctx: Context):
@@ -371,7 +385,7 @@ def detect_identity_drift(
371
385
  if before_brain_id:
372
386
  before = _get_snapshot(before_brain_id)
373
387
  if before is None:
374
- available = list(_brain_snapshots.keys())
388
+ available = _snapshot_ids()
375
389
  return {
376
390
  "error": f"No snapshot found for brain_id '{before_brain_id}'",
377
391
  "available_snapshots": available,
@@ -7,6 +7,7 @@ All pure computation, zero I/O.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import re
10
11
  from dataclasses import asdict, dataclass, field
11
12
 
12
13
  from .models import (