@hraness/dawg 0.0.0-stage → 0.2.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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,976 @@
1
+ import {
2
+ AUTOMATION_LANES,
3
+ SCORE_LIMITS,
4
+ type AutomationParameter,
5
+ type ScoreOperation,
6
+ type TrackScore,
7
+ } from "../../core/score.ts";
8
+ import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
9
+ import type { ChatTool } from "./gateway.ts";
10
+ import { pitchToMidi } from "./ops.ts";
11
+ import {
12
+ DRUM_VOICES,
13
+ drumVoicePitch,
14
+ isDrumInstrument,
15
+ parseDrumVoice,
16
+ type DrumVoice,
17
+ } from "../../core/drums.ts";
18
+
19
+ /** What a validated tool call asks the host to do. */
20
+ export type ToolPlan =
21
+ | Readonly<{
22
+ kind: "score";
23
+ operations: readonly ScoreOperation[];
24
+ summary: string;
25
+ trackId?: string;
26
+ }>
27
+ | Readonly<{
28
+ kind: "transport";
29
+ action: "play" | "pause" | "toggle";
30
+ summary: string;
31
+ }>
32
+ | Readonly<{ kind: "explain"; text: string; summary: string }>;
33
+
34
+ export type ToolContext = Readonly<{
35
+ score: TrackScore;
36
+ focusedTrackId: string;
37
+ revision: number;
38
+ /** Deterministic per-call note ID factory. */
39
+ newNoteId: (trackId: string, index: number) => string;
40
+ }>;
41
+
42
+ export type AgentTool = Readonly<{
43
+ name: string;
44
+ description: string;
45
+ parameters: Record<string, unknown>;
46
+ plan: (args: Record<string, unknown>, context: ToolContext) => ToolPlan;
47
+ }>;
48
+
49
+ /** A model-supplied argument that the tool refused; never mutates the score. */
50
+ export class ToolArgumentError extends Error {
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "ToolArgumentError";
54
+ }
55
+ }
56
+
57
+ const AUTOMATION_PARAMETERS = Object.keys(
58
+ AUTOMATION_LANES,
59
+ ) as AutomationParameter[];
60
+
61
+ const MAX_NOTES_PER_CALL = 128;
62
+ const MAX_EXPLAIN_CHARS = 2_000;
63
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
64
+
65
+ const trackIdSchema = {
66
+ type: "string",
67
+ description: "Target track id. Defaults to the focused track.",
68
+ maxLength: SCORE_LIMITS.maxIdLength,
69
+ };
70
+ const beatSchema = (description: string) => ({
71
+ type: "number",
72
+ minimum: 0,
73
+ description,
74
+ });
75
+
76
+ /**
77
+ * One tool per score operation family. Add a new family by appending an entry
78
+ * here; the agent loop, schemas, and validation pick it up automatically.
79
+ */
80
+ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
81
+ {
82
+ name: "add_notes",
83
+ description:
84
+ "Add notes to one track. Times are in beats from the loop start; pitch is MIDI 0..127 or a name like C4/F#2.",
85
+ parameters: {
86
+ type: "object",
87
+ properties: {
88
+ trackId: trackIdSchema,
89
+ notes: {
90
+ type: "array",
91
+ minItems: 1,
92
+ maxItems: MAX_NOTES_PER_CALL,
93
+ items: {
94
+ type: "object",
95
+ properties: {
96
+ pitch: {
97
+ anyOf: [
98
+ { type: "integer", minimum: 0, maximum: 127 },
99
+ { type: "string", maxLength: 4 },
100
+ ],
101
+ },
102
+ start: beatSchema("Start in beats"),
103
+ duration: {
104
+ type: "number",
105
+ exclusiveMinimum: 0,
106
+ description: "Length in beats",
107
+ },
108
+ velocity: { type: "number", minimum: 0, maximum: 1 },
109
+ },
110
+ required: ["pitch", "start", "duration"],
111
+ additionalProperties: false,
112
+ },
113
+ },
114
+ },
115
+ required: ["notes"],
116
+ additionalProperties: false,
117
+ },
118
+ plan(args, context) {
119
+ const trackId = targetTrack(args, context);
120
+ const notes = list(args, "notes", 1, MAX_NOTES_PER_CALL);
121
+ const tpb = context.score.ticksPerBeat;
122
+ const operations = notes.map((value, index): ScoreOperation => {
123
+ const note = record(value, `notes[${index}]`);
124
+ const start = number(note, "start", { min: 0 });
125
+ const duration = number(note, "duration", { min: 0, exclusive: true });
126
+ const velocity = optionalNumber(note, "velocity", { min: 0, max: 1 });
127
+ return {
128
+ type: "addNote",
129
+ note: {
130
+ id: context.newNoteId(trackId, index),
131
+ trackId,
132
+ startTick: Math.round(start * tpb),
133
+ durationTicks: Math.max(1, Math.round(duration * tpb)),
134
+ pitch: pitch(note.pitch, `notes[${index}].pitch`),
135
+ velocity: velocity ?? 0.8,
136
+ },
137
+ };
138
+ });
139
+ return {
140
+ kind: "score",
141
+ operations,
142
+ trackId,
143
+ summary: `+${operations.length} ${trackId} note${operations.length === 1 ? "" : "s"}`,
144
+ };
145
+ },
146
+ },
147
+ {
148
+ name: "remove_notes",
149
+ description:
150
+ "Remove notes by id, or every note on a track with all=true. Note ids come from the composition brief.",
151
+ parameters: {
152
+ type: "object",
153
+ properties: {
154
+ trackId: trackIdSchema,
155
+ noteIds: {
156
+ type: "array",
157
+ maxItems: MAX_NOTES_PER_CALL,
158
+ items: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
159
+ },
160
+ all: { type: "boolean" },
161
+ },
162
+ additionalProperties: false,
163
+ },
164
+ plan(args, context) {
165
+ if (args.all === true) {
166
+ const trackId = targetTrack(args, context);
167
+ return {
168
+ kind: "score",
169
+ operations: [{ type: "clearTrack", trackId }],
170
+ trackId,
171
+ summary: `cleared ${trackId}`,
172
+ };
173
+ }
174
+ const ids = list(args, "noteIds", 1, MAX_NOTES_PER_CALL).map((id, i) =>
175
+ knownNoteId(id, context, `noteIds[${i}]`),
176
+ );
177
+ return {
178
+ kind: "score",
179
+ operations: ids.map((noteId) => ({ type: "removeNote", noteId })),
180
+ summary: `−${ids.length} note${ids.length === 1 ? "" : "s"}`,
181
+ };
182
+ },
183
+ },
184
+ {
185
+ name: "update_notes",
186
+ description:
187
+ "Move, resize, transpose, or re-velocity existing notes by id. Times are in beats.",
188
+ parameters: {
189
+ type: "object",
190
+ properties: {
191
+ updates: {
192
+ type: "array",
193
+ minItems: 1,
194
+ maxItems: MAX_NOTES_PER_CALL,
195
+ items: {
196
+ type: "object",
197
+ properties: {
198
+ noteId: { type: "string", maxLength: SCORE_LIMITS.maxIdLength },
199
+ start: beatSchema("New start in beats"),
200
+ duration: { type: "number", exclusiveMinimum: 0 },
201
+ pitch: {
202
+ anyOf: [
203
+ { type: "integer", minimum: 0, maximum: 127 },
204
+ { type: "string", maxLength: 4 },
205
+ ],
206
+ },
207
+ velocity: { type: "number", minimum: 0, maximum: 1 },
208
+ },
209
+ required: ["noteId"],
210
+ additionalProperties: false,
211
+ },
212
+ },
213
+ },
214
+ required: ["updates"],
215
+ additionalProperties: false,
216
+ },
217
+ plan(args, context) {
218
+ const tpb = context.score.ticksPerBeat;
219
+ const operations = list(args, "updates", 1, MAX_NOTES_PER_CALL).map(
220
+ (value, index): ScoreOperation => {
221
+ const update = record(value, `updates[${index}]`);
222
+ const noteId = knownNoteId(
223
+ update.noteId,
224
+ context,
225
+ `updates[${index}].noteId`,
226
+ );
227
+ const patch: Record<string, number> = {};
228
+ const start = optionalNumber(update, "start", { min: 0 });
229
+ if (start !== undefined) patch.startTick = Math.round(start * tpb);
230
+ const duration = optionalNumber(update, "duration", {
231
+ min: 0,
232
+ exclusive: true,
233
+ });
234
+ if (duration !== undefined)
235
+ patch.durationTicks = Math.max(1, Math.round(duration * tpb));
236
+ if (update.pitch !== undefined)
237
+ patch.pitch = pitch(update.pitch, `updates[${index}].pitch`);
238
+ const velocity = optionalNumber(update, "velocity", {
239
+ min: 0,
240
+ max: 1,
241
+ });
242
+ if (velocity !== undefined) patch.velocity = velocity;
243
+ if (Object.keys(patch).length === 0)
244
+ throw new ToolArgumentError(`updates[${index}] changes nothing`);
245
+ return { type: "updateNote", noteId, patch };
246
+ },
247
+ );
248
+ return {
249
+ kind: "score",
250
+ operations,
251
+ summary: `~${operations.length} note${operations.length === 1 ? "" : "s"}`,
252
+ };
253
+ },
254
+ },
255
+ {
256
+ name: "set_instrument",
257
+ description: "Change a track's instrument voice.",
258
+ parameters: {
259
+ type: "object",
260
+ properties: {
261
+ trackId: trackIdSchema,
262
+ instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
263
+ },
264
+ required: ["instrument"],
265
+ additionalProperties: false,
266
+ },
267
+ plan(args, context) {
268
+ const trackId = targetTrack(args, context);
269
+ const instrument = instrumentName(args.instrument);
270
+ return {
271
+ kind: "score",
272
+ operations: [{ type: "updateTrack", trackId, patch: { instrument } }],
273
+ trackId,
274
+ summary: `${trackId} → ${instrument}`,
275
+ };
276
+ },
277
+ },
278
+ {
279
+ name: "set_mix",
280
+ description:
281
+ "Set a track's static volume (0..1), pan (-1 left .. 1 right), mute, or solo. While any track is soloed only soloed tracks play.",
282
+ parameters: {
283
+ type: "object",
284
+ properties: {
285
+ trackId: trackIdSchema,
286
+ volume: { type: "number", minimum: 0, maximum: 1 },
287
+ pan: { type: "number", minimum: -1, maximum: 1 },
288
+ muted: { type: "boolean" },
289
+ solo: { type: "boolean" },
290
+ },
291
+ additionalProperties: false,
292
+ },
293
+ plan(args, context) {
294
+ const trackId = targetTrack(args, context);
295
+ const patch: {
296
+ volume?: number;
297
+ pan?: number;
298
+ muted?: boolean;
299
+ solo?: boolean;
300
+ } = {};
301
+ const volume = optionalNumber(args, "volume", { min: 0, max: 1 });
302
+ if (volume !== undefined) patch.volume = volume;
303
+ const pan = optionalNumber(args, "pan", { min: -1, max: 1 });
304
+ if (pan !== undefined) patch.pan = pan;
305
+ if (args.muted !== undefined) {
306
+ if (typeof args.muted !== "boolean")
307
+ throw new ToolArgumentError("muted must be a boolean");
308
+ patch.muted = args.muted;
309
+ }
310
+ if (args.solo !== undefined) {
311
+ if (typeof args.solo !== "boolean")
312
+ throw new ToolArgumentError("solo must be a boolean");
313
+ patch.solo = args.solo;
314
+ }
315
+ const parts = Object.entries(patch).map(([key, value]) =>
316
+ key === "muted"
317
+ ? value
318
+ ? "muted"
319
+ : "unmuted"
320
+ : key === "solo"
321
+ ? value
322
+ ? "solo"
323
+ : "unsolo"
324
+ : `${key} ${value}`,
325
+ );
326
+ if (parts.length === 0)
327
+ throw new ToolArgumentError(
328
+ "set_mix needs volume, pan, muted, or solo",
329
+ );
330
+ return {
331
+ kind: "score",
332
+ operations: [{ type: "updateTrack", trackId, patch }],
333
+ trackId,
334
+ summary: `${trackId} ${parts.join(", ")}`,
335
+ };
336
+ },
337
+ },
338
+ {
339
+ name: "set_automation",
340
+ description: `Write an automation lane: volume (0..1), pan (-1..1), filter cutoff (${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz low-pass), resonance (0..${SCORE_LIMITS.maxFilterResonance}), delay-feedback (0..${SCORE_LIMITS.maxDelayFeedback}) or delay-mix (0..${SCORE_LIMITS.maxDelayMix}). Delay lanes need a delay on the track (set_effects) to be heard. mode=replace (default) rewrites the lane; merge keeps existing points at other beats. An empty replace clears it.`,
341
+ parameters: {
342
+ type: "object",
343
+ properties: {
344
+ trackId: trackIdSchema,
345
+ parameter: { type: "string", enum: AUTOMATION_PARAMETERS },
346
+ mode: { type: "string", enum: ["replace", "merge"] },
347
+ points: {
348
+ type: "array",
349
+ maxItems: SCORE_LIMITS.maxAutomationPoints,
350
+ items: {
351
+ type: "object",
352
+ properties: {
353
+ beat: beatSchema("Beat position"),
354
+ value: {
355
+ type: "number",
356
+ description:
357
+ "volume 0..1, pan -1..1, filter cutoff in Hz, resonance 0..1, delay-feedback 0..0.9, delay-mix 0..1",
358
+ },
359
+ },
360
+ required: ["beat", "value"],
361
+ additionalProperties: false,
362
+ },
363
+ },
364
+ },
365
+ required: ["parameter", "points"],
366
+ additionalProperties: false,
367
+ },
368
+ plan(args, context) {
369
+ const trackId = targetTrack(args, context);
370
+ const parameter = oneOf(
371
+ args.parameter,
372
+ AUTOMATION_PARAMETERS,
373
+ "parameter",
374
+ );
375
+ const mode =
376
+ args.mode === undefined
377
+ ? "replace"
378
+ : oneOf(args.mode, ["replace", "merge"], "mode");
379
+ const { min, max } = AUTOMATION_LANES[parameter];
380
+ const tpb = context.score.ticksPerBeat;
381
+ const points = list(
382
+ args,
383
+ "points",
384
+ 0,
385
+ SCORE_LIMITS.maxAutomationPoints,
386
+ ).map((value, index) => {
387
+ const point = record(value, `points[${index}]`);
388
+ return {
389
+ tick: Math.round(number(point, "beat", { min: 0 }) * tpb),
390
+ value: number(point, "value", { min, max }),
391
+ };
392
+ });
393
+ const track = context.score.tracks.find((t) => t.id === trackId)!;
394
+ const existing = track[AUTOMATION_LANES[parameter].field] ?? [];
395
+ const merged = Array.from(
396
+ new Map(
397
+ [...(mode === "merge" ? existing : []), ...points].map((point) => [
398
+ point.tick,
399
+ point,
400
+ ]),
401
+ ).values(),
402
+ ).sort((left, right) => left.tick - right.tick);
403
+ return {
404
+ kind: "score",
405
+ operations: [
406
+ { type: "setAutomation", trackId, parameter, points: merged },
407
+ ],
408
+ trackId,
409
+ summary:
410
+ merged.length === 0
411
+ ? `${trackId} ${parameter} automation cleared`
412
+ : `${trackId} ${parameter} automation · ${merged.length} point${merged.length === 1 ? "" : "s"}`,
413
+ };
414
+ },
415
+ },
416
+ {
417
+ name: "set_effects",
418
+ description: `Set or remove a track's low-pass filter, tempo-synced stereo delay, and stereo reverb send. Pass null to remove an effect. filter: cutoff ${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz, resonance 0..${SCORE_LIMITS.maxFilterResonance}. delay: beats ${SCORE_LIMITS.minDelayBeats}..${SCORE_LIMITS.maxDelayBeats}, feedback 0..${SCORE_LIMITS.maxDelayFeedback}, mix 0..${SCORE_LIMITS.maxDelayMix}. reverb: mix 0..${SCORE_LIMITS.maxReverbMix} (0.15..0.35 is a natural room), size ${SCORE_LIMITS.minReverbSize}..${SCORE_LIMITS.maxReverbSize} (default 0.5; higher is a longer, darker tail).`,
419
+ parameters: {
420
+ type: "object",
421
+ properties: {
422
+ trackId: trackIdSchema,
423
+ filter: {
424
+ anyOf: [
425
+ {
426
+ type: "object",
427
+ properties: {
428
+ cutoff: {
429
+ type: "number",
430
+ minimum: SCORE_LIMITS.minFilterCutoff,
431
+ maximum: SCORE_LIMITS.maxFilterCutoff,
432
+ },
433
+ resonance: {
434
+ type: "number",
435
+ minimum: 0,
436
+ maximum: SCORE_LIMITS.maxFilterResonance,
437
+ },
438
+ },
439
+ required: ["cutoff"],
440
+ additionalProperties: false,
441
+ },
442
+ { type: "null" },
443
+ ],
444
+ },
445
+ delay: {
446
+ anyOf: [
447
+ {
448
+ type: "object",
449
+ properties: {
450
+ beats: {
451
+ type: "number",
452
+ minimum: SCORE_LIMITS.minDelayBeats,
453
+ maximum: SCORE_LIMITS.maxDelayBeats,
454
+ },
455
+ feedback: {
456
+ type: "number",
457
+ minimum: 0,
458
+ maximum: SCORE_LIMITS.maxDelayFeedback,
459
+ },
460
+ mix: {
461
+ type: "number",
462
+ minimum: 0,
463
+ maximum: SCORE_LIMITS.maxDelayMix,
464
+ },
465
+ },
466
+ required: ["beats"],
467
+ additionalProperties: false,
468
+ },
469
+ { type: "null" },
470
+ ],
471
+ },
472
+ reverb: {
473
+ anyOf: [
474
+ {
475
+ type: "object",
476
+ properties: {
477
+ mix: {
478
+ type: "number",
479
+ minimum: 0,
480
+ maximum: SCORE_LIMITS.maxReverbMix,
481
+ },
482
+ size: {
483
+ type: "number",
484
+ minimum: SCORE_LIMITS.minReverbSize,
485
+ maximum: SCORE_LIMITS.maxReverbSize,
486
+ },
487
+ },
488
+ required: ["mix"],
489
+ additionalProperties: false,
490
+ },
491
+ { type: "null" },
492
+ ],
493
+ },
494
+ },
495
+ additionalProperties: false,
496
+ },
497
+ plan(args, context) {
498
+ const trackId = targetTrack(args, context);
499
+ const patch: {
500
+ filter?: { cutoff: number; resonance: number } | null;
501
+ delay?: { beats: number; feedback: number; mix: number } | null;
502
+ reverb?: { mix: number; size: number } | null;
503
+ } = {};
504
+ const parts: string[] = [];
505
+ if (args.filter === null) {
506
+ patch.filter = null;
507
+ parts.push("filter off");
508
+ } else if (args.filter !== undefined) {
509
+ const filter = record(args.filter, "filter");
510
+ patch.filter = {
511
+ cutoff: number(filter, "cutoff", {
512
+ min: SCORE_LIMITS.minFilterCutoff,
513
+ max: SCORE_LIMITS.maxFilterCutoff,
514
+ }),
515
+ resonance:
516
+ optionalNumber(filter, "resonance", {
517
+ min: 0,
518
+ max: SCORE_LIMITS.maxFilterResonance,
519
+ }) ?? 0,
520
+ };
521
+ parts.push(`filter ${Math.round(patch.filter.cutoff)} Hz`);
522
+ }
523
+ if (args.delay === null) {
524
+ patch.delay = null;
525
+ parts.push("delay off");
526
+ } else if (args.delay !== undefined) {
527
+ const delay = record(args.delay, "delay");
528
+ patch.delay = {
529
+ beats: number(delay, "beats", {
530
+ min: SCORE_LIMITS.minDelayBeats,
531
+ max: SCORE_LIMITS.maxDelayBeats,
532
+ }),
533
+ feedback:
534
+ optionalNumber(delay, "feedback", {
535
+ min: 0,
536
+ max: SCORE_LIMITS.maxDelayFeedback,
537
+ }) ?? 0.35,
538
+ mix:
539
+ optionalNumber(delay, "mix", {
540
+ min: 0,
541
+ max: SCORE_LIMITS.maxDelayMix,
542
+ }) ?? 0.3,
543
+ };
544
+ parts.push(`delay ${patch.delay.beats} beats`);
545
+ }
546
+ if (args.reverb === null) {
547
+ patch.reverb = null;
548
+ parts.push("reverb off");
549
+ } else if (args.reverb !== undefined) {
550
+ const reverb = record(args.reverb, "reverb");
551
+ patch.reverb = {
552
+ mix: number(reverb, "mix", {
553
+ min: 0,
554
+ max: SCORE_LIMITS.maxReverbMix,
555
+ }),
556
+ size:
557
+ optionalNumber(reverb, "size", {
558
+ min: SCORE_LIMITS.minReverbSize,
559
+ max: SCORE_LIMITS.maxReverbSize,
560
+ }) ?? 0.5,
561
+ };
562
+ parts.push(`reverb ${patch.reverb.mix}`);
563
+ }
564
+ if (parts.length === 0)
565
+ throw new ToolArgumentError(
566
+ "set_effects needs filter, delay, or reverb",
567
+ );
568
+ return {
569
+ kind: "score",
570
+ operations: [{ type: "updateTrack", trackId, patch }],
571
+ trackId,
572
+ summary: `${trackId} ${parts.join(", ")}`,
573
+ };
574
+ },
575
+ },
576
+ {
577
+ name: "add_drums",
578
+ description: `Add drum hits to a kit track (create one with create_track instrument "kit"). Voices: ${DRUM_VOICES.map((info) => info.voice).join(", ")}. Give explicit hits, and/or patterns that repeat a voice every N beats across the loop.`,
579
+ parameters: {
580
+ type: "object",
581
+ properties: {
582
+ trackId: trackIdSchema,
583
+ hits: {
584
+ type: "array",
585
+ maxItems: MAX_NOTES_PER_CALL,
586
+ items: {
587
+ type: "object",
588
+ properties: {
589
+ voice: {
590
+ type: "string",
591
+ enum: DRUM_VOICES.map((info) => info.voice),
592
+ },
593
+ beat: beatSchema("Hit position in beats"),
594
+ velocity: { type: "number", minimum: 0, maximum: 1 },
595
+ },
596
+ required: ["voice", "beat"],
597
+ additionalProperties: false,
598
+ },
599
+ },
600
+ patterns: {
601
+ type: "array",
602
+ maxItems: 8,
603
+ items: {
604
+ type: "object",
605
+ properties: {
606
+ voice: {
607
+ type: "string",
608
+ enum: DRUM_VOICES.map((info) => info.voice),
609
+ },
610
+ every: {
611
+ type: "number",
612
+ minimum: 0.125,
613
+ description: "Step in beats",
614
+ },
615
+ from: beatSchema("First hit in beats (default 0)"),
616
+ velocity: { type: "number", minimum: 0, maximum: 1 },
617
+ },
618
+ required: ["voice", "every"],
619
+ additionalProperties: false,
620
+ },
621
+ },
622
+ },
623
+ additionalProperties: false,
624
+ },
625
+ plan(args, context) {
626
+ const trackId = targetTrack(args, context);
627
+ const track = context.score.tracks.find((t) => t.id === trackId)!;
628
+ if (!isDrumInstrument(track.instrument))
629
+ throw new ToolArgumentError(
630
+ `track ${trackId} is ${track.instrument}, not a drum kit; set_instrument kit or create a kit track`,
631
+ );
632
+ const tpb = context.score.ticksPerBeat;
633
+ const loopBeats = context.score.bars * context.score.beatsPerBar;
634
+ const voiceOf = (value: unknown, label: string) => {
635
+ const voice =
636
+ typeof value === "string" ? parseDrumVoice(value) : undefined;
637
+ if (!voice) throw new ToolArgumentError(`${label}: unknown drum voice`);
638
+ return voice;
639
+ };
640
+ const hits: { voice: DrumVoice; beat: number; velocity: number }[] = [];
641
+ for (const [index, value] of (args.hits === undefined
642
+ ? []
643
+ : list(args, "hits", 0, MAX_NOTES_PER_CALL)
644
+ ).entries()) {
645
+ const hit = record(value, `hits[${index}]`);
646
+ hits.push({
647
+ voice: voiceOf(hit.voice, `hits[${index}].voice`),
648
+ beat: number(hit, "beat", { min: 0 }),
649
+ velocity: optionalNumber(hit, "velocity", { min: 0, max: 1 }) ?? 0.9,
650
+ });
651
+ }
652
+ for (const [index, value] of (args.patterns === undefined
653
+ ? []
654
+ : list(args, "patterns", 0, 8)
655
+ ).entries()) {
656
+ const pattern = record(value, `patterns[${index}]`);
657
+ const voice = voiceOf(pattern.voice, `patterns[${index}].voice`);
658
+ const every = number(pattern, "every", { min: 0.125 });
659
+ const from = optionalNumber(pattern, "from", { min: 0 }) ?? 0;
660
+ const velocity =
661
+ optionalNumber(pattern, "velocity", { min: 0, max: 1 }) ?? 0.85;
662
+ for (let beat = from; beat < loopBeats - 1e-9; beat += every) {
663
+ hits.push({ voice, beat, velocity });
664
+ if (hits.length > MAX_NOTES_PER_CALL)
665
+ throw new ToolArgumentError(
666
+ `add_drums is limited to ${MAX_NOTES_PER_CALL} hits per call`,
667
+ );
668
+ }
669
+ }
670
+ if (hits.length === 0)
671
+ throw new ToolArgumentError("add_drums needs hits or patterns");
672
+ const operations = hits.map((hit, index): ScoreOperation => ({
673
+ type: "addNote",
674
+ note: {
675
+ id: context.newNoteId(trackId, index),
676
+ trackId,
677
+ startTick: Math.round(hit.beat * tpb),
678
+ durationTicks: Math.max(1, Math.round(tpb / 4)),
679
+ pitch: drumVoicePitch(hit.voice),
680
+ velocity: hit.velocity,
681
+ },
682
+ }));
683
+ return {
684
+ kind: "score",
685
+ operations,
686
+ trackId,
687
+ summary: `+${operations.length} ${trackId} hit${operations.length === 1 ? "" : "s"}`,
688
+ };
689
+ },
690
+ },
691
+ {
692
+ name: "extend_loop",
693
+ description:
694
+ "Resize the loop: bars sets the total, addBars appends. Notes and automation are preserved.",
695
+ parameters: {
696
+ type: "object",
697
+ properties: {
698
+ bars: { type: "integer", minimum: 1, maximum: SCORE_LIMITS.maxBars },
699
+ addBars: {
700
+ type: "integer",
701
+ minimum: 1,
702
+ maximum: SCORE_LIMITS.maxBars,
703
+ },
704
+ },
705
+ additionalProperties: false,
706
+ },
707
+ plan(args, context) {
708
+ const current = context.score.bars;
709
+ const add = optionalNumber(args, "addBars", {
710
+ min: 1,
711
+ max: SCORE_LIMITS.maxBars,
712
+ integer: true,
713
+ });
714
+ const total = optionalNumber(args, "bars", {
715
+ min: 1,
716
+ max: SCORE_LIMITS.maxBars,
717
+ integer: true,
718
+ });
719
+ if ((add === undefined) === (total === undefined))
720
+ throw new ToolArgumentError(
721
+ "extend_loop needs exactly one of bars or addBars",
722
+ );
723
+ const bars = total ?? current + add!;
724
+ if (bars > SCORE_LIMITS.maxBars)
725
+ throw new ToolArgumentError(
726
+ `loop cannot exceed ${SCORE_LIMITS.maxBars} bars`,
727
+ );
728
+ return {
729
+ kind: "score",
730
+ operations: [{ type: "setBars", bars }],
731
+ summary: `loop ${current} → ${bars} bars`,
732
+ };
733
+ },
734
+ },
735
+ {
736
+ name: "set_tempo",
737
+ description: "Set the session tempo in BPM.",
738
+ parameters: {
739
+ type: "object",
740
+ properties: {
741
+ bpm: {
742
+ type: "number",
743
+ minimum: SCORE_LIMITS.minTempoBpm,
744
+ maximum: SCORE_LIMITS.maxTempoBpm,
745
+ },
746
+ },
747
+ required: ["bpm"],
748
+ additionalProperties: false,
749
+ },
750
+ plan(args, context) {
751
+ const bpm = number(args, "bpm", {
752
+ min: SCORE_LIMITS.minTempoBpm,
753
+ max: SCORE_LIMITS.maxTempoBpm,
754
+ });
755
+ return {
756
+ kind: "score",
757
+ operations: [{ type: "setTempo", tempoBpm: bpm }],
758
+ summary: `tempo ${context.score.tempoBpm} → ${bpm} BPM`,
759
+ };
760
+ },
761
+ },
762
+ {
763
+ name: "create_track",
764
+ description:
765
+ "Create a new track, then add notes to it with add_notes using the same id.",
766
+ parameters: {
767
+ type: "object",
768
+ properties: {
769
+ id: { type: "string", pattern: ID_PATTERN.source, maxLength: 64 },
770
+ name: { type: "string", maxLength: SCORE_LIMITS.maxNameLength },
771
+ instrument: { type: "string", enum: [...AVAILABLE_INSTRUMENTS] },
772
+ },
773
+ required: ["id", "instrument"],
774
+ additionalProperties: false,
775
+ },
776
+ plan(args, context) {
777
+ if (typeof args.id !== "string" || !ID_PATTERN.test(args.id))
778
+ throw new ToolArgumentError(
779
+ "id must be 1-64 letters, digits, dot, dash, or underscore",
780
+ );
781
+ const id = args.id;
782
+ if (context.score.tracks.some((track) => track.id === id))
783
+ throw new ToolArgumentError(`track ${id} already exists`);
784
+ const instrument = instrumentName(args.instrument);
785
+ const name =
786
+ typeof args.name === "string" && args.name.trim().length > 0
787
+ ? args.name.trim().slice(0, SCORE_LIMITS.maxNameLength)
788
+ : id;
789
+ return {
790
+ kind: "score",
791
+ operations: [{ type: "addTrack", track: { id, name, instrument } }],
792
+ trackId: id,
793
+ summary: `+track ${id} (${instrument})`,
794
+ };
795
+ },
796
+ },
797
+ {
798
+ name: "transport",
799
+ description: "Start, stop, or toggle shared playback.",
800
+ parameters: {
801
+ type: "object",
802
+ properties: {
803
+ action: { type: "string", enum: ["play", "pause", "toggle"] },
804
+ },
805
+ required: ["action"],
806
+ additionalProperties: false,
807
+ },
808
+ plan(args) {
809
+ const action = oneOf(args.action, ["play", "pause", "toggle"], "action");
810
+ return { kind: "transport", action, summary: action };
811
+ },
812
+ },
813
+ {
814
+ name: "explain",
815
+ description:
816
+ "Tell the user briefly what you changed or why, without editing the score.",
817
+ parameters: {
818
+ type: "object",
819
+ properties: { text: { type: "string", maxLength: MAX_EXPLAIN_CHARS } },
820
+ required: ["text"],
821
+ additionalProperties: false,
822
+ },
823
+ plan(args) {
824
+ if (typeof args.text !== "string" || args.text.trim().length === 0)
825
+ throw new ToolArgumentError("text must be a non-empty string");
826
+ const text = args.text.trim().slice(0, MAX_EXPLAIN_CHARS);
827
+ return { kind: "explain", text, summary: text.slice(0, 80) };
828
+ },
829
+ },
830
+ ] satisfies AgentTool[]);
831
+
832
+ const TOOLS_BY_NAME = new Map(AGENT_TOOLS.map((tool) => [tool.name, tool]));
833
+
834
+ export function findAgentTool(
835
+ name: string,
836
+ tools: readonly AgentTool[] = AGENT_TOOLS,
837
+ ): AgentTool | undefined {
838
+ return tools === AGENT_TOOLS
839
+ ? TOOLS_BY_NAME.get(name)
840
+ : tools.find((tool) => tool.name === name);
841
+ }
842
+
843
+ export function chatTools(
844
+ tools: readonly AgentTool[] = AGENT_TOOLS,
845
+ ): ChatTool[] {
846
+ return tools.map((tool) => ({
847
+ type: "function",
848
+ function: {
849
+ name: tool.name,
850
+ description: tool.description,
851
+ parameters: tool.parameters,
852
+ },
853
+ }));
854
+ }
855
+
856
+ function targetTrack(args: Record<string, unknown>, context: ToolContext) {
857
+ const trackId = args.trackId ?? context.focusedTrackId;
858
+ if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
859
+ throw new ToolArgumentError("trackId must be a short string");
860
+ if (!context.score.tracks.some((track) => track.id === trackId))
861
+ throw new ToolArgumentError(
862
+ `unknown track ${trackId}; create it with create_track first`,
863
+ );
864
+ return trackId;
865
+ }
866
+
867
+ function knownNoteId(value: unknown, context: ToolContext, label: string) {
868
+ if (typeof value !== "string" || value.length > SCORE_LIMITS.maxIdLength)
869
+ throw new ToolArgumentError(`${label} must be a short string`);
870
+ if (!context.score.notes.some((note) => note.id === value))
871
+ throw new ToolArgumentError(`${label}: unknown note ${value}`);
872
+ return value;
873
+ }
874
+
875
+ function instrumentName(value: unknown): string {
876
+ if (
877
+ typeof value !== "string" ||
878
+ !(AVAILABLE_INSTRUMENTS as readonly string[]).includes(value)
879
+ )
880
+ throw new ToolArgumentError(
881
+ `instrument must be one of ${AVAILABLE_INSTRUMENTS.join(", ")}`,
882
+ );
883
+ return value;
884
+ }
885
+
886
+ function pitch(value: unknown, label: string): number {
887
+ const midi =
888
+ typeof value === "string" ? pitchToMidi(value.trim().toLowerCase()) : value;
889
+ if (
890
+ typeof midi !== "number" ||
891
+ !Number.isInteger(midi) ||
892
+ midi < 0 ||
893
+ midi > 127
894
+ )
895
+ throw new ToolArgumentError(
896
+ `${label} must be MIDI 0..127 or a note name like C4`,
897
+ );
898
+ return midi;
899
+ }
900
+
901
+ function oneOf<T extends string>(
902
+ value: unknown,
903
+ options: readonly T[],
904
+ label: string,
905
+ ): T {
906
+ if (
907
+ typeof value !== "string" ||
908
+ !(options as readonly string[]).includes(value)
909
+ )
910
+ throw new ToolArgumentError(
911
+ `${label} must be one of ${options.join(", ")}`,
912
+ );
913
+ return value as T;
914
+ }
915
+
916
+ function record(value: unknown, label: string): Record<string, unknown> {
917
+ if (typeof value !== "object" || value === null || Array.isArray(value))
918
+ throw new ToolArgumentError(`${label} must be an object`);
919
+ return value as Record<string, unknown>;
920
+ }
921
+
922
+ function list(
923
+ args: Record<string, unknown>,
924
+ key: string,
925
+ min: number,
926
+ max: number,
927
+ ): unknown[] {
928
+ const value = args[key];
929
+ if (!Array.isArray(value) || value.length < min || value.length > max)
930
+ throw new ToolArgumentError(
931
+ `${key} must be an array of ${min}..${max} items`,
932
+ );
933
+ return value;
934
+ }
935
+
936
+ type NumberBounds = {
937
+ min?: number;
938
+ max?: number;
939
+ exclusive?: boolean;
940
+ integer?: boolean;
941
+ };
942
+
943
+ function number(
944
+ args: Record<string, unknown>,
945
+ key: string,
946
+ bounds: NumberBounds,
947
+ ): number {
948
+ const value = optionalNumber(args, key, bounds);
949
+ if (value === undefined) throw new ToolArgumentError(`${key} is required`);
950
+ return value;
951
+ }
952
+
953
+ function optionalNumber(
954
+ args: Record<string, unknown>,
955
+ key: string,
956
+ bounds: NumberBounds,
957
+ ): number | undefined {
958
+ const value = args[key];
959
+ if (value === undefined) return undefined;
960
+ if (typeof value !== "number" || !Number.isFinite(value))
961
+ throw new ToolArgumentError(`${key} must be a finite number`);
962
+ if (bounds.integer && !Number.isInteger(value))
963
+ throw new ToolArgumentError(`${key} must be an integer`);
964
+ if (
965
+ bounds.min !== undefined &&
966
+ (bounds.exclusive ? value <= bounds.min : value < bounds.min)
967
+ )
968
+ throw new ToolArgumentError(
969
+ `${key} must be ${bounds.exclusive ? ">" : ">="} ${bounds.min}`,
970
+ );
971
+ if (bounds.max !== undefined && value > bounds.max)
972
+ throw new ToolArgumentError(`${key} must be <= ${bounds.max}`);
973
+ if (Math.abs(value) > SCORE_LIMITS.maxTick)
974
+ throw new ToolArgumentError(`${key} is out of range`);
975
+ return value;
976
+ }