@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,107 @@
1
+ import { hostname } from "node:os";
2
+ import { defaultAuthEnv } from "./credentials.ts";
3
+ import {
4
+ authStatus,
5
+ isLogoutTarget,
6
+ logout,
7
+ modelChoices,
8
+ saveModelChoice,
9
+ type LoginIO,
10
+ } from "./login.ts";
11
+ import { systemRunner, type CommandRunner } from "./runner.ts";
12
+ import { audioStatusLine } from "../audio/engine.ts";
13
+ import {
14
+ providerLabel,
15
+ selectProvider,
16
+ type ProviderSelection,
17
+ } from "../agent/provider.ts";
18
+ import { parseLoginArgs } from "./cli.ts";
19
+
20
+ /**
21
+ * `/logout [provider]` and `/auth [--check]` inside the TUI: they print only,
22
+ * so they run without leaving the screen and return their lines as cards.
23
+ * `/login` hands the terminal to the shell flow instead (see main.ts).
24
+ */
25
+ export async function tuiAuthCommand(
26
+ command: string,
27
+ runner: CommandRunner = systemRunner,
28
+ ): Promise<string[]> {
29
+ const lines: string[] = [];
30
+ const io: LoginIO = {
31
+ interactive: false,
32
+ print: (line) => void lines.push(line),
33
+ ask: async () => "",
34
+ readSecret: async () => "",
35
+ };
36
+ const deps = { auth: defaultAuthEnv(runner), io, hostname: hostname() };
37
+ const [name, ...rest] = command.trim().split(/\s+/);
38
+ try {
39
+ if (/^\/logout$/i.test(name ?? "")) {
40
+ const target = rest[0];
41
+ if (target !== undefined && !isLogoutTarget(target))
42
+ lines.push("usage · /logout [gateway|openrouter|codex|claude|xcb]");
43
+ else await logout(deps, target);
44
+ } else if (/^\/auth$/i.test(name ?? ""))
45
+ lines.push(
46
+ ...(await authStatus(deps, {
47
+ verify: rest.includes("--check"),
48
+ })),
49
+ audioStatusLine(),
50
+ );
51
+ else lines.push(`unknown auth command: ${(name ?? "").slice(0, 20)}`);
52
+ } catch (error) {
53
+ lines.push(
54
+ `auth failed · ${error instanceof Error ? error.message : String(error)} · dawg login in a shell`,
55
+ );
56
+ }
57
+ return lines.map((line) => line.slice(0, 240));
58
+ }
59
+
60
+ /** Validate `/login …` arguments before suspending the screen. */
61
+ export function tuiLoginArgs(command: string) {
62
+ return parseLoginArgs(command.trim().split(/\s+/).slice(1));
63
+ }
64
+
65
+ export type ModelPickerItem = Readonly<{
66
+ label: string;
67
+ value: string;
68
+ detail: string;
69
+ current: boolean;
70
+ }>;
71
+
72
+ /**
73
+ * `/model` picker rows for the active provider, valued as the follow-up
74
+ * `/model <id>` command that saves the choice.
75
+ */
76
+ export async function modelPickerItems(
77
+ selection: ProviderSelection,
78
+ runner: CommandRunner = systemRunner,
79
+ ): Promise<ModelPickerItem[]> {
80
+ const rows = await modelChoices({ auth: defaultAuthEnv(runner) }, selection);
81
+ return rows.map((row) => ({
82
+ label: row.label,
83
+ detail: `${row.group} · ${row.detail}`,
84
+ current: row.current,
85
+ value: `/model ${row.value}`,
86
+ }));
87
+ }
88
+
89
+ /** `/model <value>`: save for the active provider; returns the receipt. */
90
+ export async function tuiSetModel(
91
+ value: string,
92
+ runner: CommandRunner = systemRunner,
93
+ ): Promise<string> {
94
+ const auth = defaultAuthEnv(runner);
95
+ const selection = await selectProvider(auth);
96
+ if (selection.kind === "offline")
97
+ return `model · unavailable: ${selection.reason}`;
98
+ try {
99
+ return `model · ${await saveModelChoice(auth, selection, value)}`;
100
+ } catch (error) {
101
+ const rows = await modelPickerItems(selection, runner).catch(() => []);
102
+ return `model · ${error instanceof Error ? error.message : String(error)}; try ${rows
103
+ .slice(0, 8)
104
+ .map((row) => row.label)
105
+ .join(", ")} · current ${providerLabel(selection)}`;
106
+ }
107
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Hand-editing commands the menus emit that the prompt grammar lacked. Each
3
+ * is one ScoreOperation, so it is one revision and one undo step.
4
+ *
5
+ * track name <text> rename the focused track
6
+ * meter <beats per bar> time signature numerator (1..16)
7
+ * automate <lane> points <beat:value>... merge points into a lane
8
+ * automate <lane> remove <beat> drop the point at a beat
9
+ *
10
+ * Lanes are every AUTOMATION_LANES key plus the effect aliases (`cutoff`,
11
+ * `res`, `feedback`, `delay-fb`). Parsing is pure; applying returns the next
12
+ * score and the session event, leaving persistence to the caller.
13
+ */
14
+ import {
15
+ AUTOMATION_LANES,
16
+ SCORE_LIMITS,
17
+ applyScoreOperation,
18
+ type AutomationParameter,
19
+ type AutomationPoint,
20
+ type ScoreOperation,
21
+ type TrackScore,
22
+ } from "../../core/score.ts";
23
+ import { parseEffectLane } from "./music.ts";
24
+
25
+ export type EditCommand =
26
+ | { type: "track-name"; name: string }
27
+ | { type: "meter"; beatsPerBar: number }
28
+ | {
29
+ type: "automation-points";
30
+ parameter: AutomationParameter;
31
+ points: readonly { beat: number; value: number }[];
32
+ }
33
+ | { type: "automation-remove"; parameter: AutomationParameter; beat: number };
34
+
35
+ export function parseLane(name: string): AutomationParameter | undefined {
36
+ const lower = name.toLowerCase();
37
+ if (lower === "volume" || lower === "vol") return "volume";
38
+ if (lower === "pan") return "pan";
39
+ return parseEffectLane(lower);
40
+ }
41
+
42
+ const NUMBER = /^-?\d+(?:\.\d+)?$/;
43
+
44
+ export function parseEditCommand(prompt: string): EditCommand | undefined {
45
+ const text = prompt.trim().replace(/\s+/g, " ");
46
+ if (text.length > 1_024) return undefined;
47
+ const name = text.match(/^track name (.+)$/i);
48
+ if (name) {
49
+ const value = name[1]!.trim();
50
+ return value.length > 0 && value.length <= SCORE_LIMITS.maxNameLength
51
+ ? { type: "track-name", name: value }
52
+ : undefined;
53
+ }
54
+ const meter = text.match(/^meter (\d+)$/i);
55
+ if (meter) {
56
+ const beatsPerBar = Number(meter[1]);
57
+ return beatsPerBar >= 1 && beatsPerBar <= SCORE_LIMITS.maxBeatsPerBar
58
+ ? { type: "meter", beatsPerBar }
59
+ : undefined;
60
+ }
61
+ const points = text.match(/^automate ([a-z-]+) points((?: \S+)+)$/i);
62
+ if (points) {
63
+ const parameter = parseLane(points[1]!);
64
+ if (!parameter) return undefined;
65
+ const { min, max } = AUTOMATION_LANES[parameter];
66
+ const parsed: { beat: number; value: number }[] = [];
67
+ for (const pair of points[2]!.trim().split(" ")) {
68
+ const [beatText, valueText, extra] = pair.split(":");
69
+ if (extra !== undefined || !beatText || !valueText) return undefined;
70
+ if (!NUMBER.test(beatText) || !NUMBER.test(valueText)) return undefined;
71
+ const beat = Number(beatText);
72
+ const value = Number(valueText);
73
+ if (beat > SCORE_LIMITS.maxTick) return undefined;
74
+ if (value < min || value > max) return undefined;
75
+ parsed.push({ beat, value });
76
+ }
77
+ if (parsed.length === 0 || parsed.length > SCORE_LIMITS.maxAutomationPoints)
78
+ return undefined;
79
+ return { type: "automation-points", parameter, points: parsed };
80
+ }
81
+ const remove = text.match(/^automate ([a-z-]+) remove (\d+(?:\.\d+)?)$/i);
82
+ if (remove) {
83
+ const parameter = parseLane(remove[1]!);
84
+ return parameter
85
+ ? { type: "automation-remove", parameter, beat: Number(remove[2]) }
86
+ : undefined;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ export type EditResult = Readonly<{
92
+ ok: boolean;
93
+ message: string;
94
+ next?: TrackScore;
95
+ kind?: string;
96
+ payload?: Record<string, unknown>;
97
+ }>;
98
+
99
+ /** Apply `command` to `trackId` as one ScoreOperation. */
100
+ export function applyEditCommand(
101
+ score: TrackScore,
102
+ trackId: string,
103
+ command: EditCommand,
104
+ ): EditResult {
105
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
106
+ if (command.type === "meter") {
107
+ const operation: ScoreOperation = {
108
+ type: "setMeter",
109
+ beatsPerBar: command.beatsPerBar,
110
+ };
111
+ return {
112
+ ok: true,
113
+ message: `meter · ${command.beatsPerBar}/4`,
114
+ next: applyScoreOperation(score, operation),
115
+ kind: "score.meter",
116
+ payload: { beatsPerBar: command.beatsPerBar },
117
+ };
118
+ }
119
+ if (!track) return { ok: false, message: `no track ${trackId}` };
120
+ if (command.type === "track-name") {
121
+ const patch = { name: command.name };
122
+ return {
123
+ ok: true,
124
+ message: `track name · ${command.name}`,
125
+ next: applyScoreOperation(score, {
126
+ type: "updateTrack",
127
+ trackId,
128
+ patch,
129
+ }),
130
+ kind: "score.track",
131
+ payload: { trackId, patch },
132
+ };
133
+ }
134
+ const field = AUTOMATION_LANES[command.parameter].field;
135
+ const current: readonly AutomationPoint[] = track[field] ?? [];
136
+ let points: AutomationPoint[];
137
+ if (command.type === "automation-points") {
138
+ const byTick = new Map(current.map((point) => [point.tick, point]));
139
+ for (const point of command.points) {
140
+ const tick = Math.max(0, Math.round(point.beat * score.ticksPerBeat));
141
+ byTick.set(tick, { tick, value: point.value });
142
+ }
143
+ points = [...byTick.values()].sort((left, right) => left.tick - right.tick);
144
+ if (points.length > SCORE_LIMITS.maxAutomationPoints)
145
+ return {
146
+ ok: false,
147
+ message: `${command.parameter} automation holds ${SCORE_LIMITS.maxAutomationPoints} points`,
148
+ };
149
+ } else {
150
+ const tick = Math.round(command.beat * score.ticksPerBeat);
151
+ points = current.filter((point) => point.tick !== tick);
152
+ if (points.length === current.length)
153
+ return {
154
+ ok: false,
155
+ message: `no ${command.parameter} point at beat ${command.beat}`,
156
+ };
157
+ }
158
+ return {
159
+ ok: true,
160
+ message: `automation · ${command.parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
161
+ next: applyScoreOperation(score, {
162
+ type: "setAutomation",
163
+ trackId,
164
+ parameter: command.parameter,
165
+ points,
166
+ }),
167
+ kind: "score.automation",
168
+ payload: { trackId, parameter: command.parameter, points },
169
+ };
170
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The command reference, in one place: `/help` renders it in the overlay,
3
+ * `dawg --help` prints it, and the docs mirror it. Music words are bare;
4
+ * app commands take a slash (bare aliases keep working but are listed once,
5
+ * in their canonical form).
6
+ */
7
+
8
+ export type HelpGroup = "music" | "session" | "window" | "keys";
9
+
10
+ export type HelpEntry = Readonly<{
11
+ /** Canonical form, e.g. `pan <-1..1>`. */
12
+ command: string;
13
+ /** What it does, short enough for one overlay row. */
14
+ summary: string;
15
+ }>;
16
+
17
+ export type HelpSection = Readonly<{
18
+ group: HelpGroup;
19
+ entries: readonly HelpEntry[];
20
+ }>;
21
+
22
+ export const HELP_SECTIONS: readonly HelpSection[] = [
23
+ {
24
+ group: "music",
25
+ entries: [
26
+ { command: "play", summary: "start the transport" },
27
+ { command: "pause", summary: "stop the transport" },
28
+ { command: "tempo <bpm>", summary: "20–300 BPM" },
29
+ { command: "add <note> at <beat> [for <beats>]", summary: "add C4 at 0" },
30
+ { command: "remove <id>", summary: "delete a note" },
31
+ { command: "move <id> to <beat>", summary: "shift a note" },
32
+ { command: "length <id> <beats>", summary: "resize a note" },
33
+ { command: "velocity <id> <0..1>", summary: "note loudness" },
34
+ { command: "bars <count>", summary: "loop length, 1–256" },
35
+ { command: "extend <count> bars", summary: "lengthen the loop" },
36
+ {
37
+ command: "instrument <name>",
38
+ summary: "sine piano pluck bass saw square triangle kit",
39
+ },
40
+ { command: "volume <0..1>", summary: "track level" },
41
+ { command: "pan <-1..1>", summary: "left … right" },
42
+ { command: "mute", summary: "silence this track" },
43
+ { command: "unmute", summary: "hear it again" },
44
+ { command: "solo", summary: "only this track" },
45
+ { command: "unsolo", summary: "every track again" },
46
+ { command: "clear", summary: "remove this track's notes" },
47
+ { command: "filter <hz> [res]", summary: "low-pass · filter off" },
48
+ { command: "delay <beats> [fb] [mix]", summary: "ping-pong · delay off" },
49
+ { command: "reverb <mix> [size]", summary: "room · reverb off" },
50
+ {
51
+ command: "automate <lane> at <beat> <value>",
52
+ summary: "volume pan filter resonance delay-feedback delay-mix",
53
+ },
54
+ {
55
+ command: "automate <lane> points <b:v>...",
56
+ summary: "several points · automate pan points 0:-1 4:1",
57
+ },
58
+ {
59
+ command: "automate <lane> remove <beat>",
60
+ summary: "drop one point",
61
+ },
62
+ { command: "clear [<lane>] automation", summary: "drop a lane's points" },
63
+ { command: "track name <text>", summary: "rename this track" },
64
+ { command: "meter <1..16>", summary: "beats per bar" },
65
+ {
66
+ command: "hit <voice> at <beat>",
67
+ summary: "kit tracks · hit kick at 0",
68
+ },
69
+ {
70
+ command: "pattern <voice> <beats...> | every <step>",
71
+ summary: "pattern kick every 1",
72
+ },
73
+ { command: "clear <voice>", summary: "remove one drum voice" },
74
+ { command: "undo", summary: "step back · Ctrl-Z" },
75
+ { command: "redo", summary: "step forward · Ctrl-Y" },
76
+ ],
77
+ },
78
+ {
79
+ group: "session",
80
+ entries: [
81
+ { command: "/sessions", summary: "list sessions in this workspace" },
82
+ { command: "/resume [<n>|<name>|<id>]", summary: "switch session" },
83
+ { command: "/rename <name>|--auto", summary: "name this session" },
84
+ { command: "/fork [<name>]", summary: "copy into a new session" },
85
+ { command: "/status", summary: "name · revision · digest · storage" },
86
+ { command: "/export <file>", summary: "write track.loop/v1 JSON" },
87
+ { command: "/import <file>", summary: "replace the score from a file" },
88
+ {
89
+ command: "/sample [<path> [as <voice>]]",
90
+ summary: "add a sample voice · list voices",
91
+ },
92
+ ],
93
+ },
94
+ {
95
+ group: "window",
96
+ entries: [
97
+ {
98
+ command: "/track <name>",
99
+ summary: "focus a track, creating it if new",
100
+ },
101
+ { command: "/tracks", summary: "list tracks" },
102
+ { command: "/view focus|all", summary: "one track or every track" },
103
+ { command: "/transcript", summary: "scrollable log · Ctrl-O" },
104
+ { command: "/theme default|high-contrast|mono", summary: "colors" },
105
+ { command: "/motion on|off", summary: "animation" },
106
+ { command: "/model [alias]", summary: "pick a model · cost per prompt" },
107
+ {
108
+ command: "/login [gateway|openrouter|codex|claude]",
109
+ summary: "sign in to a provider",
110
+ },
111
+ {
112
+ command: "/logout [provider]",
113
+ summary: "forget keys and the saved choice",
114
+ },
115
+ { command: "/auth [--check]", summary: "provider and audio status" },
116
+ { command: "/play [on|off]", summary: "keyboard play mode · Ctrl-P" },
117
+ {
118
+ command: "/menu [section]",
119
+ summary: "edit by hand · Ctrl-K · track effects automation …",
120
+ },
121
+ {
122
+ command: "/click on|off|<volume>",
123
+ summary: "metronome · /count-in 0-2 · /grid 1/16",
124
+ },
125
+ { command: "/help", summary: "this list · ?" },
126
+ ],
127
+ },
128
+ {
129
+ group: "keys",
130
+ entries: [
131
+ { command: "Enter", summary: "submit" },
132
+ { command: "Shift-Enter", summary: "newline" },
133
+ { command: "Alt-Enter", summary: "queue the request" },
134
+ { command: "Ctrl-Q", summary: "toggle queue mode" },
135
+ { command: "Ctrl-Z / Ctrl-Y", summary: "undo / redo" },
136
+ { command: "Ctrl-O", summary: "transcript" },
137
+ { command: "Esc", summary: "cancel the agent · close an overlay" },
138
+ { command: "Space", summary: "play/pause on an empty prompt" },
139
+ {
140
+ command: "Ctrl-P",
141
+ summary: "play mode: A–' W–P keys · Z/X octave · R rec · M click",
142
+ },
143
+ {
144
+ command: "Ctrl-K",
145
+ summary: "menu: ↑↓/jk move · enter · ←→/+- nudge · / filter",
146
+ },
147
+ { command: "Ctrl-C", summary: "exit" },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ /** Overlay rows: a heading per group, then `command summary` lines. */
153
+ export function helpLines(width = 80): string[] {
154
+ const lines: string[] = [];
155
+ const column = Math.min(
156
+ 40,
157
+ Math.max(
158
+ ...HELP_SECTIONS.flatMap((s) => s.entries.map((e) => e.command.length)),
159
+ ) + 2,
160
+ );
161
+ for (const section of HELP_SECTIONS) {
162
+ if (lines.length > 0) lines.push("");
163
+ lines.push(`── ${section.group}`);
164
+ for (const entry of section.entries) {
165
+ const pad = Math.max(1, column - entry.command.length);
166
+ lines.push(
167
+ `${entry.command}${" ".repeat(pad)}${entry.summary}`.slice(0, width),
168
+ );
169
+ }
170
+ }
171
+ return lines;
172
+ }
173
+
174
+ /** The `Commands:` block of `dawg --help`. */
175
+ export function helpText(): string {
176
+ return HELP_SECTIONS.filter((section) => section.group !== "keys")
177
+ .map(
178
+ (section) =>
179
+ `${section.group}:\n${section.entries
180
+ .map((entry) => ` ${entry.command}`)
181
+ .join("\n")}`,
182
+ )
183
+ .join("\n");
184
+ }
185
+
186
+ /** Usage for a known verb, shown instead of sending a near-miss to the agent. */
187
+ const USAGE: Readonly<Record<string, string>> = {
188
+ click: "/click on|off|<volume> · /click 50%",
189
+ "count-in": "/count-in 0|1|2",
190
+ grid: "/grid 1/4|1/8|1/8T|1/16|1/16T|1/32",
191
+ tempo: "tempo takes 20…300 · tempo 120",
192
+ bpm: "tempo takes 20…300 · tempo 120",
193
+ add: "add <note> at <beat> [for <beats>] · add C4 at 0",
194
+ put: "add <note> at <beat> [for <beats>] · add C4 at 0",
195
+ remove: "remove <id> · ids show in the transcript",
196
+ delete: "remove <id> · ids show in the transcript",
197
+ move: "move <id> to <beat>",
198
+ length: "length <id> <beats>",
199
+ velocity: "velocity <id> <0..1>",
200
+ vel: "velocity <id> <0..1>",
201
+ bars: "bars takes 1…256 · bars 8",
202
+ extend: "extend <count> bars · extend 4 bars",
203
+ instrument:
204
+ "instrument <name> · sine piano pluck bass saw square triangle kit",
205
+ volume: "volume takes 0…1 · volume 0.8",
206
+ vol: "volume takes 0…1 · volume 0.8",
207
+ pan: "pan takes -1…1 · pan -0.5",
208
+ filter: "filter <hz> [res] · filter 800 0.3 · filter off",
209
+ delay: "delay <beats> [fb] [mix] · delay 0.75 0.4 0.3 · delay off",
210
+ reverb: "reverb <mix> [size] · reverb 0.3 0.6 · reverb off",
211
+ automate: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
212
+ automation: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
213
+ hit: "hit <voice> at <beat> · hit kick at 0",
214
+ pattern: "pattern <voice> <beats...> | every <step> · pattern kick every 1",
215
+ clear: "clear · clear <voice> · clear [<lane>] automation",
216
+ track: "/track <name> · /track drums",
217
+ tracks: "/tracks",
218
+ sessions: "/sessions",
219
+ resume: "/resume [<n>|<name>|<id>]",
220
+ rename: "/rename <name> | --auto",
221
+ fork: "/fork [<name>]",
222
+ status: "/status",
223
+ export: "/export <file> · /export loop.track.json",
224
+ import: "/import <file> · /import loop.track.json",
225
+ sample: "/sample <path> [as <voice>] · /sample kick.wav as kick",
226
+ samples: "/sample · lists the focused track's voices",
227
+ view: "/view focus | all",
228
+ transcript: "/transcript",
229
+ log: "/transcript",
230
+ theme: "/theme default | high-contrast | mono",
231
+ motion: "/motion on | off",
232
+ model: "/model [alias | vendor/model]",
233
+ login: "/login [gateway | openrouter | codex | claude]",
234
+ logout: "/logout [provider]",
235
+ auth: "/auth [--check]",
236
+ help: "/help",
237
+ };
238
+
239
+ /**
240
+ * A usage hint when `command` starts with a known verb but did not parse
241
+ * (`pan 3`, `volume 2`, `add H4 at 0`, `/export` with no file); undefined for
242
+ * free text that should go to the agent.
243
+ */
244
+ export function usageHint(command: string): string | undefined {
245
+ const verb = command.trim().toLowerCase().replace(/^\//, "").split(/\s+/)[0];
246
+ return verb ? USAGE[verb] : undefined;
247
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Undo/redo over the shared session event log.
3
+ *
4
+ * Every event carries a `rewind` (see `src/session/delta.ts`): how to turn
5
+ * the composition it produced back into the one it replaced. Undo and redo
6
+ * append ordinary events (`score.undo` / `score.redo`), so history is linear,
7
+ * shared by every window, and survives restarts. Replaying the log rebuilds
8
+ * two stacks over the events that changed the composition:
9
+ *
10
+ * edit -> push onto undo, clear redo
11
+ * undo -> pop undo, push the undo event onto redo
12
+ * redo -> pop redo, push the redo event onto undo
13
+ *
14
+ * Undoing restores the composition before the top undo entry; redoing
15
+ * restores the composition before the top redo entry (the one the undo
16
+ * replaced). Both are recovered by rewinding the current composition through
17
+ * every newer event. Events whose rewind was compacted away, and events that
18
+ * did not change the composition (transport), take no part.
19
+ */
20
+ import {
21
+ isIdentityRewind,
22
+ rewindComposition,
23
+ type Rewind,
24
+ } from "../session/delta.ts";
25
+
26
+ export type HistoryEvent = Readonly<{
27
+ revision: number;
28
+ kind: string;
29
+ rewind?: Rewind | undefined;
30
+ }>;
31
+
32
+ export type HistoryTarget = Readonly<{
33
+ /** Revision of the event being undone or redone. */
34
+ revision: number;
35
+ /** Composition to restore. */
36
+ composition: unknown;
37
+ }>;
38
+
39
+ export const UNDO_KIND = "score.undo";
40
+ export const REDO_KIND = "score.redo";
41
+
42
+ export function historyTarget(
43
+ composition: unknown,
44
+ events: readonly HistoryEvent[],
45
+ direction: "undo" | "redo",
46
+ ): HistoryTarget | undefined {
47
+ const undo: number[] = [];
48
+ const redo: number[] = [];
49
+ events.forEach((event, index) => {
50
+ if (!changesComposition(event)) return;
51
+ if (event.kind === UNDO_KIND) {
52
+ if (undo.pop() !== undefined) redo.push(index);
53
+ } else if (event.kind === REDO_KIND) {
54
+ if (redo.pop() !== undefined) undo.push(index);
55
+ } else {
56
+ undo.push(index);
57
+ redo.length = 0;
58
+ }
59
+ });
60
+ const top = (direction === "undo" ? undo : redo).at(-1);
61
+ if (top === undefined) return undefined;
62
+ const restored = rewindComposition(composition, events, top);
63
+ if (restored === undefined) return undefined;
64
+ return { revision: events[top]!.revision, composition: restored };
65
+ }
66
+
67
+ function changesComposition(event: HistoryEvent): boolean {
68
+ return event.rewind !== undefined && !isIdentityRewind(event.rewind);
69
+ }