@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -1,12 +1,51 @@
1
1
  import {
2
- AUTOMATION_LANES,
2
+ AUTOMATION_PARAMETERS,
3
+ automationPoints,
4
+ automationRange,
3
5
  SCORE_LIMITS,
4
6
  type AutomationParameter,
5
7
  type ScoreOperation,
6
8
  type TrackScore,
7
9
  } from "../../core/score.ts";
8
10
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
11
+ import {
12
+ SYNTH_PRESETS,
13
+ SYNTH_SIMPLE,
14
+ isSynthPreset,
15
+ synthParamName,
16
+ } from "../../core/synth.ts";
17
+ import { applySynthCommand, type SynthCommand } from "../commands/synth.ts";
18
+ import {
19
+ setSampleControls,
20
+ type SampleControlValue,
21
+ } from "../commands/sample.ts";
22
+ import {
23
+ EFFECT_NAMES,
24
+ FX_PRESETS,
25
+ effectSpec,
26
+ type EffectName,
27
+ } from "../../core/fx.ts";
28
+ import {
29
+ applyFxCommand,
30
+ effectPatch,
31
+ effectValues,
32
+ parseEffectName,
33
+ parseParamName,
34
+ type FxCommand,
35
+ } from "../commands/fx.ts";
9
36
  import type { ChatTool } from "./gateway.ts";
37
+ import { MEDIA_TOOLS } from "../media/tools.ts";
38
+ import { PACK_TOOLS, PackToolError } from "./pack-tools.ts";
39
+ import {
40
+ PreviewToolError,
41
+ previewSoundTool,
42
+ type PreviewHost,
43
+ } from "./preview-tool.ts";
44
+ import { PackError, type PackStore } from "../audio/packs.ts";
45
+ import { RHYTHM_TOOLS } from "./rhythm-tools.ts";
46
+ import { CHORD_TOOLS } from "./chord-tools.ts";
47
+ import { DRUM_TOOLS } from "./drum-tools.ts";
48
+ import type { MediaResult, MediaRunContext } from "../media/types.ts";
10
49
  import { pitchToMidi } from "./ops.ts";
