livepilot 1.10.7 → 1.10.9
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 +254 -0
- package/README.md +19 -17
- package/bin/livepilot.js +146 -28
- package/installer/install.js +117 -11
- 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 +39 -7
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +63 -12
- package/mcp_server/project_brain/automation_graph.py +23 -1
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/models.py +20 -1
- package/mcp_server/project_brain/tools.py +10 -3
- package/mcp_server/runtime/execution_router.py +16 -2
- package/mcp_server/runtime/remote_commands.py +6 -0
- package/mcp_server/sample_engine/models.py +22 -3
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/tools.py +154 -35
- package/mcp_server/server.py +147 -17
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +13 -0
- package/mcp_server/session_continuity/tools.py +2 -0
- package/mcp_server/session_continuity/tracker.py +93 -0
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
- package/mcp_server/tools/_analyzer_engine/context.py +103 -0
- package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
- package/mcp_server/tools/_motif_engine.py +19 -4
- package/mcp_server/tools/analyzer.py +25 -180
- package/mcp_server/tools/clips.py +240 -2
- package/mcp_server/tools/midi_io.py +10 -0
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +59 -4
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +36 -9
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcp.json.disabled +0 -9
- package/.mcpbignore +0 -60
- package/AGENTS.md +0 -46
- package/BUGS.md +0 -1570
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -106
- package/scripts/sync_metadata.py +0 -349
package/installer/install.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
4
5
|
const path = require("path");
|
|
5
6
|
const { findAbletonPaths } = require("./paths");
|
|
6
7
|
|
|
@@ -10,6 +11,60 @@ const SOURCE_DIR = path.join(ROOT, "remote_script", "LivePilot");
|
|
|
10
11
|
// Files / dirs to skip during copy
|
|
11
12
|
const SKIP = new Set(["__pycache__", ".DS_Store"]);
|
|
12
13
|
|
|
14
|
+
// How many previous backups to keep on disk before auto-pruning (the upgrade
|
|
15
|
+
// path renames the old LivePilot dir to LivePilot.backup-<ts>/ so the user can
|
|
16
|
+
// recover a manual edit).
|
|
17
|
+
const BACKUP_RETENTION = 3;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Typed installer error. Wrappers (e.g. the --setup wizard) can catch this
|
|
21
|
+
* and decide whether to continue with later steps (recoverable) or abort the
|
|
22
|
+
* whole wizard (non-recoverable). The previous version called process.exit(1)
|
|
23
|
+
* mid-function, which silently short-circuited the setup wizard — callers
|
|
24
|
+
* had try/catch expecting exceptions, so later steps (bootstrap, M4L install,
|
|
25
|
+
* diagnostics) were skipped without warning.
|
|
26
|
+
*/
|
|
27
|
+
class InstallerAbort extends Error {
|
|
28
|
+
constructor(message, { recoverable = false } = {}) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "InstallerAbort";
|
|
31
|
+
this.recoverable = recoverable;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate that a user-supplied install destination is somewhere safe.
|
|
37
|
+
* Refuses to write outside of the user's home directory unless it matches
|
|
38
|
+
* one of the known Ableton Remote Scripts paths. This closes the path-
|
|
39
|
+
* traversal hole from `LIVEPILOT_INSTALL_PATH=/etc ...`.
|
|
40
|
+
*/
|
|
41
|
+
function _assertSafeInstallPath(resolvedPath, candidates) {
|
|
42
|
+
const home = os.homedir();
|
|
43
|
+
const allowedPrefixes = [
|
|
44
|
+
home,
|
|
45
|
+
// Systemwide Ableton install paths that live outside $HOME on some platforms
|
|
46
|
+
"/Applications/Ableton",
|
|
47
|
+
"C:\\ProgramData\\Ableton",
|
|
48
|
+
];
|
|
49
|
+
// The detected Ableton candidate paths are always considered safe
|
|
50
|
+
for (const c of candidates) {
|
|
51
|
+
if (path.resolve(c.path).startsWith(path.resolve(resolvedPath))) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (path.resolve(resolvedPath).startsWith(path.resolve(c.path))) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const safe = allowedPrefixes.some((p) => resolvedPath.startsWith(path.resolve(p)));
|
|
59
|
+
if (!safe) {
|
|
60
|
+
throw new InstallerAbort(
|
|
61
|
+
`LIVEPILOT_INSTALL_PATH=${resolvedPath} is outside permitted directories. ` +
|
|
62
|
+
`Refusing to install. Allowed roots: ${allowedPrefixes.join(", ")}`,
|
|
63
|
+
{ recoverable: false }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
/**
|
|
14
69
|
* Recursively copy a directory, skipping __pycache__ and .DS_Store.
|
|
15
70
|
*/
|
|
@@ -28,22 +83,51 @@ function copyDirSync(src, dest) {
|
|
|
28
83
|
}
|
|
29
84
|
}
|
|
30
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Prune old LivePilot.backup-<ts>/ dirs, keeping the most recent N.
|
|
88
|
+
*/
|
|
89
|
+
function _pruneBackups(parentDir) {
|
|
90
|
+
try {
|
|
91
|
+
const entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
92
|
+
const backups = entries
|
|
93
|
+
.filter((e) => e.isDirectory() && /^LivePilot\.backup-\d+$/.test(e.name))
|
|
94
|
+
.map((e) => e.name)
|
|
95
|
+
.sort(); // lexicographic — timestamps are monotonic, so this is age order
|
|
96
|
+
while (backups.length > BACKUP_RETENTION) {
|
|
97
|
+
const old = backups.shift();
|
|
98
|
+
try {
|
|
99
|
+
fs.rmSync(path.join(parentDir, old), { recursive: true, force: true });
|
|
100
|
+
} catch {
|
|
101
|
+
// best effort — don't let cleanup failure break an install
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// best effort
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
31
109
|
/**
|
|
32
110
|
* Install the LivePilot Remote Script into Ableton's Remote Scripts folder.
|
|
111
|
+
*
|
|
112
|
+
* Throws InstallerAbort on recoverable failures (auto-detect missing) or
|
|
113
|
+
* non-recoverable ones (path-traversal attempt). Never calls process.exit.
|
|
114
|
+
* This lets the setup wizard continue with later steps on a recoverable
|
|
115
|
+
* failure.
|
|
33
116
|
*/
|
|
34
117
|
function install() {
|
|
35
118
|
const candidates = findAbletonPaths();
|
|
36
119
|
|
|
37
120
|
if (candidates.length === 0) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
121
|
+
throw new InstallerAbort(
|
|
122
|
+
"Could not auto-detect an Ableton Live Remote Scripts directory.\n\n" +
|
|
123
|
+
"Manual install:\n" +
|
|
124
|
+
" 1. Open Ableton Live > Preferences > File/Folder\n" +
|
|
125
|
+
" 2. Find the User Remote Scripts folder path\n" +
|
|
126
|
+
" 3. Copy the 'remote_script/LivePilot' folder into that directory\n" +
|
|
127
|
+
" 4. Restart Ableton Live\n" +
|
|
128
|
+
" 5. In Preferences > Link/Tempo/MIDI, set a Control Surface to 'LivePilot'",
|
|
129
|
+
{ recoverable: true }
|
|
130
|
+
);
|
|
47
131
|
}
|
|
48
132
|
|
|
49
133
|
// If multiple candidates exist, let the user choose via --install-path
|
|
@@ -51,7 +135,9 @@ function install() {
|
|
|
51
135
|
let target;
|
|
52
136
|
const explicitPath = process.env.LIVEPILOT_INSTALL_PATH;
|
|
53
137
|
if (explicitPath) {
|
|
54
|
-
|
|
138
|
+
const resolved = path.resolve(explicitPath);
|
|
139
|
+
_assertSafeInstallPath(resolved, candidates);
|
|
140
|
+
target = { path: resolved, description: "explicit (LIVEPILOT_INSTALL_PATH)" };
|
|
55
141
|
} else if (candidates.length > 1) {
|
|
56
142
|
console.log("Multiple Ableton Remote Scripts directories detected:");
|
|
57
143
|
candidates.forEach((c, i) => {
|
|
@@ -73,6 +159,25 @@ function install() {
|
|
|
73
159
|
// Ensure target base exists
|
|
74
160
|
fs.mkdirSync(targetBase, { recursive: true });
|
|
75
161
|
|
|
162
|
+
// Clear-then-copy upgrade path. Overlay-copying on top of an existing
|
|
163
|
+
// install leaves stale files when a module is removed/renamed upstream.
|
|
164
|
+
// Instead, rename the previous install to a timestamped backup, copy
|
|
165
|
+
// fresh, then prune old backups. The rename (not delete) preserves any
|
|
166
|
+
// local edits the user may have made.
|
|
167
|
+
if (fs.existsSync(destDir)) {
|
|
168
|
+
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
169
|
+
const backup = path.join(targetBase, `LivePilot.backup-${ts}`);
|
|
170
|
+
try {
|
|
171
|
+
fs.renameSync(destDir, backup);
|
|
172
|
+
console.log("Existing install backed up to: %s", backup);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw new InstallerAbort(
|
|
175
|
+
`Could not back up previous LivePilot install at ${destDir}: ${e.message}`,
|
|
176
|
+
{ recoverable: false }
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
76
181
|
console.log("Installing LivePilot Remote Script...");
|
|
77
182
|
console.log(" Source: %s", SOURCE_DIR);
|
|
78
183
|
console.log(" Target: %s", destDir);
|
|
@@ -80,6 +185,7 @@ function install() {
|
|
|
80
185
|
console.log("");
|
|
81
186
|
|
|
82
187
|
copyDirSync(SOURCE_DIR, destDir);
|
|
188
|
+
_pruneBackups(targetBase);
|
|
83
189
|
|
|
84
190
|
console.log("Done! Next steps:");
|
|
85
191
|
console.log(" 1. Restart Ableton Live (or press Cmd+, to open Preferences)");
|
|
@@ -111,4 +217,4 @@ function uninstall() {
|
|
|
111
217
|
}
|
|
112
218
|
}
|
|
113
219
|
|
|
114
|
-
module.exports = { install, uninstall };
|
|
220
|
+
module.exports = { install, uninstall, InstallerAbort };
|
|
Binary file
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.10.
|
|
2
|
+
__version__ = "1.10.9"
|
|
@@ -411,14 +411,46 @@ class AtlasManager:
|
|
|
411
411
|
|
|
412
412
|
|
|
413
413
|
# ── Module-level lazy loader ───────────────────────────────────────
|
|
414
|
+
#
|
|
415
|
+
# Thread-safe via services.singletons.Singleton. The previous check-then-set
|
|
416
|
+
# pattern raced under FastMCP concurrency (two handlers could both construct
|
|
417
|
+
# AtlasManager) and never refreshed the in-memory index after a rebuild of
|
|
418
|
+
# device_atlas.json on disk. The Singleton helper handles both.
|
|
419
|
+
#
|
|
420
|
+
# The ``_atlas_instance`` module attribute is preserved for backward
|
|
421
|
+
# compatibility with call sites that read it directly (atlas/tools.py),
|
|
422
|
+
# but new code should call ``get_atlas()`` / ``invalidate_atlas()`` instead.
|
|
414
423
|
|
|
415
|
-
|
|
424
|
+
from pathlib import Path
|
|
425
|
+
from ..services.singletons import Singleton
|
|
416
426
|
|
|
427
|
+
ATLAS_PATH = Path(__file__).parent / "device_atlas.json"
|
|
417
428
|
|
|
418
|
-
|
|
419
|
-
|
|
429
|
+
_atlas_instance: Optional[AtlasManager] = None # kept for legacy imports
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _build_atlas() -> AtlasManager:
|
|
433
|
+
return AtlasManager(str(ATLAS_PATH))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
_atlas_holder = Singleton(_build_atlas)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def get_atlas() -> AtlasManager:
|
|
440
|
+
"""Thread-safe accessor. Re-reads device_atlas.json if its mtime advanced."""
|
|
420
441
|
global _atlas_instance
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
442
|
+
instance = _atlas_holder.get(reload_if_newer=ATLAS_PATH)
|
|
443
|
+
_atlas_instance = instance # keep legacy attribute in sync
|
|
444
|
+
return instance
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def invalidate_atlas() -> None:
|
|
448
|
+
"""Force the next get_atlas() to re-read device_atlas.json from disk."""
|
|
449
|
+
global _atlas_instance
|
|
450
|
+
_atlas_holder.invalidate()
|
|
451
|
+
_atlas_instance = None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _load_atlas() -> AtlasManager:
|
|
455
|
+
"""Legacy shim — kept so atlas/tools.py still works. Prefer get_atlas()."""
|
|
456
|
+
return get_atlas()
|
|
@@ -19,15 +19,17 @@ def _get_ableton(ctx: Context):
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _get_atlas():
|
|
22
|
-
"""Get the global AtlasManager instance, loading lazily if needed.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
"""Get the global AtlasManager instance, loading lazily if needed.
|
|
23
|
+
|
|
24
|
+
Uses the thread-safe singleton helper — concurrent FastMCP tool calls no
|
|
25
|
+
longer race on the check-then-set, and the atlas auto-reloads from disk
|
|
26
|
+
if device_atlas.json's mtime advanced (e.g. after scan_full_library).
|
|
27
|
+
"""
|
|
28
|
+
from . import get_atlas
|
|
29
|
+
try:
|
|
30
|
+
return get_atlas()
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
return None
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
@mcp.tool()
|
|
@@ -197,23 +199,44 @@ def scan_full_library(ctx: Context, force: bool = False) -> dict:
|
|
|
197
199
|
stats[cat] = stats.get(cat, 0) + 1
|
|
198
200
|
stats["enriched_devices"] = sum(1 for d in devices if d.get("enriched"))
|
|
199
201
|
|
|
202
|
+
# Read the actual running Live version from the session rather than
|
|
203
|
+
# hardcoding "12.3.6" — the hardcoded string was baking last year's
|
|
204
|
+
# version into every new user's atlas until they forced a rescan.
|
|
205
|
+
try:
|
|
206
|
+
session_info = ableton.send_command("get_session_info", {}) or {}
|
|
207
|
+
live_version = session_info.get("live_version", "unknown")
|
|
208
|
+
except Exception:
|
|
209
|
+
live_version = "unknown"
|
|
210
|
+
|
|
200
211
|
# Build atlas
|
|
201
212
|
atlas_data = {
|
|
202
213
|
"version": "2.0.0",
|
|
203
|
-
"live_version":
|
|
214
|
+
"live_version": live_version,
|
|
204
215
|
"scanned_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
205
216
|
"stats": stats,
|
|
206
217
|
"devices": devices,
|
|
207
218
|
"packs": [],
|
|
208
219
|
}
|
|
209
220
|
|
|
210
|
-
#
|
|
211
|
-
|
|
221
|
+
# Atomic write: tmp + rename. Same pattern as PersistentJsonStore. Previous
|
|
222
|
+
# version used open(atlas_path, "w") + json.dump with no fsync, so a crash
|
|
223
|
+
# mid-write produced a truncated JSON file that the next AtlasManager init
|
|
224
|
+
# silently read as empty-dict — devices vanished from memory.
|
|
225
|
+
tmp_path = atlas_path + ".tmp"
|
|
226
|
+
with open(tmp_path, "w") as f:
|
|
212
227
|
json.dump(atlas_data, f, indent=2)
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
f.flush()
|
|
229
|
+
try:
|
|
230
|
+
os.fsync(f.fileno())
|
|
231
|
+
except OSError:
|
|
232
|
+
# fsync may be unavailable on some filesystems/Windows paths —
|
|
233
|
+
# best-effort; the rename below is still atomic on POSIX.
|
|
234
|
+
pass
|
|
235
|
+
os.replace(tmp_path, atlas_path)
|
|
236
|
+
|
|
237
|
+
# Invalidate singleton so next get_atlas() picks up the new file.
|
|
215
238
|
import mcp_server.atlas as atlas_mod
|
|
216
|
-
atlas_mod.
|
|
239
|
+
atlas_mod.invalidate_atlas()
|
|
217
240
|
|
|
218
241
|
return {
|
|
219
242
|
"status": "scanned",
|
|
@@ -222,3 +245,21 @@ def scan_full_library(ctx: Context, force: bool = False) -> dict:
|
|
|
222
245
|
"stats": stats,
|
|
223
246
|
"atlas_path": atlas_path,
|
|
224
247
|
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
def reload_atlas(ctx: Context) -> dict:
|
|
252
|
+
"""Force the atlas to re-read device_atlas.json from disk.
|
|
253
|
+
|
|
254
|
+
Useful after an out-of-band rebuild (e.g. a manual edit to the JSON file,
|
|
255
|
+
or a scan that crashed before invalidating the cache). The next search /
|
|
256
|
+
suggest / compare call will see the fresh data. No-op if the atlas has
|
|
257
|
+
never been loaded — the first real call will load it fresh anyway.
|
|
258
|
+
"""
|
|
259
|
+
from . import invalidate_atlas, get_atlas
|
|
260
|
+
invalidate_atlas()
|
|
261
|
+
atlas = get_atlas()
|
|
262
|
+
return {
|
|
263
|
+
"reloaded": True,
|
|
264
|
+
"device_count": atlas.device_count if atlas else 0,
|
|
265
|
+
}
|
|
@@ -403,6 +403,33 @@ def plan_sections(intent: CompositionIntent) -> list[dict]:
|
|
|
403
403
|
})
|
|
404
404
|
current_bar += scaled_bars
|
|
405
405
|
|
|
406
|
+
# Clamp overshoot. Rounding each section up to the nearest 4 bars plus
|
|
407
|
+
# the min-of-4-bars floor means a short duration_bars (e.g. 16) against
|
|
408
|
+
# a 6-section template could produce 24+ bars of sections — a 50%
|
|
409
|
+
# overshoot that pushed arrangement clips into unexpected territory.
|
|
410
|
+
# Trim from the longest non-intro section until we fit.
|
|
411
|
+
total_placed = sum(s["bars"] for s in sections)
|
|
412
|
+
overshoot = total_placed - intent.duration_bars
|
|
413
|
+
if overshoot > 0 and sections:
|
|
414
|
+
# Sort indices by section length desc, skipping the first section
|
|
415
|
+
# (usually intro) which we'd rather preserve at its snapped length.
|
|
416
|
+
trimmable = sorted(
|
|
417
|
+
range(1, len(sections)),
|
|
418
|
+
key=lambda i: -sections[i]["bars"],
|
|
419
|
+
) or [0]
|
|
420
|
+
i = 0
|
|
421
|
+
while overshoot > 0 and i < len(trimmable) * 4:
|
|
422
|
+
idx = trimmable[i % len(trimmable)]
|
|
423
|
+
if sections[idx]["bars"] > 4:
|
|
424
|
+
sections[idx]["bars"] -= 4
|
|
425
|
+
overshoot -= 4
|
|
426
|
+
i += 1
|
|
427
|
+
# Recompute start_bar values after any trim
|
|
428
|
+
running = 0
|
|
429
|
+
for s in sections:
|
|
430
|
+
s["start_bar"] = running
|
|
431
|
+
running += s["bars"]
|
|
432
|
+
|
|
406
433
|
return sections
|
|
407
434
|
|
|
408
435
|
|
|
@@ -202,9 +202,15 @@ _ELEMENT_PATTERNS: list[tuple[str, str]] = [
|
|
|
202
202
|
|
|
203
203
|
_TEMPO_RE = re.compile(r"\b(\d{2,3})\s*bpm\b", re.IGNORECASE)
|
|
204
204
|
|
|
205
|
-
# Key patterns:
|
|
205
|
+
# Key patterns: must have either an accidental (C#, Db) OR an explicit
|
|
206
|
+
# quality word (C minor, F major, Am). The previous regex made the
|
|
207
|
+
# quality group optional AND allowed a bare letter — so "dark ambient"
|
|
208
|
+
# matched D as a key root, silently overwriting any mood-inferred key.
|
|
206
209
|
_KEY_RE = re.compile(
|
|
207
|
-
|
|
210
|
+
# Case 1: root + quality word (explicit minor/major/min/maj/m suffix)
|
|
211
|
+
r"\b([A-Ga-g])\s*(minor|major|min|maj|m)\b"
|
|
212
|
+
# Case 2: root + accidental (optional quality)
|
|
213
|
+
r"|\b([A-Ga-g][#b])\s*(minor|major|min|maj|m)?\b"
|
|
208
214
|
)
|
|
209
215
|
|
|
210
216
|
|
|
@@ -228,19 +234,22 @@ def parse_prompt(text: str) -> CompositionIntent:
|
|
|
228
234
|
intent.tempo = int(tempo_match.group(1))
|
|
229
235
|
|
|
230
236
|
# 2. Extract key (search original text to preserve case)
|
|
237
|
+
# Regex has TWO alternations (root+quality OR root-with-accidental
|
|
238
|
+
# +optional-quality). Take whichever branch matched.
|
|
231
239
|
key_match = _KEY_RE.search(text)
|
|
232
240
|
if key_match:
|
|
233
|
-
root = key_match.group(1)
|
|
234
|
-
|
|
241
|
+
root = key_match.group(1) or key_match.group(3)
|
|
242
|
+
quality = key_match.group(2) or key_match.group(4) or ""
|
|
243
|
+
# Normalize root: uppercase first letter, preserve accidental
|
|
235
244
|
root = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
|
|
236
|
-
quality = key_match.group(2) or ""
|
|
237
245
|
quality_lower = quality.lower()
|
|
238
246
|
if quality_lower in ("minor", "min", "m"):
|
|
239
247
|
intent.key = f"{root}m"
|
|
240
248
|
elif quality_lower in ("major", "maj"):
|
|
241
249
|
intent.key = root
|
|
242
250
|
else:
|
|
243
|
-
#
|
|
251
|
+
# Only reached when Case 2 matched without quality — an
|
|
252
|
+
# accidental was present (C#, Db), so this IS a legit key root.
|
|
244
253
|
intent.key = root
|
|
245
254
|
|
|
246
255
|
# 3. Match genre (check aliases first, then canonical names)
|
package/mcp_server/connection.py
CHANGED
|
@@ -213,9 +213,17 @@ class AbletonConnection:
|
|
|
213
213
|
# The single-client guard can briefly reject an immediate reconnect
|
|
214
214
|
# after this process closes a previous socket. Retry once after a
|
|
215
215
|
# short delay when the command was rejected before execution.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
#
|
|
217
|
+
# IMPORTANT: release the lock around the sleep so concurrent tool
|
|
218
|
+
# calls are not blocked on an idle timer. The previous version
|
|
219
|
+
# slept 250ms while holding the lock, which stalled every other
|
|
220
|
+
# async MCP handler in the server.
|
|
221
|
+
needs_retry = fresh_connect and _is_single_client_state_error(response)
|
|
222
|
+
|
|
223
|
+
if needs_retry:
|
|
224
|
+
self.disconnect()
|
|
225
|
+
time.sleep(SINGLE_CLIENT_RETRY_DELAY)
|
|
226
|
+
with self._lock:
|
|
219
227
|
self.connect()
|
|
220
228
|
response = self._send_raw(
|
|
221
229
|
command,
|
|
@@ -365,13 +365,23 @@ def load_corpus() -> Corpus:
|
|
|
365
365
|
|
|
366
366
|
|
|
367
367
|
# ── Module-level lazy singleton ─────────────────────────────────────────
|
|
368
|
+
#
|
|
369
|
+
# Thread-safe via services.singletons.Singleton — concurrent FastMCP
|
|
370
|
+
# handlers can no longer both trigger load_corpus() (which did heavy
|
|
371
|
+
# filesystem I/O) on a cold start.
|
|
368
372
|
|
|
373
|
+
from ..services.singletons import Singleton
|
|
374
|
+
|
|
375
|
+
_corpus_holder = Singleton(load_corpus)
|
|
376
|
+
|
|
377
|
+
# Preserved for backward compatibility with any code that reads the legacy
|
|
378
|
+
# attribute directly.
|
|
369
379
|
_corpus_instance: Optional[Corpus] = None
|
|
370
380
|
|
|
371
381
|
|
|
372
382
|
def get_corpus() -> Corpus:
|
|
373
|
-
"""Get the global corpus instance (lazy-loaded
|
|
383
|
+
"""Get the global corpus instance (lazy-loaded, thread-safe)."""
|
|
374
384
|
global _corpus_instance
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return
|
|
385
|
+
instance = _corpus_holder.get()
|
|
386
|
+
_corpus_instance = instance
|
|
387
|
+
return instance
|
|
@@ -31,6 +31,66 @@ from .policy import apply_hard_rules
|
|
|
31
31
|
# ── Sonic Evaluator ──────────────────────────────────────────────────
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def _compute_taste_fit(
|
|
35
|
+
dimension_changes: dict[str, dict],
|
|
36
|
+
outcome_history: Optional[list[dict]],
|
|
37
|
+
) -> float:
|
|
38
|
+
"""Score how well this move aligns with the user's recent taste.
|
|
39
|
+
|
|
40
|
+
Shipped in v1.10.9 — previously hardcoded to 0.0.
|
|
41
|
+
|
|
42
|
+
For each dimension that moved (in ``dimension_changes``), look at the
|
|
43
|
+
user's last few kept/undone outcomes for the same dimension:
|
|
44
|
+
* kept with the same direction of delta → +0.2 per match
|
|
45
|
+
* undone with the same direction of delta → −0.2 per match
|
|
46
|
+
|
|
47
|
+
Returns a value in 0..1 (0.5 = neutral, neither signal). Empty history
|
|
48
|
+
returns 0.5, which is the correct "no information yet" neutral the
|
|
49
|
+
composite score already expects.
|
|
50
|
+
|
|
51
|
+
``outcome_history`` entries are dicts of the shape::
|
|
52
|
+
|
|
53
|
+
{"dimension": "punch", "delta": 0.12, "kept": True}
|
|
54
|
+
|
|
55
|
+
Callers that pass a richer shape should extract those three fields.
|
|
56
|
+
Malformed entries are skipped silently so a schema bump upstream can't
|
|
57
|
+
break the evaluator.
|
|
58
|
+
"""
|
|
59
|
+
if not outcome_history or not dimension_changes:
|
|
60
|
+
return 0.5
|
|
61
|
+
|
|
62
|
+
# Only weigh the most recent slice — taste drifts, and older signals
|
|
63
|
+
# shouldn't veto a current evaluation.
|
|
64
|
+
recent = outcome_history[-10:]
|
|
65
|
+
|
|
66
|
+
adjustment = 0.0
|
|
67
|
+
matched = 0
|
|
68
|
+
for dim, change in dimension_changes.items():
|
|
69
|
+
current_delta = change.get("delta", 0.0)
|
|
70
|
+
current_sign = 1 if current_delta > 0 else (-1 if current_delta < 0 else 0)
|
|
71
|
+
if current_sign == 0:
|
|
72
|
+
continue
|
|
73
|
+
for entry in recent:
|
|
74
|
+
if not isinstance(entry, dict):
|
|
75
|
+
continue
|
|
76
|
+
if entry.get("dimension") != dim:
|
|
77
|
+
continue
|
|
78
|
+
past_delta = entry.get("delta")
|
|
79
|
+
if not isinstance(past_delta, (int, float)):
|
|
80
|
+
continue
|
|
81
|
+
past_sign = 1 if past_delta > 0 else (-1 if past_delta < 0 else 0)
|
|
82
|
+
if past_sign != current_sign:
|
|
83
|
+
continue
|
|
84
|
+
kept = bool(entry.get("kept", False))
|
|
85
|
+
adjustment += 0.2 if kept else -0.2
|
|
86
|
+
matched += 1
|
|
87
|
+
|
|
88
|
+
if matched == 0:
|
|
89
|
+
return 0.5
|
|
90
|
+
# Neutral baseline 0.5 + averaged adjustment, clamped to [0, 1].
|
|
91
|
+
return _clamp(0.5 + adjustment / matched)
|
|
92
|
+
|
|
93
|
+
|
|
34
94
|
def evaluate_sonic_move(
|
|
35
95
|
request: EvaluationRequest,
|
|
36
96
|
outcome_history: Optional[list[dict]] = None,
|
|
@@ -109,12 +169,13 @@ def evaluate_sonic_move(
|
|
|
109
169
|
measurable_component = _clamp(0.5 + measurable_delta)
|
|
110
170
|
preservation = _clamp(1.0 - collateral_damage * 5)
|
|
111
171
|
confidence = measurable_count / max(len(targets), 1)
|
|
172
|
+
taste_fit = _compute_taste_fit(dimension_changes, outcome_history)
|
|
112
173
|
|
|
113
174
|
score = (
|
|
114
175
|
0.30 * goal_fit
|
|
115
176
|
+ 0.25 * measurable_component
|
|
116
177
|
+ 0.15 * preservation
|
|
117
|
-
+ 0.10 *
|
|
178
|
+
+ 0.10 * taste_fit
|
|
118
179
|
+ 0.10 * confidence
|
|
119
180
|
+ 0.10 * 1.0 # reversibility: 1.0 for undo-able moves
|
|
120
181
|
)
|