@map-audio/pam-mcp-server 1.12.0 → 1.18.1

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/dist/server.js CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
4
5
  import { z } from "zod";
5
6
  import { readFile } from "node:fs/promises";
7
+ import { existsSync, readFileSync } from "node:fs";
6
8
  import { inflateSync } from "node:zlib";
7
9
  import { basename, dirname, resolve } from "node:path";
8
10
  import { fileURLToPath } from "node:url";
9
- import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
11
+ import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
10
12
  import { PluginBridge } from "./bridge.js";
13
+ import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
14
+ import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
15
+ import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
11
16
  // --- State ---
12
17
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
13
18
  // interface/src/stores/Preset.ts. Defined locally because the MCP package
@@ -16,6 +21,8 @@ const PRESET_VARIATION_SLOTS = 8;
16
21
  const bridge = new PluginBridge();
17
22
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18
23
  const cellModeChangeSeq = {};
24
+ let lastSavedPresetName;
25
+ let lastSavedPresetPath;
19
26
  // --- Helpers ---
20
27
  function text(s) {
21
28
  return { content: [{ type: "text", text: s }] };
@@ -23,12 +30,78 @@ function text(s) {
23
30
  function json(obj) {
24
31
  return text(JSON.stringify(obj, null, 2));
25
32
  }
33
+ /**
34
+ * Structured result: the same JSON in the text block (for text-only consumers,
35
+ * including ask_assistant's internal dispatch) plus structuredContent for
36
+ * clients that consume the tool's outputSchema. Only use from tools that
37
+ * declare an outputSchema — the SDK requires structuredContent on success then.
38
+ */
39
+ function structured(obj) {
40
+ const record = isRecord(obj) ? obj : { value: obj };
41
+ return {
42
+ content: [{ type: "text", text: JSON.stringify(record, null, 2) }],
43
+ structuredContent: record,
44
+ };
45
+ }
46
+ /** structured() for bridge results that may arrive as a raw JSON string. */
47
+ function structuredFromBridge(raw) {
48
+ if (typeof raw === "string") {
49
+ try {
50
+ return structured(JSON.parse(raw));
51
+ }
52
+ catch {
53
+ return structured({ value: raw });
54
+ }
55
+ }
56
+ return structured(raw);
57
+ }
26
58
  function error(msg) {
27
59
  return { content: [{ type: "text", text: msg }], isError: true };
28
60
  }
29
- async function readPackageVersion() {
61
+ function isMfxManifest(manifest) {
62
+ return (manifest.name?.toLowerCase() === "mfx" ||
63
+ manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
64
+ manifest.pluginCode?.toLowerCase() === "amfx");
65
+ }
66
+ const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
67
+ async function loadManifestForProductGuard() {
68
+ try {
69
+ return await loadManifest();
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ function manifestMaxCellId(manifest) {
76
+ if (!manifest)
77
+ return null;
78
+ const entries = [
79
+ ...(manifest.parameters ?? []),
80
+ ...(manifest.stateVariables ?? []),
81
+ ...(manifest.parameterInfos ?? []),
82
+ ];
83
+ const ids = new Set();
84
+ for (const entry of entries) {
85
+ const cellMatch = typeof entry.cell === "string" ? /^cell(\d+)$/.exec(entry.cell) : null;
86
+ const paramMatch = typeof entry.paramId === "string" ? /^cell(\d+)_/.exec(entry.paramId) : null;
87
+ const raw = cellMatch?.[1] ?? paramMatch?.[1];
88
+ if (raw)
89
+ ids.add(Number(raw));
90
+ }
91
+ return ids.size > 0 ? Math.max(...ids) : null;
92
+ }
93
+ function validateCellIdForManifest(cellId, manifest) {
94
+ const maxCellId = manifestMaxCellId(manifest);
95
+ if (maxCellId !== null && cellId > maxCellId) {
96
+ const productName = manifest?.name ?? "This product";
97
+ const noun = maxCellId === 1 ? "cell" : "cells";
98
+ return error(`${productName} exposes ${maxCellId} ${noun}; cell ${cellId} is not available in this product.`);
99
+ }
100
+ return true;
101
+ }
102
+ function readPackageVersion() {
30
103
  try {
31
- const raw = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
104
+ const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
32
105
  const pkg = JSON.parse(raw);
33
106
  return typeof pkg.version === "string" ? pkg.version : "unknown";
34
107
  }
@@ -36,14 +109,17 @@ async function readPackageVersion() {
36
109
  return "unknown";
37
110
  }
38
111
  }
112
+ // Single source of truth for the server version: package.json. The MCP handshake,
113
+ // --version, and --help must all report the same number.
114
+ const SERVER_VERSION = readPackageVersion();
39
115
  async function handleCliArgs() {
40
116
  const args = new Set(process.argv.slice(2));
41
117
  if (args.has("--version") || args.has("-v")) {
42
- console.log(await readPackageVersion());
118
+ console.log(SERVER_VERSION);
43
119
  return true;
44
120
  }
45
121
  if (args.has("--help") || args.has("-h")) {
46
- const version = await readPackageVersion();
122
+ const version = SERVER_VERSION;
47
123
  console.log(`pam-mcp ${version}\n\n` +
48
124
  "MCP stdio server for PAM. Configure your MCP client to launch this command; " +
49
125
  "it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
@@ -51,14 +127,24 @@ async function handleCliArgs() {
51
127
  }
52
128
  return false;
53
129
  }
130
+ // Point offline tools (param validation, design_context) at the manifest of
131
+ // the product we're actually connected to.
132
+ function syncProductHint() {
133
+ setActiveProductHint(bridge.getConnectedInstance()?.product);
134
+ }
54
135
  async function requireBridge() {
55
136
  if (bridge.isConnected())
56
137
  return true;
57
138
  const ok = await bridge.connect();
58
139
  if (!ok) {
140
+ const pin = process.env.PAM_BRIDGE_PRODUCT;
59
141
  return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
60
- "The bridge writes its port to ~/.pam-mcp-bridge.json on startup.");
142
+ "The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
143
+ (pin
144
+ ? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
145
+ : ""));
61
146
  }
147
+ syncProductHint();
62
148
  return true;
63
149
  }
64
150
  /**
@@ -176,12 +262,218 @@ function formatParam(p) {
176
262
  desc += ` — ${p.tooltip}`;
177
263
  return desc;
178
264
  }
265
+ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
266
+ const CELL_EFFECTS = ["filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor"];
267
+ const PATTERN_LANES = {
268
+ note: "midiSequence",
269
+ pitch: "pitchSequence",
270
+ slice: "sliceSequence",
271
+ stretch: "stretchSequence",
272
+ };
273
+ const LFO_DIVISIONS = [
274
+ "16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
275
+ "1/2D", "1/2", "1/2T",
276
+ "1/4D", "1/4", "1/4T",
277
+ "1/8D", "1/8", "1/8T",
278
+ "1/16D", "1/16", "1/16T",
279
+ "1/32D", "1/32", "1/32T",
280
+ "1/64D", "1/64", "1/64T",
281
+ ];
282
+ function levenshtein(a, b) {
283
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
284
+ const curr = Array(b.length + 1).fill(0);
285
+ for (let i = 1; i <= a.length; i++) {
286
+ curr[0] = i;
287
+ for (let j = 1; j <= b.length; j++) {
288
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
289
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
290
+ }
291
+ for (let j = 0; j <= b.length; j++)
292
+ prev[j] = curr[j];
293
+ }
294
+ return prev[b.length];
295
+ }
296
+ function similarity(a, b) {
297
+ if (a === b)
298
+ return 1;
299
+ const maxLen = Math.max(a.length, b.length);
300
+ if (maxLen === 0)
301
+ return 1;
302
+ return 1 - levenshtein(a, b) / maxLen;
303
+ }
304
+ function paramSuffix(paramId) {
305
+ const m = paramId.match(/^(?:cell\d+|macro|lfo\d+)_?(.*)$/);
306
+ return m?.[1] || paramId;
307
+ }
308
+ function explicitParamAliases(paramId) {
309
+ const aliases = new Set();
310
+ const aliasSuffixes = {
311
+ delayTime: "delayDelayTime",
312
+ crusherBits: "crusherBitReduction",
313
+ crusherBitDepth: "crusherBitReduction",
314
+ crusherDownsample: "crusherRateReduction",
315
+ fdnDecay: "fdnFeedback",
316
+ filterResonance: "filterQ",
317
+ };
318
+ const lfo0 = paramId.match(/^lfo0_(.+)$/);
319
+ if (lfo0)
320
+ aliases.add(`lfo1_${lfo0[1]}`);
321
+ const cellMatch = paramId.match(/^(cell\d+_)(.+)$/);
322
+ const macroMatch = paramId.match(/^(macro_)(.+)$/);
323
+ const prefix = cellMatch?.[1] ?? macroMatch?.[1] ?? "";
324
+ const suffix = cellMatch?.[2] ?? macroMatch?.[2] ?? paramId;
325
+ const replacement = aliasSuffixes[suffix];
326
+ if (replacement)
327
+ aliases.add(`${prefix}${replacement}`);
328
+ return [...aliases];
329
+ }
330
+ function suggestParams(paramId, index, opts = {}) {
331
+ const explicit = explicitParamAliases(paramId).filter((id) => {
332
+ const p = index.get(id);
333
+ return p && (!opts.modulatableOnly || p.isModulatable);
334
+ });
335
+ const scored = [...index.values()]
336
+ .filter((p) => !opts.modulatableOnly || p.isModulatable)
337
+ .map((p) => {
338
+ const full = similarity(paramId.toLowerCase(), p.paramId.toLowerCase());
339
+ const suffix = similarity(paramSuffix(paramId).toLowerCase(), paramSuffix(p.paramId).toLowerCase());
340
+ const sameCell = paramId.match(/^cell\d+_/)?.[0] === p.paramId.match(/^cell\d+_/)?.[0] ? 0.12 : 0;
341
+ const sameFamily = paramId.startsWith("macro_") && p.paramId.startsWith("macro_") ? 0.08 :
342
+ paramId.startsWith("lfo") && p.paramId.startsWith("lfo") ? 0.08 :
343
+ 0;
344
+ return { id: p.paramId, score: Math.max(full, suffix) + sameCell + sameFamily };
345
+ })
346
+ .filter((x) => x.score >= 0.52)
347
+ .sort((a, b) => b.score - a.score)
348
+ .map((x) => x.id);
349
+ return [...new Set([...explicit, ...scored])].slice(0, 5);
350
+ }
351
+ function formatUnknownParamError(paramId, index, opts = {}) {
352
+ const suggestions = suggestParams(paramId, index, opts);
353
+ const hint = paramId.startsWith("lfo0_")
354
+ ? " LFO parameter IDs are 1-based: use lfo1_... through lfo6_..."
355
+ : "";
356
+ const scope = opts.modulatableOnly ? " modulatable parameter" : " parameter";
357
+ return `Unknown${scope}: ${paramId}.${hint}${suggestions.length ? ` Did you mean ${suggestions.join(", ")}?` : ""}`;
358
+ }
359
+ function formatNotModulatableError(paramId, index) {
360
+ const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
361
+ return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
362
+ }
363
+ function formatRangeError(param, value) {
364
+ const unit = param.suffix ? param.suffix : "unitless";
365
+ const traits = [
366
+ param.bipolar ? "bipolar" : "unipolar",
367
+ unit,
368
+ param.strRepr && param.strRepr.length > 0
369
+ ? `values: ${param.strRepr.map((v, i) => `${i}=${v}`).join(", ")}`
370
+ : undefined,
371
+ ].filter(Boolean);
372
+ const panHint = param.paramId.endsWith("_pan") && Math.abs(value) > 1 && Math.abs(value) <= 100
373
+ ? `; ${value / 100} is approximately ${Math.abs(value)}% ${value < 0 ? "left" : "right"}`
374
+ : "";
375
+ return `${param.paramId}: ${value} out of range [${param.min}..${param.max}] (${traits.join("; ")}${panHint})`;
376
+ }
377
+ function jsonWithLimit(obj, tooLargeHint) {
378
+ const serialized = JSON.stringify(obj, null, 2);
379
+ if (serialized.length > FULL_STATE_RESPONSE_LIMIT_CHARS) {
380
+ return error(`Result is ${serialized.length.toLocaleString()} characters, above the ${FULL_STATE_RESPONSE_LIMIT_CHARS.toLocaleString()} character safety cap. ` +
381
+ tooLargeHint);
382
+ }
383
+ return text(serialized);
384
+ }
385
+ function isRecord(value) {
386
+ return !!value && typeof value === "object" && !Array.isArray(value);
387
+ }
388
+ function cellPrefixSet(cellIds) {
389
+ return new Set((cellIds ?? []).map((id) => `cell${id}_`));
390
+ }
391
+ function paramTargetsCell(value, cells) {
392
+ if (!isRecord(value))
393
+ return false;
394
+ const paramId = typeof value.paramId === "string" ? value.paramId : undefined;
395
+ if (paramId && [...cells].some((prefix) => paramId.startsWith(prefix)))
396
+ return true;
397
+ const cellId = typeof value.cellId === "number" ? value.cellId : undefined;
398
+ return cellId !== undefined && cells.has(`cell${cellId}_`);
399
+ }
400
+ function filterState(state, opts) {
401
+ if (!isRecord(state))
402
+ return state;
403
+ const keySet = opts.keys?.length ? new Set(opts.keys) : null;
404
+ const prefixes = cellPrefixSet(opts.cellIds);
405
+ const filterByCell = prefixes.size > 0;
406
+ if (!keySet && !filterByCell)
407
+ return state;
408
+ const out = {};
409
+ for (const [key, value] of Object.entries(state)) {
410
+ if (keySet?.has(key)) {
411
+ out[key] = value;
412
+ continue;
413
+ }
414
+ if (!filterByCell)
415
+ continue;
416
+ if ([...prefixes].some((prefix) => key.startsWith(prefix))) {
417
+ out[key] = value;
418
+ continue;
419
+ }
420
+ if (key === "cells" && isRecord(value)) {
421
+ const cells = {};
422
+ for (const id of opts.cellIds ?? []) {
423
+ if (value[String(id)] !== undefined)
424
+ cells[String(id)] = value[String(id)];
425
+ }
426
+ out.cells = cells;
427
+ continue;
428
+ }
429
+ if (["lfos", "envelopes", "vary", "paramSeqs"].includes(key) && Array.isArray(value)) {
430
+ out[key] = value.filter((entry) => paramTargetsCell(entry, prefixes));
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+ function filterSectionResult(section, result, opts) {
436
+ const keySet = opts.keys?.length ? new Set(opts.keys) : null;
437
+ const prefixes = cellPrefixSet(opts.cellIds);
438
+ if (section === "cells" && isRecord(result) && prefixes.size > 0) {
439
+ const out = {};
440
+ for (const id of opts.cellIds ?? []) {
441
+ if (result[String(id)] !== undefined)
442
+ out[String(id)] = result[String(id)];
443
+ }
444
+ return out;
445
+ }
446
+ if (["lfos", "envelopes", "vary", "paramSeqs"].includes(section) && Array.isArray(result) && prefixes.size > 0) {
447
+ return result.filter((entry) => paramTargetsCell(entry, prefixes));
448
+ }
449
+ if (keySet && isRecord(result)) {
450
+ const out = {};
451
+ for (const key of keySet) {
452
+ if (result[key] !== undefined)
453
+ out[key] = result[key];
454
+ }
455
+ return out;
456
+ }
457
+ return result;
458
+ }
459
+ function countEvents(value) {
460
+ return Array.isArray(value) ? value.length : 0;
461
+ }
462
+ function stemName(path) {
463
+ if (typeof path !== "string" || path.length === 0)
464
+ return undefined;
465
+ return basename(path);
466
+ }
179
467
  // --- Server ---
180
468
  const server = new McpServer({
181
469
  name: "pam",
182
- version: "1.8.0",
470
+ title: "PAM Audio Sampler",
471
+ version: SERVER_VERSION,
183
472
  }, {
184
- instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
473
+ instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
474
+ "cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
475
+ "broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
476
+ "When browsing samples, ALWAYS call get_default_locations " +
185
477
  "first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
186
478
  "The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
187
479
  "Use the returned paths with list_samples. When loading samples, use load_sample with " +
@@ -195,16 +487,255 @@ const server = new McpServer({
195
487
  "set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
196
488
  "VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
197
489
  "remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
198
- "bind_video_param, set_video_settings) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
199
- "(the video engine renders in the WebView; there is no headless renderer). For " +
490
+ "bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
491
+ "(the video engine renders in the WebView; there is no headless renderer). " +
492
+ "capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
493
+ "WebView-routed too and need the window open for the same reason. For " +
200
494
  "audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
201
495
  "are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
202
496
  "the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
497
+ "AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
498
+ "delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
499
+ "matching experimental flags; load/unload/delete can block for seconds — poll " +
500
+ "get_model_status rather than tight-looping. " +
203
501
  "Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
204
- "live resample, variation scheduling, AI generation) require PAM ≥ 1.4.50 / 1.5.0. " +
502
+ "live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
503
+ "SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
504
+ "song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
505
+ "slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
506
+ "manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
507
+ "save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
508
+ "variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
509
+ "export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
205
510
  "If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
206
- "this MCP server; the user must update PAM (updating the npm package alone is not enough).",
207
- });
511
+ "this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
512
+ "Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
513
+ "tools discard user state (confirm intent before calling them). When several product " +
514
+ "instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
515
+ "(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
516
+ "make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
517
+ });
518
+ // --- tool annotation policy ---------------------------------------------------
519
+ // Single audit surface for MCP tool annotations. Every tool MUST have an entry;
520
+ // the registerTool shim below merges it (inline annotations at the call site win
521
+ // per key) and warns on stderr when a tool registers unclassified — and
522
+ // scripts/check-tool-surface.mjs fails the release on a gap. openWorldHint is
523
+ // false server-wide: every tool targets the local plugin instance.
524
+ // Policy: readOnlyHint = no plugin/file mutation. destructiveHint = discards
525
+ // user state that isn't trivially recoverable (delete/clear/reset/overwrite the
526
+ // working state); slot-scoped saves follow the save_variation precedent (false).
527
+ // idempotentHint = repeating the same args reaches the same end state.
528
+ const RO = { readOnlyHint: true };
529
+ const W = { destructiveHint: false }; // additive / one-shot write
530
+ const WI = { destructiveHint: false, idempotentHint: true };
531
+ const D = { destructiveHint: true };
532
+ const DI = { destructiveHint: true, idempotentHint: true };
533
+ const TOOL_ANNOTATIONS = {
534
+ // Reads (offline + live introspection)
535
+ get_parameter_info: RO,
536
+ list_presets: RO,
537
+ read_preset: RO,
538
+ get_manifest_summary: RO,
539
+ design_context: RO,
540
+ get_state: RO,
541
+ get_design_overview: RO,
542
+ get_transport: RO,
543
+ get_master_analysis: RO,
544
+ suggest_mix: RO, // pure function over cached analysis + current state
545
+ connection_status: RO,
546
+ list_samples: RO,
547
+ get_default_locations: RO,
548
+ get_audio_info: RO,
549
+ get_video_state: RO,
550
+ capture_ui: RO, // writes a screenshot file, mutates no plugin state
551
+ get_sample_peaks: RO,
552
+ get_audio_device_setup: RO,
553
+ get_sample_usage: RO,
554
+ get_model_status: RO,
555
+ list_models: RO,
556
+ workbench_get_state: RO,
557
+ workbench_list_takes: RO,
558
+ workbench_sidecar_status: RO,
559
+ list_visuals: RO,
560
+ get_visual_data_url: RO,
561
+ list_plugins: RO,
562
+ get_plugin_scan_status: RO,
563
+ list_loaded_plugins: RO,
564
+ get_mix_analysis: W, // refresh:true runs an offline analysis render
565
+ // Parameter / cell / modulation writes
566
+ set_parameters: WI,
567
+ load_sample: WI,
568
+ update_pattern: WI,
569
+ add_modulation: W,
570
+ update_modulation: WI,
571
+ clear_modulations: DI,
572
+ reset_patterns: DI,
573
+ update_effect_config: WI,
574
+ update_cell_config: WI,
575
+ set_cell_mute: WI,
576
+ set_master_effects: WI,
577
+ add_param_seq: W,
578
+ update_param_seq: WI,
579
+ remove_param_seq: DI,
580
+ set_drum_synth: WI,
581
+ set_drum_synth_wave: WI,
582
+ stop_cell: WI,
583
+ reset_cell: DI,
584
+ copy_cell: DI, // overwrites the destination cell
585
+ randomize: W,
586
+ randomize_samples: W,
587
+ randomize_patterns: W,
588
+ // Transport / MIDI
589
+ start_transport: WI,
590
+ stop_transport: WI,
591
+ full_stop_transport: WI,
592
+ set_bpm: WI,
593
+ set_metronome: WI,
594
+ configure_transport: WI,
595
+ trigger_midi: W,
596
+ trigger_midi_cc: W,
597
+ trigger_midi_program_change: W,
598
+ midi_recording: W,
599
+ // Presets / variations
600
+ load_preset: DI, // replaces the working state (unsaved edits are lost)
601
+ save_preset: WI,
602
+ new_preset: DI,
603
+ import_preset: WI,
604
+ export_preset: W,
605
+ update_preset_metadata: WI,
606
+ delete_preset: DI,
607
+ set_preset_factory_status: WI,
608
+ repair_preset_samples: WI,
609
+ load_variation: WI,
610
+ save_variation: WI,
611
+ queue_variation: WI,
612
+ cancel_queued_variation: WI,
613
+ update_variations: WI,
614
+ variation_hold: WI,
615
+ // Sample ops
616
+ preview_sample: WI, // audible only, no state change
617
+ preview_slice: WI,
618
+ trim_sample: W, // restore_original_sample recovers the pre-edit file
619
+ reverse_sample: W,
620
+ gain_sample: W,
621
+ normalize_sample: WI,
622
+ analyze_sample_tempo: WI, // dispatches results into state
623
+ resample_sample_to_bpm: WI,
624
+ restore_original_sample: DI, // discards all working edits on the sample
625
+ build_sample_chain: W,
626
+ export_slice: W,
627
+ // Looper
628
+ arm_looper_track: WI,
629
+ set_looper_track: WI,
630
+ clear_looper_track: DI, // looper state is global, not per-preset
631
+ set_looper_track_selection: WI,
632
+ start_looper_recording: W,
633
+ stop_looper_recording: WI,
634
+ capture_looper_now: W,
635
+ load_audio_to_looper_track: WI,
636
+ request_looper_warp: WI,
637
+ looper_settings: WI,
638
+ looper_variation: W,
639
+ looper_variation_sync: WI,
640
+ live_resample: W,
641
+ // Song mode / export
642
+ song_mode: W,
643
+ export_song: W,
644
+ export_audio: W,
645
+ // Settings / devices
646
+ update_settings: WI,
647
+ set_midi_io: WI,
648
+ set_midi_mapping: WI,
649
+ // Plugin host
650
+ scan_plugins: WI,
651
+ cancel_plugin_scan: WI,
652
+ skip_current_plugin_scan: W, // blocklists the currently scanning plugin
653
+ remove_plugin_from_blocklist: WI,
654
+ load_plugin: WI,
655
+ unload_plugin: DI, // discards the hosted plugin's state
656
+ set_plugin_param: WI,
657
+ set_plugin_params: WI,
658
+ set_plugin_bypass: WI,
659
+ // AI models / generation
660
+ download_model: WI,
661
+ load_model: WI,
662
+ unload_model: WI,
663
+ delete_model: DI,
664
+ generate_sample: W,
665
+ generate_kit: W,
666
+ // Workbench
667
+ workbench_set_state: WI,
668
+ workbench_render: W,
669
+ workbench_load_take: WI,
670
+ workbench_undo: W,
671
+ workbench_reset_chain: DI,
672
+ // Visuals / VX video
673
+ set_visual: WI,
674
+ delete_visual: DI,
675
+ import_visual_folder: WI,
676
+ video_command: D, // generic escape hatch — includes resetWorkspace
677
+ add_video_layer: W,
678
+ update_video_layer: WI,
679
+ set_video_layer_source: WI,
680
+ remove_video_layer: DI,
681
+ apply_video_layout: WI,
682
+ add_video_fx: W,
683
+ set_video_fx: WI,
684
+ remove_video_fx: DI,
685
+ bind_video_param: WI,
686
+ set_video_settings: WI,
687
+ vx_variation: W,
688
+ // Connection management
689
+ select_instance: WI,
690
+ // Dev-tree-only tools
691
+ set_view: WI,
692
+ ask_assistant: D, // executes arbitrary model-chosen tool calls
693
+ };
694
+ const internalTools = new Map();
695
+ {
696
+ const origRegisterTool = server.registerTool.bind(server);
697
+ server.registerTool = ((name, config, handler) => {
698
+ internalTools.set(name, { shape: config.inputSchema, handler });
699
+ // Merge the central annotation policy. Inline annotations at the call site
700
+ // win per key; openWorldHint is false server-wide (local plugin only).
701
+ const cfg = config;
702
+ if (!TOOL_ANNOTATIONS[name]) {
703
+ console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
704
+ }
705
+ cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
706
+ return origRegisterTool(name, config, handler);
707
+ });
708
+ }
709
+ async function callInternalTool(name, args) {
710
+ const tool = internalTools.get(name);
711
+ if (!tool)
712
+ return { text: `Unknown tool: ${name}.`, isError: true };
713
+ // Direct handler calls skip the SDK's zod layer — parse here so schema
714
+ // defaults apply and unknown keys strip, exactly like a real MCP call
715
+ // (list_samples without offset/limit would otherwise slice(undefined, NaN)
716
+ // to an empty page).
717
+ let parsed = args;
718
+ if (tool.shape) {
719
+ tool.parser ??= z.object(tool.shape);
720
+ const r = tool.parser.safeParse(args);
721
+ if (!r.success) {
722
+ const issues = r.error.issues.map((i) => `${i.path.join(".") || "(args)"}: ${i.message}`).join("; ");
723
+ return { text: `Invalid arguments for ${name} — ${issues}`, isError: true };
724
+ }
725
+ parsed = r.data;
726
+ }
727
+ try {
728
+ const result = await tool.handler(parsed);
729
+ const out = (result.content ?? [])
730
+ .filter((c) => c.type === "text")
731
+ .map((c) => c.text ?? "")
732
+ .join("\n");
733
+ return { text: out, isError: Boolean(result.isError) };
734
+ }
735
+ catch (e) {
736
+ return { text: `Tool error: ${e.message}`, isError: true };
737
+ }
738
+ }
208
739
  // ============================================================
209
740
  // TOOLS — Offline (work without plugin running)
210
741
  // ============================================================
@@ -241,7 +772,7 @@ server.registerTool("get_parameter_info", {
241
772
  if (paramId) {
242
773
  const p = index.get(paramId);
243
774
  if (!p)
244
- return error(`Unknown parameter: ${paramId}`);
775
+ return error(formatUnknownParamError(paramId, index));
245
776
  return json(p);
246
777
  }
247
778
  let params;
@@ -269,8 +800,10 @@ server.registerTool("get_parameter_info", {
269
800
  if (params.length === 0)
270
801
  return text("No parameters found matching criteria.");
271
802
  if (params.length > 50) {
272
- return text(`${params.length} parameters found. Showing summary:\n\n` +
273
- params.map(formatParam).join("\n"));
803
+ const shown = params.slice(0, 80);
804
+ return text(`${params.length} parameters found. Showing first ${shown.length}. ` +
805
+ "Use paramId, cell, effect, search, type, or design_context to narrow the response.\n\n" +
806
+ shown.map(formatParam).join("\n"));
274
807
  }
275
808
  return json(params);
276
809
  });
@@ -347,6 +880,16 @@ server.registerTool("get_manifest_summary", {
347
880
  description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
348
881
  "modulators, and parameter counts.",
349
882
  inputSchema: {},
883
+ outputSchema: {
884
+ product: z.string().optional(),
885
+ company: z.string().optional(),
886
+ totalParameters: z.number().optional(),
887
+ cells: z.array(z.string()).optional(),
888
+ effects: z.array(z.string()).optional(),
889
+ globalParameters: z.number().optional(),
890
+ modulatableParameters: z.number().optional(),
891
+ pluginParameters: z.number().optional(),
892
+ },
350
893
  annotations: { readOnlyHint: true },
351
894
  }, async () => {
352
895
  const manifest = await loadManifest();
@@ -355,7 +898,7 @@ server.registerTool("get_manifest_summary", {
355
898
  const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
356
899
  const global = params.filter((p) => !p.cell);
357
900
  const modulatable = params.filter((p) => p.isModulatable);
358
- return json({
901
+ return structured({
359
902
  product: manifest.name,
360
903
  company: manifest.company,
361
904
  totalParameters: params.length,
@@ -366,6 +909,98 @@ server.registerTool("get_manifest_summary", {
366
909
  pluginParameters: params.filter((p) => p.isPluginParameter).length,
367
910
  });
368
911
  });
912
+ server.registerTool("design_context", {
913
+ title: "Design Context",
914
+ description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
915
+ "unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
916
+ inputSchema: {
917
+ cellId: z
918
+ .number()
919
+ .int()
920
+ .min(1)
921
+ .max(8)
922
+ .default(1)
923
+ .describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
924
+ },
925
+ annotations: { readOnlyHint: true },
926
+ }, async ({ cellId }) => {
927
+ const manifest = await loadManifest();
928
+ const params = manifest.parameters;
929
+ const byId = new Map(params.map((p) => [p.paramId, p]));
930
+ const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
931
+ const macroTargetsByEffect = {};
932
+ for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
933
+ const key = p.effect ?? "global";
934
+ if (!macroTargetsByEffect[key])
935
+ macroTargetsByEffect[key] = [];
936
+ macroTargetsByEffect[key].push(p.paramId);
937
+ }
938
+ for (const key of Object.keys(macroTargetsByEffect)) {
939
+ macroTargetsByEffect[key] = macroTargetsByEffect[key].sort();
940
+ }
941
+ const filterType = byId.get(example("filterType"));
942
+ const lfoQuick = [
943
+ ["16 bar", 0, 0.000],
944
+ ["4 bar", 2, 0.091],
945
+ ["2 bar", 3, 0.136],
946
+ ["1 bar", 4, 0.182],
947
+ ["1/2", 6, 0.273],
948
+ ["1/4", 9, 0.409],
949
+ ["1/8", 12, 0.545],
950
+ ["1/16", 15, 0.682],
951
+ ["1/32", 18, 0.818],
952
+ ["1/64", 21, 0.955],
953
+ ].map(([division, index, value]) => ({ division, index, lfoRate: value }));
954
+ return json({
955
+ product: manifest.name,
956
+ exampleCell: cellId,
957
+ macroTargetsByEffect,
958
+ keyPerCellParams: {
959
+ mixer: [example("volume"), example("pan"), example("gainGain")],
960
+ filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
961
+ delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
962
+ crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
963
+ fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
964
+ gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
965
+ compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
966
+ drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
967
+ },
968
+ unitNotes: {
969
+ pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
970
+ percentages: "Most mix/feedback/depth controls are 0..100 percent.",
971
+ filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
972
+ delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
973
+ lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
974
+ },
975
+ musicalRanges: {
976
+ filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
977
+ delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
978
+ wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
979
+ crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
980
+ volumeDb: "Layer cells with -12..0 dB staging before master processing",
981
+ },
982
+ lfoSyncTable: {
983
+ formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
984
+ quick: lfoQuick,
985
+ fullDivisions: LFO_DIVISIONS,
986
+ shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
987
+ },
988
+ delaySyncDivisions: [
989
+ "1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
990
+ "1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D",
991
+ "1/2T", "1/2", "1/2D", "1",
992
+ ],
993
+ filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
994
+ "0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
995
+ ],
996
+ midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
997
+ const id = i + 1;
998
+ return [`cell${id}`, 47 + id];
999
+ })),
1000
+ variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1001
+ modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
1002
+ });
1003
+ });
369
1004
  // Standard full update — graph dispatch + KV sync + parameter updates.
370
1005
  const KV_OPTS_STANDARD = {
371
1006
  shouldDispatch: true,
@@ -394,6 +1029,20 @@ const KV_OPTS_PATTERN = {
394
1029
  updateParameters: false,
395
1030
  broadcastFollowers: false,
396
1031
  };
1032
+ // Master-chain config — KV + config-resource sync, NO graph dispatch. Every
1033
+ // master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
1034
+ // limiter, EQ/width) is live-polled from master_config by its native node, so
1035
+ // a rebuild here is pure dropout risk (the plan's no-rebuild contract).
1036
+ // Mirrors interop.ts updateMasterConfig's options.
1037
+ const KV_OPTS_MASTER_CONFIG = {
1038
+ shouldDispatch: false,
1039
+ syncKVResources: true,
1040
+ syncConfigResources: true,
1041
+ skipFileLoadingIfUnchanged: true,
1042
+ resyncKVAfterDispatch: false,
1043
+ updateParameters: true,
1044
+ broadcastFollowers: false,
1045
+ };
397
1046
  // Effect config toggle — lighter KV path, no graph dispatch.
398
1047
  const KV_OPTS_EFFECT_CONFIG = {
399
1048
  shouldDispatch: false,
@@ -423,30 +1072,132 @@ async function applyKv(delta, options, directNodeOps) {
423
1072
  }
424
1073
  server.registerTool("get_state", {
425
1074
  title: "Get Plugin State",
426
- description: "Read the current full state of the running PAM plugin. " +
427
- "Returns all parameter values, cell configs, modulation, and sequences.",
1075
+ description: "Read the current state of the running PAM plugin. Prefer section, keys, cellIds, or get_design_overview " +
1076
+ "over an unfiltered full-state read; large full-state payloads are capped to protect agent context.",
428
1077
  inputSchema: {
429
1078
  section: z
430
1079
  .enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
431
1080
  .optional()
432
1081
  .describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
1082
+ keys: z
1083
+ .array(z.string())
1084
+ .optional()
1085
+ .describe("Exact top-level state keys to return, e.g. ['cell1_filterCutoff','cell1_midiSequence']."),
1086
+ cellIds: z
1087
+ .array(z.number().int().min(1).max(8))
1088
+ .optional()
1089
+ .describe("Return only cell-scoped keys/config/modulation for these cells."),
1090
+ allowLarge: z
1091
+ .boolean()
1092
+ .optional()
1093
+ .describe("Set true to force an uncapped full-state response. Prefer filters unless you truly need everything."),
433
1094
  },
434
1095
  annotations: { readOnlyHint: true },
435
- }, async ({ section }) => {
1096
+ }, async ({ section, keys, cellIds, allowLarge }) => {
436
1097
  const check = await requireBridge();
437
1098
  if (check !== true)
438
1099
  return check;
1100
+ const hasFilters = (keys?.length ?? 0) > 0 || (cellIds?.length ?? 0) > 0;
439
1101
  if (section && section !== "full") {
440
1102
  const result = await bridge.send("getState", section);
441
- return json(result ?? `Section '${section}' not found in state.`);
1103
+ const filtered = filterSectionResult(section, result, { keys, cellIds });
1104
+ return jsonWithLimit(filtered ?? `Section '${section}' not found in state.`, "Use a narrower section, keys, or cellIds filter.");
442
1105
  }
443
1106
  const state = await bridge.send("getState");
444
- return json(state);
1107
+ const filtered = filterState(state, { keys, cellIds });
1108
+ if (!hasFilters && !allowLarge) {
1109
+ return jsonWithLimit(filtered, "Use get_design_overview for preset work, or get_state with section, keys, or cellIds. Pass allowLarge:true only for offline debugging.");
1110
+ }
1111
+ return json(filtered);
1112
+ });
1113
+ server.registerTool("get_design_overview", {
1114
+ title: "Get Design Overview",
1115
+ description: "Return a compact preset-design snapshot: cells, loaded samples/synth engines, enabled effects, pattern counts, " +
1116
+ "modulation link counts, mixer values, BPM, and master FX. Use this instead of full get_state during preset design.",
1117
+ inputSchema: {
1118
+ cellIds: z
1119
+ .array(z.number().int().min(1).max(8))
1120
+ .optional()
1121
+ .describe("Optional subset of cells to include. Defaults to all cells present in the selected manifest."),
1122
+ },
1123
+ annotations: { readOnlyHint: true },
1124
+ }, async ({ cellIds }) => {
1125
+ const check = await requireBridge();
1126
+ if (check !== true)
1127
+ return check;
1128
+ const [stateRaw, transportRaw, manifest] = await Promise.all([
1129
+ bridge.send("getState"),
1130
+ bridge.send("getTransport").catch(() => undefined),
1131
+ loadManifest(),
1132
+ ]);
1133
+ const state = isRecord(stateRaw) ? stateRaw : {};
1134
+ const transport = isRecord(transportRaw) ? transportRaw : {};
1135
+ const manifestCellIds = [...new Set(manifest.parameters
1136
+ .map((p) => p.cell)
1137
+ .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
1138
+ .map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
1139
+ const ids = cellIds?.length ? cellIds : manifestCellIds;
1140
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
1141
+ const modArrays = {
1142
+ lfo: Array.isArray(state.lfos) ? state.lfos : [],
1143
+ envelope: Array.isArray(state.envelopes) ? state.envelopes : [],
1144
+ vary: Array.isArray(state.vary) ? state.vary : [],
1145
+ paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
1146
+ };
1147
+ const cells = ids.map((id) => {
1148
+ const prefix = `cell${id}_`;
1149
+ const rawCellState = cellsObj[String(id)];
1150
+ const cellState = isRecord(rawCellState) ? rawCellState : {};
1151
+ const samplePath = cellState.filePath ??
1152
+ cellState.samplePath ??
1153
+ state[`${prefix}filePath`] ??
1154
+ state[`${prefix}samplePath`];
1155
+ const modulation = Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
1156
+ kind,
1157
+ arr.filter((entry) => paramTargetsCell(entry, new Set([prefix]))).length,
1158
+ ]));
1159
+ return {
1160
+ cellId: id,
1161
+ sample: {
1162
+ name: stemName(samplePath),
1163
+ path: typeof samplePath === "string" ? samplePath : undefined,
1164
+ },
1165
+ synthEngine: state[`${prefix}synthEngine`] ?? 0,
1166
+ enabledEffects: CELL_EFFECTS.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1167
+ patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1168
+ lane,
1169
+ countEvents(state[`${prefix}${suffix}`]),
1170
+ ])),
1171
+ modulation,
1172
+ mixer: {
1173
+ volume: state[`${prefix}volume`],
1174
+ pan: state[`${prefix}pan`],
1175
+ gain: state[`${prefix}gainGain`],
1176
+ },
1177
+ };
1178
+ });
1179
+ return json({
1180
+ product: manifest.name,
1181
+ bpm: state.bpm ?? transport.bpm,
1182
+ playing: transport.playing,
1183
+ masterFx: {
1184
+ compressorEnabled: state.masterCompressorEnabled,
1185
+ limiterEnabled: state.masterLimiterEnabled,
1186
+ tapeEnabled: state.masterTapeEnabled,
1187
+ },
1188
+ totals: {
1189
+ cellsWithSamples: cells.filter((c) => c.sample.path).length,
1190
+ activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
1191
+ modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
1192
+ },
1193
+ cells,
1194
+ });
445
1195
  });
446
1196
  server.registerTool("set_parameters", {
447
1197
  title: "Set Parameters",
448
1198
  description: "Set one or more PAM parameters. Values are validated against the manifest. " +
449
- "Use get_parameter_info first to discover valid parameter IDs and ranges.",
1199
+ "Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
1200
+ "when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges.",
450
1201
  inputSchema: {
451
1202
  parameters: z
452
1203
  .record(z.string(), z.number())
@@ -460,11 +1211,11 @@ server.registerTool("set_parameters", {
460
1211
  for (const [paramId, value] of Object.entries(parameters)) {
461
1212
  const p = index.get(paramId);
462
1213
  if (!p) {
463
- errors.push(`Unknown parameter: ${paramId}`);
1214
+ errors.push(formatUnknownParamError(paramId, index));
464
1215
  continue;
465
1216
  }
466
1217
  if (value < p.min || value > p.max) {
467
- errors.push(`${paramId}: ${value} out of range [${p.min}..${p.max}]`);
1218
+ errors.push(formatRangeError(p, value));
468
1219
  }
469
1220
  }
470
1221
  if (errors.length > 0)
@@ -542,11 +1293,21 @@ server.registerTool("get_model_status", {
542
1293
  title: "Get AI Model Status",
543
1294
  description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
544
1295
  inputSchema: { engine: MODEL_ENGINE },
1296
+ outputSchema: {
1297
+ engine: z.string().optional(),
1298
+ enabled: z.boolean().optional(),
1299
+ downloaded: z.boolean().optional(),
1300
+ loaded: z.boolean().optional(),
1301
+ inFlight: z.boolean().optional(),
1302
+ version: z.string().optional(),
1303
+ state: z.string().optional(),
1304
+ progress: z.number().optional(),
1305
+ },
545
1306
  }, async ({ engine }) => {
546
1307
  const check = await requireBridge();
547
1308
  if (check !== true)
548
1309
  return check;
549
- return json(await bridge.send("modelStatus", { engine }));
1310
+ return structuredFromBridge(await bridge.send("modelStatus", { engine }));
550
1311
  });
551
1312
  server.registerTool("load_model", {
552
1313
  title: "Load AI Model",
@@ -798,11 +1559,15 @@ server.registerTool("workbench_sidecar_status", {
798
1559
  title: "Workbench: Sidecar Status",
799
1560
  description: "Ping the SA3 render sidecar (127.0.0.1:8093). Returns { connected, loaded } — loaded means a pipeline is resident (first render after a cold start also pays the model load).",
800
1561
  inputSchema: {},
1562
+ outputSchema: {
1563
+ connected: z.boolean().optional(),
1564
+ loaded: z.boolean().optional(),
1565
+ },
801
1566
  }, async () => {
802
1567
  const check = await requireBridge();
803
1568
  if (check !== true)
804
1569
  return check;
805
- return json(await bridge.send("workbenchSidecarStatus"));
1570
+ return structuredFromBridge(await bridge.send("workbenchSidecarStatus"));
806
1571
  });
807
1572
  server.registerTool("trigger_midi", {
808
1573
  title: "Trigger MIDI",
@@ -867,13 +1632,13 @@ server.registerTool("add_modulation", {
867
1632
  .min(1)
868
1633
  .max(8)
869
1634
  .optional()
870
- .describe("Cell ID for envelope/vary sources (1-8)."),
1635
+ .describe("Required for envelope/vary: source cell driving the follower (1-8). This is not the target cell; target is inferred from paramId."),
871
1636
  lfoIndex: z
872
1637
  .number()
873
1638
  .min(0)
874
1639
  .max(5)
875
1640
  .optional()
876
- .describe("LFO generator index (0-5). Required for LFO type."),
1641
+ .describe("Required for LFO: generator index 0-5. Parameter IDs remain 1-based (lfo1_... through lfo6_...)."),
877
1642
  strength: z
878
1643
  .number()
879
1644
  .min(0)
@@ -898,9 +1663,9 @@ server.registerTool("add_modulation", {
898
1663
  const index = await getParameterIndex();
899
1664
  const param = index.get(paramId);
900
1665
  if (!param)
901
- return error(`Unknown parameter: ${paramId}`);
1666
+ return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
902
1667
  if (!param.isModulatable)
903
- return error(`Parameter '${paramId}' is not modulatable.`);
1668
+ return error(formatNotModulatableError(paramId, index));
904
1669
  const check = await requireBridge();
905
1670
  if (check !== true)
906
1671
  return check;
@@ -916,12 +1681,12 @@ server.registerTool("add_modulation", {
916
1681
  }
917
1682
  else if (type === "envelope") {
918
1683
  if (!cellId)
919
- return error("cellId is required for envelope modulation.");
1684
+ return error("cellId is required for envelope modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
920
1685
  await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
921
1686
  }
922
1687
  else if (type === "vary") {
923
1688
  if (!cellId)
924
- return error("cellId is required for vary modulation.");
1689
+ return error("cellId is required for vary modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
925
1690
  await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
926
1691
  }
927
1692
  return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
@@ -994,12 +1759,17 @@ server.registerTool("update_pattern", {
994
1759
  });
995
1760
  server.registerTool("randomize", {
996
1761
  title: "Randomize",
997
- description: "Trigger PAM's randomization engine to generate random sounds and patterns.",
1762
+ description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
1763
+ "target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
1764
+ "shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
1765
+ "to be OPEN (samples/sequences/full run natively and do not). target:'synth' needs PAM ≥ 1.13.0.",
998
1766
  inputSchema: {
1767
+ // 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
1768
+ // like 'synth'. The describe() text is pinned (exported toolpack bytes).
999
1769
  target: z
1000
- .enum(["full", "samples", "sequences"])
1770
+ .enum(["full", "samples", "sequences", "synth", "lfos", "mods"])
1001
1771
  .default("full")
1002
- .describe("What to randomize."),
1772
+ .describe("What to randomize. 'synth' = the built-in drum-synth machines (PAM window must be open)."),
1003
1773
  cellIds: z
1004
1774
  .array(z.number().min(1).max(8))
1005
1775
  .optional()
@@ -1025,6 +1795,21 @@ server.registerTool("randomize", {
1025
1795
  const check = await requireBridge();
1026
1796
  if (check !== true)
1027
1797
  return check;
1798
+ if (target === "synth") {
1799
+ // Routed to the canonical WebView randomizer (single source of truth) — needs the
1800
+ // window open. Empty array → all non-excluded cells.
1801
+ await bridge.send("randomizeDrumSynth", cellIds ?? []);
1802
+ return text(`Randomized drum synths${cellIds ? ` for cells ${cellIds.join(", ")}` : " (all cells)"}. ` +
1803
+ `(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
1804
+ }
1805
+ if (target === "lfos" || target === "mods") {
1806
+ // The data-driven modulation randomizer lives in WebView JS
1807
+ // (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
1808
+ // like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
1809
+ await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
1810
+ return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
1811
+ `(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
1812
+ }
1028
1813
  await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
1029
1814
  return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
1030
1815
  });
@@ -1039,7 +1824,7 @@ server.registerTool("randomize_samples", {
1039
1824
  genreHint: z
1040
1825
  .string()
1041
1826
  .optional()
1042
- .describe("Genre hint to guide sample selection. Supported: techno, house, trap, boom_bap, drill, dnb, hip-hop, ambient, industrial, breaks."),
1827
+ .describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
1043
1828
  bpmHint: z
1044
1829
  .number()
1045
1830
  .min(20)
@@ -1127,13 +1912,148 @@ server.registerTool("get_transport", {
1127
1912
  title: "Get Transport",
1128
1913
  description: "Get current transport state (BPM, playing, position).",
1129
1914
  inputSchema: {},
1915
+ outputSchema: {
1916
+ bpm: z.number().optional(),
1917
+ isPlaying: z.boolean().optional(),
1918
+ ppqPosition: z.number().optional(),
1919
+ },
1130
1920
  annotations: { readOnlyHint: true },
1131
1921
  }, async () => {
1132
1922
  const check = await requireBridge();
1133
1923
  if (check !== true)
1134
1924
  return check;
1135
1925
  const transport = await bridge.send("getTransport");
1136
- return json(transport);
1926
+ return structuredFromBridge(transport);
1927
+ });
1928
+ server.registerTool("get_master_analysis", {
1929
+ title: "Get Master Analysis",
1930
+ description: "Read the master-bus loudness measurement (BS.1770-5 / EBU R128): momentary/short-term/integrated " +
1931
+ "LUFS (m/s/i), rolling 30 s ungated loudness (roll), loudness range (lra, stable after 60 s — " +
1932
+ "check lraStable), true peak dBTP (tp; tp3s = 3 s window), sample peak (sp), PSR/PLR crest " +
1933
+ "metrics, stereo correlation (corr; corrLow = <120 Hz band), elapsed ms since reset, and ageMs " +
1934
+ "(snapshot staleness). Absent field = no reading yet. Works headless (window can be closed); " +
1935
+ "audio must be flowing — start the transport or play something first. Guard rails for " +
1936
+ "mastering moves: keep tp <= -1 dBTP and PSR >= 8 in the loudest section.",
1937
+ inputSchema: {},
1938
+ annotations: { readOnlyHint: true },
1939
+ }, async () => {
1940
+ const check = await requireBridge();
1941
+ if (check !== true)
1942
+ return check;
1943
+ const analysis = await bridge.send("getMasterAnalysis");
1944
+ return json(analysis);
1945
+ });
1946
+ server.registerTool("get_mix_analysis", {
1947
+ title: "Get Per-Cell Mix Analysis",
1948
+ description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
1949
+ "true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
1950
+ "spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
1951
+ "(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
1952
+ "playing). Cached result otherwise. Requires PAM ≥ 1.17.0. PAM only.",
1953
+ inputSchema: {
1954
+ refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
1955
+ durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
1956
+ cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to analyze (default: every cell with a sample or active drum synth)."),
1957
+ },
1958
+ }, async ({ refresh, durationSeconds, cellIds }) => {
1959
+ const check = await requireBridge();
1960
+ if (check !== true)
1961
+ return check;
1962
+ if (refresh) {
1963
+ const started = (await bridge.send("analyzeMix", { durationSeconds, cellIds }));
1964
+ if (started.status !== "started")
1965
+ return error("Mix analysis is busy — an offline render already owns the engine. Retry when it finishes.");
1966
+ const t0 = Date.now();
1967
+ while (Date.now() - t0 < 120_000) {
1968
+ await new Promise((r) => setTimeout(r, 500));
1969
+ const status = (await bridge.send("getMixAnalysis"));
1970
+ if (status.status !== "running")
1971
+ return json(status);
1972
+ }
1973
+ return error("Mix analysis timed out after 120 s.");
1974
+ }
1975
+ const result = await bridge.send("getMixAnalysis");
1976
+ return json(result);
1977
+ });
1978
+ // Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
1979
+ // stacking guard's memory (live payloads refresh their epoch, so they never
1980
+ // stay consumed). Session-scoped by design: a server restart just re-allows a
1981
+ // suggest, which the refresh-first workflow tolerates.
1982
+ let lastAppliedMixEpoch = -1;
1983
+ server.registerTool("suggest_mix", {
1984
+ title: "Suggest Mix Balance Moves",
1985
+ description: "Runs the Mix Assist rule engine over the latest per-cell analysis (get_mix_analysis): " +
1986
+ "partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
1987
+ "settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
1988
+ "as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
1989
+ "Requires PAM ≥ 1.17.0. PAM only.",
1990
+ inputSchema: {
1991
+ scopes: z
1992
+ .array(z.enum(["balance", "pan", "eq", "dynamics"]))
1993
+ .optional()
1994
+ .describe("Stages to run (default balance+pan+eq; dynamics is opt-in)."),
1995
+ apply: z.boolean().optional().describe("Apply the suggested moves to the running plugin."),
1996
+ },
1997
+ }, async ({ scopes, apply }) => {
1998
+ const check = await requireBridge();
1999
+ if (check !== true)
2000
+ return check;
2001
+ const r = (await bridge.send("getMixAnalysis"));
2002
+ if (!r?.available)
2003
+ return error("No mix analysis available — run get_mix_analysis with refresh:true first.");
2004
+ const analysis = parseMixAnalysisPayload(r.analysis);
2005
+ if (!analysis)
2006
+ return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
2007
+ // Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
2008
+ // deltas come from the analysis but land on the LIVE params, so suggesting
2009
+ // again from an analysis that was already applied doubles the correction.
2010
+ if (!analysis.live && analysis.epoch === lastAppliedMixEpoch)
2011
+ return error("This analysis was already applied — re-run get_mix_analysis with refresh:true to measure the new balance first.");
2012
+ const state = (await bridge.send("getState"));
2013
+ // Guard-rail parity with the in-app panel (context.ts contract): macros
2014
+ // count as modulation, and cells whose source was removed after the
2015
+ // analysis must not receive moves.
2016
+ const mods = {
2017
+ lfos: state.lfos,
2018
+ envelopes: state.envelopes,
2019
+ vary: state.vary,
2020
+ paramSeqs: state.paramSeqs,
2021
+ macros: state.macros,
2022
+ };
2023
+ const hasSource = (id) => {
2024
+ const cells = state.cells;
2025
+ if (cells?.[String(id)]?.filePath)
2026
+ return true;
2027
+ if (Number(state[`cell${id}_synthEngine`] ?? 0) > 0.5)
2028
+ return true;
2029
+ if (Number(state[`cell${id}_magentaEngine`] ?? 0) > 0.5)
2030
+ return true;
2031
+ const pluginPath = state[`cell${id}_pluginPath`];
2032
+ return typeof pluginPath === "string" && pluginPath.length > 0;
2033
+ };
2034
+ const measurement = buildMixMeasurementFromState(analysis, state, mods, { hasSource });
2035
+ const current = currentFlatFromState(analysis, state);
2036
+ // Rider ownership (the panel's parked-bal rule): while Auto Mix AUTO runs,
2037
+ // the cMixRide rider owns the balance — fader suggestions would
2038
+ // double-drive it on top of the signed trims.
2039
+ const riderOwnsBalance = Number(state.masterMixRideEnabled ?? 0) === 1 && Number(state.masterMixRideAuto ?? 0) === 1;
2040
+ let resolvedScopes = scopes ?? ["balance", "pan", "eq"];
2041
+ const balanceParked = riderOwnsBalance && resolvedScopes.includes("balance");
2042
+ if (balanceParked)
2043
+ resolvedScopes = resolvedScopes.filter((s) => s !== "balance");
2044
+ if (resolvedScopes.length === 0)
2045
+ return error("Auto Mix is running and owns the balance — request pan, eq, or dynamics scopes while it is on.");
2046
+ const suggestion = suggestMix(measurement, { scopes: resolvedScopes }, current);
2047
+ const parkedNote = balanceParked
2048
+ ? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
2049
+ : {};
2050
+ if (apply && suggestion.moves.length > 0) {
2051
+ await applyKv(suggestion.flat, KV_OPTS_STANDARD);
2052
+ if (!analysis.live)
2053
+ lastAppliedMixEpoch = analysis.epoch;
2054
+ return json({ ...suggestion, ...parkedNote, applied: true });
2055
+ }
2056
+ return json({ ...suggestion, ...parkedNote });
1137
2057
  });
1138
2058
  server.registerTool("new_preset", {
1139
2059
  title: "New Preset",
@@ -1154,7 +2074,11 @@ server.registerTool("save_preset", {
1154
2074
  "Writes to the user preset folder by default; pass `path` for a specific location. " +
1155
2075
  "Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
1156
2076
  inputSchema: {
1157
- name: z.string().describe("Preset name (used for filename if `path` omitted)."),
2077
+ // Optional since 1.15.0: omitted name = re-save the ACTIVE user preset in
2078
+ // place (the UI Save button's semantics; the assistant's bare "save").
2079
+ // The describe() text is pinned — the exported assistant toolpack keeps
2080
+ // property descriptions, and the model is trained against those bytes.
2081
+ name: z.string().optional().describe("Preset name (used for filename if `path` omitted)."),
1158
2082
  author: z.string().optional().describe("Author name."),
1159
2083
  category: z
1160
2084
  .string()
@@ -1179,26 +2103,56 @@ server.registerTool("save_preset", {
1179
2103
  const check = await requireBridge();
1180
2104
  if (check !== true)
1181
2105
  return check;
2106
+ const state = (await bridge.send("getState"));
2107
+ // Bare save (no name): the UI Save button's semantics — overwrite the
2108
+ // ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
2109
+ // the in-plugin assistant executor (error strings included — the model's
2110
+ // repair turns are trained on those shapes). Factory/Init need a name.
2111
+ const extras = rest;
2112
+ if (!name?.trim()) {
2113
+ const activePath = typeof state.activePresetPath === "string" ? state.activePresetPath : "";
2114
+ if (!activePath || activePath === "Init")
2115
+ return error('No preset is active — give this one a name, e.g. save_preset({ name: "Night Drive" }).');
2116
+ const readPath = normalizePresetLookupPath(activePath);
2117
+ if (readPath.startsWith("Factory/"))
2118
+ return error("The current preset is a factory patch — save a copy under a new name instead.");
2119
+ name = (activePath.split("/").pop() || "").replace(/\.preset$/, "");
2120
+ if (!name)
2121
+ return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
2122
+ if (path === undefined)
2123
+ path = activePath;
2124
+ try {
2125
+ const parsed = parsePresetFile(await readFile(await resolvePresetPath(readPath), "utf-8"));
2126
+ for (const [k, v] of Object.entries(parsed.metadata)) {
2127
+ // Structural keys are rebuilt below; everything else (author/tags/
2128
+ // category/…) carries over unless the caller overrode it.
2129
+ if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
2130
+ continue;
2131
+ if (extras[k] === undefined)
2132
+ extras[k] = v;
2133
+ }
2134
+ }
2135
+ catch {
2136
+ // unreadable/new — save fresh with whatever the caller passed
2137
+ }
2138
+ }
1182
2139
  const metadata = { name, factory: false };
1183
2140
  if (path !== undefined)
1184
2141
  metadata.path = path;
1185
- for (const [k, v] of Object.entries(rest)) {
2142
+ for (const [k, v] of Object.entries(extras)) {
1186
2143
  if (v !== undefined)
1187
2144
  metadata[k] = v;
1188
2145
  }
1189
2146
  // Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
1190
2147
  // otherwise update the current active variation slot.
1191
- const state = (await bridge.send("getState"));
1192
2148
  const variationString = buildVariationString(state);
1193
2149
  const presetRelPath = path || `User/${name}.preset`;
1194
- // Strip "Presets/" prefix if present — resolvePresetPath already prepends
1195
- // the presets base directory, so "Presets/User/X" would double up to
1196
- // ".../Presets/Presets/User/X" and silently fail to read back variations,
1197
- // wiping slots 1-7 on save. Mirrors save_variation's handling.
1198
- const readPath = presetRelPath.startsWith("Presets/")
1199
- ? presetRelPath.slice("Presets/".length)
1200
- : presetRelPath;
1201
- const existingVariations = await readExistingVariations(readPath);
2150
+ const readPath = normalizePresetLookupPath(presetRelPath);
2151
+ // Save As must copy the active preset's complete variation bank. Reading
2152
+ // the new destination starts from eight empty slots because that file does
2153
+ // not exist yet, which used to collapse every named save to one slot.
2154
+ const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
2155
+ const existingVariations = await readExistingVariations(variationSourcePath);
1202
2156
  const hasVariations = existingVariations.some((v) => v !== null);
1203
2157
  if (!hasVariations) {
1204
2158
  existingVariations[0] = variationString;
@@ -1212,6 +2166,8 @@ server.registerTool("save_preset", {
1212
2166
  }
1213
2167
  metadata.variations = existingVariations;
1214
2168
  const result = (await bridge.send("savePreset", metadata));
2169
+ lastSavedPresetName = name;
2170
+ lastSavedPresetPath = result?.path || readPath;
1215
2171
  return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
1216
2172
  });
1217
2173
  server.registerTool("save_variation", {
@@ -1259,11 +2215,15 @@ server.registerTool("save_variation", {
1259
2215
  // Strip "Presets/" prefix if present — resolvePresetPath already prepends
1260
2216
  // the presets base directory, so "Presets/User/X" would double up to
1261
2217
  // ".../Presets/Presets/User/X" and silently fail to read back variations.
1262
- diskPath = activePath.startsWith("Presets/") ? activePath.slice("Presets/".length) : activePath;
2218
+ diskPath = normalizePresetLookupPath(activePath);
2219
+ }
2220
+ if (!presetName && lastSavedPresetName) {
2221
+ presetName = lastSavedPresetName;
2222
+ diskPath = lastSavedPresetPath;
1263
2223
  }
1264
2224
  if (!presetName) {
1265
- return text("Error: No preset name provided and no active preset loaded. " +
1266
- "Save the preset first with save_preset, or pass a name.");
2225
+ return error("No preset name provided and no active preset loaded.\n" +
2226
+ "Next call: save_preset({ name: 'Your Preset Name', ...metadata }) first, or call save_variation with name: 'Your Preset Name'.");
1267
2227
  }
1268
2228
  if (!diskPath) {
1269
2229
  diskPath = `User/${presetName}.preset`;
@@ -1302,6 +2262,8 @@ server.registerTool("save_variation", {
1302
2262
  resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
1303
2263
  }
1304
2264
  const result = (await bridge.send("savePreset", metadata));
2265
+ lastSavedPresetName = presetName;
2266
+ lastSavedPresetPath = result?.path || diskPath;
1305
2267
  return text(`Saved variation ${index} for "${presetName}"\n` +
1306
2268
  `Path: ${result?.path || "(unknown path)"}\n` +
1307
2269
  `Filled slots: ${variations.filter((v) => v !== null).length}/8` +
@@ -1419,30 +2381,141 @@ server.registerTool("reverse_sample", {
1419
2381
  await bridge.send("reverseSampleForCell", cellId);
1420
2382
  return text(`Reversed sample in cell ${cellId}.`);
1421
2383
  });
2384
+ const BRIDGE_INSTANCE_SHAPE = z.object({
2385
+ port: z.number().optional(),
2386
+ instanceId: z.string().optional(),
2387
+ product: z.string().optional(),
2388
+ pid: z.number().optional(),
2389
+ });
1422
2390
  server.registerTool("connection_status", {
1423
2391
  title: "Connection Status",
1424
2392
  description: "Check if the MCP server can connect to a running PAM plugin instance. " +
1425
2393
  "Returns connection details or instructions for enabling the bridge.",
1426
2394
  inputSchema: {},
2395
+ outputSchema: {
2396
+ instances: z.array(BRIDGE_INSTANCE_SHAPE.passthrough()).optional(),
2397
+ connected: z.boolean().optional(),
2398
+ connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
2399
+ note: z.string().optional(),
2400
+ },
1427
2401
  annotations: { readOnlyHint: true },
1428
2402
  }, async () => {
1429
2403
  const instances = await bridge.discover();
1430
2404
  if (instances.length === 0) {
1431
- return text("No running PAM instances found.\n\n" +
1432
- "To use live tools, PAM needs the MCP bridge enabled:\n" +
1433
- "1. Start PAM (standalone or in a DAW)\n" +
1434
- "2. The bridge will write its port to ~/.pam-mcp-bridge.json\n\n" +
1435
- "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.");
2405
+ return structured({
2406
+ instances: [],
2407
+ connected: false,
2408
+ note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
2409
+ "start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
2410
+ "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
2411
+ });
1436
2412
  }
1437
2413
  const connected = bridge.isConnected() || (await bridge.connect());
1438
- return json({
2414
+ if (connected)
2415
+ syncProductHint();
2416
+ return structured({
1439
2417
  instances,
1440
2418
  connected,
2419
+ connectedTo: bridge.getConnectedInstance(),
1441
2420
  note: connected
1442
- ? "Connected to PAM. All tools available."
2421
+ ? instances.length > 1
2422
+ ? "Connected. Multiple instances are running — use select_instance to drive a specific product."
2423
+ : "Connected to PAM. All tools available."
1443
2424
  : "Found instances but could not connect. The plugin may have shut down.",
1444
2425
  });
1445
2426
  });
2427
+ server.registerTool("select_instance", {
2428
+ title: "Select Plugin Instance",
2429
+ description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
2430
+ "one is running. Match by product name, instanceId, or port — connection_status lists them. " +
2431
+ "Reconnects if currently connected to a different instance, and points offline parameter " +
2432
+ "validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
2433
+ "choice at server launch.",
2434
+ inputSchema: {
2435
+ product: z.string().optional().describe("Product name: PAM, CELL1, or MFX (case-insensitive)."),
2436
+ instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
2437
+ port: z.number().int().optional().describe("Exact TCP port from connection_status."),
2438
+ },
2439
+ outputSchema: {
2440
+ connected: z.boolean().optional(),
2441
+ connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
2442
+ note: z.string().optional(),
2443
+ },
2444
+ }, async ({ product, instanceId, port }) => {
2445
+ if (!product && !instanceId && port === undefined) {
2446
+ return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
2447
+ }
2448
+ const ok = await bridge.connect({ product, instanceId, port });
2449
+ if (!ok) {
2450
+ const instances = await bridge.discover();
2451
+ const listing = instances.length
2452
+ ? ` Running instances: ${instances
2453
+ .map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
2454
+ .join("; ")}.`
2455
+ : " No running instances found.";
2456
+ return error(`No running instance matches that selector.${listing}`);
2457
+ }
2458
+ syncProductHint();
2459
+ return structured({
2460
+ connected: true,
2461
+ connectedTo: bridge.getConnectedInstance(),
2462
+ note: "Live tools now drive this instance.",
2463
+ });
2464
+ });
2465
+ // Bounded recursive name search under a library root (BFS — shallow matches
2466
+ // first), over the bridge's listDirectory. Mirrors the in-plugin executor's
2467
+ // searchLibrary (assistantExecutor.ts) so the assistant's trained browse chain
2468
+ // (list_samples{path,search} → load_sample of a path-joined result) behaves
2469
+ // identically through the dev server. Returned names are relative to the
2470
+ // searched root; listDirectory accepts base-relative paths, so recursion can
2471
+ // use each entry's `path` verbatim.
2472
+ async function searchSampleLibrary(rootPath, term, audioOnly) {
2473
+ const t = term.toLowerCase();
2474
+ const matches = [];
2475
+ const queue = [{ path: rootPath, prefix: "", depth: 0 }];
2476
+ const options = JSON.stringify({ audioOnly, sort: "name" });
2477
+ let dirReads = 0;
2478
+ while (queue.length && dirReads < 40 && matches.length < 60) {
2479
+ const { path, prefix, depth } = queue.shift();
2480
+ dirReads++;
2481
+ let entries = [];
2482
+ try {
2483
+ const res = await bridge.send("listDirectory", path, options);
2484
+ if (Array.isArray(res))
2485
+ entries = res;
2486
+ }
2487
+ catch {
2488
+ continue; // unreadable subdir — keep sweeping the rest
2489
+ }
2490
+ for (const e of entries) {
2491
+ const name = typeof e.name === "string" ? e.name : "";
2492
+ if (!name)
2493
+ continue;
2494
+ if (e.isDir) {
2495
+ if (depth < 3 && typeof e.path === "string")
2496
+ queue.push({ path: e.path, prefix: `${prefix}${name}/`, depth: depth + 1 });
2497
+ }
2498
+ else if (name.toLowerCase().includes(t)) {
2499
+ matches.push(`${prefix}${name}`);
2500
+ if (matches.length >= 60)
2501
+ break;
2502
+ }
2503
+ }
2504
+ }
2505
+ return matches;
2506
+ }
2507
+ // Search-result payloads feed the assistant's 256-token continuation round —
2508
+ // trim to that budget instead of letting the listing collapse to an opaque
2509
+ // truncation string (executor parity: assistantExecutor.ts fitListing).
2510
+ function fitListing(base, itemsKey, items, moreKey, total) {
2511
+ let page = items;
2512
+ for (;;) {
2513
+ const payload = { ...base, [itemsKey]: page, ...(total > page.length ? { [moreKey]: total - page.length } : {}) };
2514
+ if (JSON.stringify(payload).length <= 380 || page.length <= 2)
2515
+ return payload;
2516
+ page = page.slice(0, Math.max(2, Math.floor(page.length / 2)));
2517
+ }
2518
+ }
1446
2519
  server.registerTool("list_samples", {
1447
2520
  title: "List Samples",
1448
2521
  description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
@@ -1474,12 +2547,25 @@ server.registerTool("list_samples", {
1474
2547
  .max(200)
1475
2548
  .default(50)
1476
2549
  .describe("Maximum number of entries to return (1-200). Default: 50."),
2550
+ // Declared LAST so the exported assistant toolpack keeps its property
2551
+ // order byte-identical (export-toolpack.mjs overwrites this prop's text).
2552
+ search: z
2553
+ .string()
2554
+ .optional()
2555
+ .describe("Filter by file name, searched recursively under `path` (e.g. 'kick', '808')."),
1477
2556
  },
1478
2557
  annotations: { readOnlyHint: true },
1479
- }, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
2558
+ }, async ({ path: dirPath, audioOnly, sort, offset, limit, search }) => {
1480
2559
  const check = await requireBridge();
1481
2560
  if (check !== true)
1482
2561
  return check;
2562
+ const term = search?.trim();
2563
+ if (term) {
2564
+ const matches = await searchSampleLibrary(dirPath, term, audioOnly);
2565
+ // Executor-shaped payload; empty files:[] is a SUCCESS (the assistant
2566
+ // reports the miss in text instead of repair-looping).
2567
+ return json(fitListing({ path: dirPath }, "files", matches.slice(0, 10), "moreFiles", matches.length));
2568
+ }
1483
2569
  const options = JSON.stringify({ audioOnly, sort });
1484
2570
  const result = await bridge.send("listDirectory", dirPath, options);
1485
2571
  if (!Array.isArray(result) || result.length === 0) {
@@ -1678,7 +2764,7 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
1678
2764
  "mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
1679
2765
  "halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
1680
2766
  "polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
1681
- "cameraShake, blur";
2767
+ "cameraShake, blur, cymatics, lineJitter, crt, vhs";
1682
2768
  // Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
1683
2769
  const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
1684
2770
  "patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
@@ -1704,12 +2790,24 @@ async function getVideoStateRaw() {
1704
2790
  return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
1705
2791
  }
1706
2792
  const sendVideoCommand = (command) => bridge.send("videoCommand", command);
1707
- /** Create ops are fire-and-forget; resolve the minted id by diffing the snapshot. */
1708
- async function resolveNewId(beforeIds, pick, timeoutMs = 2500) {
2793
+ let videoRequestCounter = 0;
2794
+ const mintVideoRequestId = () => `mcp-${process.pid}-${Date.now().toString(36)}-${++videoRequestCounter}`;
2795
+ /** Create ops are fire-and-forget. Ack-capable PAM builds record the command's
2796
+ * result in the snapshot as `lastCommandAck` keyed by our requestId — exact
2797
+ * under concurrent edits. Older builds never publish an ack, so the snapshot
2798
+ * id-set diff stays as the fallback. */
2799
+ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 2500) {
1709
2800
  const deadline = Date.now() + timeoutMs;
1710
2801
  while (Date.now() < deadline) {
1711
2802
  await sleep(120);
1712
2803
  const s = await getVideoStateRaw();
2804
+ const ack = s.lastCommandAck;
2805
+ if (ack && ack.requestId === requestId) {
2806
+ const minted = ack.result?.[ackIdKey];
2807
+ if (typeof minted === "string" && minted.length > 0)
2808
+ return minted;
2809
+ return null; // acked but failed / no id — don't misattribute via the diff
2810
+ }
1713
2811
  const found = pick(s).find((id) => !beforeIds.has(id));
1714
2812
  if (found)
1715
2813
  return found;
@@ -1720,9 +2818,14 @@ server.registerTool("get_video_state", {
1720
2818
  title: "Get Video State",
1721
2819
  description: "Read the full VX video scene: layers (in render order, top-first) with their " +
1722
2820
  "per-layer FX chains, the output/global FX chain, audio-reactive bindings, " +
1723
- "mod-matrix routes, the 3D model overlay, the active cell, and global render/sync " +
1724
- "settings. Layer/FX/binding ids returned here are what the other video tools " +
1725
- "address. If `available` is false, open PAM's window so the video UI mounts.",
2821
+ "mod-matrix routes, the 3D model overlay, and global render/sync settings " +
2822
+ "(incl. vxVariationFollowAudio + VX bar-sync). Also carries: vxVariations " +
2823
+ "(8-slot occupancy booleans) + currentVxVariation; the songMode arrangement " +
2824
+ "read-model (nodes with per-section VX slot assignments, compiled steps with " +
2825
+ "the VX slot each recalls, arrangementBars); and the poll targets for " +
2826
+ "long-running jobs — export (exportVideo), recording (start/stop/saveVideoRecording), " +
2827
+ "songExport (exportSong). Layer/FX/binding ids returned here are what the other " +
2828
+ "video tools address. If `available` is false, open PAM's window so the video UI mounts.",
1726
2829
  inputSchema: {},
1727
2830
  annotations: { readOnlyHint: true },
1728
2831
  }, async () => {
@@ -1803,7 +2906,16 @@ server.registerTool("video_command", {
1803
2906
  "setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
1804
2907
  "addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
1805
2908
  "setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
1806
- "resetModel; setSettings{...}, setActiveCell{cellId}, resetWorkspace. " +
2909
+ "resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
2910
+ "setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
2911
+ "VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
2912
+ "fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
2913
+ "export/record: exportVideo{durationInSeconds,encoderId?,filename?,fps?,width?,height?,bitrate?,audioBitDepth?}, " +
2914
+ "cancelVideoExport, startVideoRecording{encoderId?,saveMode?,fps?,barSync?,bars?,skipSilence?,normalize?}, " +
2915
+ "stopVideoRecording, saveVideoRecording (poll get_video_state export/recording); " +
2916
+ "song: setSongArrangement{sections:[{variationIndex,bars?,repeat?,muted?,probability?,presetPath?,label?,vxVariationIndex?,vxScenePath?}],connections?:[{from,to,probability?}],loop?,enabled?,crossfadeMs?}, " +
2917
+ "setSongNodeVx{nodeId|stepIndex,index|null,vxScenePath?}, exportSong{name?,bars?,normalize?,includeVideo?,encoderId?,strategy?}, " +
2918
+ "cancelSongExport (poll get_video_state songExport/songMode; or use the song_mode / export_song tools). " +
1807
2919
  "Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
1808
2920
  VIDEO_BIND_SOURCES,
1809
2921
  inputSchema: {
@@ -1834,15 +2946,25 @@ server.registerTool("add_video_layer", {
1834
2946
  .int()
1835
2947
  .optional()
1836
2948
  .describe("Data-index to insert at (default: top of stack)."),
2949
+ sourcePath: z
2950
+ .string()
2951
+ .optional()
2952
+ .describe("Absolute file path to image/video/model media to load into the new layer (no file picker). " +
2953
+ "Bytes are read natively and handed to the WebView as a blob. Large videos are bounded by maxBytes."),
2954
+ maxBytes: z
2955
+ .number()
2956
+ .optional()
2957
+ .describe("Max bytes to read for sourcePath (default ~16MB; raise for video, e.g. 50000000)."),
1837
2958
  },
1838
- }, async ({ kind, name, insertAt }) => {
2959
+ }, async ({ kind, name, insertAt, sourcePath, maxBytes }) => {
1839
2960
  const check = await requireBridge();
1840
2961
  if (check !== true)
1841
2962
  return check;
1842
2963
  const before = await getVideoStateRaw();
1843
2964
  const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
1844
- await sendVideoCommand({ op: "addLayer", kind, name, insertAt });
1845
- const id = await resolveNewId(beforeIds, (s) => (s.layers ?? []).map((l) => l.id));
2965
+ const requestId = mintVideoRequestId();
2966
+ await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
2967
+ const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
1846
2968
  return text(id
1847
2969
  ? `Added ${kind} layer: ${id}`
1848
2970
  : `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
@@ -1890,6 +3012,23 @@ server.registerTool("update_video_layer", {
1890
3012
  const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
1891
3013
  return json(result);
1892
3014
  });
3015
+ server.registerTool("set_video_layer_source", {
3016
+ title: "Set Video Layer Source",
3017
+ description: "Load image/video/model media into an existing VX layer from a file path — the headless " +
3018
+ "equivalent of the file picker. Reads bytes natively and assigns a blob the layer renders. " +
3019
+ "PAM's window must be open (VX renders in the WebView). Large videos: raise maxBytes.",
3020
+ inputSchema: {
3021
+ layerId: z.string().describe("Target layer id (from get_video_state / add_video_layer)."),
3022
+ sourcePath: z.string().describe("Absolute path to the media file."),
3023
+ maxBytes: z.number().optional().describe("Max bytes to read (default ~16MB; raise for video)."),
3024
+ },
3025
+ }, async ({ layerId, sourcePath, maxBytes }) => {
3026
+ const check = await requireBridge();
3027
+ if (check !== true)
3028
+ return check;
3029
+ await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath, maxBytes });
3030
+ return { content: [{ type: "text", text: `Loading ${sourcePath} into layer ${layerId}.` }] };
3031
+ });
1893
3032
  server.registerTool("remove_video_layer", {
1894
3033
  title: "Remove Video Layer",
1895
3034
  description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
@@ -1946,8 +3085,9 @@ server.registerTool("add_video_fx", {
1946
3085
  if (target === "output") {
1947
3086
  const before = await getVideoStateRaw();
1948
3087
  const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
1949
- await sendVideoCommand({ op: "addOutputFx", fxId: fxType });
1950
- const id = await resolveNewId(beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId));
3088
+ const requestId = mintVideoRequestId();
3089
+ await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
3090
+ const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
1951
3091
  // Apply param overrides now that the instance exists.
1952
3092
  if (params && id)
1953
3093
  for (const [param, value] of Object.entries(params))
@@ -1959,11 +3099,12 @@ server.registerTool("add_video_fx", {
1959
3099
  const before = await getVideoStateRaw();
1960
3100
  const layer = (before.layers ?? []).find((l) => l.id === layerId);
1961
3101
  const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
1962
- await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId });
1963
- const id = await resolveNewId(beforeIds, (s) => {
3102
+ const requestId = mintVideoRequestId();
3103
+ await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
3104
+ const id = await resolveNewId(requestId, beforeIds, (s) => {
1964
3105
  const l = (s.layers ?? []).find((x) => x.id === layerId);
1965
3106
  return (l?.fx ?? []).map((f) => f.id);
1966
- });
3107
+ }, "fxId");
1967
3108
  return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
1968
3109
  });
1969
3110
  server.registerTool("set_video_fx", {
@@ -2087,8 +3228,9 @@ server.registerTool("set_video_settings", {
2087
3228
  title: "Set Video Settings",
2088
3229
  description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
2089
3230
  "aspectRatio (auto|16:9|4:3|1:1|stretch), renderQuality (low|medium|high), " +
2090
- "designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the active " +
2091
- "video cell (cellId), and the mod-matrix master enable. resetWorkspace clears the " +
3231
+ "designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the " +
3232
+ "mod-matrix master enable, VX-variation follow-audio, and VX bar-sync (queue slot " +
3233
+ "loads on the next bar boundary while playing). resetWorkspace clears the " +
2092
3234
  "scene (preserves the master layer).",
2093
3235
  inputSchema: {
2094
3236
  syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
@@ -2099,10 +3241,22 @@ server.registerTool("set_video_settings", {
2099
3241
  letterboxColor: z.string().optional(),
2100
3242
  canvasColor: z.string().optional(),
2101
3243
  modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
2102
- activeCell: z.number().int().optional().describe("Switch the active per-cell video slice."),
3244
+ vxVariationFollowAudio: z
3245
+ .boolean()
3246
+ .optional()
3247
+ .describe("VX variation slots follow audio variation switches (default true)."),
3248
+ vxBarSyncEnabled: z
3249
+ .boolean()
3250
+ .optional()
3251
+ .describe("Queue VX-local slot loads on the next bar-sync boundary while the transport plays."),
3252
+ vxBarSyncIntervalBars: z
3253
+ .number()
3254
+ .optional()
3255
+ .describe("VX bar-sync interval in bars (-1 = longest-pattern sentinel)."),
3256
+ activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
2103
3257
  resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
2104
3258
  },
2105
- }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, activeCell, resetWorkspace }) => {
3259
+ }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
2106
3260
  const check = await requireBridge();
2107
3261
  if (check !== true)
2108
3262
  return check;
@@ -2127,10 +3281,75 @@ server.registerTool("set_video_settings", {
2127
3281
  settings.letterboxColor = letterboxColor;
2128
3282
  if (canvasColor !== undefined)
2129
3283
  settings.canvasColor = canvasColor;
3284
+ if (vxVariationFollowAudio !== undefined)
3285
+ settings.vxVariationFollowAudio = vxVariationFollowAudio;
3286
+ if (vxBarSyncEnabled !== undefined)
3287
+ settings.vxBarSyncEnabled = vxBarSyncEnabled;
3288
+ if (vxBarSyncIntervalBars !== undefined)
3289
+ settings.vxBarSyncIntervalBars = vxBarSyncIntervalBars;
2130
3290
  if (Object.keys(settings).length > 0)
2131
3291
  await sendVideoCommand({ op: "setSettings", ...settings });
2132
3292
  return text("Video settings updated.");
2133
3293
  });
3294
+ server.registerTool("vx_variation", {
3295
+ title: "VX Variation Slots",
3296
+ description: "Manage the 8 VX (visual) variation slots — the visual siblings of the audio " +
3297
+ "variation slots (save_variation / load_variation). 'save' snapshots the live VX " +
3298
+ "scene into a slot; 'load' recalls one (an empty slot moves the index but keeps the " +
3299
+ "current visuals); 'clear' empties a slot; 'fill' saves 8 randomized scene variations " +
3300
+ "into all slots. With vxVariationFollowAudio on (default), audio variation switches — " +
3301
+ "including Song Mode section changes — recall the matching VX slot automatically, so " +
3302
+ "saving VX slots alongside audio slots gives every song section its own visuals " +
3303
+ "(pin a different slot per section via song_mode assignVx). WebView-routed: PAM's " +
3304
+ "window must be OPEN. Verify via get_video_state → vxVariations / currentVxVariation.",
3305
+ inputSchema: {
3306
+ op: z.enum(["save", "load", "clear", "fill"]).describe("Slot operation."),
3307
+ index: z
3308
+ .number()
3309
+ .int()
3310
+ .min(0)
3311
+ .max(7)
3312
+ .optional()
3313
+ .describe("Slot index 0-7 (required for save/load/clear; ignored for fill)."),
3314
+ },
3315
+ }, async ({ op, index }) => {
3316
+ const check = await requireBridge();
3317
+ if (check !== true)
3318
+ return check;
3319
+ if (op !== "fill" && typeof index !== "number")
3320
+ return error(`'${op}' requires index (0-7).`);
3321
+ const opMap = {
3322
+ save: "saveVxVariation",
3323
+ load: "loadVxVariation",
3324
+ clear: "clearVxVariation",
3325
+ fill: "fillVxVariationsRandomized",
3326
+ };
3327
+ await sendVideoCommand(op === "fill" ? { op: opMap.fill } : { op: opMap[op], index });
3328
+ // Fire-and-forget forward path — poll until the snapshot reflects the op
3329
+ // (save→occupied, clear→empty, load→index moved [sticky-empty rule], fill→all occupied).
3330
+ const expectMet = (s) => {
3331
+ const slots = Array.isArray(s.vxVariations) ? s.vxVariations : [];
3332
+ if (op === "save")
3333
+ return slots[index] === true;
3334
+ if (op === "clear")
3335
+ return slots[index] === false;
3336
+ if (op === "load")
3337
+ return s.currentVxVariation === index;
3338
+ return slots.length > 0 && slots.every(Boolean);
3339
+ };
3340
+ let state = await getVideoStateRaw();
3341
+ const deadline = Date.now() + 2500;
3342
+ while (state.available !== false && !expectMet(state) && Date.now() < deadline) {
3343
+ await sleep(150);
3344
+ state = await getVideoStateRaw();
3345
+ }
3346
+ if (state.available === false)
3347
+ return error("VX unavailable — open PAM's window (the video engine renders in the WebView).");
3348
+ const slots = Array.isArray(state.vxVariations) ? state.vxVariations : [];
3349
+ return text(`vx_variation ${op}${op === "fill" ? "" : ` slot ${index}`} ${expectMet(state) ? "applied" : "sent (state not yet reflecting it — window open? rejected op?)"}. ` +
3350
+ `Occupied slots: [${slots.map((o, i) => (o ? i : null)).filter((v) => v !== null).join(", ")}], ` +
3351
+ `current: ${String(state.currentVxVariation ?? "?")}.`);
3352
+ });
2134
3353
  // ============================================================
2135
3354
  // Composite cell-operation tools
2136
3355
  // ============================================================
@@ -2152,6 +3371,10 @@ server.registerTool("reset_cell", {
2152
3371
  const check = await requireBridge();
2153
3372
  if (check !== true)
2154
3373
  return check;
3374
+ const manifest = await loadManifestForProductGuard();
3375
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3376
+ if (cellCheck !== true)
3377
+ return cellCheck;
2155
3378
  const [cellParams, envelopes] = await Promise.all([
2156
3379
  getParametersByCell(`cell${cellId}`),
2157
3380
  bridge.send("getState", "envelopes"),
@@ -2185,6 +3408,13 @@ server.registerTool("copy_cell", {
2185
3408
  const check = await requireBridge();
2186
3409
  if (check !== true)
2187
3410
  return check;
3411
+ const manifest = await loadManifestForProductGuard();
3412
+ const sourceCheck = validateCellIdForManifest(sourceCellId, manifest);
3413
+ if (sourceCheck !== true)
3414
+ return sourceCheck;
3415
+ const targetCheck = validateCellIdForManifest(targetCellId, manifest);
3416
+ if (targetCheck !== true)
3417
+ return targetCheck;
2188
3418
  // Full state needed: cell params live as flat top-level cellN_* keys, no section.
2189
3419
  const state = (await bridge.send("getState"));
2190
3420
  const srcPrefix = `cell${sourceCellId}_`;
@@ -2285,6 +3515,10 @@ server.registerTool("clear_modulations", {
2285
3515
  const check = await requireBridge();
2286
3516
  if (check !== true)
2287
3517
  return check;
3518
+ const manifest = await loadManifestForProductGuard();
3519
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3520
+ if (cellCheck !== true)
3521
+ return cellCheck;
2288
3522
  const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
2289
3523
  bridge.send("getState", "lfos"),
2290
3524
  bridge.send("getState", "vary"),
@@ -2317,7 +3551,7 @@ server.registerTool("clear_modulations", {
2317
3551
  });
2318
3552
  server.registerTool("reset_patterns", {
2319
3553
  title: "Reset Patterns",
2320
- description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single cell.",
3554
+ description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
2321
3555
  inputSchema: {
2322
3556
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2323
3557
  },
@@ -2326,6 +3560,15 @@ server.registerTool("reset_patterns", {
2326
3560
  const check = await requireBridge();
2327
3561
  if (check !== true)
2328
3562
  return check;
3563
+ const manifest = await loadManifestForProductGuard();
3564
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3565
+ if (cellCheck !== true)
3566
+ return cellCheck;
3567
+ const hasPatternLanes = (manifest?.stateVariables ?? []).some((entry) => entry.paramId === `cell${cellId}_midiSequence`);
3568
+ if (manifest && !hasPatternLanes) {
3569
+ const productName = manifest.name ?? "This product";
3570
+ return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
3571
+ }
2329
3572
  const delta = { __forceRender__: true };
2330
3573
  for (const suffix of PATTERN_SUFFIXES) {
2331
3574
  delta[`cell${cellId}_${suffix}`] = [];
@@ -2352,6 +3595,10 @@ server.registerTool("update_effect_config", {
2352
3595
  const check = await requireBridge();
2353
3596
  if (check !== true)
2354
3597
  return check;
3598
+ const manifest = await loadManifestForProductGuard();
3599
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3600
+ if (cellCheck !== true)
3601
+ return cellCheck;
2355
3602
  const delta = {};
2356
3603
  for (const [key, value] of Object.entries(config)) {
2357
3604
  if (value !== undefined)
@@ -2370,7 +3617,8 @@ server.registerTool("update_cell_config", {
2370
3617
  title: "Update Cell Config",
2371
3618
  description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
2372
3619
  "These are KV-only properties that don't appear in the parameter manifest. " +
2373
- "Uses a lightweight KV path without graph rebuild.",
3620
+ "Uses a lightweight KV path without graph rebuild. " +
3621
+ "When bound to MFX, this tool only accepts sidechainFrom; sampler/MIDI/instrument config is PAM/Cell1-only.",
2374
3622
  inputSchema: {
2375
3623
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2376
3624
  isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
@@ -2380,7 +3628,18 @@ server.registerTool("update_cell_config", {
2380
3628
  modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
2381
3629
  varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
2382
3630
  armed: z.union([z.literal(0), z.literal(1)]).optional().describe("Whether cell accepts recorded MIDI."),
2383
- polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation."),
3631
+ polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation. In Slice Keymap mode this is the key that triggers slice 1."),
3632
+ sliceKeymapMode: z
3633
+ .union([z.literal(0), z.literal(1)])
3634
+ .optional()
3635
+ .describe("Slice Keymap mode: incoming MIDI notes select slices by index instead of pitch (polyRootNote -> slice 1, +1 semitone -> slice 2, ...), each at its native pitch. Works in poly (polyphonic slice play) and mono (each new note chokes the previous slice). Requires the cell's sample to be sliced; notes outside the slice range are silent. The slice lane is ignored while this is on. 0=off, 1=on."),
3636
+ sliceTriggerLength: z
3637
+ .number()
3638
+ .int()
3639
+ .min(0)
3640
+ .max(2)
3641
+ .optional()
3642
+ .describe("Slice playback length when sliceKeymapMode=1: 0=Boundary (stop at the next slice edge), 1=Sample End (play to end of sample), 2=Note Off (sustain until key release; requires the cell to be in Gate mode)."),
2384
3643
  scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
2385
3644
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
2386
3645
  pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
@@ -2401,6 +3660,10 @@ server.registerTool("update_cell_config", {
2401
3660
  const check = await requireBridge();
2402
3661
  if (check !== true)
2403
3662
  return check;
3663
+ const manifest = await loadManifestForProductGuard();
3664
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3665
+ if (cellCheck !== true)
3666
+ return cellCheck;
2404
3667
  const cellConfig = {};
2405
3668
  const flatDelta = {};
2406
3669
  for (const [key, value] of Object.entries(config)) {
@@ -2410,11 +3673,23 @@ server.registerTool("update_cell_config", {
2410
3673
  if (Object.keys(cellConfig).length === 0) {
2411
3674
  return error("At least one config property must be provided.");
2412
3675
  }
3676
+ if (manifest && isMfxManifest(manifest)) {
3677
+ const unsupported = Object.keys(cellConfig).filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
3678
+ if (unsupported.length > 0) {
3679
+ return error("update_cell_config in MFX only accepts sidechainFrom. " +
3680
+ `Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
3681
+ }
3682
+ }
2413
3683
  // Auto-enable compressor when setting sidechain source
2414
3684
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
2415
3685
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
2416
3686
  }
2417
- if (cellConfig.pitchMode !== undefined || cellConfig.stretchMode !== undefined) {
3687
+ // Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
3688
+ // sliceKeymapMode flip (which changes pitch interpretation) bumps
3689
+ // modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
3690
+ if (cellConfig.pitchMode !== undefined ||
3691
+ cellConfig.stretchMode !== undefined ||
3692
+ cellConfig.sliceKeymapMode !== undefined) {
2418
3693
  const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
2419
3694
  cellModeChangeSeq[cellId] = next;
2420
3695
  cellConfig.modeChangeSeq = next;
@@ -2426,11 +3701,50 @@ server.registerTool("update_cell_config", {
2426
3701
  .join(", ");
2427
3702
  return text(`Updated cell ${cellId} config: ${summary}`);
2428
3703
  });
3704
+ server.registerTool("set_cell_mute", {
3705
+ title: "Mute / Solo Cell",
3706
+ description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
3707
+ "(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
3708
+ "non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
3709
+ "Prefer this over set_parameter volume -60 when the user says mute, unmute, solo, unsolo, isolate, or 'just this cell'.",
3710
+ inputSchema: {
3711
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3712
+ mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
3713
+ solo: z.boolean().optional().describe("Solo (true) or unsolo (false) the cell — solo silences all other non-soloed cells."),
3714
+ },
3715
+ }, async ({ cellId, mute, solo }) => {
3716
+ const check = await requireBridge();
3717
+ if (check !== true)
3718
+ return check;
3719
+ const manifest = await loadManifestForProductGuard();
3720
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3721
+ if (cellCheck !== true)
3722
+ return cellCheck;
3723
+ const delta = {};
3724
+ if (typeof mute === "boolean")
3725
+ delta[`cell${cellId}_mute`] = mute ? 1 : 0;
3726
+ if (typeof solo === "boolean")
3727
+ delta[`cell${cellId}_solo`] = solo ? 1 : 0;
3728
+ if (Object.keys(delta).length === 0) {
3729
+ return error("Provide mute and/or solo.");
3730
+ }
3731
+ // cell{N}_mute / cell{N}_solo are flat state vars projected to
3732
+ // cell{N}_config.enabled by KVStateSyncManager — same options the UI's
3733
+ // applyStateDelta uses (KV_OPTS_STANDARD); the projection needs
3734
+ // syncConfigResources, no graph rebuild required but the standard path is
3735
+ // the exact UI parity.
3736
+ await applyKv(delta, KV_OPTS_STANDARD);
3737
+ const summary = Object.entries(delta)
3738
+ .map(([k, v]) => `${k}=${v}`)
3739
+ .join(", ");
3740
+ return text(`Cell ${cellId}: ${summary}`);
3741
+ });
2429
3742
  server.registerTool("set_master_effects", {
2430
3743
  title: "Set Master Effects",
2431
3744
  description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
2432
3745
  "The Punch compressor is a musical bus compressor — use it for glue and pump. " +
2433
- "The Limiter is a 3-stage brick-wall limiter with tanh saturation. " +
3746
+ "The Limiter is a zero-latency brickwall safety stage (enable/disable only). " +
3747
+ "Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
2434
3748
  "The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
2435
3749
  "the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
2436
3750
  "Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
@@ -2438,7 +3752,7 @@ server.registerTool("set_master_effects", {
2438
3752
  "medium release (80-200ms), ratio 4-8, autoMakeup true. " +
2439
3753
  "For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
2440
3754
  "masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
2441
- "NOTE: the Tape requires a PAM build that ships it (manifest key masterTapeEnabled). On older builds these keys are ignored.",
3755
+ "NOTE: the Tape requires a PAM build that ships it (flat state key masterTapeEnabled — master-chain keys are not manifest params). On older builds these keys are ignored.",
2442
3756
  inputSchema: {
2443
3757
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
2444
3758
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
@@ -2453,13 +3767,46 @@ server.registerTool("set_master_effects", {
2453
3767
  masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
2454
3768
  masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
2455
3769
  masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
3770
+ masterCompOutTrim: z.number().min(-12).max(12).optional().describe("Output trim in dB (default 0) — the Punch panel's Makeup knob (post-compression level, separate from masterCompMakeupGain)."),
3771
+ masterCompMix: z.number().min(0).max(1).optional().describe("Punch parallel dry/wet (0-1, default 1 = fully compressed)."),
2456
3772
  masterTapeDrive: z.number().min(0).max(1).optional().describe("Tape drive / saturation amount (0-1, default 0.5). Higher = more harmonic grit + HF compression ('push it harder → it gets duller')."),
2457
3773
  masterTapeTone: z.number().min(-1).max(1).optional().describe("Tape tone tilt (-1 dark .. 0 flat .. +1 bright, default 0). Go negative for dusty/lo-fi."),
2458
3774
  masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
2459
- masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive). Default 0."),
3775
+ masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive), 3=Studio (Jiles-Atherton magnetic hysteresis — the deep hi-fi machine, no wow/hiss). Default 0."),
2460
3776
  masterTapeWear: z.number().min(0).max(1).optional().describe("Tape wear: wow/flutter pitch drift + age (0-1, default 0.5). Raise for warble/sag."),
3777
+ masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
3778
+ masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
3779
+ masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
3780
+ masterEqTilt: z.number().min(-6).max(6).optional().describe("Tilt in dB at the extremes around a 650 Hz pivot (+ = brighter; the coarse tonal-balance lever)."),
3781
+ masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
3782
+ masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
3783
+ masterEqHsFreq: z.number().min(1000).max(16000).optional().describe("High-shelf corner Hz (default 8000)."),
3784
+ masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
3785
+ masterWidth: z.number().min(0).max(2).optional().describe("Stereo width (0=mono, 1=unity, 2=double side; default 1). Check corr in get_master_analysis after widening."),
3786
+ masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
3787
+ masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Adds 23 samples of reported latency while on."),
3788
+ masterClipDrive: z.number().min(0).max(12).optional().describe("Clipper drive dB (default 0). Staging guidance: the clipper shaves the sharpest 2-3 dB so the limiter works 1-2 dB."),
3789
+ masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
3790
+ masterClipMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Clipper transfer: 0=Smooth (knee-tanh), 1=Silicon diode (hard knee), 2=Germanium diode (earlier, softer knee), 3=LED (higher knee). Diode modes are exact antiparallel-pair solves, ADAA at the 2x rate. Default 0."),
3791
+ masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
3792
+ masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
3793
+ masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
3794
+ masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
3795
+ masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
3796
+ masterEqBells: z
3797
+ .array(z.object({
3798
+ freq: z.number().min(30).max(16000).optional(),
3799
+ gain: z.number().min(-12).max(12).optional(),
3800
+ q: z.number().min(0.3).max(8).optional(),
3801
+ dyn: z.union([z.literal(0), z.literal(1)]).optional().describe("Dynamic option: the band's gain rides the in-band level."),
3802
+ dynThresh: z.number().min(-60).max(0).optional().describe("Dynamic threshold dB (default -24)."),
3803
+ dynRange: z.number().min(-12).max(12).optional().describe("Dynamic range dB (negative ducks when loud — de-harsh; default -3)."),
3804
+ }))
3805
+ .max(4)
3806
+ .optional()
3807
+ .describe("The 4 parametric bells in order (defaults 200/700/2000/5000 Hz). Pass null-ish entries to skip a band."),
2461
3808
  },
2462
- }, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
3809
+ }, async ({ compressorEnabled, limiterEnabled, tapeEnabled, masterEqBells, ...effectParams }) => {
2463
3810
  const check = await requireBridge();
2464
3811
  if (check !== true)
2465
3812
  return check;
@@ -2471,8 +3818,28 @@ server.registerTool("set_master_effects", {
2471
3818
  delta.masterLimiterEnabled = limiterEnabled;
2472
3819
  if (tapeEnabled !== undefined)
2473
3820
  delta.masterTapeEnabled = tapeEnabled;
2474
- // Compressor + tape parameters are already the flat state keys read by the
2475
- // DSP graph / native master node (masterComp*, masterTape*). Pass through.
3821
+ // Bell array → flat masterEqB{n}{Field} keys.
3822
+ if (masterEqBells) {
3823
+ masterEqBells.forEach((bell, i) => {
3824
+ if (!bell)
3825
+ return;
3826
+ const base = `masterEqB${i + 1}`;
3827
+ if (bell.freq !== undefined)
3828
+ delta[`${base}Freq`] = bell.freq;
3829
+ if (bell.gain !== undefined)
3830
+ delta[`${base}Gain`] = bell.gain;
3831
+ if (bell.q !== undefined)
3832
+ delta[`${base}Q`] = bell.q;
3833
+ if (bell.dyn !== undefined)
3834
+ delta[`${base}Dyn`] = bell.dyn;
3835
+ if (bell.dynThresh !== undefined)
3836
+ delta[`${base}DynThresh`] = bell.dynThresh;
3837
+ if (bell.dynRange !== undefined)
3838
+ delta[`${base}DynRange`] = bell.dynRange;
3839
+ });
3840
+ }
3841
+ // Compressor + tape + limiter + EQ parameters are already the flat state
3842
+ // keys read by the DSP graph / native master nodes. Pass through.
2476
3843
  for (const [key, value] of Object.entries(effectParams)) {
2477
3844
  if (value !== undefined)
2478
3845
  delta[key] = value;
@@ -2480,8 +3847,10 @@ server.registerTool("set_master_effects", {
2480
3847
  if (Object.keys(delta).length === 0) {
2481
3848
  return error("At least one parameter must be provided.");
2482
3849
  }
2483
- // Use standard opts with graph dispatch so the DSP graph picks up new compressor params
2484
- await applyKv(delta, KV_OPTS_STANDARD);
3850
+ // No-dispatch path: every master key is live-polled from master_config
3851
+ // (the full Punch surface included), so a graph rebuild here would only
3852
+ // risk dropouts during automated mastering.
3853
+ await applyKv(delta, KV_OPTS_MASTER_CONFIG);
2485
3854
  const summary = Object.entries(delta)
2486
3855
  .map(([k, v]) => `${k}=${v}`)
2487
3856
  .join(", ");
@@ -2520,7 +3889,7 @@ server.registerTool("set_drum_synth", {
2520
3889
  "only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
2521
3890
  DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
2522
3891
  "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
2523
- "On builds without it, the cell{N}_synthEngine state key is ignored.",
3892
+ "On products without it, this tool returns a product error instead of writing inert synth keys.",
2524
3893
  inputSchema: {
2525
3894
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2526
3895
  engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
@@ -2538,6 +3907,13 @@ server.registerTool("set_drum_synth", {
2538
3907
  const check = await requireBridge();
2539
3908
  if (check !== true)
2540
3909
  return check;
3910
+ const manifest = await loadManifestForProductGuard();
3911
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3912
+ if (cellCheck !== true)
3913
+ return cellCheck;
3914
+ if (manifest && manifest.drumSynth?.enabled !== true) {
3915
+ return error("set_drum_synth is PAM-only. This product manifest does not expose the built-in drum synth.");
3916
+ }
2541
3917
  const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
2542
3918
  if (engineId < 0)
2543
3919
  return error(`Unknown drum synth engine: ${engine}`);
@@ -2564,6 +3940,44 @@ server.registerTool("set_drum_synth", {
2564
3940
  const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
2565
3941
  return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
2566
3942
  });
3943
+ server.registerTool("set_drum_synth_wave", {
3944
+ title: "Set Drum Synth Wave Curve (Wave engine)",
3945
+ description: "Set the custom single-cycle waveform for a cell's drum-synth WAVE engine (engine #16). " +
3946
+ "Provide 2–16 breakpoints { t, v, tension } (t = phase 0..1, v = amplitude 0..1 with 0.5 the zero-crossing " +
3947
+ "centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
3948
+ "that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
3949
+ "select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
3950
+ "is single-sourced there), so PAM's window must be OPEN. Needs PAM ≥ 1.13.0.",
3951
+ inputSchema: {
3952
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3953
+ points: z
3954
+ .array(z.object({
3955
+ t: z.number().min(0).max(1).describe("Phase position 0..1 (left→right)."),
3956
+ v: z.number().min(0).max(1).describe("Amplitude 0..1 (0.5 = zero-crossing centre)."),
3957
+ tension: z.number().min(-1).max(1).default(0).describe("Segment curve −1..+1 (0 = linear)."),
3958
+ }))
3959
+ .min(2)
3960
+ .max(16)
3961
+ .describe("2–16 breakpoints defining the single cycle, ordered left→right by t."),
3962
+ },
3963
+ }, async ({ cellId, points }) => {
3964
+ const check = await requireBridge();
3965
+ if (check !== true)
3966
+ return check;
3967
+ const manifest = await loadManifestForProductGuard();
3968
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3969
+ if (cellCheck !== true)
3970
+ return cellCheck;
3971
+ if (manifest && manifest.drumSynth?.enabled !== true) {
3972
+ return error("set_drum_synth_wave is PAM-only. This product manifest does not expose the built-in drum synth.");
3973
+ }
3974
+ await bridge.send("setDrumSynthWaveCurve", {
3975
+ cellId,
3976
+ points: points.map((p) => ({ t: p.t, v: p.v, tension: p.tension ?? 0 })),
3977
+ });
3978
+ return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
3979
+ `Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
3980
+ });
2567
3981
  server.registerTool("add_param_seq", {
2568
3982
  title: "Add Parameter Sequencer",
2569
3983
  description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
@@ -2586,8 +4000,11 @@ server.registerTool("add_param_seq", {
2586
4000
  if (check !== true)
2587
4001
  return check;
2588
4002
  const index = await getParameterIndex();
2589
- if (!index.get(paramId))
2590
- return error(`Unknown parameter: ${paramId}`);
4003
+ const param = index.get(paramId);
4004
+ if (!param)
4005
+ return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
4006
+ if (!param.isModulatable)
4007
+ return error(formatNotModulatableError(paramId, index));
2591
4008
  const existing = await bridge.send("getState", "paramSeqs");
2592
4009
  const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
2593
4010
  const newSeq = {
@@ -2696,11 +4113,30 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
2696
4113
  while (Date.now() - t0 < maxWaitMs) {
2697
4114
  await new Promise((r) => setTimeout(r, pollMs));
2698
4115
  const status = (await bridge.send("getExportStatus"));
2699
- if (status.complete)
4116
+ if (status.complete) {
4117
+ lastExportLoudness = status.loudness ?? null;
2700
4118
  return status.success ? "success" : "failed";
4119
+ }
2701
4120
  }
2702
4121
  return "timeout";
2703
4122
  }
4123
+ // Loudness report of the last completed bounce (set by pollExportStatus).
4124
+ let lastExportLoudness = null;
4125
+ function loudnessReportLine() {
4126
+ const l = lastExportLoudness;
4127
+ if (!l || typeof l.i !== "number")
4128
+ return "";
4129
+ const parts = [`${l.i.toFixed(1)} LUFS-I`];
4130
+ if (typeof l.lra === "number")
4131
+ parts.push(`LRA ${l.lra.toFixed(1)}`);
4132
+ if (typeof l.tp === "number")
4133
+ parts.push(`TP ${l.tp.toFixed(1)} dBTP`);
4134
+ if (typeof l.psr === "number")
4135
+ parts.push(`PSR ${l.psr.toFixed(1)}`);
4136
+ if (typeof l.plr === "number")
4137
+ parts.push(`PLR ${l.plr.toFixed(1)}`);
4138
+ return `\nLoudness: ${parts.join(" · ")}`;
4139
+ }
2704
4140
  server.registerTool("export_audio", {
2705
4141
  title: "Export Audio",
2706
4142
  description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
@@ -2709,9 +4145,9 @@ server.registerTool("export_audio", {
2709
4145
  "Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
2710
4146
  "'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
2711
4147
  "'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
2712
- "NOTE on 'song-mode': the OFFLINE bounce only renders the arrangement's FIRST section's live state — " +
2713
- "variation/preset switches are not applied offline (the message thread is blocked during render). " +
2714
- "For a true full track that captures every section change, use the `export_song` tool (live capture).",
4148
+ "NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
4149
+ "offline song export; older builds render only the first section). Wall-clock sources " +
4150
+ "(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those.",
2715
4151
  inputSchema: {
2716
4152
  bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
2717
4153
  cellId: z
@@ -2756,7 +4192,8 @@ server.registerTool("export_audio", {
2756
4192
  const outcome = await pollExportStatus();
2757
4193
  if (outcome === "success")
2758
4194
  return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
2759
- `Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
4195
+ `Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
4196
+ loudnessReportLine());
2760
4197
  if (outcome === "failed")
2761
4198
  return error("Audio export failed.");
2762
4199
  return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
@@ -2787,6 +4224,7 @@ server.registerTool("export_audio", {
2787
4224
  if (outcome === "success")
2788
4225
  return text(`Export complete (mode '${effectiveMode}').\n` +
2789
4226
  `File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
4227
+ loudnessReportLine() +
2790
4228
  (effectiveMode === "song-mode"
2791
4229
  ? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
2792
4230
  "Use export_song for a full track with section changes."
@@ -2905,6 +4343,24 @@ Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over tim
2905
4343
  - Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
2906
4344
  - Combine with \`prob\` field (0-1) on events for controlled randomness
2907
4345
 
4346
+ ### Slice Keymap — Trigger Slices from MIDI
4347
+ Map incoming MIDI notes directly to slices (Octatrack / Geist style): the cell's Root note
4348
+ plays slice 1, root+1 semitone plays slice 2, and so on chromatically up. The slice
4349
+ sequencer lane above is ignored while this is on — the note picks the slice.
4350
+
4351
+ \`\`\`
4352
+ update_cell_config: cellId=1, sliceKeymapMode=1, polyRootNote=60, sliceTriggerLength=0
4353
+ trigger_midi: cellId=1, note=60 # -> slice 1
4354
+ trigger_midi: cellId=1, note=62 # -> slice 3
4355
+ \`\`\`
4356
+ - Requires the cell's sample to be sliced first. Notes outside the slice range are silent.
4357
+ - \`polyRootNote\` = the key that triggers slice 1.
4358
+ - \`sliceTriggerLength\`: 0=Boundary (stop at next slice edge), 1=Sample End, 2=Note Off (needs cell mode=Gate).
4359
+ - Set \`isPoly=1\` for polyphonic slice play (held notes layer); leave poly off for monophonic
4360
+ jamming (each new note chokes the previous slice). Slice Keymap works in both.
4361
+ - Each slice plays at its own per-slice pitch, so the keymap performs a chopped sample at
4362
+ native rate. Played notes can be recorded into the cell's MIDI sequence and looped back.
4363
+
2908
4364
  ## Sidechain Compression — The Pump
2909
4365
 
2910
4366
  ### Setup via MCP
@@ -3186,6 +4642,86 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
3186
4642
  ],
3187
4643
  }));
3188
4644
  // ============================================================
4645
+ // PROMPTS — guided workflows (client-invokable, e.g. slash
4646
+ // commands in Claude Code). Each stages the same flows the
4647
+ // README and tool descriptions teach, with exact tool names.
4648
+ // ============================================================
4649
+ const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
4650
+ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
4651
+ // completable() tags the schema OBJECT with a symbol, so ordering is delicate:
4652
+ // .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
4653
+ // optionals when DETECTING completable prompt args but not when RESOLVING the
4654
+ // completer at request time — the tag must sit on both the inner type and the
4655
+ // outer optional. Covered by the completion assertion in check-tool-surface's
4656
+ // sibling smoke; revisit when the SDK unifies the two lookups.
4657
+ const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
4658
+ function promptText(text) {
4659
+ return {
4660
+ messages: [
4661
+ { role: "user", content: { type: "text", text } },
4662
+ ],
4663
+ };
4664
+ }
4665
+ server.registerPrompt("build-a-beat", {
4666
+ title: "Build a Beat",
4667
+ description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
4668
+ argsSchema: {
4669
+ genre: genreArg,
4670
+ bpm: z.string().optional().describe("Target tempo in BPM."),
4671
+ },
4672
+ }, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
4673
+ `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
4674
+ `2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
4675
+ `3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
4676
+ `4. start_transport — sequencer output needs the transport rolling.\n` +
4677
+ `5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
4678
+ `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
4679
+ server.registerPrompt("make-a-song", {
4680
+ title: "Make a Song",
4681
+ description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
4682
+ argsSchema: {
4683
+ bars: z.string().optional().describe("Approximate song length in bars."),
4684
+ },
4685
+ }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
4686
+ `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
4687
+ `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
4688
+ `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
4689
+ `4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
4690
+ `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
4691
+ server.registerPrompt("design-a-preset", {
4692
+ title: "Design a Preset",
4693
+ description: "Sound-design workflow from a creative brief to a saved preset.",
4694
+ argsSchema: {
4695
+ brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
4696
+ },
4697
+ }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
4698
+ `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
4699
+ `2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
4700
+ `3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
4701
+ `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
4702
+ `5. Write patterns with update_pattern, then start_transport to audition.\n` +
4703
+ `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
4704
+ server.registerPrompt("mix-and-master", {
4705
+ title: "Mix and Master",
4706
+ description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
4707
+ argsSchema: {},
4708
+ }, () => promptText(`Balance and master the current PAM project:\n\n` +
4709
+ `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
4710
+ `2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
4711
+ `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
4712
+ `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
4713
+ `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
4714
+ server.registerPrompt("troubleshoot-bridge", {
4715
+ title: "Troubleshoot the PAM Bridge",
4716
+ description: "Diagnose why live tools can't reach the plugin.",
4717
+ argsSchema: {},
4718
+ }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
4719
+ `1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
4720
+ `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
4721
+ `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
4722
+ `4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
4723
+ `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
4724
+ // ============================================================
3189
4725
  // TOOLS — Looper (state deltas)
3190
4726
  // ============================================================
3191
4727
  // applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
@@ -3797,11 +5333,27 @@ server.registerTool("update_modulation", {
3797
5333
  // ============================================================
3798
5334
  server.registerTool("song_mode", {
3799
5335
  title: "Song Mode",
3800
- description: "Control PAM's song mode (timeline of variation/preset steps). Ops: 'setSequence', 'clear', " +
3801
- "'toggle', 'setLoopPoints', 'toggleLoop', 'setCrossfadeMs', 'setPlayhead'.",
5336
+ description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
5337
+ "'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
5338
+ "'setCrossfadeMs', 'setPlayhead'.\n\n" +
5339
+ "PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
5340
+ "from a `sections` list, with an optional weighted `connections` graph. The result is " +
5341
+ "visible/editable in the UI, and each section can pin a " +
5342
+ "VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
5343
+ "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
5344
+ "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
5345
+ "from different scenes). 'get' reads the arrangement back (nodes, compiled steps with the " +
5346
+ "VX scene+slot each recalls, total bars); 'assignVx' re-pins one section's VX slot (and " +
5347
+ "optional scene) after authoring.\n\n" +
5348
+ "'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
5349
+ "land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
5350
+ "follow 1:1 by audio slot.",
3802
5351
  inputSchema: {
3803
5352
  op: z
3804
5353
  .enum([
5354
+ "setArrangement",
5355
+ "get",
5356
+ "assignVx",
3805
5357
  "setSequence",
3806
5358
  "clear",
3807
5359
  "toggle",
@@ -3811,6 +5363,62 @@ server.registerTool("song_mode", {
3811
5363
  "setPlayhead",
3812
5364
  ])
3813
5365
  .describe("Operation."),
5366
+ sections: z
5367
+ .array(z.object({
5368
+ variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays."),
5369
+ bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
5370
+ repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
5371
+ muted: z.boolean().optional().describe("Pass through without an audio trigger."),
5372
+ probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
5373
+ presetPath: z.string().optional().describe("Load this preset instead of switching variation."),
5374
+ label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
5375
+ vxVariationIndex: z
5376
+ .number()
5377
+ .int()
5378
+ .min(0)
5379
+ .max(7)
5380
+ .optional()
5381
+ .describe("VX variation slot recalled while this section plays (omit = follow the audio slot)."),
5382
+ vxScenePath: z
5383
+ .string()
5384
+ .optional()
5385
+ .describe("VX scene (.vxpreset path) whose slots vxVariationIndex indexes into — loaded before the " +
5386
+ "section plays, so sections can pull visuals from different scenes. Requires vxVariationIndex; " +
5387
+ "omit = the live scene's slots."),
5388
+ }))
5389
+ .min(1)
5390
+ .optional()
5391
+ .describe("For 'setArrangement': section list. Without `connections`, sections play in order."),
5392
+ connections: z
5393
+ .array(z.object({
5394
+ from: z.number().int().min(0).describe("Source section index."),
5395
+ to: z.number().int().min(0).describe("Destination section index."),
5396
+ probability: z
5397
+ .number()
5398
+ .min(0)
5399
+ .max(1)
5400
+ .optional()
5401
+ .describe("Branch probability (0-1). Sibling routes normalize to 100%."),
5402
+ }))
5403
+ .optional()
5404
+ .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section."),
5405
+ loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
5406
+ nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
5407
+ stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
5408
+ vxIndex: z
5409
+ .number()
5410
+ .int()
5411
+ .min(0)
5412
+ .max(7)
5413
+ .nullable()
5414
+ .optional()
5415
+ .describe("For 'assignVx': VX slot 0-7, or null to clear (follow the audio slot)."),
5416
+ vxScenePath: z
5417
+ .string()
5418
+ .nullable()
5419
+ .optional()
5420
+ .describe("For 'assignVx': pin the scene (.vxpreset path) vxIndex indexes into (requires vxIndex; " +
5421
+ "null/'' clears the pin → the live scene's slots; omit = keep the node's current pin)."),
3814
5422
  steps: z
3815
5423
  .array(z.object({
3816
5424
  variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
@@ -3824,8 +5432,8 @@ server.registerTool("song_mode", {
3824
5432
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
3825
5433
  }))
3826
5434
  .optional()
3827
- .describe("For 'setSequence': step list."),
3828
- enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
5435
+ .describe("For 'setSequence': raw step list (headless path; prefer 'setArrangement')."),
5436
+ enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop' / 'setArrangement': enabled state."),
3829
5437
  start: z
3830
5438
  .number()
3831
5439
  .nullable()
@@ -3849,6 +5457,65 @@ server.registerTool("song_mode", {
3849
5457
  if (check !== true)
3850
5458
  return check;
3851
5459
  switch (args.op) {
5460
+ case "setArrangement": {
5461
+ if (!args.sections || args.sections.length === 0)
5462
+ return error("'setArrangement' requires sections");
5463
+ await sendVideoCommand({
5464
+ op: "setSongArrangement",
5465
+ sections: args.sections,
5466
+ ...(args.connections !== undefined && { connections: args.connections }),
5467
+ ...(args.loop !== undefined && { loop: args.loop }),
5468
+ ...(args.enabled !== undefined && { enabled: args.enabled }),
5469
+ ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
5470
+ });
5471
+ // The forward path is fire-and-forget — poll until the WebView's apply
5472
+ // republishes the snapshot (same pattern as resolveNewId).
5473
+ const deadline = Date.now() + 3000;
5474
+ let sm;
5475
+ for (;;) {
5476
+ const state = await getVideoStateRaw();
5477
+ sm = state.songMode;
5478
+ const landed = sm?.available === true &&
5479
+ Array.isArray(sm.nodes) &&
5480
+ sm.nodes.length === args.sections.length;
5481
+ if (landed || Date.now() > deadline)
5482
+ break;
5483
+ await sleep(150);
5484
+ }
5485
+ if (!sm || sm.available !== true)
5486
+ return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
5487
+ "with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
5488
+ const nodes = Array.isArray(sm.nodes) ? sm.nodes.length : 0;
5489
+ if (nodes !== args.sections.length)
5490
+ return error(`Arrangement did not land within 3s (${nodes} nodes vs ${args.sections.length} sections) — the ` +
5491
+ "command may have been rejected (variationIndex/vxVariationIndex must be 0-7; bars must be greater than 0).");
5492
+ return text(`Song arrangement set: ${nodes} sections, ${Array.isArray(sm.steps) ? sm.steps.length : 0} steps, ` +
5493
+ `${String(sm.arrangementBars)} bars${args.loop ? ", looped" : ""}${args.enabled ? ", song mode ON" : ""}. ` +
5494
+ "Read it back with op 'get'.");
5495
+ }
5496
+ case "get": {
5497
+ const state = await getVideoStateRaw();
5498
+ if (state.available === false)
5499
+ return error("VX/song read-model unavailable — open PAM's window (WebView-routed).");
5500
+ const sm = state.songMode;
5501
+ if (!sm)
5502
+ return error("No songMode read-model in the snapshot — the connected PAM predates it (needs ≥ 1.5.7).");
5503
+ return json(sm);
5504
+ }
5505
+ case "assignVx": {
5506
+ if (args.nodeId === undefined && args.stepIndex === undefined)
5507
+ return error("'assignVx' requires nodeId or stepIndex");
5508
+ await sendVideoCommand({
5509
+ op: "setSongNodeVx",
5510
+ ...(args.nodeId !== undefined && { nodeId: args.nodeId }),
5511
+ ...(args.stepIndex !== undefined && { stepIndex: args.stepIndex }),
5512
+ index: args.vxIndex ?? null,
5513
+ ...(args.vxScenePath !== undefined && { vxScenePath: args.vxScenePath }),
5514
+ });
5515
+ return text(`VX assignment sent (${args.nodeId ?? `step ${args.stepIndex}`} → ${args.vxIndex ?? "follow audio"}` +
5516
+ `${args.vxIndex != null && args.vxScenePath ? ` in ${args.vxScenePath}` : ""}). ` +
5517
+ "Verify with op 'get' (nodes[].vxVariationIndex / vxScenePath).");
5518
+ }
3852
5519
  case "setSequence":
3853
5520
  if (!args.steps)
3854
5521
  return error("'setSequence' requires steps");
@@ -3888,17 +5555,24 @@ server.registerTool("song_mode", {
3888
5555
  });
3889
5556
  server.registerTool("export_song", {
3890
5557
  title: "Export Song (Full Track)",
3891
- description: "Bounce the full Song Mode arrangement to a single WAV — the 'render my whole track' button. " +
3892
- "Set up the arrangement first with the `song_mode` tool (setSequence), then call this.\n\n" +
3893
- "Two strategies:\n" +
3894
- "• 'live' (DEFAULT, full fidelity): plays the arrangement in real time and captures the master mix, " +
3895
- "so EVERY section/variation/preset change and all modulation is rendered. Needs a free `cellId` to " +
3896
- "land the captured clip in (use an empty cell). Capture is capped at 64 bars; for longer songs bounce " +
3897
- "in sections or raise the cap. The captured WAV also lands in PAM's Resamples folder.\n" +
3898
- "• 'offline' (fast, LIMITED): renders via the offline song-mode bounce. Only the first section's state " +
3899
- "is rendered (variation/preset switches are NOT applied offline). Use only for single-section sketches.\n\n" +
3900
- "After a 'live' bounce, the captured file path is read back from the destination cell; run get_audio_info " +
3901
- "(resolve against the Resamples location from get_default_locations) to verify it.",
5558
+ description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
5559
+ "Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
5560
+ "Strategies:\n" +
5561
+ "• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
5562
+ "scene (VX variation follow runs live, so per-section visuals land exactly as assigned) and muxes " +
5563
+ "the master mix into a video file. Needs PAM ≥ 1.5.7 with the window OPEN and at least one VX layer.\n" +
5564
+ "• 'live' without cellId — bar-synced audio WAV via the same WebView Song Export (lands in " +
5565
+ "VX/Resamples; window must be open, PAM ≥ 1.5.7). No destination cell consumed.\n" +
5566
+ "• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
5567
+ "closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
5568
+ "• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
5569
+ "variation section applies sample-accurately on its anchor; renders faster than realtime with no bar cap. " +
5570
+ "Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
5571
+ "recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
5572
+ "and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
5573
+ "Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
5574
+ "Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
5575
+ "from get_default_locations).",
3902
5576
  inputSchema: {
3903
5577
  strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
3904
5578
  bars: z
@@ -3911,41 +5585,113 @@ server.registerTool("export_song", {
3911
5585
  .min(1)
3912
5586
  .max(8)
3913
5587
  .optional()
3914
- .describe("For 'live': empty destination cell the bounced master clip is written into."),
3915
- bitDepth: z.number().default(24).describe("Bit depth (16 or 24)."),
5588
+ .describe("For legacy headless 'live': empty destination cell for the bounced clip. Omit to use the WebView Song Export (window open)."),
5589
+ includeVideo: z
5590
+ .boolean()
5591
+ .default(false)
5592
+ .describe("Film the VX scene alongside the audio and mux to video (window open, VX layers required)."),
5593
+ name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
5594
+ normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
5595
+ encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
5596
+ bitDepth: z.number().default(24).describe("Bit depth (16 or 24; legacy cell-capture + offline paths)."),
3916
5597
  tailBars: z
3917
5598
  .number()
3918
5599
  .min(0)
3919
5600
  .max(8)
3920
5601
  .default(1)
3921
- .describe("For 'live': extra bars captured after the arrangement end for reverb/release tails."),
5602
+ .describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
3922
5603
  },
3923
- }, async ({ strategy, bars, cellId, bitDepth, tailBars }) => {
5604
+ }, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
3924
5605
  const check = await requireBridge();
3925
5606
  if (check !== true)
3926
5607
  return check;
3927
- // -------- Offline strategy: reuse the song-mode bounce (first-section only) --------
3928
- if (strategy === "offline") {
5608
+ // -------- Offline audio-only: headless multi-section bounce --------
5609
+ // (Offline WITH video routes through the WebView Song Export below — the
5610
+ // pump needs the window; strategy rides the op.)
5611
+ if (strategy === "offline" && !includeVideo) {
3929
5612
  const filename = `pam_song_${Date.now()}`;
3930
5613
  const transport = (await bridge.send("getTransport"));
3931
5614
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
3932
5615
  await bridge.send("exportAudio", {
3933
5616
  filename,
3934
5617
  mode: "song-mode",
5618
+ // Estimate only — exportSongMode computes the real length from the arrangement.
3935
5619
  durationInSeconds: (bars * 4 * 60) / bpm,
3936
5620
  bitDepth,
3937
5621
  });
3938
5622
  const outcome = await pollExportStatus();
3939
5623
  if (outcome === "success")
3940
- return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder).\n` +
3941
- "Note: only the first section's state was rendered. Use strategy 'live' for the full track.");
5624
+ return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder) — every variation section ` +
5625
+ "applied on its anchor.\n" +
5626
+ "Caveats: preset-path steps hold the previous section (pending); Magenta/Notochord render silent " +
5627
+ "offline (use strategy 'live' for generative sources). Verify with get_audio_info.");
3942
5628
  if (outcome === "failed")
3943
- return error("Offline song bounce failed. Is Song Mode enabled with a non-empty sequence?");
5629
+ return error("Offline song bounce failed (or was cancelled). Is Song Mode enabled with a non-empty sequence?");
3944
5630
  return text("Offline song bounce timed out. The render may still be running.");
3945
5631
  }
3946
- // -------- Live strategy: real-time master capture of the whole arrangement --------
3947
- if (typeof cellId !== "number")
3948
- return error("'live' strategy requires an empty destination cellId.");
5632
+ // -------- VX-capture path (WebView Song Export): audio WAV or audio+video --------
5633
+ // Chosen when the caller wants video, or asked for 'live' without a destination
5634
+ // cell — runSongExport bar-syncs the capture, runs VX variation follow live, and
5635
+ // needs no free cell. Fire-and-forget op + snapshot polling (songExport status).
5636
+ if (includeVideo || typeof cellId !== "number") {
5637
+ const pre = await getVideoStateRaw();
5638
+ if (pre.available === false)
5639
+ return error(includeVideo
5640
+ ? "includeVideo needs PAM's window OPEN (the VX scene renders in the WebView)."
5641
+ : "'live' without cellId uses the WebView Song Export (window must be OPEN). Open the window, or pass an empty destination cellId for the headless cell-capture path.");
5642
+ if (!("songExport" in pre))
5643
+ return error("The connected PAM predates the Song Export op (needs ≥ 1.5.7). " +
5644
+ "Pass an empty destination cellId for the legacy live capture instead.");
5645
+ const preJob = pre.songExport ?? {};
5646
+ if (preJob.state === "running")
5647
+ return error("A song export is already running — poll get_video_state songExport, or cancel it first.");
5648
+ await sendVideoCommand({
5649
+ op: "exportSong",
5650
+ bars,
5651
+ includeVideo,
5652
+ strategy,
5653
+ ...(name !== undefined && { name }),
5654
+ ...(normalize !== undefined && { normalize }),
5655
+ ...(encoderId !== undefined && { encoderId }),
5656
+ });
5657
+ // The op returns immediately; confirm OUR job actually started. A rejected
5658
+ // op (sync guards: empty arrangement, busy capture, no VX layers) leaves
5659
+ // the previous status — with its old startedAt stamp — in place.
5660
+ let started = false;
5661
+ for (let i = 0; i < 10; i++) {
5662
+ await sleep(500);
5663
+ const s = await getVideoStateRaw();
5664
+ const job = s.songExport ?? {};
5665
+ if (typeof job.startedAt === "number" && job.startedAt !== preJob.startedAt) {
5666
+ started = true;
5667
+ break;
5668
+ }
5669
+ }
5670
+ if (!started)
5671
+ return error("Song export did not start — likely a guard failure (empty arrangement, another capture " +
5672
+ "running, or includeVideo with no VX layers). Check song_mode get / get_video_state.");
5673
+ const transport = (await bridge.send("getTransport"));
5674
+ const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
5675
+ // Capture length + bar-sync start slack + save/mux margin.
5676
+ const deadline = Date.now() + ((bars + 2) * 4 * 60 * 1000) / bpm + 120_000;
5677
+ let last = {};
5678
+ while (Date.now() < deadline) {
5679
+ await sleep(2000);
5680
+ const s = await getVideoStateRaw();
5681
+ last = s.songExport ?? {};
5682
+ if (last.state === "done")
5683
+ return text(`Song export complete (${bars} bars, ${includeVideo ? "audio+video" : "audio WAV"}).\n` +
5684
+ `File: ${String(last.path ?? "(unknown — check VX/Resamples / Visuals)")}\n` +
5685
+ (includeVideo ? "" : "Verify with get_audio_info."));
5686
+ if (last.state === "failed")
5687
+ return error(`Song export failed: ${String(last.error ?? "unknown")}`);
5688
+ if (last.state === "cancelled")
5689
+ return error("Song export was cancelled.");
5690
+ }
5691
+ return error(`Song export timed out (last status: ${JSON.stringify(last)}). It may still be running — ` +
5692
+ "poll get_video_state songExport, or cancel via video_command {op:'cancelSongExport'}.");
5693
+ }
5694
+ // -------- Legacy live strategy: headless master capture into a destination cell --------
3949
5695
  const transport = (await bridge.send("getTransport"));
3950
5696
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
3951
5697
  const totalBars = bars + (tailBars ?? 1);
@@ -4235,6 +5981,13 @@ function isPathUnderWhitelist(p) {
4235
5981
  // accepts entries observed here so an agent can't instantiate arbitrary
4236
5982
  // binaries.
4237
5983
  const knownPluginIdentifiers = new Set();
5984
+ // PAM's bridge returns some plugin-host results as a JSON OBJECT in `result`
5985
+ // (newer builds) and others as a pre-serialized string (older builds). The
5986
+ // handlers below must accept both — treating an object as a string made
5987
+ // text() emit a non-string (MCP result-validation error) and JSON.parse
5988
+ // throw, leaving knownPluginIdentifiers empty so load_plugin refused every
5989
+ // path.
5990
+ const bridgeResultText = (raw) => typeof raw === "string" ? raw : JSON.stringify(raw);
4238
5991
  // Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
4239
5992
  // (falls back to 8 — PAM's shipping max) rather than the C++ slot
4240
5993
  // reservation of 32.
@@ -4281,12 +6034,12 @@ server.registerTool("list_plugins", {
4281
6034
  filter.isInstrument = args.isInstrument;
4282
6035
  if (args.nameContains)
4283
6036
  filter.nameContains = args.nameContains;
4284
- const raw = (await bridge.send("listPlugins", filter));
6037
+ const raw = await bridge.send("listPlugins", filter);
4285
6038
  // Record every fileOrIdentifier so load_plugin can validate that the
4286
6039
  // caller-supplied path was discovered by PAM's own scanner.
4287
6040
  try {
4288
- const parsed = JSON.parse(raw);
4289
- if (Array.isArray(parsed.plugins)) {
6041
+ const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw);
6042
+ if (Array.isArray(parsed?.plugins)) {
4290
6043
  for (const p of parsed.plugins) {
4291
6044
  if (typeof p.fileOrIdentifier === "string") {
4292
6045
  knownPluginIdentifiers.add(p.fileOrIdentifier);
@@ -4297,7 +6050,7 @@ server.registerTool("list_plugins", {
4297
6050
  catch {
4298
6051
  // C++ payload not JSON — fall through; load_plugin will reject unknowns.
4299
6052
  }
4300
- return text(raw);
6053
+ return text(bridgeResultText(raw));
4301
6054
  });
4302
6055
  server.registerTool("scan_plugins", {
4303
6056
  title: "Scan Plugins",
@@ -4353,12 +6106,21 @@ server.registerTool("get_plugin_scan_status", {
4353
6106
  "`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
4354
6107
  "and `updatedAtMs`. Poll while a scan is running.",
4355
6108
  inputSchema: {},
6109
+ outputSchema: {
6110
+ isScanning: z.boolean().optional(),
6111
+ format: z.string().optional(),
6112
+ currentPath: z.string().optional(),
6113
+ fraction: z.number().optional(),
6114
+ elapsedMs: z.number().optional(),
6115
+ isStall: z.boolean().optional(),
6116
+ updatedAtMs: z.number().optional(),
6117
+ },
4356
6118
  }, async () => {
4357
6119
  const check = await requireBridge();
4358
6120
  if (check !== true)
4359
6121
  return check;
4360
- const raw = (await bridge.send("getPluginScanStatus"));
4361
- return text(raw);
6122
+ const raw = await bridge.send("getPluginScanStatus");
6123
+ return structuredFromBridge(raw);
4362
6124
  });
4363
6125
  server.registerTool("list_loaded_plugins", {
4364
6126
  title: "List Loaded Plugins",
@@ -4371,8 +6133,8 @@ server.registerTool("list_loaded_plugins", {
4371
6133
  const check = await requireBridge();
4372
6134
  if (check !== true)
4373
6135
  return check;
4374
- const raw = (await bridge.send("listLoadedPlugins"));
4375
- return text(raw);
6136
+ const raw = await bridge.send("listLoadedPlugins");
6137
+ return text(bridgeResultText(raw));
4376
6138
  });
4377
6139
  server.registerTool("load_plugin", {
4378
6140
  title: "Load Plugin Into Cell",
@@ -4383,6 +6145,10 @@ server.registerTool("load_plugin", {
4383
6145
  cellId: z.number().int().min(1).describe("Target cell (1-based)."),
4384
6146
  path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
4385
6147
  format: PLUGIN_FORMAT.describe("Plugin format."),
6148
+ uniqueId: z.number().int().optional().describe("Optional type disambiguator for multi-type bundles (one .vst3 exposing several plugin types, " +
6149
+ "e.g. Serum2.vst3 = instrument + FX). Pass the uniqueId from the list_plugins entry you want. " +
6150
+ "Absent → PAM prefers the instrument type at that path."),
6151
+ name: z.string().optional().describe("Optional type name disambiguator (the list_plugins `name`), used when uniqueId is unavailable."),
4386
6152
  },
4387
6153
  }, async (args) => {
4388
6154
  const check = await requireBridge();
@@ -4399,6 +6165,8 @@ server.registerTool("load_plugin", {
4399
6165
  cellId: clamped,
4400
6166
  path: args.path,
4401
6167
  format: args.format,
6168
+ ...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
6169
+ ...(args.name ? { name: args.name } : {}),
4402
6170
  }));
4403
6171
  return text(raw);
4404
6172
  });
@@ -4581,16 +6349,16 @@ server.registerTool("set_midi_io", {
4581
6349
  server.registerTool("set_midi_mapping", {
4582
6350
  title: "Set MIDI Action Mapping",
4583
6351
  description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
4584
- "looper record, live resample, and looper-variation switch. Set the enable flag, MIDI channel, CC number, " +
4585
- "preset source, or bar-sync for each target. One field per call.",
6352
+ "looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
6353
+ "MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
4586
6354
  inputSchema: {
4587
6355
  target: z
4588
- .enum(["variation", "preset", "looper", "resample", "looperVariation"])
4589
- .describe("Which action's mapping to configure."),
6356
+ .enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
6357
+ .describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
4590
6358
  field: z
4591
6359
  .enum(["enabled", "channel", "ccNumber", "source", "barSync"])
4592
6360
  .describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
4593
- "looper/resample have no source/barSync."),
6361
+ "looper/resample/vxRecord have no source/barSync."),
4594
6362
  value: z
4595
6363
  .union([z.number(), z.boolean()])
4596
6364
  .describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
@@ -4755,13 +6523,309 @@ server.registerTool("remove_plugin_from_blocklist", {
4755
6523
  return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
4756
6524
  });
4757
6525
  // ============================================================
6526
+ // TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
6527
+ // ============================================================
6528
+ // The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
6529
+ // (shared fgemma-format module + live [ctx] line), generates against a local
6530
+ // OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
6531
+ // call blocks, and executes them through this server's own handlers. Lets an
6532
+ // MCP client (Claude Code) test the assistant model directly against a live
6533
+ // PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
6534
+ // from the previous trace as `history`.
6535
+ //
6536
+ // Dev-tree only: registered only when the fgemma toolpack resolves (the
6537
+ // published npm package doesn't carry it — the tool would be dead weight).
6538
+ const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../fgemma-poc/out/toolpack.json");
6539
+ // The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
6540
+ // Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
6541
+ // vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
6542
+ // the cell grid (selectMainView(null) semantics in the WebView).
6543
+ const SET_VIEW_MAIN_VIEW = {
6544
+ cells: null,
6545
+ pads: null,
6546
+ grid: null,
6547
+ mixer: "Mixer",
6548
+ ctrl: "CtrlAll",
6549
+ ctrlall: "CtrlAll",
6550
+ looper: "Looper",
6551
+ song: "SongMode",
6552
+ lfo: "LFO",
6553
+ envelope: "Envelope",
6554
+ env: "Envelope",
6555
+ vary: "Vary",
6556
+ paramseq: "ParamSeq",
6557
+ video: "Video",
6558
+ vx: "Video",
6559
+ visuals: "Video",
6560
+ export: "Export",
6561
+ presets: "PresetBrowser",
6562
+ browser: "PresetBrowser",
6563
+ };
6564
+ // Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
6565
+ // ask_assistant — the published server deliberately excludes window/view
6566
+ // chrome). Error strings mirror the executor exactly: the model's repair
6567
+ // turns are trained on those shapes.
6568
+ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6569
+ server.registerTool("set_view", {
6570
+ title: "Set View (assistant surface)",
6571
+ description: "Navigate PAM's UI: switch the main view, focus a cell, or set the pad-grid size. " +
6572
+ "Dev-tree twin of the in-plugin assistant's set_view, routed through the WebView — " +
6573
+ "PAM's window must be OPEN. On PAM builds without the setView bridge action it falls " +
6574
+ "back to the capture_ui compose path (view + gridSize only; cell selection needs a " +
6575
+ "rebuilt plugin).",
6576
+ inputSchema: {
6577
+ // Union: "8 cell view" phrasings make the model emit view:8 (a number) —
6578
+ // accept it so the gridSize coercion below can run instead of a zod reject.
6579
+ view: z
6580
+ .union([z.string(), z.number()])
6581
+ .optional()
6582
+ .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
6583
+ cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
6584
+ gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
6585
+ },
6586
+ }, async ({ view: viewArg, cellId, gridSize }) => {
6587
+ const check = await requireBridge();
6588
+ if (check !== true)
6589
+ return check;
6590
+ let view = viewArg === undefined ? undefined : String(viewArg);
6591
+ // Numeric view token → gridSize (executor does the same coercion); an
6592
+ // explicit gridSize wins and the numeric view is dropped either way.
6593
+ if (view !== undefined && /^[148]$/.test(view.trim())) {
6594
+ if (gridSize === undefined)
6595
+ gridSize = Number(view.trim());
6596
+ view = undefined;
6597
+ }
6598
+ const applied = {};
6599
+ let token;
6600
+ if (typeof view === "string" && view.trim()) {
6601
+ token = view.trim().toLowerCase();
6602
+ if (!(token in SET_VIEW_MAIN_VIEW))
6603
+ return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
6604
+ applied.view = token;
6605
+ }
6606
+ if (gridSize !== undefined) {
6607
+ if (gridSize !== 1 && gridSize !== 4 && gridSize !== 8)
6608
+ return error("gridSize must be 1, 4, or 8.");
6609
+ applied.gridSize = gridSize;
6610
+ }
6611
+ if (cellId !== undefined) {
6612
+ if (!Number.isInteger(cellId) || cellId < 1 || cellId > 8)
6613
+ return error("cellId must be 1-8.");
6614
+ applied.cellId = cellId;
6615
+ }
6616
+ if (!Object.keys(applied).length)
6617
+ return error("Provide view, cellId, and/or gridSize.");
6618
+ try {
6619
+ await bridge.send("setView", {
6620
+ ...(token !== undefined ? { view: token } : {}),
6621
+ ...(cellId !== undefined ? { cellId } : {}),
6622
+ ...(gridSize !== undefined ? { gridSize } : {}),
6623
+ });
6624
+ return json(applied);
6625
+ }
6626
+ catch (e) {
6627
+ const msg = e.message;
6628
+ if (!/unknown action|does not support/i.test(msg))
6629
+ return error(`set_view failed: ${msg}`);
6630
+ // Pre-1.15.0 PAM build — compose via the shipped captureUi handler
6631
+ // (applies mainView/gridSize in the WebView before its rasterize; the
6632
+ // capture itself is discarded). No cell-selection fallback exists.
6633
+ if (token === undefined && gridSize === undefined)
6634
+ return error("Cell selection needs the setView bridge action — this PAM build predates it (rebuild PAM).");
6635
+ const setup = { settleMs: 0 };
6636
+ if (token !== undefined)
6637
+ setup.mainView = SET_VIEW_MAIN_VIEW[token];
6638
+ if (gridSize !== undefined)
6639
+ setup.gridSize = gridSize;
6640
+ try {
6641
+ await bridge.send("captureUi", setup);
6642
+ }
6643
+ catch (e2) {
6644
+ return error(`set_view failed: ${e2.message}`);
6645
+ }
6646
+ delete applied.cellId; // not applied on the fallback path — don't over-claim
6647
+ return json(applied);
6648
+ }
6649
+ });
6650
+ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6651
+ server.registerTool("ask_assistant", {
6652
+ title: "Ask Assistant (FunctionGemma e2e test)",
6653
+ description: "Run one natural-language command through the on-device assistant model: render the " +
6654
+ "FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
6655
+ "parse the tool calls, execute them against PAM, and return the full trace. Requires " +
6656
+ "llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
6657
+ "previous trace's modelTurn back via history.",
6658
+ inputSchema: {
6659
+ text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
6660
+ endpoint: z
6661
+ .string()
6662
+ .optional()
6663
+ .describe("OpenAI-compatible /v1 base URL (default env PAM_LLM_URL or http://127.0.0.1:8080/v1)."),
6664
+ sel: z
6665
+ .number()
6666
+ .int()
6667
+ .optional()
6668
+ .describe("Selected-cell override for the [ctx] line (the bridge can't see UI selection)."),
6669
+ noCtx: z.boolean().optional().describe("Skip the [ctx] line (test the no-context prompt shape)."),
6670
+ history: z
6671
+ .array(z.object({ user: z.string(), model: z.string() }))
6672
+ .optional()
6673
+ .describe("Prior exchanges to thread (user text + the modelTurn from that trace)."),
6674
+ maxRounds: z.number().int().min(1).max(5).optional().describe("Continuation rounds (default 3)."),
6675
+ },
6676
+ }, async ({ text: userText, endpoint, sel, noCtx, history, maxRounds }) => {
6677
+ // Toolpack: the model-facing surface (dev tree; regenerate with export-toolpack.mjs).
6678
+ let pack;
6679
+ try {
6680
+ const doc = JSON.parse(readFileSync(ASSISTANT_TOOLPACK_PATH, "utf-8"));
6681
+ pack = doc.tools;
6682
+ }
6683
+ catch {
6684
+ return error("assistantToolpack not found — run `node scripts/export-toolpack.mjs` in packages/mcp-server first.");
6685
+ }
6686
+ const declared = new Set(pack.map((t) => t.name));
6687
+ const llmUrl = (endpoint || process.env.PAM_LLM_URL || "http://127.0.0.1:8080/v1").replace(/\/$/, "");
6688
+ // Live [ctx] line from this server's own read tools (sel is UI-only state
6689
+ // the bridge can't see — honor the override, else none).
6690
+ let ctx = null;
6691
+ if (!noCtx) {
6692
+ try {
6693
+ const t = await callInternalTool("get_transport", {});
6694
+ const o = await callInternalTool("get_design_overview", {});
6695
+ if (!t.isError && !o.isError) {
6696
+ const transport = JSON.parse(t.text);
6697
+ const overview = JSON.parse(o.text);
6698
+ // preset= IS bridge-visible (state.activePresetPath) — only view/sel
6699
+ // are UI-only. Renders `none` for Init/Default, like the executor.
6700
+ let preset = null;
6701
+ try {
6702
+ const s = await callInternalTool("get_state", { keys: ["activePresetPath"] });
6703
+ const activePath = JSON.parse(s.text).activePresetPath;
6704
+ if (typeof activePath === "string" && activePath && activePath !== "Init") {
6705
+ const stem = (activePath.split("/").pop() ?? "").replace(/\.preset$/, "");
6706
+ preset = stem && stem !== "Default" ? stem : null;
6707
+ }
6708
+ }
6709
+ catch {
6710
+ preset = null;
6711
+ }
6712
+ ctx = {
6713
+ bpm: Number(transport.bpm ?? 120),
6714
+ playing: Boolean(transport.playing ?? transport.isPlaying),
6715
+ preset,
6716
+ sel: sel ?? null,
6717
+ cells: (overview.cells ?? [])
6718
+ .filter((c) => c.sample || c.synth)
6719
+ .map((c) => ({ id: c.id, name: c.sample ?? `${c.synth}(synth)` })),
6720
+ };
6721
+ }
6722
+ }
6723
+ catch {
6724
+ ctx = null; // no PAM — trained no-ctx prompt shape still works
6725
+ }
6726
+ }
6727
+ let prompt = buildGenerationPrompt(pack, withContext(userText, ctx), {
6728
+ bos: "none",
6729
+ history,
6730
+ });
6731
+ const trace = { ctxLine: ctx ? renderContextLine(ctx) : null, rounds: [], executed: [], modelTurn: "" };
6732
+ const rounds = maxRounds ?? 3;
6733
+ // A repair round that re-emits the exact call that just errored will error
6734
+ // identically forever — cut the loop (executor parity).
6735
+ const failedCalls = new Set();
6736
+ for (let round = 0; round < rounds; round++) {
6737
+ const t0 = Date.now();
6738
+ let raw;
6739
+ try {
6740
+ const resp = await fetch(`${llmUrl}/completions`, {
6741
+ method: "POST",
6742
+ headers: { "Content-Type": "application/json" },
6743
+ body: JSON.stringify({ prompt, max_tokens: 256, temperature: 0, stop: STOP_SEQUENCES, stream: false }),
6744
+ });
6745
+ if (!resp.ok)
6746
+ return error(`model endpoint ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
6747
+ raw = (await resp.json()).choices?.[0]?.text ?? "";
6748
+ }
6749
+ catch (e) {
6750
+ return error(`cannot reach model endpoint ${llmUrl} — start llama-server with the fine-tuned GGUF and --special. (${e.message})`);
6751
+ }
6752
+ raw = truncateAtFabricatedResponse(raw);
6753
+ const calls = parseFunctionCalls(raw);
6754
+ trace.rounds.push({ raw, ms: Date.now() - t0, calls });
6755
+ if (!calls.length) {
6756
+ trace.text = raw.trim();
6757
+ trace.modelTurn += trace.text;
6758
+ return json(trace);
6759
+ }
6760
+ const unknown = calls.filter((c) => !declared.has(c.name));
6761
+ if (unknown.length) {
6762
+ trace.refused = `undeclared tool(s): ${unknown.map((c) => c.name).join(", ")}`;
6763
+ return json(trace);
6764
+ }
6765
+ let continuation = raw.trimEnd();
6766
+ let responses = "";
6767
+ let wantContinue = false;
6768
+ for (const call of calls) {
6769
+ const signature = `${call.name} ${JSON.stringify(call.arguments)}`;
6770
+ if (failedCalls.has(signature)) {
6771
+ trace.stopped = `model repeated a call that already failed (${call.name}) — loop cut`;
6772
+ return json(trace);
6773
+ }
6774
+ // The flat set_parameter alias maps onto the real object-map tool.
6775
+ const real = pack.find((t) => t.name === call.name)?.aliasOf === "set_parameters"
6776
+ ? {
6777
+ name: "set_parameters",
6778
+ arguments: { parameters: { [String(call.arguments.paramId)]: call.arguments.value } },
6779
+ }
6780
+ : call;
6781
+ const result = await callInternalTool(real.name, real.arguments);
6782
+ if (result.isError)
6783
+ failedCalls.add(signature);
6784
+ trace.executed.push({
6785
+ name: call.name,
6786
+ arguments: call.arguments,
6787
+ result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
6788
+ isError: result.isError,
6789
+ });
6790
+ let payload;
6791
+ if (result.isError)
6792
+ payload = { error: result.text.slice(0, 400) };
6793
+ else {
6794
+ try {
6795
+ const parsed = JSON.parse(result.text);
6796
+ payload =
6797
+ parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
6798
+ ? parsed
6799
+ : { result: result.text.slice(0, 400) };
6800
+ }
6801
+ catch {
6802
+ payload = { result: result.text.slice(0, 400) };
6803
+ }
6804
+ }
6805
+ const response = renderFunctionResponse(call.name, payload);
6806
+ continuation += response;
6807
+ responses += response;
6808
+ if (result.isError || /^(get_|list_|connection_status)/.test(call.name))
6809
+ wantContinue = true;
6810
+ }
6811
+ trace.modelTurn += calls.map(renderFunctionCall).join("") + responses;
6812
+ if (!wantContinue)
6813
+ return json(trace);
6814
+ prompt += continuation;
6815
+ }
6816
+ return json(trace);
6817
+ });
6818
+ // ============================================================
4758
6819
  // Start
4759
6820
  // ============================================================
4760
6821
  async function main() {
4761
6822
  if (await handleCliArgs())
4762
6823
  return;
4763
6824
  // Try to connect to plugin on startup (non-blocking)
4764
- void bridge.connect();
6825
+ void bridge.connect().then((ok) => {
6826
+ if (ok)
6827
+ syncProductHint();
6828
+ });
4765
6829
  const transport = new StdioServerTransport();
4766
6830
  await server.connect(transport);
4767
6831
  // Log to stderr (stdout is the MCP JSON-RPC channel)