11
50
  import {
12
51
  DRUM_VOICES,
@@ -15,8 +54,39 @@ import {
15
54
  parseDrumVoice,
16
55
  type DrumVoice,
17
56
  } from "../../core/drums.ts";
57
+ import { trackSlug } from "../../core/slug.ts";
58
+ import {
59
+ editFile,
60
+ listFiles,
61
+ readFile,
62
+ WORKSPACE_LIMITS,
63
+ WorkspaceError,
64
+ writeFile,
65
+ type WorkspaceScope,
66
+ } from "./workspace.ts";
67
+ import {
68
+ fetchUrl,
69
+ FETCH_LIMITS,
70
+ formatFetchedPage,
71
+ type Lookup,
72
+ } from "../web/fetch.ts";
73
+ import { WebError, type FetchLike } from "../web/http.ts";
74
+ import {
75
+ describeSearchProvider,
76
+ formatSearchResults,
77
+ SEARCH_LIMITS,
78
+ webSearch,
79
+ type SearchSpend,
80
+ } from "../web/search.ts";
18
81
 
19
82
  /** What a validated tool call asks the host to do. */
83
+ export type ScorePlan = Readonly<{
84
+ kind: "score";
85
+ operations: readonly ScoreOperation[];
86
+ summary: string;
87
+ trackId?: string;
88
+ }>;
89
+
20
90
  export type ToolPlan =
21
91
  | Readonly<{
22
92
  kind: "score";
@@ -29,7 +99,79 @@ export type ToolPlan =
29
99
  action: "play" | "pause" | "toggle";
30
100
  summary: string;
31
101
  }>
32
- | Readonly<{ kind: "explain"; text: string; summary: string }>;
102
+ | Readonly<{ kind: "explain"; text: string; summary: string }>
103
+ | Readonly<{
104
+ kind: "action";
105
+ summary: string;
106
+ /** Side effects outside the score (files, network); bounded and async. */
107
+ run: (context: ActionContext) => Promise<ActionResult>;
108
+ }>
109
+ /**
110
+ * Score edits that need async work first (fetching a pack sound); `run`
111
+ * returns the score plan, committed like any other.
112
+ */
113
+ | Readonly<{
114
+ kind: "prepare";
115
+ summary: string;
116
+ run: (context: ActionContext) => Promise<ScorePlan>;
117
+ }>
118
+ /** A long-running local media job (download, stems, analysis, …). */
119
+ | Readonly<{
120
+ kind: "media";
121
+ summary: string;
122
+ run: (context: MediaRunContext) => Promise<MediaResult>;
123
+ }>;
124
+
125
+ /** The project directory the workspace tools operate in. */
126
+ export type WorkspaceHost = Readonly<{ root: string }>;
127
+
128
+ /** Injection points for the web tools; defaults are the real network. */
129
+ export type WebHost = Readonly<{
130
+ fetch?: FetchLike;
131
+ lookup?: Lookup;
132
+ /** Brave Web Search API key; defaults to `BRAVE_SEARCH_API_KEY` and overrides the chain. */
133
+ braveApiKey?: string;
134
+ /** AI Gateway key of the active provider; enables gateway search tools. */
135
+ gatewayApiKey?: string;
136
+ gatewayBaseUrl?: string;
137
+ /** Gateway search tool; defaults to `DAWG_WEB_SEARCH`, then `exa`. */
138
+ searchTool?: string;
139
+ /** OpenRouter key; defaults to `OPENROUTER_API_KEY`. */
140
+ openRouterApiKey?: string;
141
+ openRouterBaseUrl?: string;
142
+ /**
143
+ * Billed searches (gateway tools, OpenRouter web plugin) are reported here
144
+ * so the host can add them to its spend ledger (`webHostFor` in
145
+ * `src/agent/usage.ts`).
146
+ */
147
+ onSpend?: (spend: SearchSpend) => void;
148
+ }>;
149
+
150
+ /** What an `action` plan receives from the host when it runs. */
151
+ export type ActionContext = Readonly<{
152
+ workspace?: WorkspaceHost;
153
+ /**
154
+ * Called after a successful `write_file`/`edit_file` with the
155
+ * project-relative path. Returned text (for example typecheck
156
+ * diagnostics) is appended to the tool result.
157
+ */
158
+ onWorkspaceWrite?: (path: string) => Promise<string | void> | string | void;
159
+ web?: WebHost;
160
+ signal?: AbortSignal;
161
+ /** Sample packs for list_packs/search_sounds/use_sound; default the user cache. */
162
+ packs?: PackStore;
163
+ /** Render and play hooks for preview_sound; absent renders in-thread, silent. */
164
+ preview?: PreviewHost;
165
+ }>;
166
+
167
+ export type ActionResult = Readonly<{
168
+ /** What the model reads; already bounded by the tool. */
169
+ content: string;
170
+ /** One line for the activity card. */
171
+ summary: string;
172
+ /** True when something outside the score changed (a file write). */
173
+ mutated?: boolean;
174
+ }>;
33
175
 
34
176
  export type ToolContext = Readonly<{
35
177
  score: TrackScore;
@@ -54,10 +196,6 @@ export class ToolArgumentError extends Error {
54
196
  }
55
197
  }
56
198
 
57
- const AUTOMATION_PARAMETERS = Object.keys(
58
- AUTOMATION_LANES,
59
- ) as AutomationParameter[];
60
-
61
199
  const MAX_NOTES_PER_CALL = 128;
62
200
  const MAX_EXPLAIN_CHARS = 2_000;
63
201
  const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
@@ -337,12 +475,16 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
337
475
  },
