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.
Files changed (135) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/README.md +19 -17
  3. package/bin/livepilot.js +146 -28
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +1 -1
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +39 -7
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/evaluation/fabric.py +62 -1
  15. package/mcp_server/m4l_bridge.py +63 -12
  16. package/mcp_server/project_brain/automation_graph.py +23 -1
  17. package/mcp_server/project_brain/builder.py +2 -0
  18. package/mcp_server/project_brain/models.py +20 -1
  19. package/mcp_server/project_brain/tools.py +10 -3
  20. package/mcp_server/runtime/execution_router.py +16 -2
  21. package/mcp_server/runtime/remote_commands.py +6 -0
  22. package/mcp_server/sample_engine/models.py +22 -3
  23. package/mcp_server/semantic_moves/__init__.py +1 -0
  24. package/mcp_server/semantic_moves/compiler.py +9 -1
  25. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  26. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  27. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  28. package/mcp_server/semantic_moves/models.py +5 -0
  29. package/mcp_server/semantic_moves/tools.py +154 -35
  30. package/mcp_server/server.py +147 -17
  31. package/mcp_server/services/singletons.py +68 -0
  32. package/mcp_server/session_continuity/models.py +13 -0
  33. package/mcp_server/session_continuity/tools.py +2 -0
  34. package/mcp_server/session_continuity/tracker.py +93 -0
  35. package/mcp_server/splice_client/client.py +29 -8
  36. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  37. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  38. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  39. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  40. package/mcp_server/tools/_motif_engine.py +19 -4
  41. package/mcp_server/tools/analyzer.py +25 -180
  42. package/mcp_server/tools/clips.py +240 -2
  43. package/mcp_server/tools/midi_io.py +10 -0
  44. package/mcp_server/tools/tracks.py +1 -1
  45. package/mcp_server/tools/transport.py +59 -4
  46. package/mcp_server/translation_engine/tools.py +8 -4
  47. package/package.json +25 -3
  48. package/remote_script/LivePilot/__init__.py +36 -9
  49. package/remote_script/LivePilot/arrangement.py +12 -2
  50. package/remote_script/LivePilot/browser.py +16 -6
  51. package/remote_script/LivePilot/devices.py +10 -5
  52. package/remote_script/LivePilot/notes.py +13 -2
  53. package/remote_script/LivePilot/server.py +51 -13
  54. package/remote_script/LivePilot/version_detect.py +7 -4
  55. package/server.json +20 -0
  56. package/.claude-plugin/marketplace.json +0 -21
  57. package/.mcp.json.disabled +0 -9
  58. package/.mcpbignore +0 -60
  59. package/AGENTS.md +0 -46
  60. package/BUGS.md +0 -1570
  61. package/CODE_OF_CONDUCT.md +0 -27
  62. package/CONTRIBUTING.md +0 -131
  63. package/SECURITY.md +0 -48
  64. package/livepilot/.Codex-plugin/plugin.json +0 -8
  65. package/livepilot/.claude-plugin/plugin.json +0 -8
  66. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  67. package/livepilot/commands/arrange.md +0 -47
  68. package/livepilot/commands/beat.md +0 -77
  69. package/livepilot/commands/evaluate.md +0 -49
  70. package/livepilot/commands/memory.md +0 -22
  71. package/livepilot/commands/mix.md +0 -44
  72. package/livepilot/commands/perform.md +0 -42
  73. package/livepilot/commands/session.md +0 -13
  74. package/livepilot/commands/sounddesign.md +0 -43
  75. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  76. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  77. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  78. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  79. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  80. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  81. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  82. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  83. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  84. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  85. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  86. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  87. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  88. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  89. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  90. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  91. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  92. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  93. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  94. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  95. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  96. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  97. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  98. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  99. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  100. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  101. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  102. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  103. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  104. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  105. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  106. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  107. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  108. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  109. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  110. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  111. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  112. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  113. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  114. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  115. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  116. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  117. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  118. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  119. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  120. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  121. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  122. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  123. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  124. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  125. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  126. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  127. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  128. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  129. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  130. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  131. package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
  132. package/manifest.json +0 -91
  133. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  134. package/scripts/generate_tool_catalog.py +0 -106
  135. package/scripts/sync_metadata.py +0 -349
@@ -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
- console.log("Could not auto-detect an Ableton Live Remote Scripts directory.");
39
- console.log("");
40
- console.log("Manual install:");
41
- console.log(" 1. Open Ableton Live > Preferences > File/Folder");
42
- console.log(" 2. Find the User Remote Scripts folder path");
43
- console.log(" 3. Copy the 'remote_script/LivePilot' folder into that directory");
44
- console.log(" 4. Restart Ableton Live");
45
- console.log(" 5. In Preferences > Link/Tempo/MIDI, set a Control Surface to 'LivePilot'");
46
- process.exit(1);
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
- target = { path: explicitPath, description: "explicit (LIVEPILOT_INSTALL_PATH)" };
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
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.10.6"});
98
+ send_response({"ok": true, "version": "1.10.9"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.10.7"
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
- _atlas_instance: Optional[AtlasManager] = None
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
- def _load_atlas() -> AtlasManager:
419
- """Lazy-load the atlas from device_atlas.json in the same directory."""
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
- if _atlas_instance is None:
422
- atlas_path = os.path.join(os.path.dirname(__file__), "device_atlas.json")
423
- _atlas_instance = AtlasManager(atlas_path)
424
- return _atlas_instance
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
- from . import _atlas_instance, _load_atlas
24
- if _atlas_instance is None:
25
- try:
26
- _load_atlas()
27
- except FileNotFoundError:
28
- return None
29
- from . import _atlas_instance as inst
30
- return inst
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": "12.3.6",
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
- # Write
211
- with open(atlas_path, "w") as f:
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
- # Reload into global
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._atlas_instance = AtlasManager(atlas_path)
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: C, Cm, C#, C# minor, Db, Dbm, F# minor, Bb major
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
- r"\b([A-Ga-g][#b]?)\s*(minor|major|min|maj|m)?\b"
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
- # Normalize root: uppercase first letter
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
- # Standalone note check if followed by 'm' in the original
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)
@@ -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
- if fresh_connect and _is_single_client_state_error(response):
217
- self.disconnect()
218
- time.sleep(SINGLE_CLIENT_RETRY_DELAY)
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 on first call)."""
383
+ """Get the global corpus instance (lazy-loaded, thread-safe)."""
374
384
  global _corpus_instance
375
- if _corpus_instance is None:
376
- _corpus_instance = load_corpus()
377
- return _corpus_instance
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 * 0.0 # taste_fit: placeholder, no history in fabric v1
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
  )