@hraness/dawg 0.2.0 → 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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -56,6 +56,8 @@ export type WindowPlayer = {
56
56
  /** Start at `beat`, or swap a playing loop in place (gapless engine). */
57
57
  play(score: TrackScore, beat?: number): Promise<void>;
58
58
  stop(): void;
59
+ /** Stops and releases the render worker; the player is done for good. */
60
+ dispose(): Promise<void>;
59
61
  };
60
62
 
61
63
  /**
@@ -65,6 +67,8 @@ export type WindowPlayer = {
65
67
  */
66
68
  export interface SessionPort<T> {
67
69
  readonly mode: "daemon" | "file";
70
+ /** This window's presence id, to exclude itself from `presence()`. */
71
+ readonly clientId: string;
68
72
  /** Human-readable status for the activity line. */
69
73
  readonly status: string;
70
74
  readonly player: WindowPlayer;
@@ -157,10 +161,12 @@ export async function openSessionPort<T>(
157
161
  const silentPlayer: WindowPlayer = {
158
162
  play: async () => undefined,
159
163
  stop: () => undefined,
164
+ dispose: async () => undefined,
160
165
  };
161
166
 
162
167
  class DaemonPort<T> implements SessionPort<T> {
163
168
  public readonly mode = "daemon" as const;
169
+ public readonly clientId: string;
164
170
  public readonly player = silentPlayer;
165
171
  public status: string;
166
172
  public sync: SyncStatus = "synced";
@@ -168,6 +174,7 @@ class DaemonPort<T> implements SessionPort<T> {
168
174
 
169
175
  public constructor(private readonly client: DaemonClient) {
170
176
  this.status = `dawgd pid ${client.daemonPid}`;
177
+ this.clientId = client.clientId;
171
178
  }
172
179
 
173
180
  private setSync(sync: SyncStatus): void {
@@ -303,17 +310,24 @@ class DaemonPort<T> implements SessionPort<T> {
303
310
 
304
311
  class FilePort<T> implements SessionPort<T> {
305
312
  public readonly mode = "file" as const;
313
+ public readonly clientId = randomUUID();
306
314
  public status = "file session";
307
315
  public readonly sync = "local" as const;
308
316
  public readonly player: WindowPlayer;
309
317
  private readonly presenceStore: FilePresence;
318
+ private readonly statusListeners = new Set<(update: PortUpdate<T>) => void>();
310
319
 
311
320
  private constructor(private readonly options: OpenPortOptions) {
312
321
  this.player = new AudioEngine({
313
322
  lockPath: `${options.paths.record}.audio.lock`,
323
+ projectRoot: dirname(options.paths.root),
324
+ onStatus: (status) => {
325
+ for (const listener of this.statusListeners)
326
+ listener({ type: "status", message: status.message });
327
+ },
314
328
  });
315
329
  this.presenceStore = new FilePresence(options.paths, {
316
- clientId: randomUUID(),
330
+ clientId: this.clientId,
317
331
  pid: process.pid,
318
332
  label: options.label.slice(0, 128),
319
333
  focusedTrackId: options.focusedTrackId,
@@ -393,13 +407,17 @@ class FilePort<T> implements SessionPort<T> {
393
407
  });
394
408
  };
395
409
  const recordName = basename(this.options.paths.record);
410
+ // macOS coalesces the temp write and the rename into events named after
411
+ // the temp file (`<record>.<pid>.tmp`), so match that too, but not the
412
+ // sibling `.audio.lock` / `.daemon.log` files that share the prefix.
413
+ const recordFile = new RegExp(
414
+ `^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
415
+ );
396
416
  let watcher: FSWatcher | undefined;
397
417
  try {
398
418
  watcher = watch(dirname(this.options.paths.record), (_event, file) => {
399
- // macOS coalesces the temp write and the rename into events named
400
- // after the temp file (`<record>.<pid>.tmp`), so match the prefix and
401
- // re-check shortly after in case the event preceded the rename.
402
- if (file !== null && !String(file).startsWith(recordName)) return;
419
+ // Re-check shortly after in case the event preceded the rename.
420
+ if (file !== null && !recordFile.test(String(file))) return;
403
421
  check();
404
422
  setTimeout(check, 40).unref?.();
405
423
  });
@@ -412,9 +430,11 @@ class FilePort<T> implements SessionPort<T> {
412
430
  watcher = undefined;
413
431
  }
414
432
  const timer = setInterval(check, watcher ? WATCHED_POLL_MS : POLL_MS);
433
+ this.statusListeners.add(listener);
415
434
  check();
416
435
  return () => {
417
436
  closed = true;
437
+ this.statusListeners.delete(listener);
418
438
  clearInterval(timer);
419
439
  watcher?.close();
420
440
  };
@@ -455,7 +475,7 @@ class FilePort<T> implements SessionPort<T> {
455
475
  }
456
476
 
457
477
  public async close(): Promise<void> {
458
- this.player.stop();
478
+ void this.player.dispose();
459
479
  await this.presenceStore.stop();
460
480
  }
461
481
  }
@@ -15,6 +15,7 @@ import {
15
15
  type ScoreOperation,
16
16
  type TrackScore,
17
17
  } from "../../core/score.ts";
18
+ import { rewindComposition, type Rewind } from "./delta.ts";
18
19
 
19
20
  /** How far behind a base may be and still be rebased. */
20
21
  export const MAX_REBASE_DISTANCE = 64;
@@ -105,6 +106,30 @@ export function rebaseOperations(
105
106
  if (base.bars !== current.bars)
106
107
  return { ok: false, reason: "loop length changed" };
107
108
  break;
109
+ case "setKey":
110
+ if (base.key !== current.key)
111
+ return { ok: false, reason: "key changed" };
112
+ break;
113
+ case "setMeter":
114
+ if (base.beatsPerBar !== current.beatsPerBar)
115
+ return { ok: false, reason: "meter changed" };
116
+ break;
117
+ case "removeTrack": {
118
+ if (added.tracks.has(operation.trackId)) break;
119
+ if (
120
+ !settingsUnchanged(operation.trackId) ||
121
+ !notesUnchanged(operation.trackId)
122
+ )
123
+ return { ok: false, reason: `track ${operation.trackId} changed` };
124
+ break;
125
+ }
126
+ case "moveTrack": {
127
+ const order = (score: TrackScore) =>
128
+ score.tracks.map((track) => track.id).join("\u0000");
129
+ if (order(base) !== order(current))
130
+ return { ok: false, reason: "track order changed" };
131
+ break;
132
+ }
108
133
  default:
109
134
  return { ok: false, reason: "unsupported operation" };
110
135
  }
@@ -123,16 +148,21 @@ export function rebaseOperations(
123
148
  }
124
149
 
125
150
  /**
126
- * The composition at revision `revision`, recovered from the `before` of the
127
- * event that replaced it. Undefined when the log cannot vouch for it.
151
+ * The composition at revision `revision`, recovered by rewinding the current
152
+ * composition through every later event's `rewind`. Undefined when the log
153
+ * cannot vouch for it (a gap in revisions, or a rewind compacted away).
128
154
  */
129
155
  export function compositionAt(
130
- events: readonly { revision: number; payload: unknown }[],
156
+ record: {
157
+ composition: unknown;
158
+ events: readonly { revision: number; rewind?: Rewind | undefined }[];
159
+ },
131
160
  revision: number,
132
161
  ): unknown {
133
- const event = events[revision];
134
- if (event === undefined || event.revision !== revision + 1) return undefined;
135
- const payload = event.payload;
136
- if (typeof payload !== "object" || payload === null) return undefined;
137
- return (payload as { before?: unknown }).before;
162
+ const { events } = record;
163
+ if (!Number.isSafeInteger(revision) || revision < 0) return undefined;
164
+ if (revision >= events.length) return undefined;
165
+ for (let index = revision; index < events.length; index += 1)
166
+ if (events[index]!.revision !== index + 1) return undefined;
167
+ return rewindComposition(record.composition, events, revision);
138
168
  }
@@ -1,7 +1,8 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { acquireSessionLock } from "./lock.ts";
5
+ import { diffRewind, parseRewind, type Rewind } from "./delta.ts";
5
6
  import {
6
7
  applyMetaPatch,
7
8
  defaultSessionMeta,
@@ -14,9 +15,14 @@ import {
14
15
  type SessionMeta,
15
16
  } from "./meta.ts";
16
17
 
18
+ /** Caller payloads; the store keeps history separately in each event's `rewind`. */
17
19
  const MAX_EVENT_BYTES = 64 * 1024;
20
+ /** A rewind larger than this (a wholesale import) is not kept; the edit still lands. */
21
+ const MAX_REWIND_BYTES = 1024 * 1024;
18
22
  const MAX_EVENTS = 2_000;
19
- const MAX_RECORD_BYTES = 4 * 1024 * 1024;
23
+ export const MAX_RECORD_BYTES = 4 * 1024 * 1024;
24
+ /** Oldest rewinds are dropped in batches of this size when the record fills up. */
25
+ const COMPACT_BATCH = 64;
20
26
  const MAX_SESSION_ID_LENGTH = 64;
21
27
  const MAX_EVENT_KIND_LENGTH = 128;
22
28
  const MAX_TIMESTAMP_LENGTH = 64;
@@ -34,6 +40,14 @@ export type SessionEvent = {
34
40
  kind: string;
35
41
  payload: unknown;
36
42
  at: string;
43
+ /**
44
+ * How to recover the composition this event replaced from the one it
45
+ * produced (see `delta.ts`). Absent once compacted away: the store drops
46
+ * the oldest rewinds first when the record would exceed its size cap, so
47
+ * an edit never fails because of history, and undo reaches as far back as
48
+ * the remaining rewinds form a contiguous suffix.
49
+ */
50
+ rewind?: Rewind;
37
51
  };
38
52
 
39
53
  export type SessionRecord<T> = {
@@ -149,7 +163,7 @@ export async function ensureSession<T>(
149
163
  ),
150
164
  };
151
165
  const validated = validateSessionRecord<T>(record);
152
- await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
166
+ await writeAtomic(paths.record, JSON.stringify(validated));
153
167
  return { paths, record: validated };
154
168
  } catch (error) {
155
169
  if (!isCode(error, "ENOENT")) throw error;
@@ -167,13 +181,18 @@ export async function ensureSession<T>(
167
181
  export async function appendSessionEvent<T>(
168
182
  paths: SessionPaths,
169
183
  current: SessionRecord<T>,
170
- event: Omit<SessionEvent, "id" | "revision" | "at"> & { id?: string },
184
+ event: Omit<SessionEvent, "id" | "revision" | "at" | "rewind"> & {
185
+ id?: string;
186
+ },
171
187
  composition: T,
172
188
  ): Promise<SessionRecord<T>> {
173
189
  // dawgd passes the client's idempotency key as the durable event id so a
174
190
  // retried intent stays a no-op across daemon restarts.
175
191
  if (event.id !== undefined) assertSessionId(event.id);
176
- const payloadJson = stringifyJson(event.payload, "session event payload");
192
+ // History is the store's job: a `before` composition in the payload (the
193
+ // pre-rewind convention) is dropped rather than persisted in full.
194
+ const payload = stripBefore(event.payload);
195
+ const payloadJson = stringifyJson(payload, "session event payload");
177
196
  const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
178
197
  if (payloadBytes > MAX_EVENT_BYTES)
179
198
  throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
@@ -183,6 +202,7 @@ export async function appendSessionEvent<T>(
183
202
  event.kind.length > MAX_EVENT_KIND_LENGTH
184
203
  )
185
204
  throw new SessionValidationError("session event kind is invalid");
205
+ stringifyJson(composition, "session composition");
186
206
  const release = await acquireSessionLock(paths.lock);
187
207
  try {
188
208
  const disk = await readRecord<T>(paths.record);
@@ -193,33 +213,79 @@ export async function appendSessionEvent<T>(
193
213
  throw new Error(`session event limit ${MAX_EVENTS} reached`);
194
214
  const revision = disk.revision + 1;
195
215
  const at = new Date().toISOString();
216
+ const appended: SessionEvent = {
217
+ kind: event.kind,
218
+ payload,
219
+ id: event.id ?? randomUUID(),
220
+ revision,
221
+ at,
222
+ };
223
+ const rewind = diffRewind(disk.composition, composition);
224
+ if (Buffer.byteLength(JSON.stringify(rewind), "utf8") <= MAX_REWIND_BYTES)
225
+ appended.rewind = rewind;
196
226
  const next: SessionRecord<T> = {
197
227
  sessionId: disk.sessionId,
198
228
  revision,
199
229
  updatedAt: at,
200
230
  composition,
201
- events: [
202
- ...disk.events,
203
- {
204
- kind: event.kind,
205
- payload: event.payload,
206
- id: event.id ?? randomUUID(),
207
- revision,
208
- at,
209
- },
210
- ],
231
+ events: [...disk.events, appended],
211
232
  // Metadata always comes from disk so a rename written by another window
212
233
  // between this caller's read and its write is never reverted.
213
234
  meta: disk.meta,
214
235
  };
215
236
  const validated = validateSessionRecord<T>(next);
216
- await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
217
- return validated;
237
+ const { record, json } = compactRecord(validated);
238
+ await writeAtomic(paths.record, json);
239
+ return record;
218
240
  } finally {
219
241
  await release();
220
242
  }
221
243
  }
222
244
 
245
+ /**
246
+ * Keeps the record under its size cap by dropping the oldest events'
247
+ * rewinds, oldest first, so the rewinds that remain are always the newest
248
+ * contiguous run. Only the composition and the bounded payloads are
249
+ * irreducible; a record that still does not fit is rejected as before.
250
+ */
251
+ function compactRecord<T>(record: SessionRecord<T>): {
252
+ record: SessionRecord<T>;
253
+ json: string;
254
+ } {
255
+ let current = record;
256
+ let json = JSON.stringify(current);
257
+ while (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES) {
258
+ const first = current.events.findIndex(
259
+ (event) => event.rewind !== undefined,
260
+ );
261
+ if (first < 0)
262
+ throw new SessionValidationError(
263
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
264
+ );
265
+ const events = current.events.map((event, index) => {
266
+ if (index < first || index >= first + COMPACT_BATCH || !event.rewind)
267
+ return event;
268
+ const { rewind: _rewind, ...rest } = event;
269
+ return rest;
270
+ });
271
+ current = { ...current, events };
272
+ json = JSON.stringify(current);
273
+ }
274
+ return { record: current, json };
275
+ }
276
+
277
+ function stripBefore(payload: unknown): unknown {
278
+ if (
279
+ typeof payload !== "object" ||
280
+ payload === null ||
281
+ Array.isArray(payload) ||
282
+ !("before" in payload)
283
+ )
284
+ return payload;
285
+ const { before: _before, ...rest } = payload as Record<string, unknown>;
286
+ return rest;
287
+ }
288
+
223
289
  export type MetaUpdate<T> =
224
290
  | { status: "applied"; record: SessionRecord<T> }
225
291
  | { status: "stale"; record: SessionRecord<T> };
@@ -245,7 +311,7 @@ export async function updateSessionMeta<T>(
245
311
  meta: applyMetaPatch(disk.meta, patch, new Date().toISOString()),
246
312
  };
247
313
  const validated = validateSessionRecord<T>(next);
248
- await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
314
+ await writeAtomic(paths.record, JSON.stringify(validated));
249
315
  return { status: "applied", record: validated };
250
316
  } finally {
251
317
  await release();
@@ -277,7 +343,7 @@ export async function createForkSession<T>(
277
343
  forkOf: { sessionId: source.sessionId, revision: source.revision },
278
344
  },
279
345
  });
280
- await writeAtomic(paths.record, JSON.stringify(record, null, 2));
346
+ await writeAtomic(paths.record, JSON.stringify(record));
281
347
  return record;
282
348
  }
283
349
 
@@ -348,7 +414,18 @@ async function writeAtomic(path: string, contents: string): Promise<void> {
348
414
  `session record exceeds ${MAX_RECORD_BYTES} bytes`,
349
415
  );
350
416
  const temporary = `${path}.${process.pid}.tmp`;
351
- await writeFile(temporary, `${contents}\n`, "utf8");
417
+ // Durable before it is visible: the data reaches the disk before the
418
+ // rename publishes it, so a crash never leaves a truncated record behind.
419
+ const handle = await open(temporary, "w", 0o644);
420
+ try {
421
+ await handle.writeFile(`${contents}\n`, "utf8");
422
+ await handle.sync();
423
+ } catch (error) {
424
+ await handle.close().catch(() => undefined);
425
+ await rm(temporary, { force: true }).catch(() => undefined);
426
+ throw error;
427
+ }
428
+ await handle.close();
352
429
  await rename(temporary, path);
353
430
  }
354
431
 
@@ -432,7 +509,25 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
432
509
  throw new SessionValidationError(
433
510
  `session event exceeds ${MAX_EVENT_BYTES} bytes`,
434
511
  );
435
- return candidate as unknown as SessionEvent;
512
+ let rewind: Rewind | undefined;
513
+ try {
514
+ rewind = parseRewind(candidate.rewind);
515
+ } catch (error) {
516
+ throw new SessionValidationError(
517
+ error instanceof Error
518
+ ? error.message
519
+ : "session event rewind is invalid",
520
+ );
521
+ }
522
+ const parsed: SessionEvent = {
523
+ id: candidate.id,
524
+ revision: candidate.revision,
525
+ kind: candidate.kind,
526
+ payload: candidate.payload,
527
+ at: candidate.at,
528
+ };
529
+ if (rewind !== undefined) parsed.rewind = rewind;
530
+ return parsed;
436
531
  });
437
532
  let meta: SessionMeta;
438
533
  try {