338
476
  {
339
477
  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.`,
478
+ description: `Write an automation lane: volume 0..1, pan -1..1, filter (cutoff Hz), resonance 0..${SCORE_LIMITS.maxFilterResonance}, delay-feedback 0..${SCORE_LIMITS.maxDelayFeedback}, delay-mix, wt (wavetable position 0..1), or <effect>-<param> (the param's range). The effect must be on to be heard. mode=replace (default) rewrites the lane; merge keeps other beats. An empty replace clears it.`,
341
479
  parameters: {
342
480
  type: "object",
343
481
  properties: {
344
482
  trackId: trackIdSchema,
345
- parameter: { type: "string", enum: AUTOMATION_PARAMETERS },
483
+ parameter: {
484
+ type: "string",
485
+ description:
486
+ "volume, pan, filter, resonance, delay-feedback, delay-mix, or <effect>-<param>",
487
+ },
346
488
  mode: { type: "string", enum: ["replace", "merge"] },
347
489
  points: {
348
490
  type: "array",
@@ -353,8 +495,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
353
495
  beat: beatSchema("Beat position"),
354
496
  value: {
355
497
  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",
498
+ description: "in the lane's range",
358
499
  },
359
500
  },
360
501
  required: ["beat", "value"],
@@ -376,7 +517,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
376
517
  args.mode === undefined
377
518
  ? "replace"
378
519
  : oneOf(args.mode, ["replace", "merge"], "mode");
379
- const { min, max } = AUTOMATION_LANES[parameter];
520
+ const { min, max } = automationRange(parameter);
380
521
  const tpb = context.score.ticksPerBeat;
381
522
  const points = list(
382
523
  args,
@@ -391,7 +532,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
391
532
  };
392
533
  });
393
534
  const track = context.score.tracks.find((t) => t.id === trackId)!;
394
- const existing = track[AUTOMATION_LANES[parameter].field] ?? [];
535
+ const existing = automationPoints(track, parameter);
395
536
  const merged = Array.from(
396
537
  new Map(
397
538
  [...(mode === "merge" ? existing : []), ...points].map((point) => [
@@ -413,9 +554,153 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
413
554
  };
414
555
  },
415
556
  },
557
+ {
558
+ name: "set_fx",
559
+ description:
560
+ "Turn an effect on/off, load a preset, or set params by dawg or Strudel name (lpq, delayfeedback…). on:true uses good defaults; effects and presets are in the brief.",
561
+ parameters: {
562
+ type: "object",
563
+ properties: {
564
+ trackId: trackIdSchema,
565
+ effect: { type: "string", description: "see the brief's effects" },
566
+ on: { type: "boolean" },
567
+ preset: { type: "string" },
568
+ params: {
569
+ type: "object",
570
+ additionalProperties: { type: ["number", "string", "boolean"] },
571
+ },
572
+ },
573
+ required: ["effect"],
574
+ additionalProperties: false,
575
+ },
576
+ plan(args, context) {
577
+ const trackId = targetTrack(args, context);
578
+ const effect =
579
+ typeof args.effect === "string"
580
+ ? parseEffectName(args.effect)
581
+ : undefined;
582
+ if (!effect)
583
+ throw new ToolArgumentError(
584
+ `effect must be one of ${EFFECT_NAMES.join(", ")}`,
585
+ );
586
+ const command = fxToolCommand(effect, args);
587
+ const result = applyFxCommand(context.score, trackId, command);
588
+ if (!result.ok || !result.next)
589
+ throw new ToolArgumentError(result.message);
590
+ const track = context.score.tracks.find((t) => t.id === trackId)!;
591
+ const values = effectValues(
592
+ result.next.tracks.find((t) => t.id === trackId),
593
+ effect,
594
+ );
595
+ return {
596
+ kind: "score",
597
+ operations: [
598
+ {
599
+ type: "updateTrack",
600
+ trackId,
601
+ patch: effectPatch(track, effect, values ?? null),
602
+ },
603
+ ],
604
+ trackId,
605
+ summary: `${trackId} ${result.message}`,
606
+ };
607
+ },
608
+ },
609
+ {
610
+ name: "set_synth",
611
+ description:
612
+ "Shape a synth track's voice with Strudel synth params (attack decay sustain release, lpf lpq lpenv, fm fmh, unison detune spread, vib vibmod, penv, noise, pw…); null unsets one. preset loads a voice (instrument + params); reset clears all; zzfx takes a raw ZzFX array (empty slots null) and sets a z_* sound.",
613
+ parameters: {
614
+ type: "object",
615
+ properties: {
616
+ trackId: trackIdSchema,
617
+ preset: { type: "string", enum: Object.keys(SYNTH_PRESETS) },
618
+ reset: { type: "boolean" },
619
+ zzfx: {
620
+ type: "array",
621
+ maxItems: 21,
622
+ items: { type: ["number", "null"] },
623
+ },
624
+ params: {
625
+ type: "object",
626
+ additionalProperties: {
627
+ type: ["number", "string", "boolean", "array", "null"],
628
+ },
629
+ },
630
+ },
631
+ additionalProperties: false,
632
+ },
633
+ plan(args, context) {
634
+ const trackId = targetTrack(args, context);
635
+ const command = synthToolCommand(args);
636
+ const result = applySynthCommand(context.score, trackId, command);
637
+ if (!result.ok || !result.next)
638
+ throw new ToolArgumentError(result.message);
639
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
640
+ return {
641
+ kind: "score",
642
+ operations: [
643
+ {
644
+ type: "updateTrack",
645
+ trackId,
646
+ patch: { instrument: next.instrument, synth: next.synth ?? null },
647
+ },
648
+ ],
649
+ trackId,
650
+ summary: `${trackId} ${result.message}`,
651
+ };
652
+ },
653
+ },
654
+ {
655
+ name: "set_sample",
656
+ description:
657
+ "Set a sampler voice's Strudel sample controls: begin end gain speed unit(r|c|s) loop loopBegin loopEnd clip(legato) fit loopAt accelerate squiz cut; null unsets one.",
658
+ parameters: {
659
+ type: "object",
660
+ properties: {
661
+ trackId: trackIdSchema,
662
+ voice: { type: "string" },
663
+ params: {
664
+ type: "object",
665
+ additionalProperties: {
666
+ type: ["number", "string", "boolean", "null"],
667
+ },
668
+ },
669
+ },
670
+ required: ["voice", "params"],
671
+ additionalProperties: false,
672
+ },
673
+ plan(args, context) {
674
+ const trackId = targetTrack(args, context);
675
+ const params = args.params;
676
+ if (
677
+ typeof args.voice !== "string" ||
678
+ typeof params !== "object" ||
679
+ params === null ||
680
+ Array.isArray(params)
681
+ )
682
+ throw new ToolArgumentError("set_sample needs voice and params");
683
+ const result = setSampleControls(
684
+ context.score,
685
+ trackId,
686
+ args.voice,
687
+ params as Record<string, SampleControlValue>,
688
+ );
689
+ if (!result.ok) throw new ToolArgumentError(result.message);
690
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
691
+ return {
692
+ kind: "score",
693
+ operations: [
694
+ { type: "updateTrack", trackId, patch: { sampler: next.sampler } },
695
+ ],
696
+ trackId,
697
+ summary: `${trackId} ${result.message}`,
698
+ };
699
+ },
700
+ },
416
701
  {
417
702
  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).`,
703
+ description: `Shorthand: low-pass filter, delay (beats) and reverb (mix 0.15..0.35 is a room); null removes one. Prefer set_fx.`,
419
704
  parameters: {
420
705
  type: "object",
421
706
  properties: {
@@ -575,7 +860,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
575
860
  },
576
861
  {
577
862
  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.`,
863
+ description: `Add one-off drum hits to a kit track (create one with create_track instrument "kit"); prefer set_rhythm for repeating beats. 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
864
  parameters: {
580
865
  type: "object",
581
866
  properties: {
@@ -827,8 +1112,329 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
827
1112
  return { kind: "explain", text, summary: text.slice(0, 80) };
828
1113
  },
829
1114
  },
1115
+ {
1116
+ name: "list_files",
1117
+ description:
1118
+ "List a project directory (default: the project root): directories first, then files with size and mtime. At most 500 entries; .dawg/ is hidden.",
1119
+ parameters: {
1120
+ type: "object",
1121
+ properties: { path: pathSchema("Project-relative directory") },
1122
+ additionalProperties: false,
1123
+ },
1124
+ plan(args, context) {
1125
+ const path = optionalPath(args);
1126
+ return {
1127
+ kind: "action",
1128
+ summary: `list ${path || "."}`,
1129
+ run: async (action) => {
1130
+ const scope = workspaceScope(action, context);
1131
+ const result = await listFiles(scope, path);
1132
+ return { content: result.text, summary: result.summary };
1133
+ },
1134
+ };
1135
+ },
1136
+ },
1137
+ {
1138
+ name: "read_file",
1139
+ description: `Read a UTF-8 text file from the project by line range (offset is 1-based, limit is a line count). Output is capped at ${WORKSPACE_LIMITS.maxReadBytes / 1024} KiB; binary files report size and type.`,
1140
+ parameters: {
1141
+ type: "object",
1142
+ properties: {
1143
+ path: pathSchema("Project-relative file"),
1144
+ offset: {
1145
+ type: "integer",
1146
+ minimum: 1,
1147
+ description: "First line (1-based)",
1148
+ },
1149
+ limit: {
1150
+ type: "integer",
1151
+ minimum: 1,
1152
+ maximum: WORKSPACE_LIMITS.maxReadLines,
1153
+ description: "Number of lines",
1154
+ },
1155
+ },
1156
+ required: ["path"],
1157
+ additionalProperties: false,
1158
+ },
1159
+ plan(args, context) {
1160
+ const path = requiredPath(args);
1161
+ const offset = optionalNumber(args, "offset", { min: 1, integer: true });
1162
+ const limit = optionalNumber(args, "limit", {
1163
+ min: 1,
1164
+ max: WORKSPACE_LIMITS.maxReadLines,
1165
+ integer: true,
1166
+ });
1167
+ return {
1168
+ kind: "action",
1169
+ summary: `read ${path}`,
1170
+ run: async (action) => {
1171
+ const scope = workspaceScope(action, context);
1172
+ const result = await readFile(scope, path, {
1173
+ ...(offset !== undefined ? { offset } : {}),
1174
+ ...(limit !== undefined ? { limit } : {}),
1175
+ });
1176
+ return { content: result.text, summary: result.summary };
1177
+ },
1178
+ };
1179
+ },
1180
+ },
1181
+ {
1182
+ name: "write_file",
1183
+ description: `Create or replace a file atomically with the full content (at most ${WORKSPACE_LIMITS.maxWriteBytes / 1024 / 1024} MiB). Writable: song.ts and the focused track's tracks/<slug>/ directory, where notes.md is your scratchpad. Parent directories are created.`,
1184
+ parameters: {
1185
+ type: "object",
1186
+ properties: {
1187
+ path: pathSchema("Project-relative file"),
1188
+ content: { type: "string", description: "Entire new file content" },
1189
+ },
1190
+ required: ["path", "content"],
1191
+ additionalProperties: false,
1192
+ },
1193
+ plan(args, context) {
1194
+ const path = requiredPath(args);
1195
+ if (typeof args.content !== "string")
1196
+ throw new ToolArgumentError("content must be a string");
1197
+ const content = args.content;
1198
+ return {
1199
+ kind: "action",
1200
+ summary: `write ${path}`,
1201
+ run: async (action) => {
1202
+ const scope = workspaceScope(action, context);
1203
+ const result = await writeFile(scope, path, content);
1204
+ return {
1205
+ content: await afterWrite(action, result.rel, result.text),
1206
+ summary: result.summary,
1207
+ mutated: true,
1208
+ };
1209
+ },
1210
+ };
1211
+ },
1212
+ },
1213
+ {
1214
+ name: "edit_file",
1215
+ description:
1216
+ "Replace one exact occurrence of old with new in a writable file (same scope as write_file). old must match exactly once; otherwise the result tells you the count. Prefer this over many note tools for large edits or restructuring of tracks/<slug>/track.ts.",
1217
+ parameters: {
1218
+ type: "object",
1219
+ properties: {
1220
+ path: pathSchema("Project-relative file"),
1221
+ old: {
1222
+ type: "string",
1223
+ minLength: 1,
1224
+ description: "Exact text to replace",
1225
+ },
1226
+ new: { type: "string", description: "Replacement text" },
1227
+ },
1228
+ required: ["path", "old", "new"],
1229
+ additionalProperties: false,
1230
+ },
1231
+ plan(args, context) {
1232
+ const path = requiredPath(args);
1233
+ if (typeof args.old !== "string" || args.old.length === 0)
1234
+ throw new ToolArgumentError("old must be a non-empty string");
1235
+ if (typeof args.new !== "string")
1236
+ throw new ToolArgumentError("new must be a string");
1237
+ const { old: oldText, new: newText } = args;
1238
+ return {
1239
+ kind: "action",
1240
+ summary: `edit ${path}`,
1241
+ run: async (action) => {
1242
+ const scope = workspaceScope(action, context);
1243
+ const result = await editFile(scope, path, oldText, newText);
1244
+ return {
1245
+ content: await afterWrite(action, result.rel, result.text),
1246
+ summary: result.summary,
1247
+ mutated: true,
1248
+ };
1249
+ },
1250
+ };
1251
+ },
1252
+ },
1253
+ {
1254
+ name: "web_search",
1255
+ description: `Search the web; returns up to ${SEARCH_LIMITS.defaultCount} results as title, url and snippet. Follow up with fetch_url to read a page.`,
1256
+ parameters: {
1257
+ type: "object",
1258
+ properties: {
1259
+ query: { type: "string", maxLength: SEARCH_LIMITS.maxQueryChars },
1260
+ count: { type: "integer", minimum: 1, maximum: SEARCH_LIMITS.maxCount },
1261
+ },
1262
+ required: ["query"],
1263
+ additionalProperties: false,
1264
+ },
1265
+ plan(args) {
1266
+ if (typeof args.query !== "string" || args.query.trim().length === 0)
1267
+ throw new ToolArgumentError("query must be a non-empty string");
1268
+ if (args.query.length > SEARCH_LIMITS.maxQueryChars)
1269
+ throw new ToolArgumentError(
1270
+ `query must be at most ${SEARCH_LIMITS.maxQueryChars} characters`,
1271
+ );
1272
+ const query = args.query.trim();
1273
+ const count = optionalNumber(args, "count", {
1274
+ min: 1,
1275
+ max: SEARCH_LIMITS.maxCount,
1276
+ integer: true,
1277
+ });
1278
+ return {
1279
+ kind: "action",
1280
+ summary: `search ${query.slice(0, 60)}`,
1281
+ run: async (action) => {
1282
+ const web = action.web ?? {};
1283
+ const outcome = await webSearch(query, {
1284
+ ...(count !== undefined ? { count } : {}),
1285
+ braveApiKey: web.braveApiKey ?? process.env.BRAVE_SEARCH_API_KEY,
1286
+ ...(web.gatewayApiKey ? { gatewayApiKey: web.gatewayApiKey } : {}),
1287
+ ...(web.gatewayBaseUrl
1288
+ ? { gatewayBaseUrl: web.gatewayBaseUrl }
1289
+ : {}),
1290
+ searchTool: web.searchTool ?? process.env.DAWG_WEB_SEARCH,
1291
+ openRouterApiKey:
1292
+ web.openRouterApiKey ?? process.env.OPENROUTER_API_KEY,
1293
+ ...(web.openRouterBaseUrl
1294
+ ? { openRouterBaseUrl: web.openRouterBaseUrl }
1295
+ : {}),
1296
+ ...(web.fetch ? { fetch: web.fetch } : {}),
1297
+ ...(web.onSpend ? { onSpend: web.onSpend } : {}),
1298
+ ...(action.signal ? { signal: action.signal } : {}),
1299
+ });
1300
+ const via = describeSearchProvider(outcome);
1301
+ return {
1302
+ content: formatSearchResults(outcome, query),
1303
+ summary: `searched via ${via}${outcome.fallbackFrom ? " (fallback)" : ""} · ${outcome.results.length} result${outcome.results.length === 1 ? "" : "s"}`,
1304
+ };
1305
+ },
1306
+ };
1307
+ },
1308
+ },
1309
+ {
1310
+ name: "fetch_url",
1311
+ description: `Fetch a public http(s) page and return its readable text (HTML reduced to headings, text and links; at most ${FETCH_LIMITS.maxOutputChars / 1024} KiB). Private and local addresses are refused.`,
1312
+ parameters: {
1313
+ type: "object",
1314
+ properties: {
1315
+ url: { type: "string", maxLength: FETCH_LIMITS.maxUrlChars },
1316
+ },
1317
+ required: ["url"],
1318
+ additionalProperties: false,
1319
+ },
1320
+ plan(args) {
1321
+ if (typeof args.url !== "string" || args.url.trim().length === 0)
1322
+ throw new ToolArgumentError("url must be a non-empty string");
1323
+ if (args.url.length > FETCH_LIMITS.maxUrlChars)
1324
+ throw new ToolArgumentError(
1325
+ `url must be at most ${FETCH_LIMITS.maxUrlChars} characters`,
1326
+ );
1327
+ const url = args.url.trim();
1328
+ let host = url;
1329
+ try {
1330
+ host = new URL(url).hostname;
1331
+ } catch {
1332
+ // admitUrl reports the diagnostic when the action runs.
1333
+ }
1334
+ return {
1335
+ kind: "action",
1336
+ summary: `fetch ${host.slice(0, 60)}`,
1337
+ run: async (action) => {
1338
+ const page = await fetchUrl(url, {
1339
+ ...(action.web?.fetch ? { fetch: action.web.fetch } : {}),
1340
+ ...(action.web?.lookup ? { lookup: action.web.lookup } : {}),
1341
+ ...(action.signal ? { signal: action.signal } : {}),
1342
+ });
1343
+ return {
1344
+ content: formatFetchedPage(page),
1345
+ summary: `fetched ${new URL(page.url).hostname} (${page.bytes} bytes${page.truncated ? ", truncated" : ""})`,
1346
+ };
1347
+ },
1348
+ };
1349
+ },
1350
+ },
1351
+ ...RHYTHM_TOOLS,
1352
+ ...CHORD_TOOLS,
1353
+ ...DRUM_TOOLS,
1354
+ ...MEDIA_TOOLS,
1355
+ ...PACK_TOOLS,
1356
+ // Looks tools up at call time, so it can plan any of the above.
1357
+ previewSoundTool((name) => findAgentTool(name)),
830
1358
  ] satisfies AgentTool[]);
