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

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,20 @@
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";
6
+ import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
5
7
  import { readFile } from "node:fs/promises";
8
+ import { existsSync, readFileSync } from "node:fs";
6
9
  import { inflateSync } from "node:zlib";
7
10
  import { basename, dirname, resolve } from "node:path";
8
11
  import { fileURLToPath } from "node:url";
9
- import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
12
+ import { createDrumSynthCatalog, } from "./drumSynth.js";
13
+ import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
10
14
  import { PluginBridge } from "./bridge.js";
15
+ import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
16
+ import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
17
+ import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
11
18
  // --- State ---
12
19
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
13
20
  // interface/src/stores/Preset.ts. Defined locally because the MCP package
@@ -16,6 +23,8 @@ const PRESET_VARIATION_SLOTS = 8;
16
23
  const bridge = new PluginBridge();
17
24
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18
25
  const cellModeChangeSeq = {};
26
+ let lastSavedPresetName;
27
+ let lastSavedPresetPath;
19
28
  // --- Helpers ---
20
29
  function text(s) {
21
30
  return { content: [{ type: "text", text: s }] };
@@ -23,12 +32,78 @@ function text(s) {
23
32
  function json(obj) {
24
33
  return text(JSON.stringify(obj, null, 2));
25
34
  }
35
+ /**
36
+ * Structured result: the same JSON in the text block (for text-only consumers,
37
+ * including ask_assistant's internal dispatch) plus structuredContent for
38
+ * clients that consume the tool's outputSchema. Only use from tools that
39
+ * declare an outputSchema — the SDK requires structuredContent on success then.
40
+ */
41
+ function structured(obj) {
42
+ const record = isRecord(obj) ? obj : { value: obj };
43
+ return {
44
+ content: [{ type: "text", text: JSON.stringify(record, null, 2) }],
45
+ structuredContent: record,
46
+ };
47
+ }
48
+ /** structured() for bridge results that may arrive as a raw JSON string. */
49
+ function structuredFromBridge(raw) {
50
+ if (typeof raw === "string") {
51
+ try {
52
+ return structured(JSON.parse(raw));
53
+ }
54
+ catch {
55
+ return structured({ value: raw });
56
+ }
57
+ }
58
+ return structured(raw);
59
+ }
26
60
  function error(msg) {
27
61
  return { content: [{ type: "text", text: msg }], isError: true };
28
62
  }
29
- async function readPackageVersion() {
63
+ function isMfxManifest(manifest) {
64
+ return (manifest.name?.toLowerCase() === "mfx" ||
65
+ manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
66
+ manifest.pluginCode?.toLowerCase() === "amfx");
67
+ }
68
+ const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
69
+ async function loadManifestForProductGuard() {
30
70
  try {
31
- const raw = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
71
+ return await loadManifest();
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function manifestMaxCellId(manifest) {
78
+ if (!manifest)
79
+ return null;
80
+ const entries = [
81
+ ...(manifest.parameters ?? []),
82
+ ...(manifest.stateVariables ?? []),
83
+ ...(manifest.parameterInfos ?? []),
84
+ ];
85
+ const ids = new Set();
86
+ for (const entry of entries) {
87
+ const cellMatch = typeof entry.cell === "string" ? /^cell(\d+)$/.exec(entry.cell) : null;
88
+ const paramMatch = typeof entry.paramId === "string" ? /^cell(\d+)_/.exec(entry.paramId) : null;
89
+ const raw = cellMatch?.[1] ?? paramMatch?.[1];
90
+ if (raw)
91
+ ids.add(Number(raw));
92
+ }
93
+ return ids.size > 0 ? Math.max(...ids) : null;
94
+ }
95
+ function validateCellIdForManifest(cellId, manifest) {
96
+ const maxCellId = manifestMaxCellId(manifest);
97
+ if (maxCellId !== null && cellId > maxCellId) {
98
+ const productName = manifest?.name ?? "This product";
99
+ const noun = maxCellId === 1 ? "cell" : "cells";
100
+ return error(`${productName} exposes ${maxCellId} ${noun}; cell ${cellId} is not available in this product.`);
101
+ }
102
+ return true;
103
+ }
104
+ function readPackageVersion() {
105
+ try {
106
+ const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
32
107
  const pkg = JSON.parse(raw);
33
108
  return typeof pkg.version === "string" ? pkg.version : "unknown";
34
109
  }
@@ -36,14 +111,17 @@ async function readPackageVersion() {
36
111
  return "unknown";
37
112
  }
38
113
  }
114
+ // Single source of truth for the server version: package.json. The MCP handshake,
115
+ // --version, and --help must all report the same number.
116
+ const SERVER_VERSION = readPackageVersion();
39
117
  async function handleCliArgs() {
40
118
  const args = new Set(process.argv.slice(2));
41
119
  if (args.has("--version") || args.has("-v")) {
42
- console.log(await readPackageVersion());
120
+ console.log(SERVER_VERSION);
43
121
  return true;
44
122
  }
45
123
  if (args.has("--help") || args.has("-h")) {
46
- const version = await readPackageVersion();
124
+ const version = SERVER_VERSION;
47
125
  console.log(`pam-mcp ${version}\n\n` +
48
126
  "MCP stdio server for PAM. Configure your MCP client to launch this command; " +
49
127
  "it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
@@ -51,14 +129,24 @@ async function handleCliArgs() {
51
129
  }
52
130
  return false;
53
131
  }
132
+ // Point offline tools (param validation, design_context) at the manifest of
133
+ // the product we're actually connected to.
134
+ function syncProductHint() {
135
+ setActiveProductHint(bridge.getConnectedInstance()?.product);
136
+ }
54
137
  async function requireBridge() {
55
138
  if (bridge.isConnected())
56
139
  return true;
57
140
  const ok = await bridge.connect();
58
141
  if (!ok) {
142
+ const pin = process.env.PAM_BRIDGE_PRODUCT;
59
143
  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.");
144
+ "The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
145
+ (pin
146
+ ? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
147
+ : ""));
61
148
  }
149
+ syncProductHint();
62
150
  return true;
63
151
  }
64
152
  /**
@@ -121,42 +209,93 @@ async function readExistingVariations(presetPath) {
121
209
  return Array(PRESET_VARIATION_SLOTS).fill(null);
122
210
  }
123
211
  }
124
- // Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
212
+ const VARIATION_CONTRACT = (() => {
213
+ const here = dirname(fileURLToPath(import.meta.url));
214
+ const candidates = [
215
+ resolve(here, "..", "..", "..", "shared", "variationSessionKeys.json"), // repo checkout
216
+ resolve(here, "variationSessionKeys.json"), // published dist/
217
+ ];
218
+ for (const file of candidates) {
219
+ if (!existsSync(file))
220
+ continue;
221
+ const contract = JSON.parse(readFileSync(file, "utf8"));
222
+ if (Array.isArray(contract.all) && contract.all.length > 0)
223
+ return contract;
224
+ }
225
+ throw new Error("pam-mcp: variationSessionKeys.json not found. Run `node scripts/generate-variation-session-keys.mjs` " +
226
+ "(repo) or rebuild the package — saving a variation without it would bake session state into the slot.");
227
+ })();
228
+ // `transport` is live host position, reported in state but never variation content.
125
229
  const VARIATION_EXCLUDED_PARAMS = [
126
- "variations",
127
- "interfaceCache",
128
- "songMode",
129
- "volume",
130
- "hqMode",
131
- "previewSamplePath",
132
- "previewCellId",
133
- "transport",
134
- "snapshotRateHz",
135
- "looperCrossfade",
136
- "looperLength",
137
- "looperVolume",
138
- "looperFilter",
139
- "loopers",
140
- "looperPlaybackBars",
141
- "looperTrack1Level",
142
- "looperTrack2Level",
143
- "looperTrack3Level",
144
- "looperTrack4Level",
145
- "looperTrack5Level",
146
- "looperTrack6Level",
147
- "looperTrack7Level",
148
- "looperTrack8Level",
230
+ ...new Set([...(VARIATION_CONTRACT.all ?? []), "transport"]),
149
231
  ];
150
232
  /**
151
- * Build a variation snapshot string from plugin state (matching interop.ts logic).
233
+ * Build a variation snapshot string from plugin state.
234
+ *
235
+ * The exclusion set comes from the generated contract, so this matches what a
236
+ * UI save strips. It does NOT match a UI save in one respect: interop.ts awaits
237
+ * `refreshLoadedPluginStateBlobs()` first and stamps each hosted plugin's
238
+ * identity and bypass from `pluginHostStore` — data that lives in the WebView,
239
+ * not in the headless bridge's state. `hostedPluginCells` reports the cells
240
+ * where that difference bites so callers are told rather than silently handed a
241
+ * lossy slot.
242
+ *
243
+ * This path is the FALLBACK. A PAM build carrying the `getSaveSnapshot` action
244
+ * hands back the native canonical snapshot (hosted-plugin state included) and
245
+ * never reaches here.
152
246
  */
153
247
  function buildVariationString(state) {
154
- const stateForVariation = { ...state };
155
- for (const param of VARIATION_EXCLUDED_PARAMS) {
156
- delete stateForVariation[param];
248
+ return JSON.stringify(buildVariationSnapshotBase(state, {
249
+ sessionKeys: VARIATION_EXCLUDED_PARAMS,
250
+ visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
251
+ lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
252
+ }));
253
+ }
254
+ /**
255
+ * The canonical save-time state — engine state PLUS every live hosted plugin's
256
+ * identity, bypass and state blob (PluginProcessor::buildCanonicalSaveSnapshot).
257
+ *
258
+ * `getState` returns the live map, which carries no hosted-plugin blob, so a
259
+ * variation built from it silently dropped the hosted patch (A1). Falls back to
260
+ * `getState` on a PAM build that predates the action, and reports the fallback
261
+ * so the save result can say the snapshot is lossy.
262
+ */
263
+ async function fetchSaveSnapshot() {
264
+ try {
265
+ const snap = await bridge.send("getSaveSnapshot");
266
+ if (snap && typeof snap === "object") {
267
+ return { state: snap, canonical: true };
268
+ }
269
+ }
270
+ catch {
271
+ // Older PAM: action not registered. Fall through.
272
+ }
273
+ const state = (await bridge.send("getState"));
274
+ return { state, canonical: false };
275
+ }
276
+ /** Cells whose hosted plugin this snapshot cannot fully capture. */
277
+ function hostedPluginCells(state) {
278
+ const cells = new Set();
279
+ for (const key of Object.keys(state)) {
280
+ const m = /^cell(\d+)_plugin(?:Fx)?Path$/.exec(key);
281
+ if (m && state[key])
282
+ cells.add(Number(m[1]));
157
283
  }
158
- delete stateForVariation.activePresetPath;
159
- return JSON.stringify(stateForVariation);
284
+ return [...cells].sort((a, b) => a - b);
285
+ }
286
+ /** Appended to a save result when the snapshot is known to be lossy. */
287
+ function hostedPluginSaveWarning(state, canonical = false) {
288
+ // The canonical snapshot stamps hosted-plugin state on the message thread, so
289
+ // there is nothing to warn about.
290
+ if (canonical)
291
+ return "";
292
+ const cells = hostedPluginCells(state);
293
+ if (!cells.length)
294
+ return "";
295
+ return (`\n\n⚠️ Cell${cells.length > 1 ? "s" : ""} ${cells.join(", ")} host${cells.length > 1 ? "" : "s"} a plugin. ` +
296
+ "This save captured the plugin's last-known state, not its live one — the bridge cannot refresh a " +
297
+ "hosted plugin's state blob (that runs in the plugin UI). Re-save from the PAM window to capture " +
298
+ "current hosted-plugin settings.");
160
299
  }
161
300
  function formatParam(p) {
162
301
  let desc = `${p.paramId}: ${p.displayName}`;
@@ -176,35 +315,490 @@ function formatParam(p) {
176
315
  desc += ` — ${p.tooltip}`;
177
316
  return desc;
178
317
  }
318
+ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
319
+ // Cell effects are derived per product from the manifest (`cellEffectNames`) —
320
+ // this constant is only the fallback for a manifest that failed to load. The
321
+ // hardcoded eight it used to be is why get_design_overview under-reported:
322
+ // limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
323
+ // were enabled in state but absent from `enabledEffects`.
324
+ const CELL_EFFECTS_FALLBACK = [
325
+ "filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
326
+ ];
327
+ const PATTERN_LANES = {
328
+ note: "midiSequence",
329
+ pitch: "pitchSequence",
330
+ slice: "sliceSequence",
331
+ stretch: "stretchSequence",
332
+ };
333
+ const LFO_DIVISIONS = [
334
+ "16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
335
+ "1/2D", "1/2", "1/2T",
336
+ "1/4D", "1/4", "1/4T",
337
+ "1/8D", "1/8", "1/8T",
338
+ "1/16D", "1/16", "1/16T",
339
+ "1/32D", "1/32", "1/32T",
340
+ "1/64D", "1/64", "1/64T",
341
+ ];
342
+ function levenshtein(a, b) {
343
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
344
+ const curr = Array(b.length + 1).fill(0);
345
+ for (let i = 1; i <= a.length; i++) {
346
+ curr[0] = i;
347
+ for (let j = 1; j <= b.length; j++) {
348
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
349
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
350
+ }
351
+ for (let j = 0; j <= b.length; j++)
352
+ prev[j] = curr[j];
353
+ }
354
+ return prev[b.length];
355
+ }
356
+ function similarity(a, b) {
357
+ if (a === b)
358
+ return 1;
359
+ const maxLen = Math.max(a.length, b.length);
360
+ if (maxLen === 0)
361
+ return 1;
362
+ return 1 - levenshtein(a, b) / maxLen;
363
+ }
364
+ function paramSuffix(paramId) {
365
+ const m = paramId.match(/^(?:cell\d+|macro|lfo\d+)_?(.*)$/);
366
+ return m?.[1] || paramId;
367
+ }
368
+ function explicitParamAliases(paramId) {
369
+ const aliases = new Set();
370
+ const aliasSuffixes = {
371
+ delayTime: "delayDelayTime",
372
+ crusherBits: "crusherBitReduction",
373
+ crusherBitDepth: "crusherBitReduction",
374
+ crusherDownsample: "crusherRateReduction",
375
+ fdnDecay: "fdnFeedback",
376
+ filterResonance: "filterQ",
377
+ };
378
+ const lfo0 = paramId.match(/^lfo0_(.+)$/);
379
+ if (lfo0)
380
+ aliases.add(`lfo1_${lfo0[1]}`);
381
+ const cellMatch = paramId.match(/^(cell\d+_)(.+)$/);
382
+ const macroMatch = paramId.match(/^(macro_)(.+)$/);
383
+ const prefix = cellMatch?.[1] ?? macroMatch?.[1] ?? "";
384
+ const suffix = cellMatch?.[2] ?? macroMatch?.[2] ?? paramId;
385
+ const replacement = aliasSuffixes[suffix];
386
+ if (replacement)
387
+ aliases.add(`${prefix}${replacement}`);
388
+ return [...aliases];
389
+ }
390
+ function suggestParams(paramId, index, opts = {}) {
391
+ const explicit = explicitParamAliases(paramId).filter((id) => {
392
+ const p = index.get(id);
393
+ return p && (!opts.modulatableOnly || p.isModulatable);
394
+ });
395
+ const scored = [...index.values()]
396
+ .filter((p) => !opts.modulatableOnly || p.isModulatable)
397
+ .map((p) => {
398
+ const full = similarity(paramId.toLowerCase(), p.paramId.toLowerCase());
399
+ const suffix = similarity(paramSuffix(paramId).toLowerCase(), paramSuffix(p.paramId).toLowerCase());
400
+ const sameCell = paramId.match(/^cell\d+_/)?.[0] === p.paramId.match(/^cell\d+_/)?.[0] ? 0.12 : 0;
401
+ const sameFamily = paramId.startsWith("macro_") && p.paramId.startsWith("macro_") ? 0.08 :
402
+ paramId.startsWith("lfo") && p.paramId.startsWith("lfo") ? 0.08 :
403
+ 0;
404
+ return { id: p.paramId, score: Math.max(full, suffix) + sameCell + sameFamily };
405
+ })
406
+ .filter((x) => x.score >= 0.52)
407
+ .sort((a, b) => b.score - a.score)
408
+ .map((x) => x.id);
409
+ return [...new Set([...explicit, ...scored])].slice(0, 5);
410
+ }
411
+ function formatUnknownParamError(paramId, index, opts = {}) {
412
+ const suggestions = suggestParams(paramId, index, opts);
413
+ const hint = paramId.startsWith("lfo0_")
414
+ ? " LFO parameter IDs are 1-based: use lfo1_... through lfo6_..."
415
+ : "";
416
+ const scope = opts.modulatableOnly ? " modulatable parameter" : " parameter";
417
+ return `Unknown${scope}: ${paramId}.${hint}${suggestions.length ? ` Did you mean ${suggestions.join(", ")}?` : ""}`;
418
+ }
419
+ function formatNotModulatableError(paramId, index) {
420
+ const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
421
+ return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
422
+ }
423
+ function formatRangeError(param, value) {
424
+ const unit = param.suffix ? param.suffix : "unitless";
425
+ const traits = [
426
+ param.bipolar ? "bipolar" : "unipolar",
427
+ unit,
428
+ param.strRepr && param.strRepr.length > 0
429
+ ? `values: ${param.strRepr.map((v, i) => `${i}=${v}`).join(", ")}`
430
+ : undefined,
431
+ ].filter(Boolean);
432
+ const panHint = param.paramId.endsWith("_pan") && Math.abs(value) > 1 && Math.abs(value) <= 100
433
+ ? `; ${value / 100} is approximately ${Math.abs(value)}% ${value < 0 ? "left" : "right"}`
434
+ : "";
435
+ return `${param.paramId}: ${value} out of range [${param.min}..${param.max}] (${traits.join("; ")}${panHint})`;
436
+ }
437
+ function jsonWithLimit(obj, tooLargeHint) {
438
+ const serialized = JSON.stringify(obj, null, 2);
439
+ if (serialized.length > FULL_STATE_RESPONSE_LIMIT_CHARS) {
440
+ return error(`Result is ${serialized.length.toLocaleString()} characters, above the ${FULL_STATE_RESPONSE_LIMIT_CHARS.toLocaleString()} character safety cap. ` +
441
+ tooLargeHint);
442
+ }
443
+ return text(serialized);
444
+ }
445
+ function isRecord(value) {
446
+ return !!value && typeof value === "object" && !Array.isArray(value);
447
+ }
448
+ function cellPrefixSet(cellIds) {
449
+ return new Set((cellIds ?? []).map((id) => `cell${id}_`));
450
+ }
451
+ function paramTargetsCell(value, cells) {
452
+ if (!isRecord(value))
453
+ return false;
454
+ const paramId = typeof value.paramId === "string" ? value.paramId : undefined;
455
+ if (paramId && [...cells].some((prefix) => paramId.startsWith(prefix)))
456
+ return true;
457
+ const cellId = typeof value.cellId === "number" ? value.cellId : undefined;
458
+ return cellId !== undefined && cells.has(`cell${cellId}_`);
459
+ }
460
+ function filterState(state, opts) {
461
+ if (!isRecord(state))
462
+ return state;
463
+ const keySet = opts.keys?.length ? new Set(opts.keys) : null;
464
+ const prefixes = cellPrefixSet(opts.cellIds);
465
+ const filterByCell = prefixes.size > 0;
466
+ if (!keySet && !filterByCell)
467
+ return state;
468
+ const out = {};
469
+ for (const [key, value] of Object.entries(state)) {
470
+ if (keySet?.has(key)) {
471
+ out[key] = value;
472
+ continue;
473
+ }
474
+ if (!filterByCell)
475
+ continue;
476
+ if ([...prefixes].some((prefix) => key.startsWith(prefix))) {
477
+ out[key] = value;
478
+ continue;
479
+ }
480
+ if (key === "cells" && isRecord(value)) {
481
+ const cells = {};
482
+ for (const id of opts.cellIds ?? []) {
483
+ if (value[String(id)] !== undefined)
484
+ cells[String(id)] = value[String(id)];
485
+ }
486
+ out.cells = cells;
487
+ continue;
488
+ }
489
+ if (["lfos", "envelopes", "vary", "paramSeqs"].includes(key) && Array.isArray(value)) {
490
+ out[key] = value.filter((entry) => paramTargetsCell(entry, prefixes));
491
+ }
492
+ }
493
+ return out;
494
+ }
495
+ function filterSectionResult(section, result, opts) {
496
+ const keySet = opts.keys?.length ? new Set(opts.keys) : null;
497
+ const prefixes = cellPrefixSet(opts.cellIds);
498
+ if (section === "cells" && isRecord(result) && prefixes.size > 0) {
499
+ const out = {};
500
+ for (const id of opts.cellIds ?? []) {
501
+ if (result[String(id)] !== undefined)
502
+ out[String(id)] = result[String(id)];
503
+ }
504
+ return out;
505
+ }
506
+ if (["lfos", "envelopes", "vary", "paramSeqs"].includes(section) && Array.isArray(result) && prefixes.size > 0) {
507
+ return result.filter((entry) => paramTargetsCell(entry, prefixes));
508
+ }
509
+ if (keySet && isRecord(result)) {
510
+ const out = {};
511
+ for (const key of keySet) {
512
+ if (result[key] !== undefined)
513
+ out[key] = result[key];
514
+ }
515
+ return out;
516
+ }
517
+ return result;
518
+ }
519
+ function countEvents(value) {
520
+ return Array.isArray(value) ? value.length : 0;
521
+ }
522
+ function stemName(path) {
523
+ if (typeof path !== "string" || path.length === 0)
524
+ return undefined;
525
+ return basename(path);
526
+ }
179
527
  // --- Server ---
180
528
  const server = new McpServer({
181
529
  name: "pam",
182
- version: "1.8.0",
530
+ title: "PAM Audio Sampler",
531
+ version: SERVER_VERSION,
183
532
  }, {
184
- instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
533
+ instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
534
+ "cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
535
+ "broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
536
+ "When browsing samples, ALWAYS call get_default_locations " +
185
537
  "first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
186
538
  "The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
187
539
  "Use the returned paths with list_samples. When loading samples, use load_sample with " +
188
540
  "paths from list_samples. Transport must be playing for sequencer output. " +
189
541
  "To enable experimental features (plugin host, AI engines) call update_settings with both " +
190
542
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
191
- "The drum synth is a standard feature (PAM ≥ 1.5) — no settings flag needed. " +
543
+ "The standard Drum Synth UI follows drumSynthEnabled (default true); this preference does not mute synth audio. " +
544
+ "set_mfx_routing is the MFX cable patchbay and errors on PAM/Cell1; set_audio_device is standalone-only. " +
192
545
  "Tools added in server version 1.7.0 — update_settings, configure_transport, " +
193
546
  "get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
194
547
  "set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
195
548
  "set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
196
549
  "VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
197
550
  "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 " +
551
+ "bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
552
+ "(the video engine renders in the WebView; there is no headless renderer). " +
553
+ "capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
554
+ "WebView-routed too and need the window open for the same reason. For " +
200
555
  "audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
201
556
  "are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
202
557
  "the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
558
+ "AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
559
+ "delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
560
+ "matching experimental flags; load/unload/delete can block for seconds — poll " +
561
+ "get_model_status rather than tight-looping. " +
203
562
  "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. " +
563
+ "live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
564
+ "SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
565
+ "song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
566
+ "slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
567
+ "manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
568
+ "save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
569
+ "variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
570
+ "export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
205
571
  "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
- });
572
+ "this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
573
+ "Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
574
+ "tools discard user state (confirm intent before calling them). When several product " +
575
+ "instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
576
+ "(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
577
+ "make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
578
+ });
579
+ // --- tool annotation policy ---------------------------------------------------
580
+ // Single audit surface for MCP tool annotations. Every tool MUST have an entry;
581
+ // the registerTool shim below merges it (inline annotations at the call site win
582
+ // per key) and warns on stderr when a tool registers unclassified — and
583
+ // scripts/check-tool-surface.mjs fails the release on a gap. openWorldHint is
584
+ // false server-wide: every tool targets the local plugin instance.
585
+ // Policy: readOnlyHint = no plugin/file mutation. destructiveHint = discards
586
+ // user state that isn't trivially recoverable (delete/clear/reset/overwrite the
587
+ // working state); slot-scoped saves follow the save_variation precedent (false).
588
+ // idempotentHint = repeating the same args reaches the same end state.
589
+ const RO = { readOnlyHint: true };
590
+ const W = { destructiveHint: false }; // additive / one-shot write
591
+ const WI = { destructiveHint: false, idempotentHint: true };
592
+ const D = { destructiveHint: true };
593
+ const DI = { destructiveHint: true, idempotentHint: true };
594
+ const TOOL_ANNOTATIONS = {
595
+ // Reads (offline + live introspection)
596
+ get_parameter_info: RO,
597
+ list_presets: RO,
598
+ read_preset: RO,
599
+ get_manifest_summary: RO,
600
+ design_context: RO,
601
+ get_state: RO,
602
+ get_design_overview: RO,
603
+ get_transport: RO,
604
+ get_master_analysis: RO,
605
+ suggest_mix: RO, // pure function over cached analysis + current state
606
+ connection_status: RO,
607
+ list_samples: RO,
608
+ get_default_locations: RO,
609
+ get_audio_info: RO,
610
+ get_video_state: RO,
611
+ capture_ui: RO, // writes a screenshot file, mutates no plugin state
612
+ get_sample_peaks: RO,
613
+ get_audio_device_setup: RO,
614
+ get_sample_usage: RO,
615
+ get_model_status: RO,
616
+ list_models: RO,
617
+ workbench_get_state: RO,
618
+ workbench_list_takes: RO,
619
+ workbench_sidecar_status: RO,
620
+ list_visuals: RO,
621
+ get_visual_data_url: RO,
622
+ list_plugins: RO,
623
+ get_plugin_scan_status: RO,
624
+ list_loaded_plugins: RO,
625
+ get_mix_analysis: W, // refresh:true runs an offline analysis render
626
+ // Parameter / cell / modulation writes
627
+ set_parameters: WI,
628
+ load_sample: WI,
629
+ update_pattern: WI,
630
+ add_modulation: W,
631
+ update_modulation: WI,
632
+ clear_modulations: DI,
633
+ reset_patterns: DI,
634
+ update_effect_config: WI,
635
+ update_cell_config: WI,
636
+ set_cell_mute: WI,
637
+ set_master_effects: WI,
638
+ add_param_seq: W,
639
+ update_param_seq: WI,
640
+ remove_param_seq: DI,
641
+ set_drum_synth: WI,
642
+ set_drum_synth_wave: WI,
643
+ stop_cell: WI,
644
+ reset_cell: DI,
645
+ copy_cell: DI, // overwrites the destination cell
646
+ randomize: W,
647
+ randomize_samples: W,
648
+ randomize_patterns: W,
649
+ // Transport / MIDI
650
+ start_transport: WI,
651
+ stop_transport: WI,
652
+ full_stop_transport: WI,
653
+ set_bpm: WI,
654
+ set_metronome: WI,
655
+ configure_transport: WI,
656
+ trigger_midi: W,
657
+ trigger_midi_cc: W,
658
+ trigger_midi_program_change: W,
659
+ midi_recording: W,
660
+ // Presets / variations
661
+ load_preset: DI, // replaces the working state (unsaved edits are lost)
662
+ save_preset: WI,
663
+ new_preset: DI,
664
+ import_preset: WI,
665
+ export_preset: W,
666
+ update_preset_metadata: WI,
667
+ delete_preset: DI,
668
+ set_preset_factory_status: WI,
669
+ repair_preset_samples: WI,
670
+ load_variation: WI,
671
+ save_variation: WI,
672
+ queue_variation: WI,
673
+ cancel_queued_variation: WI,
674
+ update_variations: WI,
675
+ variation_hold: WI,
676
+ // Sample ops
677
+ preview_sample: WI, // audible only, no state change
678
+ preview_slice: WI,
679
+ trim_sample: W, // restore_original_sample recovers the pre-edit file
680
+ reverse_sample: W,
681
+ gain_sample: W,
682
+ normalize_sample: WI,
683
+ analyze_sample_tempo: WI, // dispatches results into state
684
+ resample_sample_to_bpm: WI,
685
+ restore_original_sample: DI, // discards all working edits on the sample
686
+ build_sample_chain: W,
687
+ export_slice: W,
688
+ // Looper
689
+ arm_looper_track: WI,
690
+ set_looper_track: WI,
691
+ clear_looper_track: DI, // looper state is global, not per-preset
692
+ set_looper_track_selection: WI,
693
+ start_looper_recording: W,
694
+ stop_looper_recording: WI,
695
+ capture_looper_now: W,
696
+ load_audio_to_looper_track: WI,
697
+ request_looper_warp: WI,
698
+ looper_settings: WI,
699
+ looper_variation: W,
700
+ looper_variation_sync: WI,
701
+ live_resample: W,
702
+ // Song mode / export
703
+ song_mode: W,
704
+ export_song: W,
705
+ export_audio: W,
706
+ // Settings / devices
707
+ update_settings: WI,
708
+ set_audio_device: WI,
709
+ set_mfx_routing: WI,
710
+ set_midi_io: WI,
711
+ set_midi_mapping: WI,
712
+ // Plugin host
713
+ scan_plugins: WI,
714
+ cancel_plugin_scan: WI,
715
+ skip_current_plugin_scan: W, // blocklists the currently scanning plugin
716
+ remove_plugin_from_blocklist: WI,
717
+ load_plugin: WI,
718
+ unload_plugin: DI, // discards the hosted plugin's state
719
+ set_plugin_param: WI,
720
+ set_plugin_params: WI,
721
+ set_plugin_bypass: WI,
722
+ // AI models / generation
723
+ download_model: WI,
724
+ load_model: WI,
725
+ unload_model: WI,
726
+ delete_model: DI,
727
+ generate_sample: W,
728
+ generate_kit: W,
729
+ // Workbench
730
+ workbench_set_state: WI,
731
+ workbench_render: W,
732
+ workbench_load_take: WI,
733
+ workbench_undo: W,
734
+ workbench_reset_chain: DI,
735
+ // Visuals / VX video
736
+ set_visual: WI,
737
+ delete_visual: DI,
738
+ import_visual_folder: WI,
739
+ video_command: D, // generic escape hatch — includes resetWorkspace
740
+ add_video_layer: W,
741
+ update_video_layer: WI,
742
+ set_video_layer_source: WI,
743
+ remove_video_layer: DI,
744
+ apply_video_layout: WI,
745
+ add_video_fx: W,
746
+ set_video_fx: WI,
747
+ remove_video_fx: DI,
748
+ bind_video_param: WI,
749
+ set_video_settings: WI,
750
+ vx_variation: W,
751
+ // Connection management
752
+ select_instance: WI,
753
+ // Dev-tree-only tools
754
+ set_view: WI,
755
+ ask_assistant: D, // executes arbitrary model-chosen tool calls
756
+ };
757
+ const internalTools = new Map();
758
+ {
759
+ const origRegisterTool = server.registerTool.bind(server);
760
+ server.registerTool = ((name, config, handler) => {
761
+ internalTools.set(name, { shape: config.inputSchema, handler });
762
+ // Merge the central annotation policy. Inline annotations at the call site
763
+ // win per key; openWorldHint is false server-wide (local plugin only).
764
+ const cfg = config;
765
+ if (!TOOL_ANNOTATIONS[name]) {
766
+ console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
767
+ }
768
+ cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
769
+ return origRegisterTool(name, config, handler);
770
+ });
771
+ }
772
+ async function callInternalTool(name, args) {
773
+ const tool = internalTools.get(name);
774
+ if (!tool)
775
+ return { text: `Unknown tool: ${name}.`, isError: true };
776
+ // Direct handler calls skip the SDK's zod layer — parse here so schema
777
+ // defaults apply and unknown keys strip, exactly like a real MCP call
778
+ // (list_samples without offset/limit would otherwise slice(undefined, NaN)
779
+ // to an empty page).
780
+ let parsed = args;
781
+ if (tool.shape) {
782
+ tool.parser ??= z.object(tool.shape);
783
+ const r = tool.parser.safeParse(args);
784
+ if (!r.success) {
785
+ const issues = r.error.issues.map((i) => `${i.path.join(".") || "(args)"}: ${i.message}`).join("; ");
786
+ return { text: `Invalid arguments for ${name} — ${issues}`, isError: true };
787
+ }
788
+ parsed = r.data;
789
+ }
790
+ try {
791
+ const result = await tool.handler(parsed);
792
+ const out = (result.content ?? [])
793
+ .filter((c) => c.type === "text")
794
+ .map((c) => c.text ?? "")
795
+ .join("\n");
796
+ return { text: out, isError: Boolean(result.isError) };
797
+ }
798
+ catch (e) {
799
+ return { text: `Tool error: ${e.message}`, isError: true };
800
+ }
801
+ }
208
802
  // ============================================================
209
803
  // TOOLS — Offline (work without plugin running)
210
804
  // ============================================================
@@ -241,7 +835,7 @@ server.registerTool("get_parameter_info", {
241
835
  if (paramId) {
242
836
  const p = index.get(paramId);
243
837
  if (!p)
244
- return error(`Unknown parameter: ${paramId}`);
838
+ return error(formatUnknownParamError(paramId, index));
245
839
  return json(p);
246
840
  }
247
841
  let params;
@@ -269,8 +863,10 @@ server.registerTool("get_parameter_info", {
269
863
  if (params.length === 0)
270
864
  return text("No parameters found matching criteria.");
271
865
  if (params.length > 50) {
272
- return text(`${params.length} parameters found. Showing summary:\n\n` +
273
- params.map(formatParam).join("\n"));
866
+ const shown = params.slice(0, 80);
867
+ return text(`${params.length} parameters found. Showing first ${shown.length}. ` +
868
+ "Use paramId, cell, effect, search, type, or design_context to narrow the response.\n\n" +
869
+ shown.map(formatParam).join("\n"));
274
870
  }
275
871
  return json(params);
276
872
  });
@@ -347,6 +943,16 @@ server.registerTool("get_manifest_summary", {
347
943
  description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
348
944
  "modulators, and parameter counts.",
349
945
  inputSchema: {},
946
+ outputSchema: {
947
+ product: z.string().optional(),
948
+ company: z.string().optional(),
949
+ totalParameters: z.number().optional(),
950
+ cells: z.array(z.string()).optional(),
951
+ effects: z.array(z.string()).optional(),
952
+ globalParameters: z.number().optional(),
953
+ modulatableParameters: z.number().optional(),
954
+ pluginParameters: z.number().optional(),
955
+ },
350
956
  annotations: { readOnlyHint: true },
351
957
  }, async () => {
352
958
  const manifest = await loadManifest();
@@ -355,7 +961,7 @@ server.registerTool("get_manifest_summary", {
355
961
  const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
356
962
  const global = params.filter((p) => !p.cell);
357
963
  const modulatable = params.filter((p) => p.isModulatable);
358
- return json({
964
+ return structured({
359
965
  product: manifest.name,
360
966
  company: manifest.company,
361
967
  totalParameters: params.length,
@@ -366,6 +972,98 @@ server.registerTool("get_manifest_summary", {
366
972
  pluginParameters: params.filter((p) => p.isPluginParameter).length,
367
973
  });
368
974
  });
975
+ server.registerTool("design_context", {
976
+ title: "Design Context",
977
+ description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
978
+ "unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
979
+ inputSchema: {
980
+ cellId: z
981
+ .number()
982
+ .int()
983
+ .min(1)
984
+ .max(8)
985
+ .default(1)
986
+ .describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
987
+ },
988
+ annotations: { readOnlyHint: true },
989
+ }, async ({ cellId }) => {
990
+ const manifest = await loadManifest();
991
+ const params = manifest.parameters;
992
+ const byId = new Map(params.map((p) => [p.paramId, p]));
993
+ const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
994
+ const macroTargetsByEffect = {};
995
+ for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
996
+ const key = p.effect ?? "global";
997
+ if (!macroTargetsByEffect[key])
998
+ macroTargetsByEffect[key] = [];
999
+ macroTargetsByEffect[key].push(p.paramId);
1000
+ }
1001
+ for (const key of Object.keys(macroTargetsByEffect)) {
1002
+ macroTargetsByEffect[key] = macroTargetsByEffect[key].sort();
1003
+ }
1004
+ const filterType = byId.get(example("filterType"));
1005
+ const lfoQuick = [
1006
+ ["16 bar", 0, 0.000],
1007
+ ["4 bar", 2, 0.091],
1008
+ ["2 bar", 3, 0.136],
1009
+ ["1 bar", 4, 0.182],
1010
+ ["1/2", 6, 0.273],
1011
+ ["1/4", 9, 0.409],
1012
+ ["1/8", 12, 0.545],
1013
+ ["1/16", 15, 0.682],
1014
+ ["1/32", 18, 0.818],
1015
+ ["1/64", 21, 0.955],
1016
+ ].map(([division, index, value]) => ({ division, index, lfoRate: value }));
1017
+ return json({
1018
+ product: manifest.name,
1019
+ exampleCell: cellId,
1020
+ macroTargetsByEffect,
1021
+ keyPerCellParams: {
1022
+ mixer: [example("volume"), example("pan"), example("gainGain")],
1023
+ filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
1024
+ delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
1025
+ crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
1026
+ fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
1027
+ gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
1028
+ compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
1029
+ drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
1030
+ },
1031
+ unitNotes: {
1032
+ pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
1033
+ percentages: "Most mix/feedback/depth controls are 0..100 percent.",
1034
+ filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
1035
+ delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
1036
+ lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
1037
+ },
1038
+ musicalRanges: {
1039
+ filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
1040
+ delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
1041
+ wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
1042
+ crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
1043
+ volumeDb: "Layer cells with -12..0 dB staging before master processing",
1044
+ },
1045
+ lfoSyncTable: {
1046
+ formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
1047
+ quick: lfoQuick,
1048
+ fullDivisions: LFO_DIVISIONS,
1049
+ shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
1050
+ },
1051
+ delaySyncDivisions: [
1052
+ "1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
1053
+ "1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D",
1054
+ "1/2T", "1/2", "1/2D", "1",
1055
+ ],
1056
+ filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
1057
+ "0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
1058
+ ],
1059
+ midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
1060
+ const id = i + 1;
1061
+ return [`cell${id}`, 47 + id];
1062
+ })),
1063
+ variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1064
+ modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
1065
+ });
1066
+ });
369
1067
  // Standard full update — graph dispatch + KV sync + parameter updates.
370
1068
  const KV_OPTS_STANDARD = {
371
1069
  shouldDispatch: true,
@@ -394,6 +1092,20 @@ const KV_OPTS_PATTERN = {
394
1092
  updateParameters: false,
395
1093
  broadcastFollowers: false,
396
1094
  };
1095
+ // Master-chain config — KV + config-resource sync, NO graph dispatch. Every
1096
+ // master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
1097
+ // limiter, EQ/width) is live-polled from master_config by its native node, so
1098
+ // a rebuild here is pure dropout risk (the plan's no-rebuild contract).
1099
+ // Mirrors interop.ts updateMasterConfig's options.
1100
+ const KV_OPTS_MASTER_CONFIG = {
1101
+ shouldDispatch: false,
1102
+ syncKVResources: true,
1103
+ syncConfigResources: true,
1104
+ skipFileLoadingIfUnchanged: true,
1105
+ resyncKVAfterDispatch: false,
1106
+ updateParameters: true,
1107
+ broadcastFollowers: false,
1108
+ };
397
1109
  // Effect config toggle — lighter KV path, no graph dispatch.
398
1110
  const KV_OPTS_EFFECT_CONFIG = {
399
1111
  shouldDispatch: false,
@@ -423,30 +1135,136 @@ async function applyKv(delta, options, directNodeOps) {
423
1135
  }
424
1136
  server.registerTool("get_state", {
425
1137
  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.",
1138
+ description: "Read the current state of the running PAM plugin. Prefer section, keys, cellIds, or get_design_overview " +
1139
+ "over an unfiltered full-state read; large full-state payloads are capped to protect agent context.",
428
1140
  inputSchema: {
429
1141
  section: z
430
1142
  .enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
431
1143
  .optional()
432
1144
  .describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
1145
+ keys: z
1146
+ .array(z.string())
1147
+ .optional()
1148
+ .describe("Exact top-level state keys to return, e.g. ['cell1_filterCutoff','cell1_midiSequence']."),
1149
+ cellIds: z
1150
+ .array(z.number().int().min(1).max(8))
1151
+ .optional()
1152
+ .describe("Return only cell-scoped keys/config/modulation for these cells."),
1153
+ allowLarge: z
1154
+ .boolean()
1155
+ .optional()
1156
+ .describe("Set true to force an uncapped full-state response. Prefer filters unless you truly need everything."),
433
1157
  },
434
1158
  annotations: { readOnlyHint: true },
435
- }, async ({ section }) => {
1159
+ }, async ({ section, keys, cellIds, allowLarge }) => {
436
1160
  const check = await requireBridge();
437
1161
  if (check !== true)
438
1162
  return check;
1163
+ const hasFilters = (keys?.length ?? 0) > 0 || (cellIds?.length ?? 0) > 0;
439
1164
  if (section && section !== "full") {
440
1165
  const result = await bridge.send("getState", section);
441
- return json(result ?? `Section '${section}' not found in state.`);
1166
+ const filtered = filterSectionResult(section, result, { keys, cellIds });
1167
+ return jsonWithLimit(filtered ?? `Section '${section}' not found in state.`, "Use a narrower section, keys, or cellIds filter.");
442
1168
  }
443
1169
  const state = await bridge.send("getState");
444
- return json(state);
1170
+ const filtered = filterState(state, { keys, cellIds });
1171
+ if (!hasFilters && !allowLarge) {
1172
+ 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.");
1173
+ }
1174
+ return json(filtered);
1175
+ });
1176
+ server.registerTool("get_design_overview", {
1177
+ title: "Get Design Overview",
1178
+ description: "Return a compact preset-design snapshot: cells, loaded samples/synth engines, enabled effects, pattern counts, " +
1179
+ "modulation link counts, mixer values, BPM, and master FX. Use this instead of full get_state during preset design.",
1180
+ inputSchema: {
1181
+ cellIds: z
1182
+ .array(z.number().int().min(1).max(8))
1183
+ .optional()
1184
+ .describe("Optional subset of cells to include. Defaults to all cells present in the selected manifest."),
1185
+ },
1186
+ annotations: { readOnlyHint: true },
1187
+ }, async ({ cellIds }) => {
1188
+ const check = await requireBridge();
1189
+ if (check !== true)
1190
+ return check;
1191
+ const [stateRaw, transportRaw, manifest] = await Promise.all([
1192
+ bridge.send("getState"),
1193
+ bridge.send("getTransport").catch(() => undefined),
1194
+ loadManifest(),
1195
+ ]);
1196
+ const state = isRecord(stateRaw) ? stateRaw : {};
1197
+ const transport = isRecord(transportRaw) ? transportRaw : {};
1198
+ // Product-aware: MFX carries wavefold/resonator, PAM/Cell1 do not.
1199
+ const derivedEffects = cellEffectNames(manifest);
1200
+ const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
1201
+ const manifestCellIds = [...new Set(manifest.parameters
1202
+ .map((p) => p.cell)
1203
+ .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
1204
+ .map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
1205
+ const ids = cellIds?.length ? cellIds : manifestCellIds;
1206
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
1207
+ const modArrays = {
1208
+ lfo: Array.isArray(state.lfos) ? state.lfos : [],
1209
+ envelope: Array.isArray(state.envelopes) ? state.envelopes : [],
1210
+ vary: Array.isArray(state.vary) ? state.vary : [],
1211
+ paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
1212
+ };
1213
+ const cells = ids.map((id) => {
1214
+ const prefix = `cell${id}_`;
1215
+ const rawCellState = cellsObj[String(id)];
1216
+ const cellState = isRecord(rawCellState) ? rawCellState : {};
1217
+ const samplePath = cellState.filePath ??
1218
+ cellState.samplePath ??
1219
+ state[`${prefix}filePath`] ??
1220
+ state[`${prefix}samplePath`];
1221
+ const modulation = Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
1222
+ kind,
1223
+ arr.filter((entry) => paramTargetsCell(entry, new Set([prefix]))).length,
1224
+ ]));
1225
+ return {
1226
+ cellId: id,
1227
+ sample: {
1228
+ name: stemName(samplePath),
1229
+ path: typeof samplePath === "string" ? samplePath : undefined,
1230
+ },
1231
+ // Both halves: the enum an agent writes back, and the name it reasons about.
1232
+ synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1233
+ enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1234
+ patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1235
+ lane,
1236
+ countEvents(state[`${prefix}${suffix}`]),
1237
+ ])),
1238
+ modulation,
1239
+ mixer: {
1240
+ volume: state[`${prefix}volume`],
1241
+ pan: state[`${prefix}pan`],
1242
+ gain: state[`${prefix}gainGain`],
1243
+ },
1244
+ };
1245
+ });
1246
+ return json({
1247
+ product: manifest.name,
1248
+ bpm: state.bpm ?? transport.bpm,
1249
+ playing: transport.playing,
1250
+ masterFx: {
1251
+ compressorEnabled: state.masterCompressorEnabled,
1252
+ limiterEnabled: state.masterLimiterEnabled,
1253
+ tapeEnabled: state.masterTapeEnabled,
1254
+ },
1255
+ totals: {
1256
+ cellsWithSamples: cells.filter((c) => c.sample.path).length,
1257
+ activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
1258
+ modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
1259
+ },
1260
+ cells,
1261
+ });
445
1262
  });
446
1263
  server.registerTool("set_parameters", {
447
1264
  title: "Set Parameters",
448
1265
  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.",
1266
+ "Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
1267
+ "when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges.",
450
1268
  inputSchema: {
451
1269
  parameters: z
452
1270
  .record(z.string(), z.number())
@@ -460,11 +1278,11 @@ server.registerTool("set_parameters", {
460
1278
  for (const [paramId, value] of Object.entries(parameters)) {
461
1279
  const p = index.get(paramId);
462
1280
  if (!p) {
463
- errors.push(`Unknown parameter: ${paramId}`);
1281
+ errors.push(formatUnknownParamError(paramId, index));
464
1282
  continue;
465
1283
  }
466
1284
  if (value < p.min || value > p.max) {
467
- errors.push(`${paramId}: ${value} out of range [${p.min}..${p.max}]`);
1285
+ errors.push(formatRangeError(p, value));
468
1286
  }
469
1287
  }
470
1288
  if (errors.length > 0)
@@ -542,11 +1360,21 @@ server.registerTool("get_model_status", {
542
1360
  title: "Get AI Model Status",
543
1361
  description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
544
1362
  inputSchema: { engine: MODEL_ENGINE },
1363
+ outputSchema: {
1364
+ engine: z.string().optional(),
1365
+ enabled: z.boolean().optional(),
1366
+ downloaded: z.boolean().optional(),
1367
+ loaded: z.boolean().optional(),
1368
+ inFlight: z.boolean().optional(),
1369
+ version: z.string().optional(),
1370
+ state: z.string().optional(),
1371
+ progress: z.number().optional(),
1372
+ },
545
1373
  }, async ({ engine }) => {
546
1374
  const check = await requireBridge();
547
1375
  if (check !== true)
548
1376
  return check;
549
- return json(await bridge.send("modelStatus", { engine }));
1377
+ return structuredFromBridge(await bridge.send("modelStatus", { engine }));
550
1378
  });
551
1379
  server.registerTool("load_model", {
552
1380
  title: "Load AI Model",
@@ -798,11 +1626,15 @@ server.registerTool("workbench_sidecar_status", {
798
1626
  title: "Workbench: Sidecar Status",
799
1627
  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
1628
  inputSchema: {},
1629
+ outputSchema: {
1630
+ connected: z.boolean().optional(),
1631
+ loaded: z.boolean().optional(),
1632
+ },
801
1633
  }, async () => {
802
1634
  const check = await requireBridge();
803
1635
  if (check !== true)
804
1636
  return check;
805
- return json(await bridge.send("workbenchSidecarStatus"));
1637
+ return structuredFromBridge(await bridge.send("workbenchSidecarStatus"));
806
1638
  });
807
1639
  server.registerTool("trigger_midi", {
808
1640
  title: "Trigger MIDI",
@@ -867,13 +1699,13 @@ server.registerTool("add_modulation", {
867
1699
  .min(1)
868
1700
  .max(8)
869
1701
  .optional()
870
- .describe("Cell ID for envelope/vary sources (1-8)."),
1702
+ .describe("Required for envelope/vary: source cell driving the follower (1-8). This is not the target cell; target is inferred from paramId."),
871
1703
  lfoIndex: z
872
1704
  .number()
873
1705
  .min(0)
874
1706
  .max(5)
875
1707
  .optional()
876
- .describe("LFO generator index (0-5). Required for LFO type."),
1708
+ .describe("Required for LFO: generator index 0-5. Parameter IDs remain 1-based (lfo1_... through lfo6_...)."),
877
1709
  strength: z
878
1710
  .number()
879
1711
  .min(0)
@@ -888,19 +1720,19 @@ server.registerTool("add_modulation", {
888
1720
  .boolean()
889
1721
  .optional()
890
1722
  .describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
891
- "false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
1723
+ "false (default) = AMP; true & pitchDuck=false = PITCH (comb); true & pitchDuck=true = DUCK."),
892
1724
  pitchDuck: z
893
1725
  .boolean()
894
1726
  .optional()
895
- .describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
1727
+ .describe("Envelope only: when pitch=true, switch from PITCH (comb) to DUCK semantics (notch). Ignored if pitch=false."),
896
1728
  },
897
1729
  }, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
898
1730
  const index = await getParameterIndex();
899
1731
  const param = index.get(paramId);
900
1732
  if (!param)
901
- return error(`Unknown parameter: ${paramId}`);
1733
+ return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
902
1734
  if (!param.isModulatable)
903
- return error(`Parameter '${paramId}' is not modulatable.`);
1735
+ return error(formatNotModulatableError(paramId, index));
904
1736
  const check = await requireBridge();
905
1737
  if (check !== true)
906
1738
  return check;
@@ -916,12 +1748,12 @@ server.registerTool("add_modulation", {
916
1748
  }
917
1749
  else if (type === "envelope") {
918
1750
  if (!cellId)
919
- return error("cellId is required for envelope modulation.");
1751
+ return error("cellId is required for envelope modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
920
1752
  await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
921
1753
  }
922
1754
  else if (type === "vary") {
923
1755
  if (!cellId)
924
- return error("cellId is required for vary modulation.");
1756
+ return error("cellId is required for vary modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
925
1757
  await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
926
1758
  }
927
1759
  return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
@@ -994,12 +1826,17 @@ server.registerTool("update_pattern", {
994
1826
  });
995
1827
  server.registerTool("randomize", {
996
1828
  title: "Randomize",
997
- description: "Trigger PAM's randomization engine to generate random sounds and patterns.",
1829
+ description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
1830
+ "target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
1831
+ "shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
1832
+ "to be OPEN (samples/sequences/full run natively and do not). target:'synth' needs PAM ≥ 1.13.0.",
998
1833
  inputSchema: {
1834
+ // 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
1835
+ // like 'synth'. The describe() text is pinned (exported toolpack bytes).
999
1836
  target: z
1000
- .enum(["full", "samples", "sequences"])
1837
+ .enum(["full", "samples", "sequences", "synth", "lfos", "mods"])
1001
1838
  .default("full")
1002
- .describe("What to randomize."),
1839
+ .describe("What to randomize. 'synth' = the built-in drum-synth machines (PAM window must be open)."),
1003
1840
  cellIds: z
1004
1841
  .array(z.number().min(1).max(8))
1005
1842
  .optional()
@@ -1025,6 +1862,21 @@ server.registerTool("randomize", {
1025
1862
  const check = await requireBridge();
1026
1863
  if (check !== true)
1027
1864
  return check;
1865
+ if (target === "synth") {
1866
+ // Routed to the canonical WebView randomizer (single source of truth) — needs the
1867
+ // window open. Empty array → all non-excluded cells.
1868
+ await bridge.send("randomizeDrumSynth", cellIds ?? []);
1869
+ return text(`Randomized drum synths${cellIds ? ` for cells ${cellIds.join(", ")}` : " (all cells)"}. ` +
1870
+ `(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
1871
+ }
1872
+ if (target === "lfos" || target === "mods") {
1873
+ // The data-driven modulation randomizer lives in WebView JS
1874
+ // (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
1875
+ // like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
1876
+ await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
1877
+ return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
1878
+ `(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
1879
+ }
1028
1880
  await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
1029
1881
  return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
1030
1882
  });
@@ -1039,7 +1891,7 @@ server.registerTool("randomize_samples", {
1039
1891
  genreHint: z
1040
1892
  .string()
1041
1893
  .optional()
1042
- .describe("Genre hint to guide sample selection. Supported: techno, house, trap, boom_bap, drill, dnb, hip-hop, ambient, industrial, breaks."),
1894
+ .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
1895
  bpmHint: z
1044
1896
  .number()
1045
1897
  .min(20)
@@ -1127,13 +1979,148 @@ server.registerTool("get_transport", {
1127
1979
  title: "Get Transport",
1128
1980
  description: "Get current transport state (BPM, playing, position).",
1129
1981
  inputSchema: {},
1982
+ outputSchema: {
1983
+ bpm: z.number().optional(),
1984
+ isPlaying: z.boolean().optional(),
1985
+ ppqPosition: z.number().optional(),
1986
+ },
1130
1987
  annotations: { readOnlyHint: true },
1131
1988
  }, async () => {
1132
1989
  const check = await requireBridge();
1133
1990
  if (check !== true)
1134
1991
  return check;
1135
1992
  const transport = await bridge.send("getTransport");
1136
- return json(transport);
1993
+ return structuredFromBridge(transport);
1994
+ });
1995
+ server.registerTool("get_master_analysis", {
1996
+ title: "Get Master Analysis",
1997
+ description: "Read the master-bus loudness measurement (BS.1770-5 / EBU R128): momentary/short-term/integrated " +
1998
+ "LUFS (m/s/i), rolling 30 s ungated loudness (roll), loudness range (lra, stable after 60 s — " +
1999
+ "check lraStable), true peak dBTP (tp; tp3s = 3 s window), sample peak (sp), PSR/PLR crest " +
2000
+ "metrics, stereo correlation (corr; corrLow = <120 Hz band), elapsed ms since reset, and ageMs " +
2001
+ "(snapshot staleness). Absent field = no reading yet. Works headless (window can be closed); " +
2002
+ "audio must be flowing — start the transport or play something first. Guard rails for " +
2003
+ "mastering moves: keep tp <= -1 dBTP and PSR >= 8 in the loudest section.",
2004
+ inputSchema: {},
2005
+ annotations: { readOnlyHint: true },
2006
+ }, async () => {
2007
+ const check = await requireBridge();
2008
+ if (check !== true)
2009
+ return check;
2010
+ const analysis = await bridge.send("getMasterAnalysis");
2011
+ return json(analysis);
2012
+ });
2013
+ server.registerTool("get_mix_analysis", {
2014
+ title: "Get Per-Cell Mix Analysis",
2015
+ description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
2016
+ "true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
2017
+ "spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
2018
+ "(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
2019
+ "playing). Cached result otherwise. Requires PAM ≥ 1.17.0. PAM only.",
2020
+ inputSchema: {
2021
+ refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
2022
+ durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
2023
+ 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)."),
2024
+ },
2025
+ }, async ({ refresh, durationSeconds, cellIds }) => {
2026
+ const check = await requireBridge();
2027
+ if (check !== true)
2028
+ return check;
2029
+ if (refresh) {
2030
+ const started = (await bridge.send("analyzeMix", { durationSeconds, cellIds }));
2031
+ if (started.status !== "started")
2032
+ return error("Mix analysis is busy — an offline render already owns the engine. Retry when it finishes.");
2033
+ const t0 = Date.now();
2034
+ while (Date.now() - t0 < 120_000) {
2035
+ await new Promise((r) => setTimeout(r, 500));
2036
+ const status = (await bridge.send("getMixAnalysis"));
2037
+ if (status.status !== "running")
2038
+ return json(status);
2039
+ }
2040
+ return error("Mix analysis timed out after 120 s.");
2041
+ }
2042
+ const result = await bridge.send("getMixAnalysis");
2043
+ return json(result);
2044
+ });
2045
+ // Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
2046
+ // stacking guard's memory (live payloads refresh their epoch, so they never
2047
+ // stay consumed). Session-scoped by design: a server restart just re-allows a
2048
+ // suggest, which the refresh-first workflow tolerates.
2049
+ let lastAppliedMixEpoch = -1;
2050
+ server.registerTool("suggest_mix", {
2051
+ title: "Suggest Mix Balance Moves",
2052
+ description: "Runs the Mix Assist rule engine over the latest per-cell analysis (get_mix_analysis): " +
2053
+ "partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
2054
+ "settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
2055
+ "as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
2056
+ "Requires PAM ≥ 1.17.0. PAM only.",
2057
+ inputSchema: {
2058
+ scopes: z
2059
+ .array(z.enum(["balance", "pan", "eq", "dynamics"]))
2060
+ .optional()
2061
+ .describe("Stages to run (default balance+pan+eq; dynamics is opt-in)."),
2062
+ apply: z.boolean().optional().describe("Apply the suggested moves to the running plugin."),
2063
+ },
2064
+ }, async ({ scopes, apply }) => {
2065
+ const check = await requireBridge();
2066
+ if (check !== true)
2067
+ return check;
2068
+ const r = (await bridge.send("getMixAnalysis"));
2069
+ if (!r?.available)
2070
+ return error("No mix analysis available — run get_mix_analysis with refresh:true first.");
2071
+ const analysis = parseMixAnalysisPayload(r.analysis);
2072
+ if (!analysis)
2073
+ return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
2074
+ // Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
2075
+ // deltas come from the analysis but land on the LIVE params, so suggesting
2076
+ // again from an analysis that was already applied doubles the correction.
2077
+ if (!analysis.live && analysis.epoch === lastAppliedMixEpoch)
2078
+ return error("This analysis was already applied — re-run get_mix_analysis with refresh:true to measure the new balance first.");
2079
+ const state = (await bridge.send("getState"));
2080
+ // Guard-rail parity with the in-app panel (context.ts contract): macros
2081
+ // count as modulation, and cells whose source was removed after the
2082
+ // analysis must not receive moves.
2083
+ const mods = {
2084
+ lfos: state.lfos,
2085
+ envelopes: state.envelopes,
2086
+ vary: state.vary,
2087
+ paramSeqs: state.paramSeqs,
2088
+ macros: state.macros,
2089
+ };
2090
+ const hasSource = (id) => {
2091
+ const cells = state.cells;
2092
+ if (cells?.[String(id)]?.filePath)
2093
+ return true;
2094
+ if (Number(state[`cell${id}_synthEngine`] ?? 0) > 0.5)
2095
+ return true;
2096
+ if (Number(state[`cell${id}_magentaEngine`] ?? 0) > 0.5)
2097
+ return true;
2098
+ const pluginPath = state[`cell${id}_pluginPath`];
2099
+ return typeof pluginPath === "string" && pluginPath.length > 0;
2100
+ };
2101
+ const measurement = buildMixMeasurementFromState(analysis, state, mods, { hasSource });
2102
+ const current = currentFlatFromState(analysis, state);
2103
+ // Rider ownership (the panel's parked-bal rule): while Auto Mix AUTO runs,
2104
+ // the cMixRide rider owns the balance — fader suggestions would
2105
+ // double-drive it on top of the signed trims.
2106
+ const riderOwnsBalance = Number(state.masterMixRideEnabled ?? 0) === 1 && Number(state.masterMixRideAuto ?? 0) === 1;
2107
+ let resolvedScopes = scopes ?? ["balance", "pan", "eq"];
2108
+ const balanceParked = riderOwnsBalance && resolvedScopes.includes("balance");
2109
+ if (balanceParked)
2110
+ resolvedScopes = resolvedScopes.filter((s) => s !== "balance");
2111
+ if (resolvedScopes.length === 0)
2112
+ return error("Auto Mix is running and owns the balance — request pan, eq, or dynamics scopes while it is on.");
2113
+ const suggestion = suggestMix(measurement, { scopes: resolvedScopes }, current);
2114
+ const parkedNote = balanceParked
2115
+ ? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
2116
+ : {};
2117
+ if (apply && suggestion.moves.length > 0) {
2118
+ await applyKv(suggestion.flat, KV_OPTS_STANDARD);
2119
+ if (!analysis.live)
2120
+ lastAppliedMixEpoch = analysis.epoch;
2121
+ return json({ ...suggestion, ...parkedNote, applied: true });
2122
+ }
2123
+ return json({ ...suggestion, ...parkedNote });
1137
2124
  });
1138
2125
  server.registerTool("new_preset", {
1139
2126
  title: "New Preset",
@@ -1154,7 +2141,11 @@ server.registerTool("save_preset", {
1154
2141
  "Writes to the user preset folder by default; pass `path` for a specific location. " +
1155
2142
  "Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
1156
2143
  inputSchema: {
1157
- name: z.string().describe("Preset name (used for filename if `path` omitted)."),
2144
+ // Optional since 1.15.0: omitted name = re-save the ACTIVE user preset in
2145
+ // place (the UI Save button's semantics; the assistant's bare "save").
2146
+ // The describe() text is pinned — the exported assistant toolpack keeps
2147
+ // property descriptions, and the model is trained against those bytes.
2148
+ name: z.string().optional().describe("Preset name (used for filename if `path` omitted)."),
1158
2149
  author: z.string().optional().describe("Author name."),
1159
2150
  category: z
1160
2151
  .string()
@@ -1179,26 +2170,56 @@ server.registerTool("save_preset", {
1179
2170
  const check = await requireBridge();
1180
2171
  if (check !== true)
1181
2172
  return check;
2173
+ const { state, canonical } = await fetchSaveSnapshot();
2174
+ // Bare save (no name): the UI Save button's semantics — overwrite the
2175
+ // ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
2176
+ // the in-plugin assistant executor (error strings included — the model's
2177
+ // repair turns are trained on those shapes). Factory/Init need a name.
2178
+ const extras = rest;
2179
+ if (!name?.trim()) {
2180
+ const activePath = typeof state.activePresetPath === "string" ? state.activePresetPath : "";
2181
+ if (!activePath || activePath === "Init")
2182
+ return error('No preset is active — give this one a name, e.g. save_preset({ name: "Night Drive" }).');
2183
+ const readPath = normalizePresetLookupPath(activePath);
2184
+ if (readPath.startsWith("Factory/"))
2185
+ return error("The current preset is a factory patch — save a copy under a new name instead.");
2186
+ name = (activePath.split("/").pop() || "").replace(/\.preset$/, "");
2187
+ if (!name)
2188
+ return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
2189
+ if (path === undefined)
2190
+ path = activePath;
2191
+ try {
2192
+ const parsed = parsePresetFile(await readFile(await resolvePresetPath(readPath), "utf-8"));
2193
+ for (const [k, v] of Object.entries(parsed.metadata)) {
2194
+ // Structural keys are rebuilt below; everything else (author/tags/
2195
+ // category/…) carries over unless the caller overrode it.
2196
+ if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
2197
+ continue;
2198
+ if (extras[k] === undefined)
2199
+ extras[k] = v;
2200
+ }
2201
+ }
2202
+ catch {
2203
+ // unreadable/new — save fresh with whatever the caller passed
2204
+ }
2205
+ }
1182
2206
  const metadata = { name, factory: false };
1183
2207
  if (path !== undefined)
1184
2208
  metadata.path = path;
1185
- for (const [k, v] of Object.entries(rest)) {
2209
+ for (const [k, v] of Object.entries(extras)) {
1186
2210
  if (v !== undefined)
1187
2211
  metadata[k] = v;
1188
2212
  }
1189
2213
  // Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
1190
2214
  // otherwise update the current active variation slot.
1191
- const state = (await bridge.send("getState"));
1192
2215
  const variationString = buildVariationString(state);
1193
2216
  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);
2217
+ const readPath = normalizePresetLookupPath(presetRelPath);
2218
+ // Save As must copy the active preset's complete variation bank. Reading
2219
+ // the new destination starts from eight empty slots because that file does
2220
+ // not exist yet, which used to collapse every named save to one slot.
2221
+ const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
2222
+ const existingVariations = await readExistingVariations(variationSourcePath);
1202
2223
  const hasVariations = existingVariations.some((v) => v !== null);
1203
2224
  if (!hasVariations) {
1204
2225
  existingVariations[0] = variationString;
@@ -1212,7 +2233,10 @@ server.registerTool("save_preset", {
1212
2233
  }
1213
2234
  metadata.variations = existingVariations;
1214
2235
  const result = (await bridge.send("savePreset", metadata));
1215
- return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
2236
+ lastSavedPresetName = name;
2237
+ lastSavedPresetPath = result?.path || readPath;
2238
+ return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}` +
2239
+ hostedPluginSaveWarning(state, canonical));
1216
2240
  });
1217
2241
  server.registerTool("save_variation", {
1218
2242
  title: "Save Variation",
@@ -1245,8 +2269,8 @@ server.registerTool("save_variation", {
1245
2269
  const check = await requireBridge();
1246
2270
  if (check !== true)
1247
2271
  return check;
1248
- // 1. Get current full state and build variation snapshot
1249
- const state = (await bridge.send("getState"));
2272
+ // 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
2273
+ const { state, canonical } = await fetchSaveSnapshot();
1250
2274
  const variationString = buildVariationString(state);
1251
2275
  // 2. Determine preset name and disk path — needed to read existing variations and metadata
1252
2276
  let presetName = name;
@@ -1259,11 +2283,15 @@ server.registerTool("save_variation", {
1259
2283
  // Strip "Presets/" prefix if present — resolvePresetPath already prepends
1260
2284
  // the presets base directory, so "Presets/User/X" would double up to
1261
2285
  // ".../Presets/Presets/User/X" and silently fail to read back variations.
1262
- diskPath = activePath.startsWith("Presets/") ? activePath.slice("Presets/".length) : activePath;
2286
+ diskPath = normalizePresetLookupPath(activePath);
2287
+ }
2288
+ if (!presetName && lastSavedPresetName) {
2289
+ presetName = lastSavedPresetName;
2290
+ diskPath = lastSavedPresetPath;
1263
2291
  }
1264
2292
  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.");
2293
+ return error("No preset name provided and no active preset loaded.\n" +
2294
+ "Next call: save_preset({ name: 'Your Preset Name', ...metadata }) first, or call save_variation with name: 'Your Preset Name'.");
1267
2295
  }
1268
2296
  if (!diskPath) {
1269
2297
  diskPath = `User/${presetName}.preset`;
@@ -1302,10 +2330,13 @@ server.registerTool("save_variation", {
1302
2330
  resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
1303
2331
  }
1304
2332
  const result = (await bridge.send("savePreset", metadata));
2333
+ lastSavedPresetName = presetName;
2334
+ lastSavedPresetPath = result?.path || diskPath;
1305
2335
  return text(`Saved variation ${index} for "${presetName}"\n` +
1306
2336
  `Path: ${result?.path || "(unknown path)"}\n` +
1307
2337
  `Filled slots: ${variations.filter((v) => v !== null).length}/8` +
1308
- resultSuffix);
2338
+ resultSuffix +
2339
+ hostedPluginSaveWarning(state, canonical));
1309
2340
  });
1310
2341
  server.registerTool("update_preset_metadata", {
1311
2342
  title: "Update Preset Metadata",
@@ -1419,30 +2450,141 @@ server.registerTool("reverse_sample", {
1419
2450
  await bridge.send("reverseSampleForCell", cellId);
1420
2451
  return text(`Reversed sample in cell ${cellId}.`);
1421
2452
  });
2453
+ const BRIDGE_INSTANCE_SHAPE = z.object({
2454
+ port: z.number().optional(),
2455
+ instanceId: z.string().optional(),
2456
+ product: z.string().optional(),
2457
+ pid: z.number().optional(),
2458
+ });
1422
2459
  server.registerTool("connection_status", {
1423
2460
  title: "Connection Status",
1424
2461
  description: "Check if the MCP server can connect to a running PAM plugin instance. " +
1425
2462
  "Returns connection details or instructions for enabling the bridge.",
1426
2463
  inputSchema: {},
2464
+ outputSchema: {
2465
+ instances: z.array(BRIDGE_INSTANCE_SHAPE.passthrough()).optional(),
2466
+ connected: z.boolean().optional(),
2467
+ connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
2468
+ note: z.string().optional(),
2469
+ },
1427
2470
  annotations: { readOnlyHint: true },
1428
2471
  }, async () => {
1429
2472
  const instances = await bridge.discover();
1430
2473
  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.");
2474
+ return structured({
2475
+ instances: [],
2476
+ connected: false,
2477
+ note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
2478
+ "start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
2479
+ "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
2480
+ });
1436
2481
  }
1437
2482
  const connected = bridge.isConnected() || (await bridge.connect());
1438
- return json({
2483
+ if (connected)
2484
+ syncProductHint();
2485
+ return structured({
1439
2486
  instances,
1440
2487
  connected,
2488
+ connectedTo: bridge.getConnectedInstance(),
1441
2489
  note: connected
1442
- ? "Connected to PAM. All tools available."
2490
+ ? instances.length > 1
2491
+ ? "Connected. Multiple instances are running — use select_instance to drive a specific product."
2492
+ : "Connected to PAM. All tools available."
1443
2493
  : "Found instances but could not connect. The plugin may have shut down.",
1444
2494
  });
1445
2495
  });
2496
+ server.registerTool("select_instance", {
2497
+ title: "Select Plugin Instance",
2498
+ description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
2499
+ "one is running. Match by product name, instanceId, or port — connection_status lists them. " +
2500
+ "Reconnects if currently connected to a different instance, and points offline parameter " +
2501
+ "validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
2502
+ "choice at server launch.",
2503
+ inputSchema: {
2504
+ product: z.string().optional().describe("Product name: PAM, CELL1, or MFX (case-insensitive)."),
2505
+ instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
2506
+ port: z.number().int().optional().describe("Exact TCP port from connection_status."),
2507
+ },
2508
+ outputSchema: {
2509
+ connected: z.boolean().optional(),
2510
+ connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
2511
+ note: z.string().optional(),
2512
+ },
2513
+ }, async ({ product, instanceId, port }) => {
2514
+ if (!product && !instanceId && port === undefined) {
2515
+ return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
2516
+ }
2517
+ const ok = await bridge.connect({ product, instanceId, port });
2518
+ if (!ok) {
2519
+ const instances = await bridge.discover();
2520
+ const listing = instances.length
2521
+ ? ` Running instances: ${instances
2522
+ .map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
2523
+ .join("; ")}.`
2524
+ : " No running instances found.";
2525
+ return error(`No running instance matches that selector.${listing}`);
2526
+ }
2527
+ syncProductHint();
2528
+ return structured({
2529
+ connected: true,
2530
+ connectedTo: bridge.getConnectedInstance(),
2531
+ note: "Live tools now drive this instance.",
2532
+ });
2533
+ });
2534
+ // Bounded recursive name search under a library root (BFS — shallow matches
2535
+ // first), over the bridge's listDirectory. Mirrors the in-plugin executor's
2536
+ // searchLibrary (assistantExecutor.ts) so the assistant's trained browse chain
2537
+ // (list_samples{path,search} → load_sample of a path-joined result) behaves
2538
+ // identically through the dev server. Returned names are relative to the
2539
+ // searched root; listDirectory accepts base-relative paths, so recursion can
2540
+ // use each entry's `path` verbatim.
2541
+ async function searchSampleLibrary(rootPath, term, audioOnly) {
2542
+ const t = term.toLowerCase();
2543
+ const matches = [];
2544
+ const queue = [{ path: rootPath, prefix: "", depth: 0 }];
2545
+ const options = JSON.stringify({ audioOnly, sort: "name" });
2546
+ let dirReads = 0;
2547
+ while (queue.length && dirReads < 40 && matches.length < 60) {
2548
+ const { path, prefix, depth } = queue.shift();
2549
+ dirReads++;
2550
+ let entries = [];
2551
+ try {
2552
+ const res = await bridge.send("listDirectory", path, options);
2553
+ if (Array.isArray(res))
2554
+ entries = res;
2555
+ }
2556
+ catch {
2557
+ continue; // unreadable subdir — keep sweeping the rest
2558
+ }
2559
+ for (const e of entries) {
2560
+ const name = typeof e.name === "string" ? e.name : "";
2561
+ if (!name)
2562
+ continue;
2563
+ if (e.isDir) {
2564
+ if (depth < 3 && typeof e.path === "string")
2565
+ queue.push({ path: e.path, prefix: `${prefix}${name}/`, depth: depth + 1 });
2566
+ }
2567
+ else if (name.toLowerCase().includes(t)) {
2568
+ matches.push(`${prefix}${name}`);
2569
+ if (matches.length >= 60)
2570
+ break;
2571
+ }
2572
+ }
2573
+ }
2574
+ return matches;
2575
+ }
2576
+ // Search-result payloads feed the assistant's 256-token continuation round —
2577
+ // trim to that budget instead of letting the listing collapse to an opaque
2578
+ // truncation string (executor parity: assistantExecutor.ts fitListing).
2579
+ function fitListing(base, itemsKey, items, moreKey, total) {
2580
+ let page = items;
2581
+ for (;;) {
2582
+ const payload = { ...base, [itemsKey]: page, ...(total > page.length ? { [moreKey]: total - page.length } : {}) };
2583
+ if (JSON.stringify(payload).length <= 380 || page.length <= 2)
2584
+ return payload;
2585
+ page = page.slice(0, Math.max(2, Math.floor(page.length / 2)));
2586
+ }
2587
+ }
1446
2588
  server.registerTool("list_samples", {
1447
2589
  title: "List Samples",
1448
2590
  description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
@@ -1474,12 +2616,25 @@ server.registerTool("list_samples", {
1474
2616
  .max(200)
1475
2617
  .default(50)
1476
2618
  .describe("Maximum number of entries to return (1-200). Default: 50."),
2619
+ // Declared LAST so the exported assistant toolpack keeps its property
2620
+ // order byte-identical (export-toolpack.mjs overwrites this prop's text).
2621
+ search: z
2622
+ .string()
2623
+ .optional()
2624
+ .describe("Filter by file name, searched recursively under `path` (e.g. 'kick', '808')."),
1477
2625
  },
1478
2626
  annotations: { readOnlyHint: true },
1479
- }, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
2627
+ }, async ({ path: dirPath, audioOnly, sort, offset, limit, search }) => {
1480
2628
  const check = await requireBridge();
1481
2629
  if (check !== true)
1482
2630
  return check;
2631
+ const term = search?.trim();
2632
+ if (term) {
2633
+ const matches = await searchSampleLibrary(dirPath, term, audioOnly);
2634
+ // Executor-shaped payload; empty files:[] is a SUCCESS (the assistant
2635
+ // reports the miss in text instead of repair-looping).
2636
+ return json(fitListing({ path: dirPath }, "files", matches.slice(0, 10), "moreFiles", matches.length));
2637
+ }
1483
2638
  const options = JSON.stringify({ audioOnly, sort });
1484
2639
  const result = await bridge.send("listDirectory", dirPath, options);
1485
2640
  if (!Array.isArray(result) || result.length === 0) {
@@ -1678,7 +2833,7 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
1678
2833
  "mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
1679
2834
  "halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
1680
2835
  "polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
1681
- "cameraShake, blur";
2836
+ "cameraShake, blur, cymatics, lineJitter, crt, vhs";
1682
2837
  // Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
1683
2838
  const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
1684
2839
  "patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
@@ -1704,12 +2859,24 @@ async function getVideoStateRaw() {
1704
2859
  return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
1705
2860
  }
1706
2861
  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) {
2862
+ let videoRequestCounter = 0;
2863
+ const mintVideoRequestId = () => `mcp-${process.pid}-${Date.now().toString(36)}-${++videoRequestCounter}`;
2864
+ /** Create ops are fire-and-forget. Ack-capable PAM builds record the command's
2865
+ * result in the snapshot as `lastCommandAck` keyed by our requestId — exact
2866
+ * under concurrent edits. Older builds never publish an ack, so the snapshot
2867
+ * id-set diff stays as the fallback. */
2868
+ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 2500) {
1709
2869
  const deadline = Date.now() + timeoutMs;
1710
2870
  while (Date.now() < deadline) {
1711
2871
  await sleep(120);
1712
2872
  const s = await getVideoStateRaw();
2873
+ const ack = s.lastCommandAck;
2874
+ if (ack && ack.requestId === requestId) {
2875
+ const minted = ack.result?.[ackIdKey];
2876
+ if (typeof minted === "string" && minted.length > 0)
2877
+ return minted;
2878
+ return null; // acked but failed / no id — don't misattribute via the diff
2879
+ }
1713
2880
  const found = pick(s).find((id) => !beforeIds.has(id));
1714
2881
  if (found)
1715
2882
  return found;
@@ -1720,9 +2887,14 @@ server.registerTool("get_video_state", {
1720
2887
  title: "Get Video State",
1721
2888
  description: "Read the full VX video scene: layers (in render order, top-first) with their " +
1722
2889
  "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.",
2890
+ "mod-matrix routes, the 3D model overlay, and global render/sync settings " +
2891
+ "(incl. vxVariationFollowAudio + VX bar-sync). Also carries: vxVariations " +
2892
+ "(8-slot occupancy booleans) + currentVxVariation; the songMode arrangement " +
2893
+ "read-model (nodes with per-section VX slot assignments, compiled steps with " +
2894
+ "the VX slot each recalls, arrangementBars); and the poll targets for " +
2895
+ "long-running jobs — export (exportVideo), recording (start/stop/saveVideoRecording), " +
2896
+ "songExport (exportSong). Layer/FX/binding ids returned here are what the other " +
2897
+ "video tools address. If `available` is false, open PAM's window so the video UI mounts.",
1726
2898
  inputSchema: {},
1727
2899
  annotations: { readOnlyHint: true },
1728
2900
  }, async () => {
@@ -1793,6 +2965,10 @@ server.registerTool("video_command", {
1793
2965
  "moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
1794
2966
  "setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
1795
2967
  "resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
2968
+ "setShaderSource{layerId,source} (GLSL fragment for a 'shader' layer; auto-parses " +
2969
+ "'// @param uP_x 0..1 = 0.5 \"Label\"' rows into sliders/bind targets; read " +
2970
+ "layers[].shaderCompile in get_video_state for the compile ok/infoLog and repair), " +
2971
+ "setShaderParam{layerId,paramKey,value}; " +
1796
2972
  "addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
1797
2973
  "removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
1798
2974
  "setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
@@ -1803,7 +2979,16 @@ server.registerTool("video_command", {
1803
2979
  "setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
1804
2980
  "addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
1805
2981
  "setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
1806
- "resetModel; setSettings{...}, setActiveCell{cellId}, resetWorkspace. " +
2982
+ "resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
2983
+ "setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
2984
+ "VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
2985
+ "fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
2986
+ "export/record: exportVideo{durationInSeconds,encoderId?,filename?,fps?,width?,height?,bitrate?,audioBitDepth?}, " +
2987
+ "cancelVideoExport, startVideoRecording{encoderId?,saveMode?,fps?,barSync?,bars?,skipSilence?,normalize?}, " +
2988
+ "stopVideoRecording, saveVideoRecording (poll get_video_state export/recording); " +
2989
+ "song: setSongArrangement{sections:[{variationIndex,bars?,repeat?,muted?,probability?,presetPath?,label?,vxVariationIndex?,vxScenePath?}],connections?:[{from,to,probability?}],loop?,enabled?,crossfadeMs?}, " +
2990
+ "setSongNodeVx{nodeId|stepIndex,index|null,vxScenePath?}, exportSong{name?,bars?,normalize?,includeVideo?,encoderId?,strategy?}, " +
2991
+ "cancelSongExport (poll get_video_state songExport/songMode; or use the song_mode / export_song tools). " +
1807
2992
  "Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
1808
2993
  VIDEO_BIND_SOURCES,
1809
2994
  inputSchema: {
@@ -1826,23 +3011,33 @@ server.registerTool("add_video_layer", {
1826
3011
  "update_video_layer and stack FX with add_video_fx.",
1827
3012
  inputSchema: {
1828
3013
  kind: z
1829
- .enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
1830
- .describe("Layer kind."),
3014
+ .enum(["video", "image", "camera", "text", "drawing", "model3d", "shader", "adjustment"])
3015
+ .describe("Layer kind. 'shader' is a user-GLSL fragment layer — write its source with video_command {op:'setShaderSource'}."),
1831
3016
  name: z.string().optional().describe("Display name (auto-generated if omitted)."),
1832
3017
  insertAt: z
1833
3018
  .number()
1834
3019
  .int()
1835
3020
  .optional()
1836
3021
  .describe("Data-index to insert at (default: top of stack)."),
3022
+ sourcePath: z
3023
+ .string()
3024
+ .optional()
3025
+ .describe("Absolute file path to image/video/model media to load into the new layer (no file picker). " +
3026
+ "Bytes are read natively and handed to the WebView as a blob. Large videos are bounded by maxBytes."),
3027
+ maxBytes: z
3028
+ .number()
3029
+ .optional()
3030
+ .describe("Max bytes to read for sourcePath (default ~16MB; raise for video, e.g. 50000000)."),
1837
3031
  },
1838
- }, async ({ kind, name, insertAt }) => {
3032
+ }, async ({ kind, name, insertAt, sourcePath, maxBytes }) => {
1839
3033
  const check = await requireBridge();
1840
3034
  if (check !== true)
1841
3035
  return check;
1842
3036
  const before = await getVideoStateRaw();
1843
3037
  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));
3038
+ const requestId = mintVideoRequestId();
3039
+ await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
3040
+ const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
1846
3041
  return text(id
1847
3042
  ? `Added ${kind} layer: ${id}`
1848
3043
  : `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
@@ -1890,6 +3085,23 @@ server.registerTool("update_video_layer", {
1890
3085
  const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
1891
3086
  return json(result);
1892
3087
  });
3088
+ server.registerTool("set_video_layer_source", {
3089
+ title: "Set Video Layer Source",
3090
+ description: "Load image/video/model media into an existing VX layer from a file path — the headless " +
3091
+ "equivalent of the file picker. Reads bytes natively and assigns a blob the layer renders. " +
3092
+ "PAM's window must be open (VX renders in the WebView). Large videos: raise maxBytes.",
3093
+ inputSchema: {
3094
+ layerId: z.string().describe("Target layer id (from get_video_state / add_video_layer)."),
3095
+ sourcePath: z.string().describe("Absolute path to the media file."),
3096
+ maxBytes: z.number().optional().describe("Max bytes to read (default ~16MB; raise for video)."),
3097
+ },
3098
+ }, async ({ layerId, sourcePath, maxBytes }) => {
3099
+ const check = await requireBridge();
3100
+ if (check !== true)
3101
+ return check;
3102
+ await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath, maxBytes });
3103
+ return { content: [{ type: "text", text: `Loading ${sourcePath} into layer ${layerId}.` }] };
3104
+ });
1893
3105
  server.registerTool("remove_video_layer", {
1894
3106
  title: "Remove Video Layer",
1895
3107
  description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
@@ -1946,8 +3158,9 @@ server.registerTool("add_video_fx", {
1946
3158
  if (target === "output") {
1947
3159
  const before = await getVideoStateRaw();
1948
3160
  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));
3161
+ const requestId = mintVideoRequestId();
3162
+ await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
3163
+ const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
1951
3164
  // Apply param overrides now that the instance exists.
1952
3165
  if (params && id)
1953
3166
  for (const [param, value] of Object.entries(params))
@@ -1959,11 +3172,12 @@ server.registerTool("add_video_fx", {
1959
3172
  const before = await getVideoStateRaw();
1960
3173
  const layer = (before.layers ?? []).find((l) => l.id === layerId);
1961
3174
  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) => {
3175
+ const requestId = mintVideoRequestId();
3176
+ await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
3177
+ const id = await resolveNewId(requestId, beforeIds, (s) => {
1964
3178
  const l = (s.layers ?? []).find((x) => x.id === layerId);
1965
3179
  return (l?.fx ?? []).map((f) => f.id);
1966
- });
3180
+ }, "fxId");
1967
3181
  return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
1968
3182
  });
1969
3183
  server.registerTool("set_video_fx", {
@@ -2087,8 +3301,9 @@ server.registerTool("set_video_settings", {
2087
3301
  title: "Set Video Settings",
2088
3302
  description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
2089
3303
  "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 " +
3304
+ "designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the " +
3305
+ "mod-matrix master enable, VX-variation follow-audio, and VX bar-sync (queue slot " +
3306
+ "loads on the next bar boundary while playing). resetWorkspace clears the " +
2092
3307
  "scene (preserves the master layer).",
2093
3308
  inputSchema: {
2094
3309
  syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
@@ -2099,10 +3314,22 @@ server.registerTool("set_video_settings", {
2099
3314
  letterboxColor: z.string().optional(),
2100
3315
  canvasColor: z.string().optional(),
2101
3316
  modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
2102
- activeCell: z.number().int().optional().describe("Switch the active per-cell video slice."),
3317
+ vxVariationFollowAudio: z
3318
+ .boolean()
3319
+ .optional()
3320
+ .describe("VX variation slots follow audio variation switches (default true)."),
3321
+ vxBarSyncEnabled: z
3322
+ .boolean()
3323
+ .optional()
3324
+ .describe("Queue VX-local slot loads on the next bar-sync boundary while the transport plays."),
3325
+ vxBarSyncIntervalBars: z
3326
+ .number()
3327
+ .optional()
3328
+ .describe("VX bar-sync interval in bars (-1 = longest-pattern sentinel)."),
3329
+ activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
2103
3330
  resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
2104
3331
  },
2105
- }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, activeCell, resetWorkspace }) => {
3332
+ }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
2106
3333
  const check = await requireBridge();
2107
3334
  if (check !== true)
2108
3335
  return check;
@@ -2127,10 +3354,75 @@ server.registerTool("set_video_settings", {
2127
3354
  settings.letterboxColor = letterboxColor;
2128
3355
  if (canvasColor !== undefined)
2129
3356
  settings.canvasColor = canvasColor;
3357
+ if (vxVariationFollowAudio !== undefined)
3358
+ settings.vxVariationFollowAudio = vxVariationFollowAudio;
3359
+ if (vxBarSyncEnabled !== undefined)
3360
+ settings.vxBarSyncEnabled = vxBarSyncEnabled;
3361
+ if (vxBarSyncIntervalBars !== undefined)
3362
+ settings.vxBarSyncIntervalBars = vxBarSyncIntervalBars;
2130
3363
  if (Object.keys(settings).length > 0)
2131
3364
  await sendVideoCommand({ op: "setSettings", ...settings });
2132
3365
  return text("Video settings updated.");
2133
3366
  });
3367
+ server.registerTool("vx_variation", {
3368
+ title: "VX Variation Slots",
3369
+ description: "Manage the 8 VX (visual) variation slots — the visual siblings of the audio " +
3370
+ "variation slots (save_variation / load_variation). 'save' snapshots the live VX " +
3371
+ "scene into a slot; 'load' recalls one (an empty slot moves the index but keeps the " +
3372
+ "current visuals); 'clear' empties a slot; 'fill' saves 8 randomized scene variations " +
3373
+ "into all slots. With vxVariationFollowAudio on (default), audio variation switches — " +
3374
+ "including Song Mode section changes — recall the matching VX slot automatically, so " +
3375
+ "saving VX slots alongside audio slots gives every song section its own visuals " +
3376
+ "(pin a different slot per section via song_mode assignVx). WebView-routed: PAM's " +
3377
+ "window must be OPEN. Verify via get_video_state → vxVariations / currentVxVariation.",
3378
+ inputSchema: {
3379
+ op: z.enum(["save", "load", "clear", "fill"]).describe("Slot operation."),
3380
+ index: z
3381
+ .number()
3382
+ .int()
3383
+ .min(0)
3384
+ .max(7)
3385
+ .optional()
3386
+ .describe("Slot index 0-7 (required for save/load/clear; ignored for fill)."),
3387
+ },
3388
+ }, async ({ op, index }) => {
3389
+ const check = await requireBridge();
3390
+ if (check !== true)
3391
+ return check;
3392
+ if (op !== "fill" && typeof index !== "number")
3393
+ return error(`'${op}' requires index (0-7).`);
3394
+ const opMap = {
3395
+ save: "saveVxVariation",
3396
+ load: "loadVxVariation",
3397
+ clear: "clearVxVariation",
3398
+ fill: "fillVxVariationsRandomized",
3399
+ };
3400
+ await sendVideoCommand(op === "fill" ? { op: opMap.fill } : { op: opMap[op], index });
3401
+ // Fire-and-forget forward path — poll until the snapshot reflects the op
3402
+ // (save→occupied, clear→empty, load→index moved [sticky-empty rule], fill→all occupied).
3403
+ const expectMet = (s) => {
3404
+ const slots = Array.isArray(s.vxVariations) ? s.vxVariations : [];
3405
+ if (op === "save")
3406
+ return slots[index] === true;
3407
+ if (op === "clear")
3408
+ return slots[index] === false;
3409
+ if (op === "load")
3410
+ return s.currentVxVariation === index;
3411
+ return slots.length > 0 && slots.every(Boolean);
3412
+ };
3413
+ let state = await getVideoStateRaw();
3414
+ const deadline = Date.now() + 2500;
3415
+ while (state.available !== false && !expectMet(state) && Date.now() < deadline) {
3416
+ await sleep(150);
3417
+ state = await getVideoStateRaw();
3418
+ }
3419
+ if (state.available === false)
3420
+ return error("VX unavailable — open PAM's window (the video engine renders in the WebView).");
3421
+ const slots = Array.isArray(state.vxVariations) ? state.vxVariations : [];
3422
+ return text(`vx_variation ${op}${op === "fill" ? "" : ` slot ${index}`} ${expectMet(state) ? "applied" : "sent (state not yet reflecting it — window open? rejected op?)"}. ` +
3423
+ `Occupied slots: [${slots.map((o, i) => (o ? i : null)).filter((v) => v !== null).join(", ")}], ` +
3424
+ `current: ${String(state.currentVxVariation ?? "?")}.`);
3425
+ });
2134
3426
  // ============================================================
2135
3427
  // Composite cell-operation tools
2136
3428
  // ============================================================
@@ -2152,6 +3444,10 @@ server.registerTool("reset_cell", {
2152
3444
  const check = await requireBridge();
2153
3445
  if (check !== true)
2154
3446
  return check;
3447
+ const manifest = await loadManifestForProductGuard();
3448
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3449
+ if (cellCheck !== true)
3450
+ return cellCheck;
2155
3451
  const [cellParams, envelopes] = await Promise.all([
2156
3452
  getParametersByCell(`cell${cellId}`),
2157
3453
  bridge.send("getState", "envelopes"),
@@ -2185,6 +3481,13 @@ server.registerTool("copy_cell", {
2185
3481
  const check = await requireBridge();
2186
3482
  if (check !== true)
2187
3483
  return check;
3484
+ const manifest = await loadManifestForProductGuard();
3485
+ const sourceCheck = validateCellIdForManifest(sourceCellId, manifest);
3486
+ if (sourceCheck !== true)
3487
+ return sourceCheck;
3488
+ const targetCheck = validateCellIdForManifest(targetCellId, manifest);
3489
+ if (targetCheck !== true)
3490
+ return targetCheck;
2188
3491
  // Full state needed: cell params live as flat top-level cellN_* keys, no section.
2189
3492
  const state = (await bridge.send("getState"));
2190
3493
  const srcPrefix = `cell${sourceCellId}_`;
@@ -2285,6 +3588,10 @@ server.registerTool("clear_modulations", {
2285
3588
  const check = await requireBridge();
2286
3589
  if (check !== true)
2287
3590
  return check;
3591
+ const manifest = await loadManifestForProductGuard();
3592
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3593
+ if (cellCheck !== true)
3594
+ return cellCheck;
2288
3595
  const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
2289
3596
  bridge.send("getState", "lfos"),
2290
3597
  bridge.send("getState", "vary"),
@@ -2317,7 +3624,7 @@ server.registerTool("clear_modulations", {
2317
3624
  });
2318
3625
  server.registerTool("reset_patterns", {
2319
3626
  title: "Reset Patterns",
2320
- description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single cell.",
3627
+ description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
2321
3628
  inputSchema: {
2322
3629
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2323
3630
  },
@@ -2326,6 +3633,15 @@ server.registerTool("reset_patterns", {
2326
3633
  const check = await requireBridge();
2327
3634
  if (check !== true)
2328
3635
  return check;
3636
+ const manifest = await loadManifestForProductGuard();
3637
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3638
+ if (cellCheck !== true)
3639
+ return cellCheck;
3640
+ const hasPatternLanes = (manifest?.stateVariables ?? []).some((entry) => entry.paramId === `cell${cellId}_midiSequence`);
3641
+ if (manifest && !hasPatternLanes) {
3642
+ const productName = manifest.name ?? "This product";
3643
+ return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
3644
+ }
2329
3645
  const delta = { __forceRender__: true };
2330
3646
  for (const suffix of PATTERN_SUFFIXES) {
2331
3647
  delta[`cell${cellId}_${suffix}`] = [];
@@ -2335,7 +3651,8 @@ server.registerTool("reset_patterns", {
2335
3651
  });
2336
3652
  server.registerTool("update_effect_config", {
2337
3653
  title: "Update Effect Config",
2338
- description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, fdn, gate, compressor). " +
3654
+ description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
3655
+ "wavefold and resonator exist on MFX only. " +
2339
3656
  "Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
2340
3657
  inputSchema: {
2341
3658
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -2347,11 +3664,29 @@ server.registerTool("update_effect_config", {
2347
3664
  fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
2348
3665
  gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
2349
3666
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3667
+ // The page-2 effects. These ship on PAM/Cell1 too, so leaving them out of the
3668
+ // schema made them unreachable over the bridge even though the handler is
3669
+ // generic (it just builds `cell{N}_{key}`) and the state vars exist in
3670
+ // build.js alongside the eight above.
3671
+ eq8Enabled: z.union([z.literal(0), z.literal(1)]).optional(),
3672
+ pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3673
+ gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3674
+ vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3675
+ limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3676
+ qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3677
+ // MFX-only page-2 signature effects; harmless no-ops on PAM/Cell1, whose
3678
+ // manifests carry no cellN_wavefoldEnabled / cellN_resonatorEnabled key.
3679
+ wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3680
+ resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
2350
3681
  },
2351
3682
  }, async ({ cellId, ...config }) => {
2352
3683
  const check = await requireBridge();
2353
3684
  if (check !== true)
2354
3685
  return check;
3686
+ const manifest = await loadManifestForProductGuard();
3687
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3688
+ if (cellCheck !== true)
3689
+ return cellCheck;
2355
3690
  const delta = {};
2356
3691
  for (const [key, value] of Object.entries(config)) {
2357
3692
  if (value !== undefined)
@@ -2366,11 +3701,94 @@ server.registerTool("update_effect_config", {
2366
3701
  .join(", ");
2367
3702
  return text(`Updated effect config for cell ${cellId}: ${summary}`);
2368
3703
  });
3704
+ // MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
3705
+ // unreachable through set_parameters (numbers only) — an agent could shape an MFX
3706
+ // cell but never patch one to another, which is the whole product.
3707
+ //
3708
+ // Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
3709
+ // plugin re-validates on receipt, so this pass exists to fail loudly here rather
3710
+ // than have a typo silently drop a cable.
3711
+ const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', or 'cellN'. 'output' is a destination only.";
3712
+ const MFX_ROUTING_SINK_HINT = "'output' or 'cellN'. 'input'/'sidechain' are sources only.";
3713
+ function mfxPortCell(port) {
3714
+ const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
3715
+ return m ? Number(m[1]) : null;
3716
+ }
3717
+ function validateMfxEdges(edges, maxCells) {
3718
+ const seen = new Set();
3719
+ const out = [];
3720
+ const inRange = (port) => {
3721
+ const c = mfxPortCell(port);
3722
+ return c !== null && c >= 1 && c <= maxCells;
3723
+ };
3724
+ for (const { from, to } of edges) {
3725
+ if (from !== "input" && from !== "sidechain" && !inRange(from)) {
3726
+ return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
3727
+ }
3728
+ if (to !== "output" && !inRange(to)) {
3729
+ return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
3730
+ }
3731
+ const key = `${from}->${to}`;
3732
+ if (seen.has(key))
3733
+ continue; // dedupe, matching the plugin
3734
+ seen.add(key);
3735
+ out.push({ from, to });
3736
+ }
3737
+ return { ok: true, edges: out };
3738
+ }
3739
+ server.registerTool("set_mfx_routing", {
3740
+ title: "Set MFX Cable Routing",
3741
+ description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; on PAM/Cell1 " +
3742
+ "this returns a product error.\n\n" +
3743
+ "Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
3744
+ "A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
3745
+ "A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
3746
+ "Parallel is input→each cell plus each cell→output.\n\n" +
3747
+ "'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
3748
+ "left alone; a new cable takes the graph's own default (unity forward, 0.5 on a back or self edge). " +
3749
+ "Re-patching rebuilds the audio graph, like reordering FX.",
3750
+ inputSchema: {
3751
+ op: z.enum(["get", "set"]).describe("'get' reads the current cables; 'set' replaces them."),
3752
+ edges: z
3753
+ .array(z.object({
3754
+ from: z.string().describe(`Source port: ${MFX_ROUTING_SOURCE_HINT}`),
3755
+ to: z.string().describe(`Destination port: ${MFX_ROUTING_SINK_HINT}`),
3756
+ }))
3757
+ .optional()
3758
+ .describe("Full cable list for 'set'. An empty array clears the patch (silence)."),
3759
+ },
3760
+ annotations: { destructiveHint: false, idempotentHint: true },
3761
+ }, async ({ op, edges }) => {
3762
+ const check = await requireBridge();
3763
+ if (check !== true)
3764
+ return check;
3765
+ const manifest = await loadManifestForProductGuard();
3766
+ if (manifest && !isMfxManifest(manifest)) {
3767
+ return error(`set_mfx_routing is MFX-only. ${manifest.name ?? "This product"} has no cable patchbay — ` +
3768
+ "its cells run in a fixed per-cell chain.");
3769
+ }
3770
+ if (op === "get") {
3771
+ const raw = await bridge.send("getState", "mfx_routing");
3772
+ return json(isRecord(raw) ? raw.mfx_routing ?? raw : raw);
3773
+ }
3774
+ if (!edges)
3775
+ return error("op 'set' requires an `edges` array (pass [] to clear the patch).");
3776
+ const maxCells = manifestMaxCellId(manifest) ?? 4;
3777
+ const validated = validateMfxEdges(edges, maxCells);
3778
+ if (!validated.ok) {
3779
+ return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
3780
+ }
3781
+ // mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
3782
+ await applyKv({ mfx_routing: validated.edges }, KV_OPTS_STANDARD);
3783
+ const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
3784
+ return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
3785
+ });
2369
3786
  server.registerTool("update_cell_config", {
2370
3787
  title: "Update Cell Config",
2371
3788
  description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
2372
3789
  "These are KV-only properties that don't appear in the parameter manifest. " +
2373
- "Uses a lightweight KV path without graph rebuild.",
3790
+ "Uses a lightweight KV path without graph rebuild. " +
3791
+ "When bound to MFX, this tool only accepts sidechainFrom; sampler/MIDI/instrument config is PAM/Cell1-only.",
2374
3792
  inputSchema: {
2375
3793
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2376
3794
  isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
@@ -2380,8 +3798,36 @@ server.registerTool("update_cell_config", {
2380
3798
  modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
2381
3799
  varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
2382
3800
  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."),
2384
- scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
3801
+ 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."),
3802
+ sliceKeymapMode: z
3803
+ .union([z.literal(0), z.literal(1)])
3804
+ .optional()
3805
+ .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."),
3806
+ sliceTriggerLength: z
3807
+ .number()
3808
+ .int()
3809
+ .min(0)
3810
+ .max(2)
3811
+ .optional()
3812
+ .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)."),
3813
+ scaleName: z
3814
+ .enum([
3815
+ "chromatic",
3816
+ "major",
3817
+ "minor",
3818
+ "pentatonicMajor",
3819
+ "pentatonicMinor",
3820
+ "dorian",
3821
+ "mixolydian",
3822
+ "blues",
3823
+ "harmonicMinor",
3824
+ "phrygian",
3825
+ "lydian",
3826
+ "locrian",
3827
+ ])
3828
+ .optional()
3829
+ .describe("Scale name for pitch sequencer / note quantization ('chromatic' = no filter). " +
3830
+ "An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
2385
3831
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
2386
3832
  pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
2387
3833
  stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
@@ -2401,36 +3847,107 @@ server.registerTool("update_cell_config", {
2401
3847
  const check = await requireBridge();
2402
3848
  if (check !== true)
2403
3849
  return check;
3850
+ const manifest = await loadManifestForProductGuard();
3851
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3852
+ if (cellCheck !== true)
3853
+ return cellCheck;
3854
+ // These four are DAW params (cell{N}_quantScale, …), not cell-config keys —
3855
+ // nothing consumes them on the cells{} config channel (the sampler reads
3856
+ // the param signal inputs), so a cells{} write was a silent no-op that
3857
+ // also persisted junk keys into the preset. Route them to flat param keys,
3858
+ // which applyKv maps onto the JUCE params (updateParameters: true).
3859
+ const PARAM_ROUTED_KEYS = new Set(["quantScale", "quantRoot", "spread", "chaos"]);
3860
+ const providedKeys = Object.entries(config)
3861
+ .filter(([, value]) => value !== undefined)
3862
+ .map(([key]) => key);
3863
+ if (providedKeys.length === 0) {
3864
+ return error("At least one config property must be provided.");
3865
+ }
3866
+ if (manifest && isMfxManifest(manifest)) {
3867
+ const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
3868
+ if (unsupported.length > 0) {
3869
+ return error("update_cell_config in MFX only accepts sidechainFrom. " +
3870
+ `Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
3871
+ }
3872
+ }
2404
3873
  const cellConfig = {};
2405
3874
  const flatDelta = {};
2406
3875
  for (const [key, value] of Object.entries(config)) {
2407
- if (value !== undefined)
3876
+ if (value === undefined)
3877
+ continue;
3878
+ if (PARAM_ROUTED_KEYS.has(key))
3879
+ flatDelta[`cell${cellId}_${key}`] = value;
3880
+ else
2408
3881
  cellConfig[key] = value;
2409
3882
  }
2410
- if (Object.keys(cellConfig).length === 0) {
2411
- return error("At least one config property must be provided.");
2412
- }
2413
3883
  // Auto-enable compressor when setting sidechain source
2414
3884
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
2415
3885
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
2416
3886
  }
2417
- if (cellConfig.pitchMode !== undefined || cellConfig.stretchMode !== undefined) {
3887
+ // Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
3888
+ // sliceKeymapMode flip (which changes pitch interpretation) bumps
3889
+ // modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
3890
+ if (cellConfig.pitchMode !== undefined ||
3891
+ cellConfig.stretchMode !== undefined ||
3892
+ cellConfig.sliceKeymapMode !== undefined) {
2418
3893
  const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
2419
3894
  cellModeChangeSeq[cellId] = next;
2420
3895
  cellConfig.modeChangeSeq = next;
2421
3896
  }
2422
- const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
3897
+ const delta = { ...flatDelta };
3898
+ if (Object.keys(cellConfig).length > 0)
3899
+ delta.cells = { [cellId]: cellConfig };
2423
3900
  await applyKv(delta, KV_OPTS_CELL_CONFIG);
2424
- const summary = Object.entries(cellConfig)
3901
+ const summary = [
3902
+ ...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
3903
+ ...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
3904
+ ].join(", ");
3905
+ return text(`Updated cell ${cellId} config: ${summary}`);
3906
+ });
3907
+ server.registerTool("set_cell_mute", {
3908
+ title: "Mute / Solo Cell",
3909
+ description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
3910
+ "(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
3911
+ "non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
3912
+ "Prefer this over set_parameter volume -60 when the user says mute, unmute, solo, unsolo, isolate, or 'just this cell'.",
3913
+ inputSchema: {
3914
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3915
+ mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
3916
+ solo: z.boolean().optional().describe("Solo (true) or unsolo (false) the cell — solo silences all other non-soloed cells."),
3917
+ },
3918
+ }, async ({ cellId, mute, solo }) => {
3919
+ const check = await requireBridge();
3920
+ if (check !== true)
3921
+ return check;
3922
+ const manifest = await loadManifestForProductGuard();
3923
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
3924
+ if (cellCheck !== true)
3925
+ return cellCheck;
3926
+ const delta = {};
3927
+ if (typeof mute === "boolean")
3928
+ delta[`cell${cellId}_mute`] = mute ? 1 : 0;
3929
+ if (typeof solo === "boolean")
3930
+ delta[`cell${cellId}_solo`] = solo ? 1 : 0;
3931
+ if (Object.keys(delta).length === 0) {
3932
+ return error("Provide mute and/or solo.");
3933
+ }
3934
+ // cell{N}_mute / cell{N}_solo are flat state vars projected to
3935
+ // cell{N}_config.enabled by KVStateSyncManager — same options the UI's
3936
+ // applyStateDelta uses (KV_OPTS_STANDARD); the projection needs
3937
+ // syncConfigResources, no graph rebuild required but the standard path is
3938
+ // the exact UI parity.
3939
+ await applyKv(delta, KV_OPTS_STANDARD);
3940
+ const summary = Object.entries(delta)
2425
3941
  .map(([k, v]) => `${k}=${v}`)
2426
3942
  .join(", ");
2427
- return text(`Updated cell ${cellId} config: ${summary}`);
3943
+ return text(`Cell ${cellId}: ${summary}`);
2428
3944
  });
2429
3945
  server.registerTool("set_master_effects", {
2430
3946
  title: "Set Master Effects",
2431
3947
  description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
2432
3948
  "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. " +
3949
+ "The Limiter is a zero-latency brickwall safety stage (enable/disable only). " +
3950
+ "Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
2434
3951
  "The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
2435
3952
  "the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
2436
3953
  "Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
@@ -2438,7 +3955,8 @@ server.registerTool("set_master_effects", {
2438
3955
  "medium release (80-200ms), ratio 4-8, autoMakeup true. " +
2439
3956
  "For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
2440
3957
  "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.",
3958
+ "Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
3959
+ "NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
2442
3960
  inputSchema: {
2443
3961
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
2444
3962
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
@@ -2453,13 +3971,55 @@ server.registerTool("set_master_effects", {
2453
3971
  masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
2454
3972
  masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
2455
3973
  masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
3974
+ 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)."),
3975
+ masterCompMix: z.number().min(0).max(1).optional().describe("Punch parallel dry/wet (0-1, default 1 = fully compressed)."),
2456
3976
  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
3977
  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
3978
  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."),
3979
+ 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
3980
  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."),
3981
+ masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
3982
+ masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
3983
+ masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
3984
+ 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)."),
3985
+ masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
3986
+ masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
3987
+ masterEqHsFreq: z.number().min(1000).max(16000).optional().describe("High-shelf corner Hz (default 8000)."),
3988
+ masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
3989
+ 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."),
3990
+ masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
3991
+ 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."),
3992
+ 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."),
3993
+ masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
3994
+ 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."),
3995
+ masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
3996
+ masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
3997
+ masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
3998
+ masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
3999
+ masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (88/2500 Hz splits, thresholds, ratios) is baked; Depth is the main lever."),
4000
+ masterSqueezeDepth: z.number().min(0).max(1).optional().describe("Squeeze depth 0..1 (default 0.3): scales the applied per-band gain toward the full character — a density control, NOT a dry/wet. 0.15-0.3 densifies a master; 0.6+ is the aggressive pumped sound."),
4001
+ masterSqueezeTime: z.number().min(0.1).max(10).optional().describe("Squeeze time multiplier on all band attack+release (0.1 fast .. 10 slow, default 1)."),
4002
+ masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
4003
+ masterSqueezeInGain: z.number().min(-12).max(12).optional().describe("Squeeze input gain dB (default 0) — drives program into the fixed thresholds (more In = more of everything)."),
4004
+ masterSqueezeOutTrim: z.number().min(-12).max(12).optional().describe("Squeeze output trim dB (default 0)."),
4005
+ masterSqueezeUpAmount: z.number().min(0).max(1.5).optional().describe("Squeeze upward-branch trim 0..1.5 (default 1). 0 disables the upward lift (downward-only multiband)."),
4006
+ masterSqueezeDownAmount: z.number().min(0).max(1.5).optional().describe("Squeeze downward-branch trim 0..1.5 (default 1). 0 disables the downward limiting (pure upward lift)."),
4007
+ masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
4008
+ 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."),
4009
+ masterEqBells: z
4010
+ .array(z.object({
4011
+ freq: z.number().min(30).max(16000).optional(),
4012
+ gain: z.number().min(-12).max(12).optional(),
4013
+ q: z.number().min(0.3).max(8).optional(),
4014
+ dyn: z.union([z.literal(0), z.literal(1)]).optional().describe("Dynamic option: the band's gain rides the in-band level."),
4015
+ dynThresh: z.number().min(-60).max(0).optional().describe("Dynamic threshold dB (default -24)."),
4016
+ dynRange: z.number().min(-12).max(12).optional().describe("Dynamic range dB (negative ducks when loud — de-harsh; default -3)."),
4017
+ }))
4018
+ .max(4)
4019
+ .optional()
4020
+ .describe("The 4 parametric bells in order (defaults 200/700/2000/5000 Hz). Pass null-ish entries to skip a band."),
2461
4021
  },
2462
- }, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
4022
+ }, async ({ compressorEnabled, limiterEnabled, tapeEnabled, masterEqBells, ...effectParams }) => {
2463
4023
  const check = await requireBridge();
2464
4024
  if (check !== true)
2465
4025
  return check;
@@ -2471,8 +4031,28 @@ server.registerTool("set_master_effects", {
2471
4031
  delta.masterLimiterEnabled = limiterEnabled;
2472
4032
  if (tapeEnabled !== undefined)
2473
4033
  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.
4034
+ // Bell array → flat masterEqB{n}{Field} keys.
4035
+ if (masterEqBells) {
4036
+ masterEqBells.forEach((bell, i) => {
4037
+ if (!bell)
4038
+ return;
4039
+ const base = `masterEqB${i + 1}`;
4040
+ if (bell.freq !== undefined)
4041
+ delta[`${base}Freq`] = bell.freq;
4042
+ if (bell.gain !== undefined)
4043
+ delta[`${base}Gain`] = bell.gain;
4044
+ if (bell.q !== undefined)
4045
+ delta[`${base}Q`] = bell.q;
4046
+ if (bell.dyn !== undefined)
4047
+ delta[`${base}Dyn`] = bell.dyn;
4048
+ if (bell.dynThresh !== undefined)
4049
+ delta[`${base}DynThresh`] = bell.dynThresh;
4050
+ if (bell.dynRange !== undefined)
4051
+ delta[`${base}DynRange`] = bell.dynRange;
4052
+ });
4053
+ }
4054
+ // Compressor + tape + limiter + EQ parameters are already the flat state
4055
+ // keys read by the DSP graph / native master nodes. Pass through.
2476
4056
  for (const [key, value] of Object.entries(effectParams)) {
2477
4057
  if (value !== undefined)
2478
4058
  delta[key] = value;
@@ -2480,47 +4060,61 @@ server.registerTool("set_master_effects", {
2480
4060
  if (Object.keys(delta).length === 0) {
2481
4061
  return error("At least one parameter must be provided.");
2482
4062
  }
2483
- // Use standard opts with graph dispatch so the DSP graph picks up new compressor params
2484
- await applyKv(delta, KV_OPTS_STANDARD);
4063
+ // No-dispatch path: every master key is live-polled from master_config
4064
+ // (the full Punch surface included), so a graph rebuild here would only
4065
+ // risk dropouts during automated mastering.
4066
+ await applyKv(delta, KV_OPTS_MASTER_CONFIG);
2485
4067
  const summary = Object.entries(delta)
2486
4068
  .map(([k, v]) => `${k}=${v}`)
2487
4069
  .join(", ");
2488
4070
  return text(`Updated master effects: ${summary}`);
2489
4071
  });
2490
4072
  // Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
2491
- // (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
2492
- // in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
2493
- const DRUM_SYNTH_ENGINES = [
2494
- { name: "Kick", params: [["Tune", 0.25], ["Pitch", 0.5], ["P.Decay", 0.35], ["Decay", 0.5], ["Click", 0.3], ["Drive", 0.35], ["Tone", 0.6]] },
2495
- { name: "Snare", params: [["Tune", 0.4], ["Snap", 0.45], ["Body", 0.35], ["N.Decay", 0.45], ["N.Tone", 0.5], ["Detune", 0.3], ["Drive", 0.25]] },
2496
- { name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
2497
- { name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
2498
- { name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
2499
- { name: "FM", params: [["Tune", 0.4], ["Ratio", 0.45], ["FM Amt", 0.4], ["Decay", 0.4], ["FM Dcy", 0.4], ["F.Back", 0.1], ["Drive", 0.2]] },
2500
- { name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
2501
- { name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
2502
- { name: "808", params: [["Tune", 0.35], ["Glide", 0.25], ["P.Decay", 0.4], ["Decay", 0.55], ["Saturate", 0.2], ["Click", 0.25], ["Tone", 0.5]] },
2503
- { name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
2504
- { name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
2505
- { name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
2506
- { name: "Bell", params: [["Tune", 0.5], ["Ratio", 0.4], ["FM Amt", 0.4], ["Decay", 0.5], ["Inharm", 0.3], ["Strike", 0.3], ["Tone", 0.3]] },
2507
- { name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
2508
- { name: "Zap", params: [["Tune", 0.3], ["Sweep", 0.7], ["S.Time", 0.35], ["Decay", 0.4], ["Timbre", 0.3], ["Noise", 0.1], ["Drive", 0.25], ["Tone", 0.6]] },
2509
- { name: "Wave", params: [["Tune", 0.35], ["Position", 0.2], ["P.Env", 0.4], ["Decay", 0.4], ["Bright", 0.6], ["Morph", 0.0], ["Drive", 0.2]] },
2510
- { name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
2511
- ];
2512
- const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
4073
+ // (Off = 0). The roster, the curated defaults and the engine-switch rule all come
4074
+ // from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
4075
+ // and the manifest from, and interface/src/dsp/modules/drumSynth.ts derives its
4076
+ // own tables from. The hand-copied table this replaced had drifted on Kick,
4077
+ // Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
4078
+ // positions where the plugin carries engine units.
4079
+ const DRUM_SYNTH_REGISTRY = (() => {
4080
+ const here = dirname(fileURLToPath(import.meta.url));
4081
+ const candidates = [
4082
+ resolve(here, "..", "..", "..", "shared", "drumSynthEngines.json"), // repo checkout
4083
+ resolve(here, "drumSynthEngines.json"), // published dist/
4084
+ ];
4085
+ for (const file of candidates) {
4086
+ if (!existsSync(file))
4087
+ continue;
4088
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
4089
+ if (parsed.engines && Object.keys(parsed.engines).length > 0)
4090
+ return parsed;
4091
+ }
4092
+ throw new Error("pam-mcp: drumSynthEngines.json not found. Rebuild the package (scripts/build-postprocess.mjs " +
4093
+ "vendors it into dist/) — seeding a drum engine without it would write defaults that disagree " +
4094
+ "with the plugin.");
4095
+ })();
4096
+ const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
4097
+ const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
4098
+ /** State carries the bare enum; agents reason about the name. Report both. */
4099
+ function describeSynthEngine(raw) {
4100
+ const id = Math.round(Number(raw ?? 0));
4101
+ return { id, name: DRUM_SYNTH_ENGINE_NAMES[id] ?? "Off" };
4102
+ }
2513
4103
  server.registerTool("set_drum_synth", {
2514
4104
  title: "Set Drum Synth (built-in machine)",
2515
4105
  description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
2516
- "alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
2517
- "Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
4106
+ `alongside (or instead of) its sample. ${DRUM_SYNTH_ENGINE_NAMES.length} engines: ${DRUM_SYNTH_ENGINE_NAMES.join(", ")}. ` +
4107
+ "The 808 engine is a tuned long-glide sub that plays as a chromatic " +
2518
4108
  "bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
2519
4109
  "sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
2520
- "only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
2521
- DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
2522
- "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.",
4110
+ "only the ones you want to nuance. First activation seeds every slot from the engine's musical defaults. On a switch " +
4111
+ "between two active engines, only slots you had moved off their default travel, and they carry the engine VALUE " +
4112
+ "(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
4113
+ "Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
4114
+ drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
4115
+ "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
4116
+ "not gated by the UI-only drumSynthEnabled preference. " +
4117
+ "On products without it, this tool returns a product error instead of writing inert synth keys.",
2524
4118
  inputSchema: {
2525
4119
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2526
4120
  engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
@@ -2538,17 +4132,21 @@ server.registerTool("set_drum_synth", {
2538
4132
  const check = await requireBridge();
2539
4133
  if (check !== true)
2540
4134
  return check;
4135
+ const manifest = await loadManifestForProductGuard();
4136
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
4137
+ if (cellCheck !== true)
4138
+ return cellCheck;
4139
+ if (manifest && manifest.drumSynth?.enabled !== true) {
4140
+ return error("set_drum_synth is PAM-only. This product manifest does not expose the built-in drum synth.");
4141
+ }
2541
4142
  const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
2542
4143
  if (engineId < 0)
2543
4144
  return error(`Unknown drum synth engine: ${engine}`);
2544
- const delta = { [`cell${cellId}_synthEngine`]: engineId };
2545
- if (engineId > 0) {
2546
- // Seed the engine's musical defaults so the voice sounds good immediately,
2547
- // then apply explicit p-overrides on top (nuance only what you mean to).
2548
- const desc = DRUM_SYNTH_ENGINES[engineId - 1];
2549
- desc.params.forEach(([, def], i) => {
2550
- delta[`cell${cellId}_synth_p${i + 1}`] = def;
2551
- });
4145
+ const stateRaw = await bridge.send("getState");
4146
+ const state = isRecord(stateRaw) ? stateRaw : {};
4147
+ const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
4148
+ const desc = drumSynth.getEngine(engineId);
4149
+ if (desc) {
2552
4150
  for (const [key, value] of Object.entries(overrides)) {
2553
4151
  if (value === undefined)
2554
4152
  continue;
@@ -2561,9 +4159,47 @@ server.registerTool("set_drum_synth", {
2561
4159
  delta[`cell${cellId}_synthVolume`] = volume;
2562
4160
  // synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
2563
4161
  await applyKv(delta, KV_OPTS_STANDARD);
2564
- const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
4162
+ const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
2565
4163
  return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
2566
4164
  });
4165
+ server.registerTool("set_drum_synth_wave", {
4166
+ title: "Set Drum Synth Wave Curve (Wave engine)",
4167
+ description: "Set the custom single-cycle waveform for a cell's drum-synth WAVE engine (engine #16). " +
4168
+ "Provide 2–16 breakpoints { t, v, tension } (t = phase 0..1, v = amplitude 0..1 with 0.5 the zero-crossing " +
4169
+ "centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
4170
+ "that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
4171
+ "select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
4172
+ "is single-sourced there), so PAM's window must be OPEN. Needs PAM ≥ 1.13.0.",
4173
+ inputSchema: {
4174
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4175
+ points: z
4176
+ .array(z.object({
4177
+ t: z.number().min(0).max(1).describe("Phase position 0..1 (left→right)."),
4178
+ v: z.number().min(0).max(1).describe("Amplitude 0..1 (0.5 = zero-crossing centre)."),
4179
+ tension: z.number().min(-1).max(1).default(0).describe("Segment curve −1..+1 (0 = linear)."),
4180
+ }))
4181
+ .min(2)
4182
+ .max(16)
4183
+ .describe("2–16 breakpoints defining the single cycle, ordered left→right by t."),
4184
+ },
4185
+ }, async ({ cellId, points }) => {
4186
+ const check = await requireBridge();
4187
+ if (check !== true)
4188
+ return check;
4189
+ const manifest = await loadManifestForProductGuard();
4190
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
4191
+ if (cellCheck !== true)
4192
+ return cellCheck;
4193
+ if (manifest && manifest.drumSynth?.enabled !== true) {
4194
+ return error("set_drum_synth_wave is PAM-only. This product manifest does not expose the built-in drum synth.");
4195
+ }
4196
+ await bridge.send("setDrumSynthWaveCurve", {
4197
+ cellId,
4198
+ points: points.map((p) => ({ t: p.t, v: p.v, tension: p.tension ?? 0 })),
4199
+ });
4200
+ return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
4201
+ `Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
4202
+ });
2567
4203
  server.registerTool("add_param_seq", {
2568
4204
  title: "Add Parameter Sequencer",
2569
4205
  description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
@@ -2586,8 +4222,11 @@ server.registerTool("add_param_seq", {
2586
4222
  if (check !== true)
2587
4223
  return check;
2588
4224
  const index = await getParameterIndex();
2589
- if (!index.get(paramId))
2590
- return error(`Unknown parameter: ${paramId}`);
4225
+ const param = index.get(paramId);
4226
+ if (!param)
4227
+ return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
4228
+ if (!param.isModulatable)
4229
+ return error(formatNotModulatableError(paramId, index));
2591
4230
  const existing = await bridge.send("getState", "paramSeqs");
2592
4231
  const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
2593
4232
  const newSeq = {
@@ -2696,11 +4335,30 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
2696
4335
  while (Date.now() - t0 < maxWaitMs) {
2697
4336
  await new Promise((r) => setTimeout(r, pollMs));
2698
4337
  const status = (await bridge.send("getExportStatus"));
2699
- if (status.complete)
4338
+ if (status.complete) {
4339
+ lastExportLoudness = status.loudness ?? null;
2700
4340
  return status.success ? "success" : "failed";
4341
+ }
2701
4342
  }
2702
4343
  return "timeout";
2703
4344
  }
4345
+ // Loudness report of the last completed bounce (set by pollExportStatus).
4346
+ let lastExportLoudness = null;
4347
+ function loudnessReportLine() {
4348
+ const l = lastExportLoudness;
4349
+ if (!l || typeof l.i !== "number")
4350
+ return "";
4351
+ const parts = [`${l.i.toFixed(1)} LUFS-I`];
4352
+ if (typeof l.lra === "number")
4353
+ parts.push(`LRA ${l.lra.toFixed(1)}`);
4354
+ if (typeof l.tp === "number")
4355
+ parts.push(`TP ${l.tp.toFixed(1)} dBTP`);
4356
+ if (typeof l.psr === "number")
4357
+ parts.push(`PSR ${l.psr.toFixed(1)}`);
4358
+ if (typeof l.plr === "number")
4359
+ parts.push(`PLR ${l.plr.toFixed(1)}`);
4360
+ return `\nLoudness: ${parts.join(" · ")}`;
4361
+ }
2704
4362
  server.registerTool("export_audio", {
2705
4363
  title: "Export Audio",
2706
4364
  description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
@@ -2709,9 +4367,9 @@ server.registerTool("export_audio", {
2709
4367
  "Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
2710
4368
  "'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
2711
4369
  "'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).",
4370
+ "NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
4371
+ "offline song export; older builds render only the first section). Wall-clock sources " +
4372
+ "(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those.",
2715
4373
  inputSchema: {
2716
4374
  bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
2717
4375
  cellId: z
@@ -2756,7 +4414,8 @@ server.registerTool("export_audio", {
2756
4414
  const outcome = await pollExportStatus();
2757
4415
  if (outcome === "success")
2758
4416
  return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
2759
- `Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
4417
+ `Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
4418
+ loudnessReportLine());
2760
4419
  if (outcome === "failed")
2761
4420
  return error("Audio export failed.");
2762
4421
  return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
@@ -2787,6 +4446,7 @@ server.registerTool("export_audio", {
2787
4446
  if (outcome === "success")
2788
4447
  return text(`Export complete (mode '${effectiveMode}').\n` +
2789
4448
  `File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
4449
+ loudnessReportLine() +
2790
4450
  (effectiveMode === "song-mode"
2791
4451
  ? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
2792
4452
  "Use export_song for a full track with section changes."
@@ -2905,6 +4565,24 @@ Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over tim
2905
4565
  - Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
2906
4566
  - Combine with \`prob\` field (0-1) on events for controlled randomness
2907
4567
 
4568
+ ### Slice Keymap — Trigger Slices from MIDI
4569
+ Map incoming MIDI notes directly to slices (Octatrack / Geist style): the cell's Root note
4570
+ plays slice 1, root+1 semitone plays slice 2, and so on chromatically up. The slice
4571
+ sequencer lane above is ignored while this is on — the note picks the slice.
4572
+
4573
+ \`\`\`
4574
+ update_cell_config: cellId=1, sliceKeymapMode=1, polyRootNote=60, sliceTriggerLength=0
4575
+ trigger_midi: cellId=1, note=60 # -> slice 1
4576
+ trigger_midi: cellId=1, note=62 # -> slice 3
4577
+ \`\`\`
4578
+ - Requires the cell's sample to be sliced first. Notes outside the slice range are silent.
4579
+ - \`polyRootNote\` = the key that triggers slice 1.
4580
+ - \`sliceTriggerLength\`: 0=Boundary (stop at next slice edge), 1=Sample End, 2=Note Off (needs cell mode=Gate).
4581
+ - Set \`isPoly=1\` for polyphonic slice play (held notes layer); leave poly off for monophonic
4582
+ jamming (each new note chokes the previous slice). Slice Keymap works in both.
4583
+ - Each slice plays at its own per-slice pitch, so the keymap performs a chopped sample at
4584
+ native rate. Played notes can be recorded into the cell's MIDI sequence and looped back.
4585
+
2908
4586
  ## Sidechain Compression — The Pump
2909
4587
 
2910
4588
  ### Setup via MCP
@@ -3026,7 +4704,7 @@ Variations exclude: global volume, looper state.
3026
4704
 
3027
4705
  ## Master Effects — The Final Stage
3028
4706
 
3029
- Signal chain: Cell outputs → Bus Mix → **Punch Compressor** → **Limiter** → Volume Fader → Output
4707
+ Signal chain: Cell outputs → Bus Mix → **EQ** → **Punch Compressor** → **Squeeze** (3-band up/down) → **Tape** → **Iron** → **Clipper** → **Limiter** → Volume Fader → Output
3030
4708
 
3031
4709
  ### Punch Compressor (Musical Bus Comp)
3032
4710
  \`\`\`
@@ -3147,7 +4825,8 @@ update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
3147
4825
  \`\`\`
3148
4826
  \`scaleRoot\`: 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B
3149
4827
 
3150
- Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`
4828
+ Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`, \`lydian\`, \`locrian\`
4829
+ (The sampler pitch-quantize lane \`cell{N}_quantScale\` is a separate, deliberately frozen 10-scale DAW param — no lydian/locrian there.)
3151
4830
 
3152
4831
  ### Playback Modes
3153
4832
  \`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
@@ -3186,6 +4865,89 @@ 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
4865
  ],
3187
4866
  }));
3188
4867
  // ============================================================
4868
+ // PROMPTS — guided workflows (client-invokable, e.g. slash
4869
+ // commands in Claude Code). Each stages the same flows the
4870
+ // README and tool descriptions teach, with exact tool names.
4871
+ // ============================================================
4872
+ // Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
4873
+ // package can't import repo-relative shared modules, so the copy is pinned by
4874
+ // interface/test/randomizerGenre.test.ts (source-text parity check).
4875
+ const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
4876
+ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
4877
+ // completable() tags the schema OBJECT with a symbol, so ordering is delicate:
4878
+ // .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
4879
+ // optionals when DETECTING completable prompt args but not when RESOLVING the
4880
+ // completer at request time — the tag must sit on both the inner type and the
4881
+ // outer optional. Covered by the completion assertion in check-tool-surface's
4882
+ // sibling smoke; revisit when the SDK unifies the two lookups.
4883
+ const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
4884
+ function promptText(text) {
4885
+ return {
4886
+ messages: [
4887
+ { role: "user", content: { type: "text", text } },
4888
+ ],
4889
+ };
4890
+ }
4891
+ server.registerPrompt("build-a-beat", {
4892
+ title: "Build a Beat",
4893
+ description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
4894
+ argsSchema: {
4895
+ genre: genreArg,
4896
+ bpm: z.string().optional().describe("Target tempo in BPM."),
4897
+ },
4898
+ }, ({ 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` +
4899
+ `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
4900
+ `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` +
4901
+ `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` +
4902
+ `4. start_transport — sequencer output needs the transport rolling.\n` +
4903
+ `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` +
4904
+ `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
4905
+ server.registerPrompt("make-a-song", {
4906
+ title: "Make a Song",
4907
+ description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
4908
+ argsSchema: {
4909
+ bars: z.string().optional().describe("Approximate song length in bars."),
4910
+ },
4911
+ }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
4912
+ `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
4913
+ `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
4914
+ `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
4915
+ `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` +
4916
+ `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
4917
+ server.registerPrompt("design-a-preset", {
4918
+ title: "Design a Preset",
4919
+ description: "Sound-design workflow from a creative brief to a saved preset.",
4920
+ argsSchema: {
4921
+ brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
4922
+ },
4923
+ }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
4924
+ `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
4925
+ `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` +
4926
+ `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` +
4927
+ `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
4928
+ `5. Write patterns with update_pattern, then start_transport to audition.\n` +
4929
+ `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
4930
+ server.registerPrompt("mix-and-master", {
4931
+ title: "Mix and Master",
4932
+ description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
4933
+ argsSchema: {},
4934
+ }, () => promptText(`Balance and master the current PAM project:\n\n` +
4935
+ `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
4936
+ `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` +
4937
+ `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
4938
+ `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
4939
+ `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
4940
+ server.registerPrompt("troubleshoot-bridge", {
4941
+ title: "Troubleshoot the PAM Bridge",
4942
+ description: "Diagnose why live tools can't reach the plugin.",
4943
+ argsSchema: {},
4944
+ }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
4945
+ `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` +
4946
+ `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
4947
+ `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
4948
+ `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` +
4949
+ `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
4950
+ // ============================================================
3189
4951
  // TOOLS — Looper (state deltas)
3190
4952
  // ============================================================
3191
4953
  // applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
@@ -3297,7 +5059,7 @@ server.registerTool("set_looper_track", {
3297
5059
  if (typeof rest.sourceCell === "number")
3298
5060
  trackDelta.sourceCell = rest.sourceCell;
3299
5061
  if (typeof rest.autoSelect === "boolean")
3300
- trackDelta.autoSelect = rest.autoSelect ? 1 : 0;
5062
+ trackDelta.autoSelect = rest.autoSelect;
3301
5063
  if (typeof rest.recordedBpm === "number")
3302
5064
  trackDelta.recordedBpm = rest.recordedBpm;
3303
5065
  if (Object.keys(trackDelta).length === 0)
@@ -3378,6 +5140,10 @@ server.registerTool("start_looper_recording", {
3378
5140
  const check = await requireBridge();
3379
5141
  if (check !== true)
3380
5142
  return check;
5143
+ // Arm in UI state too — the plugin's live recording overlay and progress
5144
+ // mount off loopers[id].armed, so a bridge-only record left the timeline
5145
+ // blank while recording ran fine underneath.
5146
+ await applyLoopersDelta(Object.fromEntries(armedCellIds.map((cellId) => [cellId, { armed: 1 }])));
3381
5147
  const meta = trackMetadata ??
3382
5148
  armedCellIds.map((cellId) => ({
3383
5149
  cellId,
@@ -3593,7 +5359,9 @@ server.registerTool("looper_variation_sync", {
3593
5359
  server.registerTool("queue_variation", {
3594
5360
  title: "Queue Variation Switch",
3595
5361
  description: "Schedule a global variation switch on the next bar boundary. " +
3596
- "For an immediate switch, use load_variation instead.",
5362
+ "For an immediate switch, use load_variation instead. Optionally morph " +
5363
+ "into the new section over the bars before the boundary (song mode / an " +
5364
+ "already-engaged queued-flip window only; a from-idle flip hard-switches).",
3597
5365
  inputSchema: {
3598
5366
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
3599
5367
  intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
@@ -3602,13 +5370,48 @@ server.registerTool("queue_variation", {
3602
5370
  .boolean()
3603
5371
  .default(true)
3604
5372
  .describe("Reset pattern phase on switch."),
5373
+ morphBars: z
5374
+ .number()
5375
+ .min(0.25)
5376
+ .max(8)
5377
+ .optional()
5378
+ .describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
5379
+ morphMode: z
5380
+ .enum(["auto", "param", "mix", "seq", "all"])
5381
+ .optional()
5382
+ .describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; seq/all on older builds run param)."),
5383
+ morphCurve: z
5384
+ .enum(["linear", "ease", "exp", "log"])
5385
+ .optional()
5386
+ .describe("Morph easing; default ease."),
5387
+ transition: z
5388
+ .object({
5389
+ patternPolicy: z.enum(["dissolve", "wipe", "cells"]).optional(),
5390
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
5391
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
5392
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
5393
+ })
5394
+ .optional()
5395
+ .describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
5396
+ "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
5397
+ "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
3605
5398
  },
3606
- }, async ({ index, intervalBars, fadeMs, resetPhase }) => {
5399
+ }, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
3607
5400
  const check = await requireBridge();
3608
5401
  if (check !== true)
3609
5402
  return check;
3610
- await bridge.send("queueVariationOnBars", { index, intervalBars, fadeMs, resetPhase });
3611
- return text(`Queued variation ${index} on next ${intervalBars}-bar boundary.`);
5403
+ await bridge.send("queueVariationOnBars", {
5404
+ index,
5405
+ intervalBars,
5406
+ fadeMs,
5407
+ resetPhase,
5408
+ ...(morphBars !== undefined && { morphBars }),
5409
+ ...(morphMode !== undefined && { morphMode }),
5410
+ ...(morphCurve !== undefined && { morphCurve }),
5411
+ ...(transition !== undefined && { transition }),
5412
+ });
5413
+ const morphNote = morphBars !== undefined ? ` (morph ${morphBars} bars, ${morphMode ?? "param"})` : "";
5414
+ return text(`Queued variation ${index} on next ${intervalBars}-bar boundary${morphNote}.`);
3612
5415
  });
3613
5416
  server.registerTool("cancel_queued_variation", {
3614
5417
  title: "Cancel Queued Variation",
@@ -3762,16 +5565,17 @@ server.registerTool("midi_recording", {
3762
5565
  // ============================================================
3763
5566
  server.registerTool("update_modulation", {
3764
5567
  title: "Update Modulation",
3765
- description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, or ParamSeq sequence data. " +
5568
+ description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
5569
+ "or the Free Sequencer master pattern. " +
3766
5570
  "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
3767
5571
  "Use add_modulation to first attach a source to a target — then this tool to edit it.",
3768
5572
  inputSchema: {
3769
5573
  type: z
3770
- .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
5574
+ .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence", "freeSeqSequence"])
3771
5575
  .describe("Which modulation system to update."),
3772
5576
  payload: z
3773
5577
  .record(z.string(), z.unknown())
3774
- .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, etc."),
5578
+ .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, freeSeqSequence expects { sequence: number[] } (the master step contour every free-mode ParamSeq follows), etc."),
3775
5579
  },
3776
5580
  }, async ({ type, payload }) => {
3777
5581
  const check = await requireBridge();
@@ -3788,20 +5592,82 @@ server.registerTool("update_modulation", {
3788
5592
  ? "applyMacroUpdate"
3789
5593
  : type === "paramSeq"
3790
5594
  ? "applyParamSeqUpdate"
3791
- : "applyParamSeqSequenceUpdate";
5595
+ : type === "paramSeqSequence"
5596
+ ? "applyParamSeqSequenceUpdate"
5597
+ : "applyFreeSeqSequenceUpdate";
3792
5598
  await bridge.send(action, data);
3793
5599
  return text(`Applied ${type} update.`);
3794
5600
  });
3795
5601
  // ============================================================
3796
5602
  // TOOLS — Song mode
3797
5603
  // ============================================================
5604
+ // Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
5605
+ // PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
5606
+ // RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
5607
+ // gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
5608
+ // sections and queued bar-sync flips; preset destinations coerce to a param
5609
+ // glide, and offline bounces coerce ring→fade and blend→hard switch (each
5610
+ // reported, never silent). Wire enums are append-only.
5611
+ const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
5612
+ const transitionSpecSchema = z.object({
5613
+ mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
5614
+ window: z
5615
+ .object({
5616
+ bars: z.number().min(0).max(8).optional(),
5617
+ paramCurve: morphCurveEnum.optional(),
5618
+ patternCurve: morphCurveEnum.optional(),
5619
+ mixCurve: morphCurveEnum.optional(),
5620
+ })
5621
+ .optional(),
5622
+ pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
5623
+ audio: z
5624
+ .object({
5625
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
5626
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
5627
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
5628
+ })
5629
+ .optional(),
5630
+ phase: z.enum(["inherit", "reset", "continue"]).optional(),
5631
+ });
5632
+ const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
5633
+ "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
5634
+ "whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
5635
+ "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
5636
+ "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
5637
+ "Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
5638
+ "defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
5639
+ "when the lanePresetSections setting is on (PAM >= 1.20.1 — the dual-lane preset section " +
5640
+ "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
5641
+ "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
5642
+ "(effective values after inheritance and capability coercion) and lastMorphResolution " +
5643
+ "(the engine's latest AUTO decision + reason).";
3798
5644
  server.registerTool("song_mode", {
3799
5645
  title: "Song Mode",
3800
- description: "Control PAM's song mode (timeline of variation/preset steps). Ops: 'setSequence', 'clear', " +
3801
- "'toggle', 'setLoopPoints', 'toggleLoop', 'setCrossfadeMs', 'setPlayhead'.",
5646
+ description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
5647
+ "'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
5648
+ "'setCrossfadeMs', 'setPlayhead'.\n\n" +
5649
+ "PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
5650
+ "from a `sections` list, with an optional weighted `connections` graph. The result is " +
5651
+ "visible/editable in the UI, and each section can pin a " +
5652
+ "VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
5653
+ "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
5654
+ "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
5655
+ "from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
5656
+ "incoming variation's continuous params over the bars before its boundary (PAM >= 1.19.3), and an " +
5657
+ "advanced `transition` spec (PAM >= 1.19.6 — authored metadata; see the field description). " +
5658
+ "'get' reads the arrangement back (nodes, compiled steps with the " +
5659
+ "VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
5660
+ "transition after inheritance and capability coercion, e.g. preset destinations report param); " +
5661
+ "'assignVx' re-pins one section's VX slot (and optional scene) after authoring.\n\n" +
5662
+ "'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
5663
+ "land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
5664
+ "follow 1:1 by audio slot.",
3802
5665
  inputSchema: {
3803
5666
  op: z
3804
5667
  .enum([
5668
+ "setArrangement",
5669
+ "get",
5670
+ "assignVx",
3805
5671
  "setSequence",
3806
5672
  "clear",
3807
5673
  "toggle",
@@ -3811,6 +5677,96 @@ server.registerTool("song_mode", {
3811
5677
  "setPlayhead",
3812
5678
  ])
3813
5679
  .describe("Operation."),
5680
+ sections: z
5681
+ .array(z.object({
5682
+ variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
5683
+ bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
5684
+ repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
5685
+ muted: z.boolean().optional().describe("Pass through without an audio trigger."),
5686
+ probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
5687
+ presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on (PAM >= 1.20.1) it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
5688
+ label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
5689
+ morphBars: z
5690
+ .number()
5691
+ .min(0)
5692
+ .max(8)
5693
+ .optional()
5694
+ .describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary (PAM >= 1.19.3). 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
5695
+ morphCurve: z
5696
+ .enum(["linear", "ease", "exp", "log"])
5697
+ .optional()
5698
+ .describe("Morph glide easing; default ease. Ignored without morphBars."),
5699
+ morphMode: z
5700
+ .enum(["auto", "param", "mix", "seq", "all"])
5701
+ .optional()
5702
+ .describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; older builds run param). 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend (seq/all on older builds run param). Ignored without morphBars."),
5703
+ transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
5704
+ vxVariationIndex: z
5705
+ .number()
5706
+ .int()
5707
+ .min(0)
5708
+ .max(7)
5709
+ .optional()
5710
+ .describe("VX variation slot recalled while this section plays (omit = follow the audio slot)."),
5711
+ vxScenePath: z
5712
+ .string()
5713
+ .optional()
5714
+ .describe("VX scene (.vxpreset path) whose slots vxVariationIndex indexes into — loaded before the " +
5715
+ "section plays, so sections can pull visuals from different scenes. Requires vxVariationIndex; " +
5716
+ "omit = the live scene's slots."),
5717
+ }))
5718
+ .min(1)
5719
+ .optional()
5720
+ .describe("For 'setArrangement': section list. Without `connections`, sections play in order."),
5721
+ connections: z
5722
+ .array(z.object({
5723
+ from: z.number().int().min(0).describe("Source section index."),
5724
+ to: z.number().int().min(0).describe("Destination section index."),
5725
+ probability: z
5726
+ .number()
5727
+ .min(0)
5728
+ .max(1)
5729
+ .optional()
5730
+ .describe("Branch probability (0-1). Sibling routes normalize to 100%."),
5731
+ morphBars: z
5732
+ .number()
5733
+ .min(0)
5734
+ .max(8)
5735
+ .optional()
5736
+ .describe("Per-cable morph window in bars — overrides the destination section's morphBars for THIS cable only (branching arrangements can give each incoming cable its own window). 0 = hard switch; omit to use the section value / song default."),
5737
+ morphCurve: z
5738
+ .enum(["linear", "ease", "exp", "log"])
5739
+ .optional()
5740
+ .describe("Per-cable morph easing; overrides the destination section's morphCurve."),
5741
+ morphMode: z
5742
+ .enum(["auto", "param", "mix", "seq", "all"])
5743
+ .optional()
5744
+ .describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
5745
+ transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
5746
+ }))
5747
+ .optional()
5748
+ .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
5749
+ defaultTransition: transitionSpecSchema
5750
+ .nullable()
5751
+ .optional()
5752
+ .describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
5753
+ loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
5754
+ nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
5755
+ stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
5756
+ vxIndex: z
5757
+ .number()
5758
+ .int()
5759
+ .min(0)
5760
+ .max(7)
5761
+ .nullable()
5762
+ .optional()
5763
+ .describe("For 'assignVx': VX slot 0-7, or null to clear (follow the audio slot)."),
5764
+ vxScenePath: z
5765
+ .string()
5766
+ .nullable()
5767
+ .optional()
5768
+ .describe("For 'assignVx': pin the scene (.vxpreset path) vxIndex indexes into (requires vxIndex; " +
5769
+ "null/'' clears the pin → the live scene's slots; omit = keep the node's current pin)."),
3814
5770
  steps: z
3815
5771
  .array(z.object({
3816
5772
  variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
@@ -3824,8 +5780,8 @@ server.registerTool("song_mode", {
3824
5780
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
3825
5781
  }))
3826
5782
  .optional()
3827
- .describe("For 'setSequence': step list."),
3828
- enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
5783
+ .describe("For 'setSequence': raw step list (headless path; prefer 'setArrangement')."),
5784
+ enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop' / 'setArrangement': enabled state."),
3829
5785
  start: z
3830
5786
  .number()
3831
5787
  .nullable()
@@ -3849,6 +5805,66 @@ server.registerTool("song_mode", {
3849
5805
  if (check !== true)
3850
5806
  return check;
3851
5807
  switch (args.op) {
5808
+ case "setArrangement": {
5809
+ if (!args.sections || args.sections.length === 0)
5810
+ return error("'setArrangement' requires sections");
5811
+ await sendVideoCommand({
5812
+ op: "setSongArrangement",
5813
+ sections: args.sections,
5814
+ ...(args.connections !== undefined && { connections: args.connections }),
5815
+ ...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
5816
+ ...(args.loop !== undefined && { loop: args.loop }),
5817
+ ...(args.enabled !== undefined && { enabled: args.enabled }),
5818
+ ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
5819
+ });
5820
+ // The forward path is fire-and-forget — poll until the WebView's apply
5821
+ // republishes the snapshot (same pattern as resolveNewId).
5822
+ const deadline = Date.now() + 3000;
5823
+ let sm;
5824
+ for (;;) {
5825
+ const state = await getVideoStateRaw();
5826
+ sm = state.songMode;
5827
+ const landed = sm?.available === true &&
5828
+ Array.isArray(sm.nodes) &&
5829
+ sm.nodes.length === args.sections.length;
5830
+ if (landed || Date.now() > deadline)
5831
+ break;
5832
+ await sleep(150);
5833
+ }
5834
+ if (!sm || sm.available !== true)
5835
+ return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
5836
+ "with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
5837
+ const nodes = Array.isArray(sm.nodes) ? sm.nodes.length : 0;
5838
+ if (nodes !== args.sections.length)
5839
+ return error(`Arrangement did not land within 3s (${nodes} nodes vs ${args.sections.length} sections) — the ` +
5840
+ "command may have been rejected (variationIndex/vxVariationIndex must be 0-7; bars must be greater than 0).");
5841
+ return text(`Song arrangement set: ${nodes} sections, ${Array.isArray(sm.steps) ? sm.steps.length : 0} steps, ` +
5842
+ `${String(sm.arrangementBars)} bars${args.loop ? ", looped" : ""}${args.enabled ? ", song mode ON" : ""}. ` +
5843
+ "Read it back with op 'get'.");
5844
+ }
5845
+ case "get": {
5846
+ const state = await getVideoStateRaw();
5847
+ if (state.available === false)
5848
+ return error("VX/song read-model unavailable — open PAM's window (WebView-routed).");
5849
+ const sm = state.songMode;
5850
+ if (!sm)
5851
+ return error("No songMode read-model in the snapshot — the connected PAM predates it (needs ≥ 1.5.7).");
5852
+ return json(sm);
5853
+ }
5854
+ case "assignVx": {
5855
+ if (args.nodeId === undefined && args.stepIndex === undefined)
5856
+ return error("'assignVx' requires nodeId or stepIndex");
5857
+ await sendVideoCommand({
5858
+ op: "setSongNodeVx",
5859
+ ...(args.nodeId !== undefined && { nodeId: args.nodeId }),
5860
+ ...(args.stepIndex !== undefined && { stepIndex: args.stepIndex }),
5861
+ index: args.vxIndex ?? null,
5862
+ ...(args.vxScenePath !== undefined && { vxScenePath: args.vxScenePath }),
5863
+ });
5864
+ return text(`VX assignment sent (${args.nodeId ?? `step ${args.stepIndex}`} → ${args.vxIndex ?? "follow audio"}` +
5865
+ `${args.vxIndex != null && args.vxScenePath ? ` in ${args.vxScenePath}` : ""}). ` +
5866
+ "Verify with op 'get' (nodes[].vxVariationIndex / vxScenePath).");
5867
+ }
3852
5868
  case "setSequence":
3853
5869
  if (!args.steps)
3854
5870
  return error("'setSequence' requires steps");
@@ -3888,17 +5904,24 @@ server.registerTool("song_mode", {
3888
5904
  });
3889
5905
  server.registerTool("export_song", {
3890
5906
  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.",
5907
+ description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
5908
+ "Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
5909
+ "Strategies:\n" +
5910
+ "• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
5911
+ "scene (VX variation follow runs live, so per-section visuals land exactly as assigned) and muxes " +
5912
+ "the master mix into a video file. Needs PAM ≥ 1.5.7 with the window OPEN and at least one VX layer.\n" +
5913
+ "• 'live' without cellId — bar-synced audio WAV via the same WebView Song Export (lands in " +
5914
+ "VX/Resamples; window must be open, PAM ≥ 1.5.7). No destination cell consumed.\n" +
5915
+ "• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
5916
+ "closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
5917
+ "• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
5918
+ "variation section applies sample-accurately on its anchor; renders faster than realtime with no bar cap. " +
5919
+ "Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
5920
+ "recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
5921
+ "and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
5922
+ "Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
5923
+ "Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
5924
+ "from get_default_locations).",
3902
5925
  inputSchema: {
3903
5926
  strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
3904
5927
  bars: z
@@ -3911,41 +5934,113 @@ server.registerTool("export_song", {
3911
5934
  .min(1)
3912
5935
  .max(8)
3913
5936
  .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)."),
5937
+ .describe("For legacy headless 'live': empty destination cell for the bounced clip. Omit to use the WebView Song Export (window open)."),
5938
+ includeVideo: z
5939
+ .boolean()
5940
+ .default(false)
5941
+ .describe("Film the VX scene alongside the audio and mux to video (window open, VX layers required)."),
5942
+ name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
5943
+ normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
5944
+ encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
5945
+ bitDepth: z.number().default(24).describe("Bit depth (16 or 24; legacy cell-capture + offline paths)."),
3916
5946
  tailBars: z
3917
5947
  .number()
3918
5948
  .min(0)
3919
5949
  .max(8)
3920
5950
  .default(1)
3921
- .describe("For 'live': extra bars captured after the arrangement end for reverb/release tails."),
5951
+ .describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
3922
5952
  },
3923
- }, async ({ strategy, bars, cellId, bitDepth, tailBars }) => {
5953
+ }, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
3924
5954
  const check = await requireBridge();
3925
5955
  if (check !== true)
3926
5956
  return check;
3927
- // -------- Offline strategy: reuse the song-mode bounce (first-section only) --------
3928
- if (strategy === "offline") {
5957
+ // -------- Offline audio-only: headless multi-section bounce --------
5958
+ // (Offline WITH video routes through the WebView Song Export below — the
5959
+ // pump needs the window; strategy rides the op.)
5960
+ if (strategy === "offline" && !includeVideo) {
3929
5961
  const filename = `pam_song_${Date.now()}`;
3930
5962
  const transport = (await bridge.send("getTransport"));
3931
5963
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
3932
5964
  await bridge.send("exportAudio", {
3933
5965
  filename,
3934
5966
  mode: "song-mode",
5967
+ // Estimate only — exportSongMode computes the real length from the arrangement.
3935
5968
  durationInSeconds: (bars * 4 * 60) / bpm,
3936
5969
  bitDepth,
3937
5970
  });
3938
5971
  const outcome = await pollExportStatus();
3939
5972
  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.");
5973
+ return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder) — every variation section ` +
5974
+ "applied on its anchor.\n" +
5975
+ "Caveats: preset-path steps hold the previous section (pending); Magenta/Notochord render silent " +
5976
+ "offline (use strategy 'live' for generative sources). Verify with get_audio_info.");
3942
5977
  if (outcome === "failed")
3943
- return error("Offline song bounce failed. Is Song Mode enabled with a non-empty sequence?");
5978
+ return error("Offline song bounce failed (or was cancelled). Is Song Mode enabled with a non-empty sequence?");
3944
5979
  return text("Offline song bounce timed out. The render may still be running.");
3945
5980
  }
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.");
5981
+ // -------- VX-capture path (WebView Song Export): audio WAV or audio+video --------
5982
+ // Chosen when the caller wants video, or asked for 'live' without a destination
5983
+ // cell — runSongExport bar-syncs the capture, runs VX variation follow live, and
5984
+ // needs no free cell. Fire-and-forget op + snapshot polling (songExport status).
5985
+ if (includeVideo || typeof cellId !== "number") {
5986
+ const pre = await getVideoStateRaw();
5987
+ if (pre.available === false)
5988
+ return error(includeVideo
5989
+ ? "includeVideo needs PAM's window OPEN (the VX scene renders in the WebView)."
5990
+ : "'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.");
5991
+ if (!("songExport" in pre))
5992
+ return error("The connected PAM predates the Song Export op (needs ≥ 1.5.7). " +
5993
+ "Pass an empty destination cellId for the legacy live capture instead.");
5994
+ const preJob = pre.songExport ?? {};
5995
+ if (preJob.state === "running")
5996
+ return error("A song export is already running — poll get_video_state songExport, or cancel it first.");
5997
+ await sendVideoCommand({
5998
+ op: "exportSong",
5999
+ bars,
6000
+ includeVideo,
6001
+ strategy,
6002
+ ...(name !== undefined && { name }),
6003
+ ...(normalize !== undefined && { normalize }),
6004
+ ...(encoderId !== undefined && { encoderId }),
6005
+ });
6006
+ // The op returns immediately; confirm OUR job actually started. A rejected
6007
+ // op (sync guards: empty arrangement, busy capture, no VX layers) leaves
6008
+ // the previous status — with its old startedAt stamp — in place.
6009
+ let started = false;
6010
+ for (let i = 0; i < 10; i++) {
6011
+ await sleep(500);
6012
+ const s = await getVideoStateRaw();
6013
+ const job = s.songExport ?? {};
6014
+ if (typeof job.startedAt === "number" && job.startedAt !== preJob.startedAt) {
6015
+ started = true;
6016
+ break;
6017
+ }
6018
+ }
6019
+ if (!started)
6020
+ return error("Song export did not start — likely a guard failure (empty arrangement, another capture " +
6021
+ "running, or includeVideo with no VX layers). Check song_mode get / get_video_state.");
6022
+ const transport = (await bridge.send("getTransport"));
6023
+ const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
6024
+ // Capture length + bar-sync start slack + save/mux margin.
6025
+ const deadline = Date.now() + ((bars + 2) * 4 * 60 * 1000) / bpm + 120_000;
6026
+ let last = {};
6027
+ while (Date.now() < deadline) {
6028
+ await sleep(2000);
6029
+ const s = await getVideoStateRaw();
6030
+ last = s.songExport ?? {};
6031
+ if (last.state === "done")
6032
+ return text(`Song export complete (${bars} bars, ${includeVideo ? "audio+video" : "audio WAV"}).\n` +
6033
+ `File: ${String(last.path ?? "(unknown — check VX/Resamples / Visuals)")}\n` +
6034
+ (includeVideo ? "" : "Verify with get_audio_info."));
6035
+ if (last.state === "failed")
6036
+ return error(`Song export failed: ${String(last.error ?? "unknown")}`);
6037
+ if (last.state === "cancelled")
6038
+ return error("Song export was cancelled.");
6039
+ }
6040
+ return error(`Song export timed out (last status: ${JSON.stringify(last)}). It may still be running — ` +
6041
+ "poll get_video_state songExport, or cancel via video_command {op:'cancelSongExport'}.");
6042
+ }
6043
+ // -------- Legacy live strategy: headless master capture into a destination cell --------
3949
6044
  const transport = (await bridge.send("getTransport"));
3950
6045
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
3951
6046
  const totalBars = bars + (tailBars ?? 1);
@@ -4235,6 +6330,13 @@ function isPathUnderWhitelist(p) {
4235
6330
  // accepts entries observed here so an agent can't instantiate arbitrary
4236
6331
  // binaries.
4237
6332
  const knownPluginIdentifiers = new Set();
6333
+ // PAM's bridge returns some plugin-host results as a JSON OBJECT in `result`
6334
+ // (newer builds) and others as a pre-serialized string (older builds). The
6335
+ // handlers below must accept both — treating an object as a string made
6336
+ // text() emit a non-string (MCP result-validation error) and JSON.parse
6337
+ // throw, leaving knownPluginIdentifiers empty so load_plugin refused every
6338
+ // path.
6339
+ const bridgeResultText = (raw) => typeof raw === "string" ? raw : JSON.stringify(raw);
4238
6340
  // Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
4239
6341
  // (falls back to 8 — PAM's shipping max) rather than the C++ slot
4240
6342
  // reservation of 32.
@@ -4281,12 +6383,12 @@ server.registerTool("list_plugins", {
4281
6383
  filter.isInstrument = args.isInstrument;
4282
6384
  if (args.nameContains)
4283
6385
  filter.nameContains = args.nameContains;
4284
- const raw = (await bridge.send("listPlugins", filter));
6386
+ const raw = await bridge.send("listPlugins", filter);
4285
6387
  // Record every fileOrIdentifier so load_plugin can validate that the
4286
6388
  // caller-supplied path was discovered by PAM's own scanner.
4287
6389
  try {
4288
- const parsed = JSON.parse(raw);
4289
- if (Array.isArray(parsed.plugins)) {
6390
+ const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw);
6391
+ if (Array.isArray(parsed?.plugins)) {
4290
6392
  for (const p of parsed.plugins) {
4291
6393
  if (typeof p.fileOrIdentifier === "string") {
4292
6394
  knownPluginIdentifiers.add(p.fileOrIdentifier);
@@ -4297,7 +6399,7 @@ server.registerTool("list_plugins", {
4297
6399
  catch {
4298
6400
  // C++ payload not JSON — fall through; load_plugin will reject unknowns.
4299
6401
  }
4300
- return text(raw);
6402
+ return text(bridgeResultText(raw));
4301
6403
  });
4302
6404
  server.registerTool("scan_plugins", {
4303
6405
  title: "Scan Plugins",
@@ -4353,12 +6455,21 @@ server.registerTool("get_plugin_scan_status", {
4353
6455
  "`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
4354
6456
  "and `updatedAtMs`. Poll while a scan is running.",
4355
6457
  inputSchema: {},
6458
+ outputSchema: {
6459
+ isScanning: z.boolean().optional(),
6460
+ format: z.string().optional(),
6461
+ currentPath: z.string().optional(),
6462
+ fraction: z.number().optional(),
6463
+ elapsedMs: z.number().optional(),
6464
+ isStall: z.boolean().optional(),
6465
+ updatedAtMs: z.number().optional(),
6466
+ },
4356
6467
  }, async () => {
4357
6468
  const check = await requireBridge();
4358
6469
  if (check !== true)
4359
6470
  return check;
4360
- const raw = (await bridge.send("getPluginScanStatus"));
4361
- return text(raw);
6471
+ const raw = await bridge.send("getPluginScanStatus");
6472
+ return structuredFromBridge(raw);
4362
6473
  });
4363
6474
  server.registerTool("list_loaded_plugins", {
4364
6475
  title: "List Loaded Plugins",
@@ -4371,8 +6482,8 @@ server.registerTool("list_loaded_plugins", {
4371
6482
  const check = await requireBridge();
4372
6483
  if (check !== true)
4373
6484
  return check;
4374
- const raw = (await bridge.send("listLoadedPlugins"));
4375
- return text(raw);
6485
+ const raw = await bridge.send("listLoadedPlugins");
6486
+ return text(bridgeResultText(raw));
4376
6487
  });
4377
6488
  server.registerTool("load_plugin", {
4378
6489
  title: "Load Plugin Into Cell",
@@ -4383,6 +6494,10 @@ server.registerTool("load_plugin", {
4383
6494
  cellId: z.number().int().min(1).describe("Target cell (1-based)."),
4384
6495
  path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
4385
6496
  format: PLUGIN_FORMAT.describe("Plugin format."),
6497
+ uniqueId: z.number().int().optional().describe("Optional type disambiguator for multi-type bundles (one .vst3 exposing several plugin types, " +
6498
+ "e.g. Serum2.vst3 = instrument + FX). Pass the uniqueId from the list_plugins entry you want. " +
6499
+ "Absent → PAM prefers the instrument type at that path."),
6500
+ name: z.string().optional().describe("Optional type name disambiguator (the list_plugins `name`), used when uniqueId is unavailable."),
4386
6501
  },
4387
6502
  }, async (args) => {
4388
6503
  const check = await requireBridge();
@@ -4399,6 +6514,8 @@ server.registerTool("load_plugin", {
4399
6514
  cellId: clamped,
4400
6515
  path: args.path,
4401
6516
  format: args.format,
6517
+ ...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
6518
+ ...(args.name ? { name: args.name } : {}),
4402
6519
  }));
4403
6520
  return text(raw);
4404
6521
  });
@@ -4471,7 +6588,7 @@ server.registerTool("update_settings", {
4471
6588
  "experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
4472
6589
  "experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
4473
6590
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
4474
- "The drum synth needs no flag — it is a standard feature on PAM. Other keys " +
6591
+ "Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
4475
6592
  "include UI/library preferences — call get_state or inspect the returned object for the current set.",
4476
6593
  inputSchema: {
4477
6594
  settings: z
@@ -4548,6 +6665,24 @@ server.registerTool("get_audio_device_setup", {
4548
6665
  const setup = await bridge.send("getAudioDeviceSetup");
4549
6666
  return json(setup);
4550
6667
  });
6668
+ server.registerTool("set_audio_device", {
6669
+ title: "Set Audio Output Device",
6670
+ description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
6671
+ "device, and the call errors there). Use the exact name from get_audio_device_setup's device list. " +
6672
+ "The usual reason to call this headlessly is routing PAM into a loopback device (BlackHole, Soundflower) " +
6673
+ "so a capture run records audio. MIDI devices go through set_midi_io.",
6674
+ inputSchema: {
6675
+ deviceName: z
6676
+ .string()
6677
+ .describe("Output device name exactly as listed by get_audio_device_setup."),
6678
+ },
6679
+ }, async ({ deviceName }) => {
6680
+ const check = await requireBridge();
6681
+ if (check !== true)
6682
+ return check;
6683
+ await bridge.send("setAudioDevice", [deviceName]);
6684
+ return text(`Audio output device set to '${deviceName}'.`);
6685
+ });
4551
6686
  server.registerTool("set_midi_io", {
4552
6687
  title: "Set MIDI I/O Devices",
4553
6688
  description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
@@ -4581,16 +6716,16 @@ server.registerTool("set_midi_io", {
4581
6716
  server.registerTool("set_midi_mapping", {
4582
6717
  title: "Set MIDI Action Mapping",
4583
6718
  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.",
6719
+ "looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
6720
+ "MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
4586
6721
  inputSchema: {
4587
6722
  target: z
4588
- .enum(["variation", "preset", "looper", "resample", "looperVariation"])
4589
- .describe("Which action's mapping to configure."),
6723
+ .enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
6724
+ .describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
4590
6725
  field: z
4591
6726
  .enum(["enabled", "channel", "ccNumber", "source", "barSync"])
4592
6727
  .describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
4593
- "looper/resample have no source/barSync."),
6728
+ "looper/resample/vxRecord have no source/barSync."),
4594
6729
  value: z
4595
6730
  .union([z.number(), z.boolean()])
4596
6731
  .describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
@@ -4755,13 +6890,309 @@ server.registerTool("remove_plugin_from_blocklist", {
4755
6890
  return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
4756
6891
  });
4757
6892
  // ============================================================
6893
+ // TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
6894
+ // ============================================================
6895
+ // The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
6896
+ // (shared fgemma-format module + live [ctx] line), generates against a local
6897
+ // OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
6898
+ // call blocks, and executes them through this server's own handlers. Lets an
6899
+ // MCP client (Claude Code) test the assistant model directly against a live
6900
+ // PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
6901
+ // from the previous trace as `history`.
6902
+ //
6903
+ // Dev-tree only: registered only when the fgemma toolpack resolves (the
6904
+ // published npm package doesn't carry it — the tool would be dead weight).
6905
+ const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../fgemma-poc/out/toolpack.json");
6906
+ // The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
6907
+ // Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
6908
+ // vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
6909
+ // the cell grid (selectMainView(null) semantics in the WebView).
6910
+ const SET_VIEW_MAIN_VIEW = {
6911
+ cells: null,
6912
+ pads: null,
6913
+ grid: null,
6914
+ mixer: "Mixer",
6915
+ ctrl: "CtrlAll",
6916
+ ctrlall: "CtrlAll",
6917
+ looper: "Looper",
6918
+ song: "SongMode",
6919
+ lfo: "LFO",
6920
+ envelope: "Envelope",
6921
+ env: "Envelope",
6922
+ vary: "Vary",
6923
+ paramseq: "ParamSeq",
6924
+ video: "Video",
6925
+ vx: "Video",
6926
+ visuals: "Video",
6927
+ export: "Export",
6928
+ presets: "PresetBrowser",
6929
+ browser: "PresetBrowser",
6930
+ };
6931
+ // Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
6932
+ // ask_assistant — the published server deliberately excludes window/view
6933
+ // chrome). Error strings mirror the executor exactly: the model's repair
6934
+ // turns are trained on those shapes.
6935
+ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6936
+ server.registerTool("set_view", {
6937
+ title: "Set View (assistant surface)",
6938
+ description: "Navigate PAM's UI: switch the main view, focus a cell, or set the pad-grid size. " +
6939
+ "Dev-tree twin of the in-plugin assistant's set_view, routed through the WebView — " +
6940
+ "PAM's window must be OPEN. On PAM builds without the setView bridge action it falls " +
6941
+ "back to the capture_ui compose path (view + gridSize only; cell selection needs a " +
6942
+ "rebuilt plugin).",
6943
+ inputSchema: {
6944
+ // Union: "8 cell view" phrasings make the model emit view:8 (a number) —
6945
+ // accept it so the gridSize coercion below can run instead of a zod reject.
6946
+ view: z
6947
+ .union([z.string(), z.number()])
6948
+ .optional()
6949
+ .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
6950
+ cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
6951
+ gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
6952
+ },
6953
+ }, async ({ view: viewArg, cellId, gridSize }) => {
6954
+ const check = await requireBridge();
6955
+ if (check !== true)
6956
+ return check;
6957
+ let view = viewArg === undefined ? undefined : String(viewArg);
6958
+ // Numeric view token → gridSize (executor does the same coercion); an
6959
+ // explicit gridSize wins and the numeric view is dropped either way.
6960
+ if (view !== undefined && /^[148]$/.test(view.trim())) {
6961
+ if (gridSize === undefined)
6962
+ gridSize = Number(view.trim());
6963
+ view = undefined;
6964
+ }
6965
+ const applied = {};
6966
+ let token;
6967
+ if (typeof view === "string" && view.trim()) {
6968
+ token = view.trim().toLowerCase();
6969
+ if (!(token in SET_VIEW_MAIN_VIEW))
6970
+ return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
6971
+ applied.view = token;
6972
+ }
6973
+ if (gridSize !== undefined) {
6974
+ if (gridSize !== 1 && gridSize !== 4 && gridSize !== 8)
6975
+ return error("gridSize must be 1, 4, or 8.");
6976
+ applied.gridSize = gridSize;
6977
+ }
6978
+ if (cellId !== undefined) {
6979
+ if (!Number.isInteger(cellId) || cellId < 1 || cellId > 8)
6980
+ return error("cellId must be 1-8.");
6981
+ applied.cellId = cellId;
6982
+ }
6983
+ if (!Object.keys(applied).length)
6984
+ return error("Provide view, cellId, and/or gridSize.");
6985
+ try {
6986
+ await bridge.send("setView", {
6987
+ ...(token !== undefined ? { view: token } : {}),
6988
+ ...(cellId !== undefined ? { cellId } : {}),
6989
+ ...(gridSize !== undefined ? { gridSize } : {}),
6990
+ });
6991
+ return json(applied);
6992
+ }
6993
+ catch (e) {
6994
+ const msg = e.message;
6995
+ if (!/unknown action|does not support/i.test(msg))
6996
+ return error(`set_view failed: ${msg}`);
6997
+ // Pre-1.15.0 PAM build — compose via the shipped captureUi handler
6998
+ // (applies mainView/gridSize in the WebView before its rasterize; the
6999
+ // capture itself is discarded). No cell-selection fallback exists.
7000
+ if (token === undefined && gridSize === undefined)
7001
+ return error("Cell selection needs the setView bridge action — this PAM build predates it (rebuild PAM).");
7002
+ const setup = { settleMs: 0 };
7003
+ if (token !== undefined)
7004
+ setup.mainView = SET_VIEW_MAIN_VIEW[token];
7005
+ if (gridSize !== undefined)
7006
+ setup.gridSize = gridSize;
7007
+ try {
7008
+ await bridge.send("captureUi", setup);
7009
+ }
7010
+ catch (e2) {
7011
+ return error(`set_view failed: ${e2.message}`);
7012
+ }
7013
+ delete applied.cellId; // not applied on the fallback path — don't over-claim
7014
+ return json(applied);
7015
+ }
7016
+ });
7017
+ if (existsSync(ASSISTANT_TOOLPACK_PATH))
7018
+ server.registerTool("ask_assistant", {
7019
+ title: "Ask Assistant (FunctionGemma e2e test)",
7020
+ description: "Run one natural-language command through the on-device assistant model: render the " +
7021
+ "FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
7022
+ "parse the tool calls, execute them against PAM, and return the full trace. Requires " +
7023
+ "llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
7024
+ "previous trace's modelTurn back via history.",
7025
+ inputSchema: {
7026
+ text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
7027
+ endpoint: z
7028
+ .string()
7029
+ .optional()
7030
+ .describe("OpenAI-compatible /v1 base URL (default env PAM_LLM_URL or http://127.0.0.1:8080/v1)."),
7031
+ sel: z
7032
+ .number()
7033
+ .int()
7034
+ .optional()
7035
+ .describe("Selected-cell override for the [ctx] line (the bridge can't see UI selection)."),
7036
+ noCtx: z.boolean().optional().describe("Skip the [ctx] line (test the no-context prompt shape)."),
7037
+ history: z
7038
+ .array(z.object({ user: z.string(), model: z.string() }))
7039
+ .optional()
7040
+ .describe("Prior exchanges to thread (user text + the modelTurn from that trace)."),
7041
+ maxRounds: z.number().int().min(1).max(5).optional().describe("Continuation rounds (default 3)."),
7042
+ },
7043
+ }, async ({ text: userText, endpoint, sel, noCtx, history, maxRounds }) => {
7044
+ // Toolpack: the model-facing surface (dev tree; regenerate with export-toolpack.mjs).
7045
+ let pack;
7046
+ try {
7047
+ const doc = JSON.parse(readFileSync(ASSISTANT_TOOLPACK_PATH, "utf-8"));
7048
+ pack = doc.tools;
7049
+ }
7050
+ catch {
7051
+ return error("assistantToolpack not found — run `node scripts/export-toolpack.mjs` in packages/mcp-server first.");
7052
+ }
7053
+ const declared = new Set(pack.map((t) => t.name));
7054
+ const llmUrl = (endpoint || process.env.PAM_LLM_URL || "http://127.0.0.1:8080/v1").replace(/\/$/, "");
7055
+ // Live [ctx] line from this server's own read tools (sel is UI-only state
7056
+ // the bridge can't see — honor the override, else none).
7057
+ let ctx = null;
7058
+ if (!noCtx) {
7059
+ try {
7060
+ const t = await callInternalTool("get_transport", {});
7061
+ const o = await callInternalTool("get_design_overview", {});
7062
+ if (!t.isError && !o.isError) {
7063
+ const transport = JSON.parse(t.text);
7064
+ const overview = JSON.parse(o.text);
7065
+ // preset= IS bridge-visible (state.activePresetPath) — only view/sel
7066
+ // are UI-only. Renders `none` for Init/Default, like the executor.
7067
+ let preset = null;
7068
+ try {
7069
+ const s = await callInternalTool("get_state", { keys: ["activePresetPath"] });
7070
+ const activePath = JSON.parse(s.text).activePresetPath;
7071
+ if (typeof activePath === "string" && activePath && activePath !== "Init") {
7072
+ const stem = (activePath.split("/").pop() ?? "").replace(/\.preset$/, "");
7073
+ preset = stem && stem !== "Default" ? stem : null;
7074
+ }
7075
+ }
7076
+ catch {
7077
+ preset = null;
7078
+ }
7079
+ ctx = {
7080
+ bpm: Number(transport.bpm ?? 120),
7081
+ playing: Boolean(transport.playing ?? transport.isPlaying),
7082
+ preset,
7083
+ sel: sel ?? null,
7084
+ cells: (overview.cells ?? [])
7085
+ .filter((c) => c.sample || c.synth)
7086
+ .map((c) => ({ id: c.id, name: c.sample ?? `${c.synth}(synth)` })),
7087
+ };
7088
+ }
7089
+ }
7090
+ catch {
7091
+ ctx = null; // no PAM — trained no-ctx prompt shape still works
7092
+ }
7093
+ }
7094
+ let prompt = buildGenerationPrompt(pack, withContext(userText, ctx), {
7095
+ bos: "none",
7096
+ history,
7097
+ });
7098
+ const trace = { ctxLine: ctx ? renderContextLine(ctx) : null, rounds: [], executed: [], modelTurn: "" };
7099
+ const rounds = maxRounds ?? 3;
7100
+ // A repair round that re-emits the exact call that just errored will error
7101
+ // identically forever — cut the loop (executor parity).
7102
+ const failedCalls = new Set();
7103
+ for (let round = 0; round < rounds; round++) {
7104
+ const t0 = Date.now();
7105
+ let raw;
7106
+ try {
7107
+ const resp = await fetch(`${llmUrl}/completions`, {
7108
+ method: "POST",
7109
+ headers: { "Content-Type": "application/json" },
7110
+ body: JSON.stringify({ prompt, max_tokens: 256, temperature: 0, stop: STOP_SEQUENCES, stream: false }),
7111
+ });
7112
+ if (!resp.ok)
7113
+ return error(`model endpoint ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
7114
+ raw = (await resp.json()).choices?.[0]?.text ?? "";
7115
+ }
7116
+ catch (e) {
7117
+ return error(`cannot reach model endpoint ${llmUrl} — start llama-server with the fine-tuned GGUF and --special. (${e.message})`);
7118
+ }
7119
+ raw = truncateAtFabricatedResponse(raw);
7120
+ const calls = parseFunctionCalls(raw);
7121
+ trace.rounds.push({ raw, ms: Date.now() - t0, calls });
7122
+ if (!calls.length) {
7123
+ trace.text = raw.trim();
7124
+ trace.modelTurn += trace.text;
7125
+ return json(trace);
7126
+ }
7127
+ const unknown = calls.filter((c) => !declared.has(c.name));
7128
+ if (unknown.length) {
7129
+ trace.refused = `undeclared tool(s): ${unknown.map((c) => c.name).join(", ")}`;
7130
+ return json(trace);
7131
+ }
7132
+ let continuation = raw.trimEnd();
7133
+ let responses = "";
7134
+ let wantContinue = false;
7135
+ for (const call of calls) {
7136
+ const signature = `${call.name} ${JSON.stringify(call.arguments)}`;
7137
+ if (failedCalls.has(signature)) {
7138
+ trace.stopped = `model repeated a call that already failed (${call.name}) — loop cut`;
7139
+ return json(trace);
7140
+ }
7141
+ // The flat set_parameter alias maps onto the real object-map tool.
7142
+ const real = pack.find((t) => t.name === call.name)?.aliasOf === "set_parameters"
7143
+ ? {
7144
+ name: "set_parameters",
7145
+ arguments: { parameters: { [String(call.arguments.paramId)]: call.arguments.value } },
7146
+ }
7147
+ : call;
7148
+ const result = await callInternalTool(real.name, real.arguments);
7149
+ if (result.isError)
7150
+ failedCalls.add(signature);
7151
+ trace.executed.push({
7152
+ name: call.name,
7153
+ arguments: call.arguments,
7154
+ result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
7155
+ isError: result.isError,
7156
+ });
7157
+ let payload;
7158
+ if (result.isError)
7159
+ payload = { error: result.text.slice(0, 400) };
7160
+ else {
7161
+ try {
7162
+ const parsed = JSON.parse(result.text);
7163
+ payload =
7164
+ parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
7165
+ ? parsed
7166
+ : { result: result.text.slice(0, 400) };
7167
+ }
7168
+ catch {
7169
+ payload = { result: result.text.slice(0, 400) };
7170
+ }
7171
+ }
7172
+ const response = renderFunctionResponse(call.name, payload);
7173
+ continuation += response;
7174
+ responses += response;
7175
+ if (result.isError || /^(get_|list_|connection_status)/.test(call.name))
7176
+ wantContinue = true;
7177
+ }
7178
+ trace.modelTurn += calls.map(renderFunctionCall).join("") + responses;
7179
+ if (!wantContinue)
7180
+ return json(trace);
7181
+ prompt += continuation;
7182
+ }
7183
+ return json(trace);
7184
+ });
7185
+ // ============================================================
4758
7186
  // Start
4759
7187
  // ============================================================
4760
7188
  async function main() {
4761
7189
  if (await handleCliArgs())
4762
7190
  return;
4763
7191
  // Try to connect to plugin on startup (non-blocking)
4764
- void bridge.connect();
7192
+ void bridge.connect().then((ok) => {
7193
+ if (ok)
7194
+ syncProductHint();
7195
+ });
4765
7196
  const transport = new StdioServerTransport();
4766
7197
  await server.connect(transport);
4767
7198
  // Log to stderr (stdout is the MCP JSON-RPC channel)