livepilot 1.27.2 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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":
|
|
29
|
-
description=f"Push {dt['name']} to
|
|
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":
|
|
74
|
-
description=f"Push {mt['name']} to
|
|
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":
|
|
132
|
-
description=f"Strip {dt['name']} to
|
|
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":
|
|
140
|
-
description=f"Reduce {bt['name']} to
|
|
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
|
|
package/mcp_server/server.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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"]:
|
|
@@ -521,32 +533,22 @@ _patch_tool_schemas()
|
|
|
521
533
|
|
|
522
534
|
|
|
523
535
|
# ─────────────────────────────────────────────────────────────────────────
|
|
524
|
-
# v1.23.0: User-local atlas overlay
|
|
536
|
+
# v1.23.0: User-local atlas overlay loading.
|
|
525
537
|
#
|
|
526
|
-
#
|
|
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)
|
|
530
|
-
# registration without ordering issues.
|
|
541
|
+
# get_overlay_index() accessor).
|
|
531
542
|
#
|
|
532
|
-
#
|
|
533
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
612
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|