831
1359
 
1360
+ /** Errors an `action` plan may raise that are safe to show to the model. */
1361
+ export function isActionDiagnostic(error: unknown): boolean {
1362
+ return (
1363
+ error instanceof WorkspaceError ||
1364
+ error instanceof WebError ||
1365
+ error instanceof ToolArgumentError ||
1366
+ error instanceof PackToolError ||
1367
+ error instanceof PreviewToolError ||
1368
+ error instanceof PackError
1369
+ );
1370
+ }
1371
+
1372
+ const MAX_HOOK_CHARS = 4_000;
1373
+
1374
+ async function afterWrite(
1375
+ action: ActionContext,
1376
+ rel: string,
1377
+ text: string,
1378
+ ): Promise<string> {
1379
+ if (!action.onWorkspaceWrite) return text;
1380
+ try {
1381
+ const note = await action.onWorkspaceWrite(rel);
1382
+ return typeof note === "string" && note.trim()
1383
+ ? `${text}\n${note.trim().slice(0, MAX_HOOK_CHARS)}`
1384
+ : text;
1385
+ } catch (error) {
1386
+ const message = (
1387
+ error instanceof Error ? error.message : String(error)
1388
+ ).slice(0, MAX_HOOK_CHARS);
1389
+ return `${text}\nafter write: ${message}`;
1390
+ }
1391
+ }
1392
+
1393
+ /** The slug of the focused track: its name, or its id for a draft track. */
1394
+ export function focusedTrackSlug(
1395
+ context: Pick<ToolContext, "score" | "focusedTrackId">,
1396
+ ): string {
1397
+ const track = context.score.tracks.find(
1398
+ (candidate) => candidate.id === context.focusedTrackId,
1399
+ );
1400
+ return trackSlug(track?.name ?? context.focusedTrackId);
1401
+ }
1402
+
1403
+ function workspaceScope(
1404
+ action: ActionContext,
1405
+ context: ToolContext,
1406
+ ): WorkspaceScope {
1407
+ if (!action.workspace)
1408
+ throw new WorkspaceError("file tools are unavailable in this session");
1409
+ return { root: action.workspace.root, trackSlug: focusedTrackSlug(context) };
1410
+ }
1411
+
1412
+ function pathSchema(description: string) {
1413
+ return {
1414
+ type: "string",
1415
+ maxLength: WORKSPACE_LIMITS.maxPathChars,
1416
+ description,
1417
+ };
1418
+ }
1419
+
1420
+ function requiredPath(args: Record<string, unknown>): string {
1421
+ const path = optionalPath(args);
1422
+ if (path === undefined || path.length === 0)
1423
+ throw new ToolArgumentError("path is required");
1424
+ return path;
1425
+ }
1426
+
1427
+ function optionalPath(args: Record<string, unknown>): string | undefined {
1428
+ if (args.path === undefined) return undefined;
1429
+ if (typeof args.path !== "string")
1430
+ throw new ToolArgumentError("path must be a string");
1431
+ if (args.path.length > WORKSPACE_LIMITS.maxPathChars)
1432
+ throw new ToolArgumentError(
1433
+ `path must be at most ${WORKSPACE_LIMITS.maxPathChars} characters`,
1434
+ );
1435
+ return args.path;
1436
+ }
1437
+
832
1438
  const TOOLS_BY_NAME = new Map(AGENT_TOOLS.map((tool) => [tool.name, tool]));
