@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
package/src/render.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `dawg render <out.wav>`: renders a session (or a `track.loop/v1` file) to
3
+ * a stereo 16-bit PCM WAV through the same deterministic renderer playback
4
+ * uses, so two renders of one score are byte-identical. It reads the session
5
+ * record from disk and never starts dawgd or plays audio.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { readFile, rename, writeFile } from "node:fs/promises";
9
+ import { resolve } from "node:path";
10
+ import { scoreFromJSON, type TrackScore } from "../core/score.ts";
11
+ import { decodeLoop } from "../core/loop.ts";
12
+ import { renderScoreWav } from "./audio/wav.ts";
13
+ import { resolveSessionArg } from "./session/attach.ts";
14
+ import {
15
+ loadSession,
16
+ readCurrentSessionId,
17
+ sessionPaths,
18
+ } from "./session/store.ts";
19
+
20
+ export const RENDER_USAGE =
21
+ "usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>]";
22
+
23
+ const MAX_LOOP_FILE_BYTES = 512 * 1024;
24
+
25
+ type Output = { write(text: string): unknown };
26
+
27
+ export async function runRenderCommand(
28
+ argv: readonly string[],
29
+ workspace: string,
30
+ stdout: Output,
31
+ stderr: Output,
32
+ ): Promise<number> {
33
+ const rest = argv.slice(1);
34
+ const options = new Map<string, string>();
35
+ const positional: string[] = [];
36
+ for (let index = 0; index < rest.length; index += 1) {
37
+ const arg = rest[index]!;
38
+ if (arg === "--session" || arg === "--import") {
39
+ const value = rest[index + 1];
40
+ if (value === undefined || value.startsWith("--")) {
41
+ stderr.write(`${arg} needs a value\n${RENDER_USAGE}\n`);
42
+ return 2;
43
+ }
44
+ options.set(arg, value);
45
+ index += 1;
46
+ } else if (arg.startsWith("--")) {
47
+ stderr.write(`unknown option ${arg}\n${RENDER_USAGE}\n`);
48
+ return 2;
49
+ } else positional.push(arg);
50
+ }
51
+ const target = positional[0];
52
+ if (positional.length !== 1 || !target || !/\.wav$/i.test(target)) {
53
+ stderr.write(`${RENDER_USAGE}\n`);
54
+ return 2;
55
+ }
56
+ let score: TrackScore;
57
+ try {
58
+ score = await loadScore(workspace, options);
59
+ } catch (error) {
60
+ stderr.write(
61
+ `render failed · ${error instanceof Error ? error.message : String(error)}\n`,
62
+ );
63
+ return 1;
64
+ }
65
+ const wav = renderScoreWav(score);
66
+ const path = resolve(workspace, target);
67
+ const temporary = `${path}.${process.pid}.tmp`;
68
+ await writeFile(temporary, wav);
69
+ await rename(temporary, path);
70
+ const sha = createHash("sha256").update(wav).digest("hex");
71
+ stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
72
+ return 0;
73
+ }
74
+
75
+ async function loadScore(
76
+ workspace: string,
77
+ options: ReadonlyMap<string, string>,
78
+ ): Promise<TrackScore> {
79
+ const importPath = options.get("--import");
80
+ if (importPath !== undefined) {
81
+ const contents = await readFile(resolve(workspace, importPath));
82
+ if (contents.byteLength > MAX_LOOP_FILE_BYTES)
83
+ throw new Error("loop import exceeds 512 KiB");
84
+ return decodeLoop(contents.toString("utf8"));
85
+ }
86
+ const query = options.get("--session");
87
+ const sessionId =
88
+ query === undefined
89
+ ? await readCurrentSessionId(workspace)
90
+ : await resolveSessionArg(workspace, query);
91
+ if (sessionId === undefined)
92
+ throw new Error("no session here; run `dawg` first or pass --import");
93
+ const record = await loadSession<unknown>(
94
+ sessionPaths(workspace, sessionId),
95
+ ).catch(() => {
96
+ throw new Error(`no session named "${query ?? sessionId}"`);
97
+ });
98
+ return scoreFromJSON(record.composition);
99
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Glue between the TUI and sessions: resolving `--session <name|id>`,
3
+ * choosing a track for a newly opened window, materializing draft tracks,
4
+ * forking, and the auto-namer's port-backed target. Kept out of main.ts so
5
+ * the entry point only wires these together.
6
+ */
7
+ import { isDrumInstrument } from "../../core/drums.ts";
8
+ import type { TrackScore } from "../../core/score.ts";
9
+ import {
10
+ ambiguousSessionMessage,
11
+ listSessions,
12
+ resolveSession,
13
+ type SessionSummary,
14
+ } from "./list.ts";
15
+ import {
16
+ forkName,
17
+ normalizeSessionName,
18
+ uniqueName,
19
+ type SessionMeta,
20
+ } from "./meta.ts";
21
+ import type { NamingTarget } from "./naming.ts";
22
+ import type { SessionPort } from "./port.ts";
23
+ import {
24
+ createForkSession,
25
+ inheritedEvents,
26
+ setCurrentSession,
27
+ type SessionEvent,
28
+ type SessionRecord,
29
+ } from "./store.ts";
30
+
31
+ export const ALL_TRACKS_OPEN_HINT = "all tracks open · new track";
32
+
33
+ export class SessionLookupError extends Error {
34
+ public constructor(message: string) {
35
+ super(message);
36
+ this.name = "SessionLookupError";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Maps `--session <name|id>` to a session id. Unknown values pass through
42
+ * unchanged when they are valid ids (a new session with that id is created);
43
+ * an ambiguous name throws with the candidate list.
44
+ */
45
+ export async function resolveSessionArg(
46
+ workspace: string,
47
+ query: string,
48
+ sessions?: readonly SessionSummary[],
49
+ ): Promise<string> {
50
+ const listed = sessions ?? (await listSessions(workspace));
51
+ const result = resolveSession(listed, query);
52
+ if (result.status === "found") return result.session.sessionId;
53
+ if (result.status === "ambiguous")
54
+ throw new SessionLookupError(
55
+ ambiguousSessionMessage(query, result.candidates),
56
+ );
57
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(query)) return query;
58
+ throw new SessionLookupError(`no session named "${query}"`);
59
+ }
60
+
61
+ export type TrackAttachment = {
62
+ trackId: string;
63
+ /** Reserved id not yet in the score; created on the first edit. */
64
+ draft: boolean;
65
+ /** How the track was chosen, for the activity strip. */
66
+ reason: "explicit" | "claimed" | "draft";
67
+ };
68
+
69
+ /**
70
+ * `--track` always wins (focus is published but not exclusive). Otherwise
71
+ * atomically claim the first track no live window has focused, or a draft.
72
+ */
73
+ export async function attachTrack<T>(
74
+ port: SessionPort<T>,
75
+ score: TrackScore,
76
+ explicit: string | undefined,
77
+ ): Promise<TrackAttachment> {
78
+ if (explicit !== undefined) {
79
+ await port.focus(explicit).catch(() => undefined);
80
+ return { trackId: explicit, draft: false, reason: "explicit" };
81
+ }
82
+ const claim = await port.claimOrDraft(score.tracks.map((track) => track.id));
83
+ return {
84
+ trackId: claim.trackId,
85
+ draft: claim.draft,
86
+ reason: claim.draft ? "draft" : "claimed",
87
+ };
88
+ }
89
+
90
+ /** Score with `trackId` present: a draft becomes a real track here. */
91
+ export function withTrack(score: TrackScore, trackId: string): TrackScore {
92
+ if (score.tracks.some((track) => track.id === trackId)) return score;
93
+ const draft = trackId.match(/^track-(\d+)$/);
94
+ return score.withTracks([
95
+ ...score.tracks,
96
+ {
97
+ id: trackId,
98
+ name: draft ? `track ${draft[1]}` : trackId,
99
+ instrument: isDrumInstrument(trackId) ? "kit" : "sine",
100
+ },
101
+ ]);
102
+ }
103
+
104
+ /** Copies `source` into a new session named per the fork rules. */
105
+ export async function forkSession<T>(
106
+ workspace: string,
107
+ source: SessionRecord<T>,
108
+ requested: string | undefined,
109
+ ): Promise<SessionRecord<T>> {
110
+ const others = (await listSessions(workspace))
111
+ .filter((session) => !session.error)
112
+ .map((session) => session.name);
113
+ const name =
114
+ requested !== undefined && requested.trim().length > 0
115
+ ? uniqueName(normalizeSessionName(requested), others)
116
+ : forkName(source.meta.name, others);
117
+ const record = await createForkSession(workspace, source, name);
118
+ await setCurrentSession(workspace, record.sessionId);
119
+ return record;
120
+ }
121
+
122
+ const inheritedCache = new Map<string, Promise<SessionEvent[]>>();
123
+
124
+ /**
125
+ * The event log undo/redo replays: the fork ancestry's prefix (see
126
+ * `inheritedEvents`), then this session's own events. Undo in a fork can
127
+ * therefore step back past the fork point into the parent's edits. The
128
+ * inherited prefix never changes, so it is cached per lineage.
129
+ */
130
+ export async function historyEvents<T>(
131
+ workspace: string,
132
+ record: SessionRecord<T>,
133
+ ): Promise<SessionEvent[]> {
134
+ const fork = record.meta.forkOf;
135
+ if (!fork) return record.events;
136
+ const key = `${workspace}\0${record.sessionId}\0${fork.sessionId}@${fork.revision}`;
137
+ let inherited = inheritedCache.get(key);
138
+ if (!inherited) {
139
+ inherited = inheritedEvents(workspace, record.meta, record.sessionId);
140
+ if (inheritedCache.size > 32) inheritedCache.clear();
141
+ inheritedCache.set(key, inherited);
142
+ }
143
+ return [...(await inherited), ...record.events];
144
+ }
145
+
146
+ /** The auto-namer's view of the live session. */
147
+ export function namingTarget<T>(
148
+ workspace: string,
149
+ current: () => { port: SessionPort<T>; record: SessionRecord<T> },
150
+ adopt: (meta: SessionMeta) => void,
151
+ ): NamingTarget {
152
+ return {
153
+ meta: () => current().record.meta,
154
+ updateMeta: async (patch, expect) => {
155
+ const result = await current().port.updateMeta(patch, expect);
156
+ adopt(result.meta);
157
+ return result;
158
+ },
159
+ otherNames: async () => {
160
+ const id = current().record.sessionId;
161
+ return (await listSessions(workspace))
162
+ .filter((session) => !session.error && session.sessionId !== id)
163
+ .map((session) => session.name);
164
+ },
165
+ };
166
+ }
167
+
168
+ /** `/resume` picker text: numbered recent sessions. */
169
+ export function pickerLines(
170
+ sessions: readonly SessionSummary[],
171
+ currentId: string,
172
+ format: (session: SessionSummary) => string,
173
+ limit = 9,
174
+ ): string[] {
175
+ return sessions
176
+ .filter((session) => !session.error)
177
+ .slice(0, limit)
178
+ .map(
179
+ (session, index) =>
180
+ `${index + 1}${session.sessionId === currentId ? "*" : " "} ${format(session)}`,
181
+ );
182
+ }