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.
Files changed (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +1 -1
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. package/server.json +2 -2
@@ -0,0 +1,125 @@
1
+ # Perception (Analyzer) Reference
2
+
3
+ Deep-dive detail for `mcp_server/tools/analyzer.py` tools. The tool
4
+ docstrings carry a trimmed operational contract (params, ranges,
5
+ gotchas); this file carries the full band tables, classification
6
+ thresholds, and historical bug context that motivated each behavior.
7
+ Read this before deep spectral/health-check work — the tool docstrings
8
+ point back here for the parts that don't change from call to call.
9
+
10
+ ## `get_master_spectrum` — 9-band table
11
+
12
+ Band energies (fffb~ center frequencies shown in parens), values 0.0-1.0:
13
+
14
+ | Band | Range | Center | Use |
15
+ |---|---|---|---|
16
+ | sub_low | 20-60 Hz | ~35 Hz | kick fundamentals, deep sub-bass |
17
+ | sub | 60-120 Hz | ~85 Hz | 808s, sub-bass body |
18
+ | low | 120-250 Hz | ~175 Hz | bass body, warmth |
19
+ | low_mid | 250-500 Hz | ~350 Hz | mud zone, male vocal lows |
20
+ | mid | 500 Hz-1 kHz | ~700 Hz | vocal presence, snare body |
21
+ | high_mid | 1-2 kHz | ~1.4 kHz | consonants, pick attack |
22
+ | high | 2-4 kHz | ~2.8 kHz | presence, vocal intelligibility |
23
+ | presence | 4-8 kHz | ~5.6 kHz | cymbal definition, air of breath |
24
+ | air | 8-20 kHz | ~12 kHz | shimmer, sparkle |
25
+
26
+ Older `.amxd` builds (pre-v1.16) emit the legacy 8-band layout without
27
+ the explicit `sub_low` split — the server auto-detects band count from
28
+ the OSC payload and picks the right name set. Re-freeze the Max device
29
+ to get the 9-band resolution.
30
+
31
+ **BUG-2026-04-22#6 fix — windowed averaging.** Kick transients make
32
+ single snapshots swing wildly (0.45 → 0.05 → 0.16 within a bar). The
33
+ `window_ms` param samples the cache over a time window and mean-pools
34
+ instead of returning one instantaneous frame.
35
+
36
+ **BUG-2026-04-22#15 fix — sub-band resolution.** `sub_detail=True`
37
+ derives three finer buckets from the FluCoMa 40-band mel spectrum
38
+ (band 0-1 ≈ sub_deep 0-45 Hz — kick fundamental, band 2 ≈ sub_mid
39
+ 45-60 Hz — 808 body/kick upper, band 3 ≈ sub_high 60-80 Hz — bass
40
+ guitar low/sub-bass crossover). Mel band edges are perceptual, not
41
+ linear Hz, so these are approximations tight enough for mixing
42
+ decisions ("is energy in the 30 Hz or 60 Hz range?"). Requires FluCoMa
43
+ active — omitted with `sub_detail_warning` otherwise.
44
+
45
+ ## `verify_device_health` / `verify_all_devices_health`
46
+
47
+ **BUG-2026-04-22#19 fix.** `parameter_count` alone can't tell you
48
+ whether an AU/VST is alive — plenty of "loaded" plugins return N
49
+ params and silence. These tools fire a real test MIDI note and read
50
+ the track meter across a window (`get_track_meters(samples=N)` to
51
+ dodge the BUG-#7 "left=right=0 while level>0" artifact).
52
+
53
+ Common dead-device causes (surfaced in the `hint` field when
54
+ `alive=False`): (1) plugin waiting for preset/bank selection, (2)
55
+ algorithm/envelope configured for zero output, (3) wrong MIDI channel
56
+ or velocity curve, (4) dead VST (reinstall). Try opening the device UI
57
+ and auditioning manually.
58
+
59
+ `verify_all_devices_health` (BUG-2026-04-26#1 fix): audio-track
60
+ detection uses `has_midi_input`/`has_audio_input` from
61
+ `get_session_info` (earlier code checked nonexistent `is_audio_track`/
62
+ `type` fields, so detection silently always evaluated False).
63
+ Empty-track detection requires a `get_track_info` round-trip per track
64
+ because `get_session_info` doesn't embed per-track `devices` arrays.
65
+
66
+ ## `classify_simpler_slices` — classification thresholds
67
+
68
+ Validated on "Break Ghosts 90 bpm" reference material:
69
+ - KICK: sub+low >= 45%, high < 40%
70
+ - HAT: high >= 70% AND mid < 25% (thin metal disc = no drum body)
71
+ - SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6 (broadband loud)
72
+ - ghost: peak < 0.35
73
+
74
+ **Always run this before programming drum patterns on a sliced break.**
75
+ Slice content depends on transient detection order in the source
76
+ audio — slice 0 is NOT guaranteed to be a kick. Assuming drum-rack
77
+ convention produces wrong grooves that take iterations to diagnose.
78
+
79
+ File-path resolution order: explicit `file_path` param, then Remote
80
+ Script TCP (`get_simpler_file_path` via direct LOM read — most
81
+ reliable), then M4L bridge UDP fallback (kept registered for
82
+ environments where Remote Script is stale/unavailable; call
83
+ `reload_handlers` to refresh without a full restart).
84
+
85
+ ## `add_drum_rack_pad`
86
+
87
+ **BUG-2026-04-22#1 fix** — this tool closes a gap where
88
+ `load_browser_item` replaced the existing chain on repeat calls and
89
+ `load_sample_to_simpler` couldn't address nested rack paths. It chains
90
+ six steps atomically: locate/auto-detect the Drum Rack, insert a chain,
91
+ assign the trigger note, insert an empty Simpler into the chain, native
92
+ `replace_sample_native` with nested addressing, Snap=0 hygiene.
93
+
94
+ ## `replace_simpler_sample` / `load_sample_to_simpler`
95
+
96
+ Prefer `load_browser_item(track, uri)` when the file is browser-indexed
97
+ — the M4L bridge's replace path can silently keep the bootstrap
98
+ placeholder in some conditions (this is why both tools verify by
99
+ reading back the device name post-load and error if the replace didn't
100
+ actually take effect).
101
+
102
+ Nested addressing (`chain_index` + `nested_device_index`) is Live
103
+ 12.4+ only (BUG-#1, 2026-04-22) — resolves at
104
+ `track.devices[device_index].chains[chain_index].devices[nested_device_index or 0]`,
105
+ which is how Drum Rack pad-by-pad construction works. The M4L bridge
106
+ fallback cannot resolve nested paths; only the native 12.4 path honors
107
+ `chain_index`.
108
+
109
+ `load_sample_to_simpler`'s bootstrap flow (used pre-12.4 or when no
110
+ Simpler exists yet): loads a dummy sample via the browser to force
111
+ Ableton to create a Simpler, replaces it with the target file, then
112
+ runs the same post-load hygiene/verification as `replace_simpler_sample`.
113
+
114
+ ## `simpler_set_warp` / `compressor_set_sidechain` — LOM gap tools
115
+
116
+ **BUG-A2**: Python's Remote Script ControlSurface API can't reach
117
+ Simpler's `warping`/`warp_mode` — they live on the sample child object
118
+ (`SimplerDevice.sample.*`) that only Max for Live's JavaScript LiveAPI
119
+ can step into. Hence the M4L bridge round-trip.
120
+
121
+ **BUG-A3**: `compressor_set_sidechain`'s routing properties
122
+ (`sidechain_input_routing_type`/`_channel`) aren't in Compressor's
123
+ automatable parameter list, but Python's Remote Script reaches them
124
+ directly as device properties (same LOM pattern as `set_track_routing`)
125
+ — no M4L bridge needed here, unlike the warp case above.
@@ -0,0 +1,66 @@
1
+ # Device Parameter Units Reference
2
+
3
+ Full per-device unit table for `set_device_parameter` / `batch_set_parameters`
4
+ (`mcp_server/tools/devices.py`). The tool docstrings carry the most
5
+ commonly-hit gotchas inline; this file is the complete list plus the
6
+ bug history that motivated the error-enrichment behavior.
7
+
8
+ ## Parameter ranges are NOT always 0-1 (BUG-B4 / B9 / 2026-04-26#2)
9
+
10
+ Ableton devices use MIXED units depending on the parameter. Always read
11
+ the `value_string` in the response (and `min`/`max` from
12
+ `get_device_parameters`) before assuming 0-1 semantics:
13
+
14
+ - Auto Filter `Frequency`: 20-135 index (NOT normalized)
15
+ - Auto Filter Legacy `LFO Amount`: 0-30 absolute (displays as %)
16
+ - Auto Filter `Resonance`: 0-1.25 on legacy, 0-1 on AutoFilter2
17
+ - Auto Filter `Env. Modulation`: -127..+127 on legacy
18
+ - Compressor I (legacy): pre-2010 units (Threshold dB direct)
19
+ - **Compressor 2 (modern, default)**: 0-1 NORMALIZED. `Threshold 0.85 ≈
20
+ 0 dB`, `Ratio 0.75 = 4:1`, `Release 0.16 = 30 ms`. Setting Threshold
21
+ to a dB value like -22 will fail. Compute normalized: `(dB + 50) / 50`
22
+ for typical dB→0-1 mapping, OR read the param's `value_string` after
23
+ a probe write.
24
+ - **Saturator** `Drive`, `Output`, `Threshold`, `Color *`: 0-1
25
+ NORMALIZED (Drive 0.5 ≈ 0 dB, Drive 0.6 ≈ +7 dB).
26
+ - Dynamic Tube, Vocoder: pre-2010 units
27
+ - EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute
28
+ - Wavetable `Osc 1 Pos`: 0-1 normalized ✓
29
+ - Drift / Analog / Operator macros: 0-1 normalized ✓
30
+ - Pedal `Output`: -20..+20 dB direct
31
+ - Pedal `Bass / Mid / Treble`: -1..+1 direct
32
+
33
+ ## Error enrichment (BUG-2026-04-26#2)
34
+
35
+ If the Remote Script rejects the value as out-of-range,
36
+ `set_device_parameter` fetches the parameter's actual
37
+ min/max/value_string and re-raises with that context inline — saves a
38
+ follow-up `get_device_parameters` round-trip in the agent loop after
39
+ every miss.
40
+
41
+ ## Silent snapping (BUG #4, v1.20.2)
42
+
43
+ Quantized-enum params (e.g. Beat Repeat's "Gate" at an integer enum)
44
+ silently snap a caller's float request to the nearest step. Both
45
+ `set_device_parameter` (via the `snapped` field) and
46
+ `batch_set_parameters` (via `snapped_params`, tolerance 1e-5) surface
47
+ this so callers driving deterministic state don't mistake a snap for
48
+ success. `batch_set_parameters` accepts any of `parameter_index`,
49
+ `parameter_name`, `name`, `index`, or the legacy `name_or_index` per
50
+ entry (BUG-F4 / BUG-2026-04-22#3 — the sibling tools originally had
51
+ inconsistent schemas, so all shapes are now normalized so
52
+ `get_device_parameters`'s output can be fed straight back in).
53
+
54
+ ## Drum Rack construction workflow (12.3+)
55
+
56
+ Shared by `insert_device`, `insert_rack_chain`, and the atomic
57
+ `add_drum_rack_pad` (see `perception.md` for that one's full history):
58
+
59
+ 1. `insert_device(track_index, 'Drum Rack')` — create empty rack
60
+ 2. `insert_rack_chain(track_index, device_index)` — add a chain
61
+ 3. `set_drum_chain_note(chain_index, note=36)` — assign C1 (kick)
62
+ 4. `insert_device(track_index, 'Simpler', device_index=rack_idx,
63
+ chain_index=0)` — add the instrument into that chain
64
+
65
+ On Live < 12.3, `insert_device`/`insert_rack_chain` return an error
66
+ suggesting `find_and_load_device` instead.
@@ -173,7 +173,7 @@ Live 12.4 introduces a new capability tier that unlocks native LOM access for sa
173
173
  **Tool signatures:** unchanged. Callers do not need to detect the tier —
174
174
  routing is transparent.
175
175
 
176
- **Probe-first 12.4 surfaces in v1.27.2:**
176
+ **Probe-first 12.4 surfaces in v1.27.3:**
177
177
  - `probe_link_audio()` reports observed peer/input/routing visibility and
178
178
  returns `manual_only`, `readable`, `routable`, or `unavailable`.
179
179
  - `probe_stem_workflow()` reports observed callable paths and returns
@@ -27,6 +27,14 @@ If the M4L analyzer bridge is absent, critics fall back to role-based heuristics
27
27
 
28
28
  For detailed frequency collision data, call `get_masking_report`. For a quick status overview without the full critic pass, call `get_mix_summary`.
29
29
 
30
+ Each masking entry carries `measured` and `severity_basis`: `measured=True` /
31
+ `severity_basis="spectral_overlap"` means `severity` came from real per-track
32
+ spectral data; `measured=False` / `severity_basis="role_heuristic"` means it's
33
+ a role-pair prior with no per-track measurement behind it. Treat
34
+ `measured=False` entries as low-confidence hypotheses, not verified problems —
35
+ solo the pair and read `get_master_spectrum` before acting on them. See
36
+ `livepilot-core` SKILL.md → "Response Fields You Must Check".
37
+
30
38
  ### Step 2 — Plan
31
39
 
32
40
  Pick the highest-severity issue from the `issues` array. Call `plan_mix_move` with the issue data. The planner returns the smallest intervention that addresses the problem — a single parameter change, not a chain of edits.
@@ -31,7 +31,7 @@ Run this checklist EVERY time the user says "update everything", "push", "releas
31
31
  Current: **467 tools across 56 domains**.
32
32
  Spectral/analyzer (bridge-only): **38**. The remaining tool surface works without the bridge or degrades gracefully. Backed by 32 bridge commands.
33
33
 
34
- Verify: `grep -rc "@mcp.tool" mcp_server/tools/ | grep -v ":0" | awk -F: '{sum+=$2} END{print sum}'`
34
+ Verify: `python3 scripts/sync_metadata.py --check`
35
35
 
36
36
  Files that reference tool count:
37
37
  - [ ] `README.md` — header ("467 tools. 56 domains"), bridge section ("38 spectral/analyzer tools require bridge")
@@ -0,0 +1,56 @@
1
+ # Splice Tool Notes
2
+
3
+ Implementation history and worked examples for the Splice-facing tools
4
+ in `mcp_server/sample_engine/tools.py`. The tool docstrings carry the
5
+ operational contract (params, return shape, gating rules); this file
6
+ carries endpoint-capture history and full response examples.
7
+
8
+ ## `get_splice_credits` — full response example (Ableton Live plan)
9
+
10
+ ```json
11
+ {
12
+ "connected": true,
13
+ "username": "user-1367453956",
14
+ "plan_raw": "subscribed",
15
+ "plan_kind": "ableton_live",
16
+ "sounds_plan_id": 12,
17
+ "features": {"ableton_unmetered": true},
18
+ "credits_remaining": 80,
19
+ "credit_floor": 5,
20
+ "daily_quota": {
21
+ "used_today": 3, "remaining_today": 97, "daily_limit": 100,
22
+ "near_limit": false, "at_limit": false
23
+ },
24
+ "can_download_sample": true,
25
+ "download_gating": "daily_quota"
26
+ }
27
+ ```
28
+
29
+ `download_gating` is `"daily_quota"` on the Ableton Live plan (the
30
+ 100/day unmetered quota) or `"credit_floor"` on Sounds+/Creator/
31
+ Creator+ plans (the `CREDIT_HARD_FLOOR` protects the last 5 credits).
32
+ `connected: false` with zero credits means the Splice desktop app isn't
33
+ running or `grpcio` isn't installed.
34
+
35
+ ## `splice_describe_sound` / `splice_generate_variation` — endpoint history
36
+
37
+ Both hit GraphQL operations on `surfaces-graphql.splice.com`, captured
38
+ via mitmproxy against Splice desktop 5.4.9 + the Sounds Plugin
39
+ (status: LIVE as of 2026-04-22):
40
+
41
+ - `splice_describe_sound` → `SamplesSearch` with `semantic=1` +
42
+ `rephrase=true`. This is the Sounds Plugin's "Describe a Sound"
43
+ feature — Splice's AI matches free-form descriptions like "dark
44
+ ambient pad with shimmer" to catalog samples.
45
+ - `splice_generate_variation` → `AssetSimilarSoundsQuery` (the
46
+ right-click "Variations" menu item). Up to 10 results, no credit
47
+ cost — despite the tool name, this is a similarity-recommender
48
+ lookup, not AI audio synthesis (the "generate" naming in the original
49
+ handoff spec was aspirational).
50
+
51
+ Both tools strip the raw GraphQL response before returning (~270KB of
52
+ noise per call) — only the flattened sample list ships back.
53
+
54
+ `splice_search_with_sound` was removed 2026-04-22 — the user does this
55
+ in-Splice manually. The capture recipe for resurrecting it is at
56
+ `docs/2026-04-22-splice-https-capture-recipe.md`.
@@ -111,6 +111,12 @@ Wonder Mode now serves two callers:
111
111
  references/move-family-diversity-rule.md)
112
112
  - 1 executable + 2 analytical is an honest, useful result
113
113
  - The `variant_count_actual` field tells you how many are real
114
+ - **Check `degraded_reason`** on every `enter_wonder_mode` response — when
115
+ non-empty, fewer than 3 variants are actually executable (or none matched
116
+ and the set is cold-start fallback). Do not present a degraded set as
117
+ "three genuinely different options"; surface the degradation to the user
118
+ in the same terms the field describes. See `livepilot-core` SKILL.md →
119
+ "Response Fields You Must Check".
114
120
 
115
121
  ## Presenting Results
116
122
 
Binary file
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.27.2";
37
+ var VERSION = "1.27.3";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.27.2"
2
+ __version__ = "1.27.3"
@@ -13,6 +13,20 @@ from typing import Any, Dict, List, Optional
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
+ # BUG-B39: the real atlas scanner emits "instruments" / "audio_effects" but
17
+ # older callers and test fixtures sometimes pass the singular "instrument" /
18
+ # "effect". Hoisted to module scope (was previously re-declared inline in
19
+ # search()) so both search() and the truncation-warning helper below share
20
+ # one alias table instead of drifting independently.
21
+ _CATEGORY_ALIASES: Dict[str, set] = {
22
+ "instrument": {"instrument", "instruments"},
23
+ "instruments": {"instrument", "instruments"},
24
+ "effect": {"effect", "effects", "audio_effects"},
25
+ "effects": {"effect", "effects", "audio_effects"},
26
+ "audio_effect": {"effect", "effects", "audio_effects", "audio_effect"},
27
+ "audio_effects": {"effect", "effects", "audio_effects", "audio_effect"},
28
+ }
29
+
16
30
 
17
31
  class AtlasManager:
18
32
  """In-memory device atlas with indexed lookups."""
@@ -34,15 +48,50 @@ class AtlasManager:
34
48
  self._meta["version"] = data["version"]
35
49
  self._devices: List[Dict[str, Any]] = data.get("devices", [])
36
50
 
51
+ # P3-47 truncation observability: scan_full_library persists
52
+ # stats.category_truncated (keyed by atlas device-category, e.g.
53
+ # "drum_kits"/"sounds"/"samples") whenever the last scan hit its
54
+ # max_per_category cap — or ran out of the remote script's shared
55
+ # iteration safety bound — mid-category. Read it once here so
56
+ # search()/suggest() callers can be warned their results for a
57
+ # truncated category are a lower bound, not the full inventory.
58
+ raw_stats = data.get("stats", {})
59
+ raw_truncated = (
60
+ raw_stats.get("category_truncated", {})
61
+ if isinstance(raw_stats, dict) else {}
62
+ )
63
+ self._category_truncated: Dict[str, bool] = {
64
+ str(cat): bool(hit)
65
+ for cat, hit in raw_truncated.items()
66
+ } if isinstance(raw_truncated, dict) else {}
67
+
37
68
  # ── Build indexes ───────────────────────────────────────────
38
- self._by_id: Dict[str, Dict[str, Any]] = {}
39
- self._by_name: Dict[str, Dict[str, Any]] = {} # lowercase key
69
+ # P2-12: _by_id / _by_name keep a LIST of every device sharing a
70
+ # key, in scan order, so colliding ids/names no longer shadow each
71
+ # other (last-wins). 719 ids + 702 names collide in the shipped
72
+ # atlas while URIs are 100% unique, so every device stays reachable
73
+ # via lookup_all() / atlas_device_info's surfaced URIs. lookup()
74
+ # still returns a single Dict (the first, deterministic match) to
75
+ # preserve its Optional[Dict] contract for compare()/browser.py/
76
+ # _research_engine.py.
77
+ self._by_id: Dict[str, List[Dict[str, Any]]] = {}
78
+ self._by_name: Dict[str, List[Dict[str, Any]]] = {} # lowercase key
40
79
  self._by_uri: Dict[str, Dict[str, Any]] = {}
41
80
  self._by_category: Dict[str, List[Dict[str, Any]]] = {}
42
81
  self._by_tag: Dict[str, List[Dict[str, Any]]] = {}
43
82
  self._by_genre: Dict[str, List[Dict[str, Any]]] = {}
44
83
  self._by_pack: Dict[str, List[Dict[str, Any]]] = {}
45
84
 
85
+ # Perf batch (v1.27.3): a real 40k-device user atlas can carry
86
+ # ~16k duplicate ids/names/uris, and warning once per duplicate
87
+ # produced ~16k logger.warning calls (~122ms + multi-MB of stderr)
88
+ # at index-build time. Cap each category at _DUP_WARN_CAP explicit
89
+ # warnings and emit one summary line for the rest.
90
+ _DUP_WARN_CAP = 20
91
+ _dup_id_count = 0
92
+ _dup_name_count = 0
93
+ _dup_uri_count = 0
94
+
46
95
  for dev in self._devices:
47
96
  dev_id = dev.get("id", "")
48
97
  dev_name = dev.get("name", "")
@@ -50,26 +99,38 @@ class AtlasManager:
50
99
  dev_category = dev.get("category", "")
51
100
 
52
101
  if dev_id:
53
- if dev_id in self._by_id and self._by_id[dev_id] is not dev:
54
- logger.warning(
55
- "atlas: duplicate device id %r shadows a prior entry "
56
- "(name=%r); last-wins", dev_id, dev_name,
57
- )
58
- self._by_id[dev_id] = dev
102
+ bucket = self._by_id.setdefault(dev_id, [])
103
+ if bucket and dev not in bucket:
104
+ _dup_id_count += 1
105
+ if _dup_id_count <= _DUP_WARN_CAP:
106
+ logger.warning(
107
+ "atlas: duplicate device id %r (name=%r); both kept, "
108
+ "lookup() returns the first — disambiguate by uri %r",
109
+ dev_id, dev_name, dev_uri,
110
+ )
111
+ if dev not in bucket:
112
+ bucket.append(dev)
59
113
  if dev_name:
60
114
  name_key = dev_name.lower()
61
- if name_key in self._by_name and self._by_name[name_key] is not dev:
62
- logger.warning(
63
- "atlas: duplicate device name %r shadows a prior entry "
64
- "(id=%r); last-wins", dev_name, dev_id,
65
- )
66
- self._by_name[name_key] = dev
115
+ name_bucket = self._by_name.setdefault(name_key, [])
116
+ if name_bucket and dev not in name_bucket:
117
+ _dup_name_count += 1
118
+ if _dup_name_count <= _DUP_WARN_CAP:
119
+ logger.warning(
120
+ "atlas: duplicate device name %r (id=%r); both kept, "
121
+ "lookup() returns the first — disambiguate by uri %r",
122
+ dev_name, dev_id, dev_uri,
123
+ )
124
+ if dev not in name_bucket:
125
+ name_bucket.append(dev)
67
126
  if dev_uri:
68
127
  if dev_uri in self._by_uri and self._by_uri[dev_uri] is not dev:
69
- logger.warning(
70
- "atlas: duplicate device uri %r shadows a prior entry "
71
- "(id=%r); last-wins", dev_uri, dev_id,
72
- )
128
+ _dup_uri_count += 1
129
+ if _dup_uri_count <= _DUP_WARN_CAP:
130
+ logger.warning(
131
+ "atlas: duplicate device uri %r shadows a prior entry "
132
+ "(id=%r); last-wins", dev_uri, dev_id,
133
+ )
73
134
  self._by_uri[dev_uri] = dev
74
135
 
75
136
  # Category index
@@ -124,6 +185,22 @@ class AtlasManager:
124
185
  if pack_name:
125
186
  self._by_pack.setdefault(pack_name, []).append(dev)
126
187
 
188
+ if _dup_id_count > _DUP_WARN_CAP:
189
+ logger.warning(
190
+ "atlas: %d more duplicate device ids suppressed",
191
+ _dup_id_count - _DUP_WARN_CAP,
192
+ )
193
+ if _dup_name_count > _DUP_WARN_CAP:
194
+ logger.warning(
195
+ "atlas: %d more duplicate device names suppressed",
196
+ _dup_name_count - _DUP_WARN_CAP,
197
+ )
198
+ if _dup_uri_count > _DUP_WARN_CAP:
199
+ logger.warning(
200
+ "atlas: %d more duplicate device uris suppressed",
201
+ _dup_uri_count - _DUP_WARN_CAP,
202
+ )
203
+
127
204
  # ── Properties ──────────────────────────────────────────────────
128
205
 
129
206
  @property
@@ -155,6 +232,49 @@ class AtlasManager:
155
232
  },
156
233
  }
157
234
 
235
+ # ── Truncation observability (P3-47) ──────────────────────────
236
+ def truncated_categories(self) -> List[str]:
237
+ """Categories where the atlas's last scan_full_library run hit
238
+ its max_per_category cap (or the remote script's shared iteration
239
+ safety bound) mid-category. Devices in these categories are a
240
+ lower bound, not the full inventory, until a rescan completes."""
241
+ return sorted(
242
+ cat for cat, hit in self._category_truncated.items() if hit
243
+ )
244
+
245
+ def truncation_warning(self, category: str = "all") -> Optional[str]:
246
+ """Return a warning string if `category` overlaps a category
247
+ flagged truncated in the loaded atlas's scan stats, else None.
248
+
249
+ category="all" (the default `atlas_search`/`atlas_suggest` filter)
250
+ warns if ANY category was truncated, since an unfiltered query
251
+ silently includes those partial results alongside complete ones.
252
+ A specific category only warns when it (or an alias — see
253
+ _CATEGORY_ALIASES) is in the truncated set.
254
+ """
255
+ truncated = self.truncated_categories()
256
+ if not truncated:
257
+ return None
258
+
259
+ if category == "all":
260
+ hit = truncated
261
+ else:
262
+ allowed = _CATEGORY_ALIASES.get(category, {category})
263
+ hit = [cat for cat in truncated if cat in allowed]
264
+ if not hit:
265
+ return None
266
+
267
+ return (
268
+ "Atlas categor%s %s may be incomplete — the last scan hit its "
269
+ "size cap for %s. Run scan_full_library(force=True) (raise "
270
+ "max_per_category if your library is unusually large) for a "
271
+ "complete inventory before trusting an exhaustive search here."
272
+ ) % (
273
+ "y" if len(hit) == 1 else "ies",
274
+ ", ".join(sorted(hit)),
275
+ "this category" if len(hit) == 1 else "these categories",
276
+ )
277
+
158
278
  # ── Pack lookup ────────────────────────────────────────────────
159
279
  def pack_info(self, pack_name: str) -> Dict[str, Any]:
160
280
  """Return summary of a pack — device list + enrichment coverage.
@@ -217,19 +337,52 @@ class AtlasManager:
217
337
  # ── Lookup ──────────────────────────────────────────────────────
218
338
 
219
339
  def lookup(self, name_or_id: str) -> Optional[Dict[str, Any]]:
220
- """Exact match by ID, name (case-insensitive), or URI. Returns None on miss."""
340
+ """Exact match by ID, name (case-insensitive), or URI. Returns None on miss.
341
+
342
+ When an id/name collides across devices (719 ids / 702 names in the
343
+ shipped atlas), this returns the FIRST device in scan order
344
+ deterministically. Use lookup_all() to reach every colliding device,
345
+ or pass a unique URI (URIs never collide) to target a specific one.
346
+ """
221
347
  # Try ID first
222
- if name_or_id in self._by_id:
223
- return self._by_id[name_or_id]
348
+ bucket = self._by_id.get(name_or_id)
349
+ if bucket:
350
+ return bucket[0]
224
351
  # Try name (case-insensitive)
225
- lower = name_or_id.lower()
226
- if lower in self._by_name:
227
- return self._by_name[lower]
228
- # Try URI
352
+ name_bucket = self._by_name.get(name_or_id.lower())
353
+ if name_bucket:
354
+ return name_bucket[0]
355
+ # Try URI — the only key that is guaranteed unique
229
356
  if name_or_id in self._by_uri:
230
357
  return self._by_uri[name_or_id]
231
358
  return None
232
359
 
360
+ def lookup_all(self, name_or_id: str) -> List[Dict[str, Any]]:
361
+ """Return EVERY device matching an id, name, or URI — collision-aware.
362
+
363
+ P2-12: lookup() returns a single Dict (its contract), which shadows
364
+ the ~719 devices sharing a colliding id and ~702 sharing a name. This
365
+ companion returns all of them so id/name-keyed tools can disambiguate
366
+ (e.g. surface each candidate's unique URI). Matches are returned in
367
+ scan order with duplicates removed; an exact URI match alone returns
368
+ the single device for that URI.
369
+ """
370
+ out: List[Dict[str, Any]] = []
371
+ seen: set[int] = set()
372
+
373
+ def _extend(devs: List[Dict[str, Any]]) -> None:
374
+ for d in devs:
375
+ if id(d) not in seen:
376
+ seen.add(id(d))
377
+ out.append(d)
378
+
379
+ _extend(self._by_id.get(name_or_id, []))
380
+ _extend(self._by_name.get(name_or_id.lower(), []))
381
+ uri_hit = self._by_uri.get(name_or_id)
382
+ if uri_hit is not None:
383
+ _extend([uri_hit])
384
+ return out
385
+
233
386
  # ── Search ──────────────────────────────────────────────────────
234
387
 
235
388
  def search(
@@ -243,22 +396,10 @@ class AtlasManager:
243
396
  query_words = query_lower.split()
244
397
  results: List[Dict[str, Any]] = []
245
398
 
246
- # BUG-B39: the real atlas scanner emits "instruments" /
247
- # "audio_effects" but older callers and test fixtures sometimes
248
- # pass the singular "instrument" / "effect". Build a tolerant
249
- # category alias set so both forms work.
250
- _CAT_ALIASES = {
251
- "instrument": {"instrument", "instruments"},
252
- "instruments": {"instrument", "instruments"},
253
- "effect": {"effect", "effects", "audio_effects"},
254
- "effects": {"effect", "effects", "audio_effects"},
255
- "audio_effect": {"effect", "effects", "audio_effects",
256
- "audio_effect"},
257
- "audio_effects": {"effect", "effects", "audio_effects",
258
- "audio_effect"},
259
- }
399
+ # Tolerant category alias set so "instrument" and "instruments"
400
+ # (etc.) both work see module-level _CATEGORY_ALIASES.
260
401
  allowed_cats = (
261
- _CAT_ALIASES.get(category, {category})
402
+ _CATEGORY_ALIASES.get(category, {category})
262
403
  if category != "all" else None
263
404
  )
264
405