833
1439
 
834
1440
  export function findAgentTool(
@@ -853,6 +1459,91 @@ export function chatTools(
853
1459
  }));
854
1460
  }
855
1461
 
1462
+ function fxToolCommand(
1463
+ effect: EffectName,
1464
+ args: Record<string, unknown>,
1465
+ ): FxCommand {
1466
+ if (args.on === false) return { type: "fx-off", effect };
1467
+ if (args.preset !== undefined) {
1468
+ if (
1469
+ typeof args.preset !== "string" ||
1470
+ !Object.prototype.hasOwnProperty.call(
1471
+ FX_PRESETS[effect] ?? {},
1472
+ args.preset,
1473
+ )
1474
+ )
1475
+ throw new ToolArgumentError(
1476
+ `${effect} presets: ${Object.keys(FX_PRESETS[effect] ?? {}).join(", ") || "none"}`,
1477
+ );
1478
+ return { type: "fx-preset", effect, preset: args.preset };
1479
+ }
1480
+ if (args.params === undefined) {
1481
+ if (args.on === true) return { type: "fx-on", effect };
1482
+ throw new ToolArgumentError("set_fx needs on, preset, or params");
1483
+ }
1484
+ const params = record(args.params, "params");
1485
+ const values: Record<string, number | string | boolean> = {};
1486
+ for (const [name, value] of Object.entries(params)) {
1487
+ const param = parseParamName(effect, name);
1488
+ if (!param)
1489
+ throw new ToolArgumentError(
1490
+ `${effect} has no parameter ${name}; it takes ${Object.keys(effectSpec(effect).params).join(", ")}`,
1491
+ );
1492
+ if (
1493
+ typeof value !== "number" &&
1494
+ typeof value !== "string" &&
1495
+ typeof value !== "boolean"
1496
+ )
1497
+ throw new ToolArgumentError(`${effect} ${name} must be a value`);
1498
+ values[param] = value;
1499
+ }
1500
+ return { type: "fx-set", effect, values };
1501
+ }
1502
+
1503
+ function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1504
+ if (args.reset === true) return { type: "synth-reset" };
1505
+ if (args.zzfx !== undefined) {
1506
+ if (
1507
+ !Array.isArray(args.zzfx) ||
1508
+ args.zzfx.length > 21 ||
1509
+ !args.zzfx.every((n) => n === null || typeof n === "number")
1510
+ )
1511
+ throw new ToolArgumentError("zzfx must be up to 21 numbers or nulls");
1512
+ return { type: "synth-zzfx", values: args.zzfx as (number | null)[] };
1513
+ }
1514
+ if (args.preset !== undefined) {
1515
+ if (typeof args.preset !== "string" || !isSynthPreset(args.preset))
1516
+ throw new ToolArgumentError(
1517
+ `synth presets: ${Object.keys(SYNTH_PRESETS).join(", ")}`,
1518
+ );
1519
+ return { type: "synth-preset", preset: args.preset };
1520
+ }
1521
+ if (args.params === undefined)
1522
+ throw new ToolArgumentError("set_synth needs preset, reset, or params");
1523
+ const params = record(args.params, "params");
1524
+ const values: Record<
1525
+ string,
1526
+ number | string | boolean | readonly number[] | null
1527
+ > = {};
1528
+ for (const [name, value] of Object.entries(params)) {
1529
+ const param = synthParamName(name);
1530
+ if (!param)
1531
+ throw new ToolArgumentError(
1532
+ `synth has no parameter ${name}; basics: ${SYNTH_SIMPLE.join(", ")} (DAWG.md lists all)`,
1533
+ );
1534
+ if (
1535
+ value !== null &&
1536
+ typeof value !== "number" &&
1537
+ typeof value !== "string" &&
1538
+ typeof value !== "boolean" &&
1539
+ !(Array.isArray(value) && value.every((n) => typeof n === "number"))
1540
+ )
1541
+ throw new ToolArgumentError(`synth ${name} must be a value`);
1542
+ values[param] = value as number;
1543
+ }
1544
+ return { type: "synth-set", values };
1545
+ }
1546
+
856
1547
  function targetTrack(args: Record<string, unknown>, context: ToolContext) {
857
1548
  const trackId = args.trackId ?? context.focusedTrackId;
858
1549
  if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)