@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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.
Files changed (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -4,6 +4,7 @@
4
4
  * `browser_window` (show or screenshot the Strudel window).
5
5
  */
6
6
  import { InputValidator } from '../../utils/InputValidator.js';
7
+ import { defaultTempoFor, resolveLayers } from '../../services/StyleRegistry.js';
7
8
  const SESSION_ID_PROP = {
8
9
  session_id: {
9
10
  type: 'string',
@@ -34,7 +35,7 @@ export const tools = [
34
35
  inputSchema: {
35
36
  type: 'object',
36
37
  properties: {
37
- style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
38
+ style: { type: 'string', description: 'Genre: techno, house, dnb, breakbeat, trap, jungle, ambient, experimental, intelligent_dnb, trip_hop, boom_bap. Anything else (jazz included) gets techno drums, and the response says so.' },
38
39
  tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
39
40
  key: { type: 'string', description: 'Musical key (default: C)' },
40
41
  auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
@@ -46,27 +47,6 @@ export const tools = [
46
47
  },
47
48
  ];
48
49
  export const toolNames = new Set(tools.map(t => t.name));
49
- const TEMPO_BY_STYLE = {
50
- techno: 130,
51
- house: 125,
52
- dnb: 174,
53
- 'drum and bass': 174,
54
- ambient: 80,
55
- trap: 140,
56
- jungle: 160,
57
- jazz: 110,
58
- experimental: 120,
59
- dubstep: 140,
60
- trance: 138,
61
- breakbeat: 130,
62
- garage: 130,
63
- electro: 128,
64
- downtempo: 90,
65
- idm: 115,
66
- };
67
- function defaultTempo(style) {
68
- return TEMPO_BY_STYLE[style.toLowerCase()] ?? 120;
69
- }
70
50
  async function doShow(ctx, sid) {
71
51
  if (!sid && !ctx.isInitialized()) {
72
52
  return 'Browser not initialized. Run init first.';
@@ -98,13 +78,21 @@ export async function execute(name, args, ctx) {
98
78
  InputValidator.validateBPM(args.tempo);
99
79
  }
100
80
  // Auto-init only the default session. Named sessions must be
101
- // created via `create_session` first — ctx.getController(sid)
81
+ // created via `session({ action: "create" })` first — ctx.getController(sid)
102
82
  // throws if missing, which the dispatcher renders as err('business').
103
83
  if (!sid) {
104
84
  await ctx.ensureInitialized();
105
85
  }
106
86
  const controller = ctx.getController(sid);
107
- const tempo = args.tempo || defaultTempo(args.style);
87
+ // compose assembles four independently-resolved layers, each
88
+ // falling back on its own. One `style` field cannot describe that
89
+ // — reporting only the drum resolution called
90
+ // compose({style:'jazz'}) "techno" while its bassline, Dorian
91
+ // scale and jazz chords were all genuinely jazz, and let
92
+ // `breakbeat` claim itself supported over a techno bassline
93
+ // (#294, correcting #279).
94
+ const resolution = resolveLayers(args.style);
95
+ const tempo = args.tempo || defaultTempoFor(args.style);
108
96
  const key = args.key || 'C';
109
97
  const pattern = ctx.generator.generateCompletePattern(args.style, key, tempo);
110
98
  await controller.writePattern(pattern);
@@ -115,9 +103,21 @@ export async function execute(name, args, ctx) {
115
103
  const response = {
116
104
  success: true,
117
105
  pattern: pattern.substring(0, 200) + (pattern.length > 200 ? '...' : ''),
118
- metadata: { style: args.style, bpm: tempo, key },
106
+ metadata: {
107
+ style: args.style,
108
+ bpm: tempo,
109
+ key,
110
+ layers: resolution.layers,
111
+ substituted: resolution.substituted,
112
+ },
119
113
  status: shouldPlay ? 'playing' : 'ready',
120
- message: `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`,
114
+ message: resolution.substituted.length === 0
115
+ ? `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`
116
+ : `Created pattern in ${key}${shouldPlay ? ' - now playing' : ''}. ` +
117
+ `No ${resolution.substituted.join(' or ')} defined for style "${args.style}" — ` +
118
+ resolution.substituted
119
+ .map(l => `${l}: ${resolution.layers[l]}`)
120
+ .join(', ') + '.',
121
121
  };
122
122
  if (args.get_feedback) {
123
123
  if (ctx.geminiService.isAvailable()) {
@@ -10,6 +10,7 @@
10
10
  * touch the browser (`level='full'`) or just inspect server state
11
11
  * (`perf`, `memory`).
12
12
  */
13
+ import { empty } from './types.js';
13
14
  const SESSION_ID_PROP = {
14
15
  session_id: {
15
16
  type: 'string',
@@ -20,7 +21,7 @@ export const tools = [
20
21
  {
21
22
  name: 'diagnostics',
22
23
  description: 'Inspect server and browser state. ' +
23
- 'level=status returns a quick state snapshot (cache read, <15ms SLA). ' +
24
+ 'level=status returns a quick state snapshot (cache read, <15ms SLA), plus which config.json was read and any warnings from it. ' +
24
25
  'level=full returns detailed browser diagnostics including caches, errors, and performance. ' +
25
26
  'level=perf returns server-side timing metrics + top bottlenecks. ' +
26
27
  'level=memory returns process memory usage. ' +
@@ -52,7 +53,23 @@ function getMemoryUsage(ctx) {
52
53
  return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
53
54
  }
54
55
  function getStatus(ctx, sid) {
55
- return ctx.getController(sid).getStatus();
56
+ const status = ctx.getController(sid).getStatus();
57
+ const report = ctx.configReport;
58
+ if (!report)
59
+ return status;
60
+ // Config problems reached `logger.warn` and nothing else — a client
61
+ // log file the user may never open. A `strudel_url` typo, or an
62
+ // `audio_analysis` block that parsed to nothing, was invisible from
63
+ // inside the session. The path is reported whether or not a file was
64
+ // found, so a cwd surprise is visible rather than guessed at (#442).
65
+ return {
66
+ ...(typeof status === 'object' && status !== null ? status : { status }),
67
+ config: {
68
+ path: report.path,
69
+ found: report.found,
70
+ ...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
71
+ },
72
+ };
56
73
  }
57
74
  async function getFullDiagnostics(ctx, sid) {
58
75
  if (!sid && !ctx.isInitialized()) {
@@ -68,7 +85,7 @@ function getErrors(ctx, sid) {
68
85
  const errors = controller.getConsoleErrors();
69
86
  const warnings = controller.getConsoleWarnings();
70
87
  if (errors.length === 0 && warnings.length === 0) {
71
- return 'No errors or warnings captured.';
88
+ return empty('No errors or warnings captured.');
72
89
  }
73
90
  let result = '';
74
91
  if (errors.length > 0) {
@@ -5,7 +5,9 @@
5
5
  * write/append/insert/replace/clear modes, plus `get_pattern` (kept
6
6
  * separate — hot read path).
7
7
  */
8
+ import { empty, ok, withStashField } from './types.js';
8
9
  import { InputValidator } from '../../utils/InputValidator.js';
10
+ import { ValidationError } from '../../utils/CategorisedError.js';
9
11
  const SESSION_ID_PROP = {
10
12
  session_id: {
11
13
  type: 'string',
@@ -19,7 +21,8 @@ export const tools = [
19
21
  'mode=write replaces the editor contents (default; mirrors the old write tool exactly, including optional pattern validation and auto_play). ' +
20
22
  'mode=append concatenates `code` after the current pattern with a newline. ' +
21
23
  'mode=insert places `code` at the given line `position`. ' +
22
- 'mode=replace runs a single string-replace from `search` to `replace`. ' +
24
+ 'mode=replace substitutes `search` with `replace` — the first occurrence only, unless replace_all is true. ' +
25
+ 'The response reports how many occurrences matched, were replaced, and remain. ' +
23
26
  'mode=clear empties the editor. ' +
24
27
  'Example: edit_pattern({ mode: "write", pattern: "s(\\"bd\\")", auto_play: true }). ' +
25
28
  'For reading the editor without mutating it use get_pattern; for the on-disk pattern catalog use pattern_store.',
@@ -33,9 +36,14 @@ export const tools = [
33
36
  },
34
37
  pattern: { type: 'string', description: 'Pattern code (mode=write)' },
35
38
  code: { type: 'string', description: 'Code to append/insert (mode=append/insert)' },
36
- position: { type: 'number', description: 'Line number (mode=insert)' },
39
+ position: {
40
+ type: 'number',
41
+ description: 'mode=insert: 0-indexed line to insert BEFORE. 0 puts the code at the top; '
42
+ + 'a value equal to the line count appends. Out of range is an error, not an append.',
43
+ },
37
44
  search: { type: 'string', description: 'Text to replace (mode=replace)' },
38
45
  replace: { type: 'string', description: 'Replacement text (mode=replace)' },
46
+ replace_all: { type: 'boolean', description: 'mode=replace: replace every occurrence instead of just the first (default false)' },
39
47
  auto_play: { type: 'boolean', description: 'Start playback after write (mode=write only, default: false)' },
40
48
  validate: { type: 'boolean', description: 'Validate pattern before write (mode=write only, default: true)' },
41
49
  ...SESSION_ID_PROP,
@@ -84,20 +92,66 @@ async function doAppend(args, ctx, sid) {
84
92
  return await ctx.writePatternSafe(current + '\n' + args.code, sid);
85
93
  }
86
94
  async function doInsert(args, ctx, sid) {
87
- InputValidator.validatePositiveInteger(args.position, 'position');
88
95
  InputValidator.validateStringLength(args.code, 'code', 10000, true);
89
96
  const lines = (await ctx.getCurrentPatternSafe(sid)).split('\n');
90
- lines.splice(args.position, 0, args.code);
97
+ // 0-indexed, and 0 is a legal position.
98
+ //
99
+ // `validatePositiveInteger` rejected 0 while the `splice` below is
100
+ // 0-indexed, so inserting at the TOP of a pattern was impossible
101
+ // through this tool — and `StrudelController.insertAtLine`, which does
102
+ // the same job, documents the parameter as 0-indexed and permits 0.
103
+ //
104
+ // The other end was unguarded: `position: 9999` on a three-line
105
+ // pattern passed validation and `splice` silently appended, where
106
+ // `insertAtLine` throws. Now it matches that contract exactly
107
+ // (#442), so the two ways of doing this cannot disagree.
108
+ const position = args.position;
109
+ if (!Number.isInteger(position) || position < 0 || position > lines.length) {
110
+ throw new ValidationError(`Invalid position: ${String(position)}. Must be an integer between 0 and ${String(lines.length)} ` +
111
+ `(0 inserts before the first line, ${String(lines.length)} appends).`);
112
+ }
113
+ lines.splice(position, 0, args.code);
91
114
  return await ctx.writePatternSafe(lines.join('\n'), sid);
92
115
  }
93
116
  async function doReplace(args, ctx, sid) {
94
117
  InputValidator.validateStringLength(args.search, 'search', 1000, true);
95
118
  InputValidator.validateStringLength(args.replace, 'replace', 10000, true);
119
+ const search = args.search ?? '';
120
+ if (search.length === 0) {
121
+ throw new Error('Invalid search: must be a non-empty string.');
122
+ }
96
123
  const pattern = await ctx.getCurrentPatternSafe(sid);
124
+ // Literal string operations throughout — never a RegExp built from
125
+ // `search`. Interpolating caller text into a pattern is how #236
126
+ // happened: `(a+)+Z` against a crafted subject blocked the event loop
127
+ // for 25 seconds.
128
+ const matches = pattern.split(search).length - 1;
97
129
  // Escape $ in replacement to prevent special sequence injection ($&, $1, $', etc.)
98
130
  const safeReplacement = args.replace.replace(/\$/g, '$$$$');
99
- const replaced = pattern.replace(args.search, safeReplacement);
100
- return await ctx.writePatternSafe(replaced, sid);
131
+ const replaceAll = args.replace_all === true;
132
+ const updated = replaceAll
133
+ ? pattern.replaceAll(search, safeReplacement)
134
+ : pattern.replace(search, safeReplacement);
135
+ const replaced = replaceAll ? matches : Math.min(matches, 1);
136
+ const remaining = matches - replaced;
137
+ const written = await ctx.writePatternSafe(updated, sid);
138
+ // Counts are reported under BOTH settings. Previously a caller had no
139
+ // way to learn that other occurrences survived — and these callers are
140
+ // LLM agents, which do not reliably re-read the pattern to check (#243).
141
+ // Zero matches is a valid-empty: the search ran and found nothing.
142
+ // Wrapped in a plain ok() it was indistinguishable from a replace
143
+ // that actually changed the pattern (#288).
144
+ const wrap = matches === 0 ? empty : ok;
145
+ return wrap(withStashField({
146
+ success: true,
147
+ message: matches === 0
148
+ ? `No occurrences of ${JSON.stringify(search)} found`
149
+ : `Replaced ${String(replaced)} of ${String(matches)} occurrence(s)` +
150
+ (remaining > 0 ? `; ${String(remaining)} remain. Pass replace_all: true to replace them all.` : ''),
151
+ replaced,
152
+ remaining,
153
+ matches,
154
+ }, written));
101
155
  }
102
156
  async function doClear(ctx, sid) {
103
157
  return await ctx.writePatternSafe('', sid);
@@ -9,7 +9,9 @@
9
9
  * Full-composition generation lives in `compose` (compose.ts), which
10
10
  * orchestrates init + generate + play + optional AI feedback.
11
11
  */
12
+ import { withStashNotice } from './types.js';
12
13
  import { InputValidator } from '../../utils/InputValidator.js';
14
+ import { BASS_STYLES, DRUM_STYLES, resolveBassStyle, resolveDrumStyle } from '../../services/StyleRegistry.js';
13
15
  const SESSION_ID_PROP = {
14
16
  session_id: {
15
17
  type: 'string',
@@ -85,10 +87,17 @@ export const tools = [
85
87
  },
86
88
  ];
87
89
  export const toolNames = new Set(tools.map(t => t.name));
90
+ /**
91
+ * Appends generated material to the current pattern and writes it back.
92
+ *
93
+ * @returns Whatever `writePatternSafe` returned — callers must pass it
94
+ * to `withStashNotice` rather than discard it, because a write with
95
+ * no browser up is a stash, not a write (#285).
96
+ */
88
97
  async function appendOrSet(generated, ctx, sessionId) {
89
98
  const current = await ctx.getCurrentPatternSafe(sessionId);
90
99
  const combined = current ? current + '\n' + generated : generated;
91
- await ctx.writePatternSafe(combined, sessionId);
100
+ return await ctx.writePatternSafe(combined, sessionId);
92
101
  }
93
102
  function doScale(args, ctx) {
94
103
  InputValidator.validateRootNote(args.root);
@@ -101,38 +110,52 @@ async function doChordProgression(args, ctx, sid) {
101
110
  InputValidator.validateChordStyle(args.style);
102
111
  const progression = ctx.theory.generateChordProgression(args.key, args.style);
103
112
  const chordPattern = ctx.generator.generateChords(progression);
104
- await appendOrSet(chordPattern, ctx, sid);
105
- return `Generated ${args.style} progression in ${args.key}: ${progression}`;
113
+ const written = await appendOrSet(chordPattern, ctx, sid);
114
+ return withStashNotice(`Generated ${args.style} progression in ${args.key}: ${progression}`, written);
106
115
  }
107
116
  async function doEuclidean(args, ctx, sid) {
108
117
  InputValidator.validateEuclidean(args.hits, args.steps);
109
118
  if (args.sound)
110
119
  InputValidator.validateStringLength(args.sound, 'sound', 100, false);
111
120
  const euclidean = ctx.generator.generateEuclideanPattern(args.hits, args.steps, args.sound || 'bd');
112
- await appendOrSet(euclidean, ctx, sid);
113
- return `Generated Euclidean rhythm (${args.hits}/${args.steps})`;
121
+ const written = await appendOrSet(euclidean, ctx, sid);
122
+ return withStashNotice(`Generated Euclidean rhythm (${args.hits}/${args.steps})`, written);
114
123
  }
115
124
  async function doPolyrhythm(args, ctx, sid) {
116
125
  args.sounds.forEach((s) => InputValidator.validateStringLength(s, 'sound', 100, false));
117
126
  args.patterns.forEach((p) => InputValidator.validatePositiveInteger(p, 'pattern'));
118
127
  const poly = ctx.generator.generatePolyrhythm(args.sounds, args.patterns);
119
- await appendOrSet(poly, ctx, sid);
120
- return 'Generated polyrhythm';
128
+ const written = await appendOrSet(poly, ctx, sid);
129
+ return withStashNotice('Generated polyrhythm', written);
121
130
  }
122
131
  async function doDrums(args, ctx, sid) {
123
132
  InputValidator.validateStringLength(args.style, 'style', 100, false);
124
133
  if (args.complexity !== undefined)
125
134
  InputValidator.validateNormalizedValue(args.complexity, 'complexity');
135
+ // Say which style actually produced the notes. An unknown genre falls
136
+ // back to techno, and echoing the request back made that invisible (#279).
137
+ const resolution = resolveDrumStyle(args.style);
126
138
  const drums = ctx.generator.generateDrumPattern(args.style, args.complexity || 0.5);
127
- await appendOrSet(drums, ctx, sid);
128
- return `Generated ${args.style} drums`;
139
+ const written = await appendOrSet(drums, ctx, sid);
140
+ return withStashNotice(resolution.supported
141
+ ? `Generated ${resolution.resolved} drums`
142
+ : `No drum pattern for style "${args.style}" — generated ${resolution.resolved} drums instead. ` +
143
+ `Styles with their own drums: ${DRUM_STYLES.join(', ')}.`, written);
129
144
  }
130
145
  async function doBassline(args, ctx, sid) {
131
146
  InputValidator.validateRootNote(args.key);
132
147
  InputValidator.validateStringLength(args.style, 'style', 100, false);
148
+ // role=bass echoed args.style back while the generator had silently
149
+ // fallen back to techno — #279 in the same file, eight lines below
150
+ // the sibling it fixed (#294).
151
+ const resolution = resolveBassStyle(args.style);
133
152
  const bass = ctx.generator.generateBassline(args.key, args.style);
134
- await appendOrSet(bass, ctx, sid);
135
- return `Generated ${args.style} bassline in ${args.key}`;
153
+ const written = await appendOrSet(bass, ctx, sid);
154
+ return withStashNotice(resolution.supported
155
+ ? `Generated ${resolution.resolved} bassline in ${args.key}`
156
+ : `No bassline for style "${args.style}" — generated ${resolution.resolved} ` +
157
+ `bassline in ${args.key} instead. ` +
158
+ `Styles with their own bassline: ${BASS_STYLES.join(', ')}.`, written);
136
159
  }
137
160
  async function doMelody(args, ctx, sid) {
138
161
  InputValidator.validateRootNote(args.root);
@@ -141,16 +164,16 @@ async function doMelody(args, ctx, sid) {
141
164
  InputValidator.validatePositiveInteger(args.length, 'length');
142
165
  const scale = ctx.theory.generateScale(args.root, args.scale);
143
166
  const melody = ctx.generator.generateMelody(scale, args.length || 8);
144
- await appendOrSet(melody, ctx, sid);
145
- return `Generated melody in ${args.root} ${args.scale}`;
167
+ const written = await appendOrSet(melody, ctx, sid);
168
+ return withStashNotice(`Generated melody in ${args.root} ${args.scale}`, written);
146
169
  }
147
170
  async function doFill(args, ctx, sid) {
148
171
  InputValidator.validateStringLength(args.style, 'style', 100, false);
149
172
  if (args.bars !== undefined)
150
173
  InputValidator.validatePositiveInteger(args.bars, 'bars');
151
174
  const fill = ctx.generator.generateFill(args.style, args.bars || 1);
152
- await appendOrSet(fill, ctx, sid);
153
- return `Generated ${args.bars || 1} bar fill`;
175
+ const written = await appendOrSet(fill, ctx, sid);
176
+ return withStashNotice(`Generated ${args.bars || 1} bar fill`, written);
154
177
  }
155
178
  export async function execute(name, args, ctx) {
156
179
  const sid = args?.session_id;
@@ -9,6 +9,7 @@
9
9
  * server pushes onto these arrays on every edit_pattern call, so all
10
10
  * mutations share the same underlying storage.
11
11
  */
12
+ import { empty, err } from './types.js';
12
13
  const SESSION_ID_PROP = {
13
14
  session_id: {
14
15
  type: 'string',
@@ -96,8 +97,11 @@ async function doUndo(ctx, sid) {
96
97
  const { undoStack, redoStack, maxHistory } = ctx.getHistory(sid);
97
98
  if (!sid && !ctx.isInitialized())
98
99
  return 'Browser not initialized. Run init first.';
100
+ // A valid-empty, not a failure: the call worked, there was simply
101
+ // nothing on the stack. `empty()` lets an agent tell that apart from
102
+ // a rich result without parsing the prose (#288).
99
103
  if (undoStack.length === 0)
100
- return 'Nothing to undo';
104
+ return empty('Nothing to undo');
101
105
  const controller = ctx.getController(sid);
102
106
  const current = await controller.getCurrentPattern();
103
107
  redoStack.push(current);
@@ -112,7 +116,7 @@ async function doRedo(ctx, sid) {
112
116
  if (!sid && !ctx.isInitialized())
113
117
  return 'Browser not initialized. Run init first.';
114
118
  if (redoStack.length === 0)
115
- return 'Nothing to redo';
119
+ return empty('Nothing to redo');
116
120
  const controller = ctx.getController(sid);
117
121
  const current = await controller.getCurrentPattern();
118
122
  undoStack.push(current);
@@ -125,7 +129,14 @@ async function doRedo(ctx, sid) {
125
129
  function doList(args, ctx, sid) {
126
130
  const { historyStack } = ctx.getHistory(sid);
127
131
  if (historyStack.length === 0) {
128
- return 'No pattern history yet. Make some edits to build history.';
132
+ // Same record shape as the populated branch below, so a caller can
133
+ // read `entries` unconditionally instead of branching on a string.
134
+ return empty({
135
+ count: 0,
136
+ showing: 0,
137
+ entries: [],
138
+ message: 'No pattern history yet. Make some edits to build history.',
139
+ });
129
140
  }
130
141
  const limit = args?.limit || 10;
131
142
  const recent = historyStack.slice(-limit).reverse();
@@ -148,7 +159,10 @@ async function doRestore(args, ctx, sid) {
148
159
  const controller = ctx.getController(sid);
149
160
  const entry = historyStack.find(e => e.id === args.id);
150
161
  if (!entry) {
151
- return `History entry #${args.id} not found. Use history({ action: "list" }) to see available entries.`;
162
+ // Same bare-string false success as storage's load (#293): nothing
163
+ // was restored, but `ok: true` said otherwise.
164
+ return err('business', `History entry #${args.id} not found. ` +
165
+ 'Use history({ action: "list" }) to see available entries.');
152
166
  }
153
167
  const current = await controller.getCurrentPattern();
154
168
  undoStack.push(current);
@@ -160,14 +174,22 @@ async function doRestore(args, ctx, sid) {
160
174
  async function doCompare(args, ctx, sid) {
161
175
  const { historyStack } = ctx.getHistory(sid);
162
176
  const first = historyStack.find(e => e.id === args.id1);
163
- if (!first)
164
- return `History entry #${args.id1} not found.`;
177
+ if (!first) {
178
+ // Bare strings reach the client as `{ok: true, data: "...not
179
+ // found."}` — the #287/#293 false success, still here twelve lines
180
+ // below the `doRestore` that fixed it for the identical condition
181
+ // (#453).
182
+ return err('business', `History entry #${args.id1} not found. ` +
183
+ 'Use history({ action: "list" }) to see available entries.');
184
+ }
165
185
  let second;
166
186
  let label;
167
187
  if (args.id2) {
168
188
  const b = historyStack.find(e => e.id === args.id2);
169
- if (!b)
170
- return `History entry #${args.id2} not found.`;
189
+ if (!b) {
190
+ return err('business', `History entry #${args.id2} not found. ` +
191
+ 'Use history({ action: "list" }) to see available entries.');
192
+ }
171
193
  second = b.pattern;
172
194
  label = `#${args.id2}`;
173
195
  }
@@ -14,7 +14,7 @@ export const tools = [
14
14
  name: 'playback',
15
15
  description: 'Control transport on the current session. ' +
16
16
  'action=play starts the editor pattern. ' +
17
- 'action=pause stops without resetting clock. ' +
17
+ 'action=pause halts the clock where it is, so play resumes mid-cycle. ' +
18
18
  'action=stop ends playback. ' +
19
19
  'Example: playback({ action: "play" }). ' +
20
20
  'For pattern editing use edit_pattern; for tempo use set_tempo.',
@@ -47,6 +47,7 @@ export async function execute(name, args, ctx) {
47
47
  case 'play':
48
48
  return await controller.play();
49
49
  case 'pause':
50
+ return await controller.pause();
50
51
  case 'stop':
51
52
  return await controller.stop();
52
53
  default:
@@ -5,6 +5,7 @@
5
5
  * list/switch actions. `init` stays separate — it's the global
6
6
  * bootstrap, not a session operation.
7
7
  */
8
+ import { categorizeError, empty, err, ok } from './types.js';
8
9
  import { InputValidator } from '../../utils/InputValidator.js';
9
10
  export const tools = [
10
11
  {
@@ -41,7 +42,14 @@ async function doCreate(args, ctx) {
41
42
  };
42
43
  }
43
44
  catch (error) {
44
- return { success: false, error: error instanceof Error ? error.message : String(error) };
45
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
46
+ // failure-shaped object back into a PLAIN Error before categorising
47
+ // it, so a typed error thrown below this point loses its category on
48
+ // the way out and lands in `internal` — not retryable, and wrong
49
+ // about whose problem it is. Cross-model review caught that the
50
+ // typed errors were being destroyed at this seam (#382).
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return err(categorizeError(error), message);
45
53
  }
46
54
  }
47
55
  async function doDestroy(args, ctx) {
@@ -61,16 +69,33 @@ async function doDestroy(args, ctx) {
61
69
  };
62
70
  }
63
71
  catch (error) {
64
- return { success: false, error: error instanceof Error ? error.message : String(error) };
72
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
73
+ // failure-shaped object back into a PLAIN Error before categorising
74
+ // it, so a typed error thrown below this point loses its category on
75
+ // the way out and lands in `internal` — not retryable, and wrong
76
+ // about whose problem it is. Cross-model review caught that the
77
+ // typed errors were being destroyed at this seam (#382).
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return err(categorizeError(error), message);
65
80
  }
66
81
  }
67
82
  function doList(ctx) {
68
83
  const sm = ctx.sessionManager;
69
84
  const info = sm.getSessionsInfo();
70
- return {
85
+ const wrap = info.length === 0 ? empty : ok;
86
+ return wrap({
71
87
  count: info.length,
72
88
  max_sessions: sm.getMaxSessions(),
73
- default_session: sm.getDefaultSessionId(),
89
+ // Only when it exists. After a destroy, SessionManager resets this
90
+ // to the literal 'default', and reporting that named a session no
91
+ // caller could switch to or find in the list below (#421).
92
+ //
93
+ // Checked against `info` rather than by looking the session up:
94
+ // `getSession` stamps `lastActivity`, and listing sessions is not
95
+ // using one.
96
+ default_session: info.some(s => s.id === sm.getDefaultSessionId())
97
+ ? sm.getDefaultSessionId()
98
+ : null,
74
99
  sessions: info.map(s => ({
75
100
  id: s.id,
76
101
  created: s.created.toISOString(),
@@ -78,7 +103,7 @@ function doList(ctx) {
78
103
  is_playing: s.isPlaying,
79
104
  is_default: s.id === sm.getDefaultSessionId(),
80
105
  })),
81
- };
106
+ });
82
107
  }
83
108
  function doSwitch(args, ctx) {
84
109
  const sm = ctx.sessionManager;
@@ -92,7 +117,14 @@ function doSwitch(args, ctx) {
92
117
  };
93
118
  }
94
119
  catch (error) {
95
- return { success: false, error: error instanceof Error ? error.message : String(error) };
120
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
121
+ // failure-shaped object back into a PLAIN Error before categorising
122
+ // it, so a typed error thrown below this point loses its category on
123
+ // the way out and lands in `internal` — not retryable, and wrong
124
+ // about whose problem it is. Cross-model review caught that the
125
+ // typed errors were being destroyed at this seam (#382).
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ return err(categorizeError(error), message);
96
128
  }
97
129
  }
98
130
  export async function execute(name, args, ctx) {