@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,253 @@
1
+ /**
2
+ * Session metadata: the human name, who chose it, and fork lineage. It is
3
+ * stored inside the session record beside the composition, versioned on its
4
+ * own counter so renames never bump the score revision or the undo history.
5
+ */
6
+ export const MAX_SESSION_NAME_LENGTH = 40;
7
+ export const DEFAULT_SESSION_NAME = "untitled";
8
+ const MAX_TIMESTAMP_LENGTH = 64;
9
+ const MAX_ID_LENGTH = 64;
10
+ const FINGERPRINT_PATTERN = /^[0-9a-f]{1,32}$/;
11
+ /** C0/C1 controls, zero-width and bidi formatting characters. */
12
+ const UNPRINTABLE = new RegExp(
13
+ "[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2066-\\u2069\\ufeff]",
14
+ "g",
15
+ );
16
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
17
+
18
+ export type NameSource = "auto" | "user";
19
+
20
+ export type SessionMeta = {
21
+ name: string;
22
+ /** `user` permanently stops auto-naming until `/rename --auto`. */
23
+ nameSource: NameSource;
24
+ forkOf?: { sessionId: string; revision: number };
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ /** Incremented on every metadata write; independent of the score revision. */
28
+ version: number;
29
+ /** Fingerprint hash the current auto name was chosen for. */
30
+ namedFingerprint?: string;
31
+ /** Track-and-instrument-set hash at the last auto name. */
32
+ namedStructure?: string;
33
+ };
34
+
35
+ /** Fields a client may change. `null` clears an optional field. */
36
+ export type MetaPatch = {
37
+ name?: string;
38
+ nameSource?: NameSource;
39
+ namedFingerprint?: string | null;
40
+ namedStructure?: string | null;
41
+ };
42
+
43
+ /** A conditional write applies only when every given field still matches. */
44
+ export type MetaExpect = { name?: string; nameSource?: NameSource };
45
+
46
+ export class MetaValidationError extends Error {
47
+ public constructor(message: string) {
48
+ super(message);
49
+ this.name = "MetaValidationError";
50
+ }
51
+ }
52
+
53
+ /** Trims, collapses whitespace, drops control characters, bounds to 40. */
54
+ export function normalizeSessionName(value: unknown): string {
55
+ if (typeof value !== "string" || value.length > 1024)
56
+ throw new MetaValidationError("session name must be a short string");
57
+ const name = value
58
+ .replace(UNPRINTABLE, " ")
59
+ .replace(/\s+/g, " ")
60
+ .trim()
61
+ .slice(0, MAX_SESSION_NAME_LENGTH)
62
+ .trim();
63
+ if (name.length === 0) throw new MetaValidationError("session name is empty");
64
+ return name;
65
+ }
66
+
67
+ export function defaultSessionMeta(
68
+ at: string,
69
+ name = DEFAULT_SESSION_NAME,
70
+ ): SessionMeta {
71
+ return {
72
+ name,
73
+ nameSource: "auto",
74
+ createdAt: at,
75
+ updatedAt: at,
76
+ version: 0,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Validates metadata read from disk or the wire. Records written before
82
+ * metadata existed get a deterministic name derived from their id.
83
+ */
84
+ export function parseSessionMeta(
85
+ value: unknown,
86
+ fallback: { sessionId: string; updatedAt: string },
87
+ ): SessionMeta {
88
+ if (value === undefined || value === null)
89
+ return defaultSessionMeta(
90
+ fallback.updatedAt,
91
+ `session ${fallback.sessionId.slice(0, 8)}`,
92
+ );
93
+ if (typeof value !== "object" || Array.isArray(value))
94
+ throw new MetaValidationError("session meta must be an object");
95
+ const meta = value as Record<string, unknown>;
96
+ const nameSource = meta.nameSource;
97
+ if (nameSource !== "auto" && nameSource !== "user")
98
+ throw new MetaValidationError("session nameSource is invalid");
99
+ if (!Number.isSafeInteger(meta.version) || (meta.version as number) < 0)
100
+ throw new MetaValidationError("session meta version is invalid");
101
+ const result: SessionMeta = {
102
+ name: normalizeSessionName(meta.name),
103
+ nameSource,
104
+ createdAt: timestamp(meta.createdAt, "createdAt"),
105
+ updatedAt: timestamp(meta.updatedAt, "updatedAt"),
106
+ version: meta.version as number,
107
+ };
108
+ if (meta.forkOf !== undefined) {
109
+ const fork = meta.forkOf;
110
+ if (typeof fork !== "object" || fork === null)
111
+ throw new MetaValidationError("session forkOf is invalid");
112
+ const { sessionId, revision } = fork as Record<string, unknown>;
113
+ if (
114
+ typeof sessionId !== "string" ||
115
+ sessionId.length === 0 ||
116
+ sessionId.length > MAX_ID_LENGTH ||
117
+ !ID_PATTERN.test(sessionId) ||
118
+ !Number.isSafeInteger(revision) ||
119
+ (revision as number) < 0
120
+ )
121
+ throw new MetaValidationError("session forkOf is invalid");
122
+ result.forkOf = { sessionId, revision: revision as number };
123
+ }
124
+ const fingerprint = optionalFingerprint(meta.namedFingerprint);
125
+ if (fingerprint) result.namedFingerprint = fingerprint;
126
+ const structure = optionalFingerprint(meta.namedStructure);
127
+ if (structure) result.namedStructure = structure;
128
+ return result;
129
+ }
130
+
131
+ /** Validates a client patch from `unknown`; unknown keys are ignored. */
132
+ export function parseMetaPatch(value: unknown): MetaPatch {
133
+ if (typeof value !== "object" || value === null || Array.isArray(value))
134
+ throw new MetaValidationError("meta patch must be an object");
135
+ const raw = value as Record<string, unknown>;
136
+ const patch: MetaPatch = {};
137
+ if (raw.name !== undefined) patch.name = normalizeSessionName(raw.name);
138
+ if (raw.nameSource !== undefined) {
139
+ if (raw.nameSource !== "auto" && raw.nameSource !== "user")
140
+ throw new MetaValidationError("nameSource is invalid");
141
+ patch.nameSource = raw.nameSource;
142
+ }
143
+ for (const key of ["namedFingerprint", "namedStructure"] as const) {
144
+ if (raw[key] === undefined) continue;
145
+ patch[key] = raw[key] === null ? null : requireFingerprint(raw[key]);
146
+ }
147
+ if (Object.keys(patch).length === 0)
148
+ throw new MetaValidationError("meta patch is empty");
149
+ return patch;
150
+ }
151
+
152
+ export function parseMetaExpect(value: unknown): MetaExpect | undefined {
153
+ if (value === undefined || value === null) return undefined;
154
+ if (typeof value !== "object" || Array.isArray(value))
155
+ throw new MetaValidationError("meta condition must be an object");
156
+ const raw = value as Record<string, unknown>;
157
+ const expect: MetaExpect = {};
158
+ if (raw.name !== undefined) expect.name = normalizeSessionName(raw.name);
159
+ if (raw.nameSource !== undefined) {
160
+ if (raw.nameSource !== "auto" && raw.nameSource !== "user")
161
+ throw new MetaValidationError("nameSource is invalid");
162
+ expect.nameSource = raw.nameSource;
163
+ }
164
+ return expect;
165
+ }
166
+
167
+ export function metaMatches(meta: SessionMeta, expect?: MetaExpect): boolean {
168
+ if (!expect) return true;
169
+ if (expect.name !== undefined && expect.name !== meta.name) return false;
170
+ if (expect.nameSource !== undefined && expect.nameSource !== meta.nameSource)
171
+ return false;
172
+ return true;
173
+ }
174
+
175
+ export function applyMetaPatch(
176
+ meta: SessionMeta,
177
+ patch: MetaPatch,
178
+ at: string,
179
+ ): SessionMeta {
180
+ const next: SessionMeta = {
181
+ ...meta,
182
+ updatedAt: at,
183
+ version: meta.version + 1,
184
+ };
185
+ if (patch.name !== undefined) next.name = patch.name;
186
+ if (patch.nameSource !== undefined) next.nameSource = patch.nameSource;
187
+ for (const key of ["namedFingerprint", "namedStructure"] as const) {
188
+ if (patch[key] === null) delete next[key];
189
+ else if (patch[key] !== undefined) next[key] = patch[key];
190
+ }
191
+ return next;
192
+ }
193
+
194
+ /** Splits a trailing ` N` (N ≥ 2) suffix: `x 3` → `{ base: "x", n: 3 }`. */
195
+ export function splitNameNumber(name: string): { base: string; n: number } {
196
+ const match = name.match(/^(.*\S) (\d{1,4})$/);
197
+ if (!match) return { base: name, n: 1 };
198
+ const n = Number(match[2]);
199
+ return n >= 2 ? { base: match[1]!, n } : { base: name, n: 1 };
200
+ }
201
+
202
+ /**
203
+ * Default fork name: strip any trailing ` N` from the parent, then take the
204
+ * highest ` N` among sessions with that base, plus one (`x` → `x 2` → `x 3`;
205
+ * a fork of `x 2` is `x 3` too).
206
+ */
207
+ export function forkName(parent: string, existing: readonly string[]): string {
208
+ const { base } = splitNameNumber(parent);
209
+ let highest = 1;
210
+ for (const name of existing) {
211
+ const split = splitNameNumber(name);
212
+ if (split.base === base) highest = Math.max(highest, split.n);
213
+ }
214
+ return withNumber(base, highest + 1);
215
+ }
216
+
217
+ /** Adds or bumps a ` N` suffix until the name is not in `taken`. */
218
+ export function uniqueName(name: string, taken: readonly string[]): string {
219
+ const lower = new Set(taken.map((entry) => entry.toLowerCase()));
220
+ if (!lower.has(name.toLowerCase())) return name;
221
+ const { base, n } = splitNameNumber(name);
222
+ for (let next = Math.max(2, n + 1); next < 10_000; next += 1) {
223
+ const candidate = withNumber(base, next);
224
+ if (!lower.has(candidate.toLowerCase())) return candidate;
225
+ }
226
+ return name;
227
+ }
228
+
229
+ export function withNumber(base: string, n: number): string {
230
+ const suffix = ` ${n}`;
231
+ return `${base.slice(0, MAX_SESSION_NAME_LENGTH - suffix.length).trimEnd()}${suffix}`;
232
+ }
233
+
234
+ function timestamp(value: unknown, label: string): string {
235
+ if (
236
+ typeof value !== "string" ||
237
+ value.length === 0 ||
238
+ value.length > MAX_TIMESTAMP_LENGTH
239
+ )
240
+ throw new MetaValidationError(`session ${label} is invalid`);
241
+ return value;
242
+ }
243
+
244
+ function optionalFingerprint(value: unknown): string | undefined {
245
+ if (value === undefined || value === null) return undefined;
246
+ return requireFingerprint(value);
247
+ }
248
+
249
+ function requireFingerprint(value: unknown): string {
250
+ if (typeof value !== "string" || !FINGERPRINT_PATTERN.test(value))
251
+ throw new MetaValidationError("fingerprint is invalid");
252
+ return value;
253
+ }
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Token-efficient session auto-naming.
3
+ *
4
+ * Every accepted turn updates a local *musical fingerprint* (tempo, a key
5
+ * estimate from the pitch-class histogram, each track's role and instrument,
6
+ * note density, register, and effects). A model call happens only when that
7
+ * fingerprint differs from the one the current name was chosen for, and at
8
+ * most every `minTurns` turns unless the track or instrument set changed.
9
+ * The request is ~120 tokens in and 12 out, asks the model to echo the
10
+ * current name when it still fits (hysteresis), and is applied as a
11
+ * conditional write so a user `/rename` always wins.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import type { TrackScore } from "../../core/score.ts";
15
+ import { isDrumInstrument } from "../../core/drums.ts";
16
+ import { estimateKey } from "../../core/key.ts";
17
+
18
+ export { estimateKey };
19
+ import type { ProviderSelection } from "../agent/provider.ts";
20
+ import {
21
+ DEFAULT_SESSION_NAME,
22
+ splitNameNumber,
23
+ uniqueName,
24
+ withNumber,
25
+ type MetaExpect,
26
+ type MetaPatch,
27
+ type SessionMeta,
28
+ } from "./meta.ts";
29
+
30
+ export const NAME_MAX_TOKENS = 12;
31
+ export const NAME_TEMPERATURE = 0.3;
32
+ const PROMPT_SNIPPET_CHARS = 60;
33
+
34
+ export type TrackRole = "drums" | "bass" | "keys" | "lead";
35
+
36
+ export type Fingerprint = {
37
+ /** Human-readable summary sent to the model. */
38
+ line: string;
39
+ /** Hash of `line`; equal scores always hash equally. */
40
+ hash: string;
41
+ /** Hash of the track-and-instrument set only. */
42
+ structure: string;
43
+ bpm: number;
44
+ key: string | null;
45
+ roles: TrackRole[];
46
+ busy: boolean;
47
+ };
48
+
49
+ /**
50
+ * Pure and order-independent: note ids, note order and track names do not
51
+ * affect it, so the same music always yields the same hash.
52
+ */
53
+ export function musicalFingerprint(score: TrackScore): Fingerprint {
54
+ const bpm = Math.round(score.tempoBpm);
55
+ const bars = Math.max(1, score.bars);
56
+ const histogram = new Array<number>(12).fill(0);
57
+ const parts: string[] = [];
58
+ const roles: TrackRole[] = [];
59
+ const effects = new Set<string>();
60
+ let busy = false;
61
+ const tracks = [...score.tracks].sort((a, b) => a.id.localeCompare(b.id));
62
+ for (const track of tracks) {
63
+ const notes = score.notes.filter((note) => note.trackId === track.id);
64
+ const drums = isDrumInstrument(track.instrument);
65
+ if (!drums)
66
+ for (const note of notes)
67
+ histogram[note.pitch % 12]! += note.durationTicks / score.ticksPerBeat;
68
+ const perBar = notes.length / bars;
69
+ const density =
70
+ notes.length === 0
71
+ ? "empty"
72
+ : perBar < 2
73
+ ? "sparse"
74
+ : perBar < 6
75
+ ? "steady"
76
+ : "busy";
77
+ if (density === "busy") busy = true;
78
+ const pitches = notes.map((note) => note.pitch).sort((a, b) => a - b);
79
+ const median = pitches.length ? pitches[pitches.length >> 1]! : 60;
80
+ const register = median < 48 ? "low" : median <= 72 ? "mid" : "high";
81
+ const role: TrackRole = drums
82
+ ? "drums"
83
+ : register === "low"
84
+ ? "bass"
85
+ : register === "high"
86
+ ? "lead"
87
+ : "keys";
88
+ if (notes.length > 0) roles.push(role);
89
+ if (track.filter) effects.add("filter");
90
+ if (track.delay) effects.add("delay");
91
+ if (track.reverb) effects.add("reverb");
92
+ if (track.volumeAutomation.length || track.panAutomation.length)
93
+ effects.add("automation");
94
+ if (
95
+ track.filterAutomation?.length ||
96
+ track.resonanceAutomation?.length ||
97
+ track.delayFeedbackAutomation?.length ||
98
+ track.delayMixAutomation?.length
99
+ )
100
+ effects.add("sweep");
101
+ const instrument = track.instrument.toLowerCase().slice(0, 24);
102
+ parts.push(
103
+ drums
104
+ ? `drums(${instrument},${density})`
105
+ : `${role}(${instrument},${register},${density})`,
106
+ );
107
+ }
108
+ const key = score.key ? score.key.toLowerCase() : estimateKey(histogram);
109
+ const line = [
110
+ `${bpm} bpm`,
111
+ key ?? "no key",
112
+ parts.join(" ") || "no tracks",
113
+ effects.size ? `fx ${[...effects].sort().join(" ")}` : "",
114
+ ]
115
+ .filter(Boolean)
116
+ .join(" · ");
117
+ const structure = tracks
118
+ .map((track) => `${track.id}=${track.instrument.toLowerCase()}`)
119
+ .join(",");
120
+ return {
121
+ line,
122
+ hash: shortHash(line),
123
+ structure: shortHash(structure),
124
+ bpm,
125
+ key,
126
+ roles: [...new Set(roles)],
127
+ busy,
128
+ };
129
+ }
130
+
131
+ /** Deterministic offline name, e.g. "a minor bass groove" or "128 bpm drums + keys". */
132
+ export function localName(fingerprint: Fingerprint): string {
133
+ const roles = fingerprint.roles;
134
+ if (roles.length === 0) return DEFAULT_SESSION_NAME;
135
+ const pitched = roles.filter((role) => role !== "drums");
136
+ if (fingerprint.key && pitched.length > 0) {
137
+ const lead = pitched.includes("bass") ? "bass" : pitched[0]!;
138
+ const vibe =
139
+ roles.includes("drums") || fingerprint.busy ? "groove" : "loop";
140
+ return `${fingerprint.key} ${lead} ${vibe}`;
141
+ }
142
+ return `${fingerprint.bpm} bpm ${roles.join(" + ")}`;
143
+ }
144
+
145
+ /**
146
+ * Validates untrusted model output: lowercase, only `[a-z0-9 '-]`, 3 to 32
147
+ * characters, at most 4 words. Returns undefined when it does not fit.
148
+ */
149
+ export function sanitizeGeneratedName(text: unknown): string | undefined {
150
+ if (typeof text !== "string") return undefined;
151
+ const firstLine = text.slice(0, 256).split(/\r?\n/)[0] ?? "";
152
+ const name = firstLine
153
+ .toLowerCase()
154
+ .replace(/^name\s*:\s*/, "")
155
+ .replace(/[^a-z0-9 '-]/g, " ")
156
+ .replace(/\s+/g, " ")
157
+ .replace(/^['\s-]+|['\s-]+$/g, "")
158
+ .trim();
159
+ if (name.length < 3 || name.length > 32) return undefined;
160
+ if (name.split(" ").length > 4) return undefined;
161
+ return name;
162
+ }
163
+
164
+ export function namingPrompt(
165
+ fingerprint: Fingerprint,
166
+ recentPrompts: readonly string[],
167
+ currentName: string,
168
+ ): string {
169
+ const recent = recentPrompts
170
+ .slice(-2)
171
+ .map((prompt) => {
172
+ const flat = prompt.replace(/\s+/g, " ").trim();
173
+ return `"${flat.length > PROMPT_SNIPPET_CHARS ? flat.slice(0, PROMPT_SNIPPET_CHARS) : flat}"`;
174
+ })
175
+ .join(" | ");
176
+ return [
177
+ "Name this music session: 2-4 lowercase words evoking the vibe; reply with the current name exactly if it still fits; no quotes.",
178
+ `music: ${fingerprint.line}`,
179
+ `recent: ${recent || "none"}`,
180
+ `current name: ${currentName === DEFAULT_SESSION_NAME ? "none" : currentName}`,
181
+ ].join("\n");
182
+ }
183
+
184
+ /** The model seam. Implementations must not throw synchronously. */
185
+ export interface NameGenerator {
186
+ generate(
187
+ prompt: string,
188
+ options: { maxTokens: number; temperature: number; signal: AbortSignal },
189
+ ): Promise<string>;
190
+ }
191
+
192
+ /**
193
+ * Default generator: `generateText` on the configured provider (gateway
194
+ * haiku-4.5 or the selected xcb account). It throws when offline, which the
195
+ * namer turns into the local fingerprint name. `generateText` has no
196
+ * temperature knob, so `temperature` is advisory here.
197
+ */
198
+ export function providerNameGenerator(
199
+ selection: () => Promise<ProviderSelection>,
200
+ timeoutMs = 15_000,
201
+ ): NameGenerator {
202
+ return {
203
+ async generate(prompt, options) {
204
+ const selected = await selection();
205
+ if (selected.kind === "offline") throw new Error(selected.reason);
206
+ const { generateText } = await import("../agent/provider.ts");
207
+ return generateText(prompt, {
208
+ maxTokens: options.maxTokens,
209
+ signal: options.signal,
210
+ timeoutMs,
211
+ selection: selected,
212
+ });
213
+ },
214
+ };
215
+ }
216
+
217
+ /** Where the namer reads and conditionally writes session metadata. */
218
+ export interface NamingTarget {
219
+ meta(): SessionMeta;
220
+ updateMeta(
221
+ patch: MetaPatch,
222
+ expect: MetaExpect,
223
+ ): Promise<{ status: "applied" | "stale"; meta: SessionMeta }>;
224
+ /** Names of other sessions in the workspace, for collision suffixes. */
225
+ otherNames(): Promise<string[]>;
226
+ }
227
+
228
+ export type AutoNamerOptions = {
229
+ target: NamingTarget;
230
+ /** Omit for offline: names come from the local fingerprint only. */
231
+ generator?: NameGenerator | undefined;
232
+ delayMs?: number;
233
+ minTurns?: number;
234
+ timeoutMs?: number;
235
+ onRename?: (name: string, source: "model" | "local") => void;
236
+ };
237
+
238
+ export type NamerStats = {
239
+ /** Model requests issued. */
240
+ calls: number;
241
+ /** Runs that wrote a name (model or local). */
242
+ renames: number;
243
+ /** Runs skipped because the fingerprint was unchanged. */
244
+ unchanged: number;
245
+ /** Runs deferred by the every-N-turns limit. */
246
+ deferred: number;
247
+ /** In-flight results dropped because the name changed meanwhile. */
248
+ dropped: number;
249
+ };
250
+
251
+ /**
252
+ * Debounced, single-flight auto-namer. `noteTurn` is synchronous and never
253
+ * blocks the prompt; work happens on a timer.
254
+ */
255
+ export class AutoNamer {
256
+ public readonly stats: NamerStats = {
257
+ calls: 0,
258
+ renames: 0,
259
+ unchanged: 0,
260
+ deferred: 0,
261
+ dropped: 0,
262
+ };
263
+ private readonly delayMs: number;
264
+ private readonly minTurns: number;
265
+ private readonly timeoutMs: number;
266
+ private readonly recent: string[] = [];
267
+ private score: TrackScore | undefined;
268
+ private turnsSinceName = 0;
269
+ private timer: ReturnType<typeof setTimeout> | undefined;
270
+ private running: Promise<void> | undefined;
271
+ private rerun = false;
272
+ private disposed = false;
273
+ private readonly controller = new AbortController();
274
+
275
+ public constructor(private readonly options: AutoNamerOptions) {
276
+ this.delayMs = options.delayMs ?? 3_000;
277
+ this.minTurns = Math.max(1, options.minTurns ?? 3);
278
+ this.timeoutMs = options.timeoutMs ?? 20_000;
279
+ }
280
+
281
+ /** Call after a turn; `accepted` is false when it committed nothing. */
282
+ public noteTurn(turn: {
283
+ score: TrackScore;
284
+ prompt: string;
285
+ accepted: boolean;
286
+ }): void {
287
+ if (this.disposed || !turn.accepted) return;
288
+ this.recent.push(turn.prompt.slice(0, 256));
289
+ if (this.recent.length > 2) this.recent.shift();
290
+ this.score = turn.score;
291
+ this.turnsSinceName += 1;
292
+ if (this.options.target.meta().nameSource !== "auto") return;
293
+ // Coalesce bursts: each accepted turn restarts the quiet period.
294
+ if (this.timer) clearTimeout(this.timer);
295
+ this.timer = setTimeout(() => {
296
+ this.timer = undefined;
297
+ void this.kick();
298
+ }, this.delayMs);
299
+ this.timer.unref?.();
300
+ }
301
+
302
+ /** After `/rename --auto`: name soon from `score`, bypassing the turn limit. */
303
+ public request(score: TrackScore): void {
304
+ if (this.disposed) return;
305
+ this.score = score;
306
+ this.turnsSinceName = this.minTurns;
307
+ if (this.timer) clearTimeout(this.timer);
308
+ this.timer = setTimeout(() => {
309
+ this.timer = undefined;
310
+ void this.kick();
311
+ }, this.delayMs);
312
+ this.timer.unref?.();
313
+ }
314
+
315
+ /** Resolves when no run is pending or in flight (for tests and exit). */
316
+ public async idle(): Promise<void> {
317
+ if (this.timer) {
318
+ clearTimeout(this.timer);
319
+ this.timer = undefined;
320
+ await this.kick();
321
+ }
322
+ while (this.running) await this.running;
323
+ }
324
+
325
+ public dispose(): void {
326
+ this.disposed = true;
327
+ if (this.timer) clearTimeout(this.timer);
328
+ this.timer = undefined;
329
+ this.controller.abort();
330
+ }
331
+
332
+ private kick(): Promise<void> {
333
+ if (this.running) {
334
+ // At most one request in flight; fold this trigger into a rerun.
335
+ this.rerun = true;
336
+ return this.running;
337
+ }
338
+ this.running = this.run()
339
+ .catch(() => undefined)
340
+ .finally(() => {
341
+ this.running = undefined;
342
+ if (this.rerun && !this.disposed) {
343
+ this.rerun = false;
344
+ void this.kick();
345
+ }
346
+ });
347
+ return this.running;
348
+ }
349
+
350
+ private async run(): Promise<void> {
351
+ const score = this.score;
352
+ if (!score || this.disposed) return;
353
+ const meta = this.options.target.meta();
354
+ if (meta.nameSource !== "auto") return;
355
+ const fingerprint = musicalFingerprint(score);
356
+ if (fingerprint.hash === meta.namedFingerprint) {
357
+ this.stats.unchanged += 1;
358
+ return;
359
+ }
360
+ const structureChanged = fingerprint.structure !== meta.namedStructure;
361
+ if (
362
+ meta.namedFingerprint !== undefined &&
363
+ !structureChanged &&
364
+ this.turnsSinceName < this.minTurns
365
+ ) {
366
+ this.stats.deferred += 1;
367
+ return;
368
+ }
369
+ // Forks keep their ` N` suffix; the model sees only the base.
370
+ const split = meta.forkOf ? splitNameNumber(meta.name) : undefined;
371
+ const currentBase = split && split.n > 1 ? split.base : meta.name;
372
+ let candidate: string | undefined;
373
+ let source: "model" | "local" = "local";
374
+ if (this.options.generator) {
375
+ this.stats.calls += 1;
376
+ try {
377
+ const signal = AbortSignal.any([
378
+ this.controller.signal,
379
+ AbortSignal.timeout(this.timeoutMs),
380
+ ]);
381
+ const text = await this.options.generator.generate(
382
+ namingPrompt(fingerprint, this.recent, currentBase),
383
+ {
384
+ maxTokens: NAME_MAX_TOKENS,
385
+ temperature: NAME_TEMPERATURE,
386
+ signal,
387
+ },
388
+ );
389
+ candidate = sanitizeGeneratedName(text);
390
+ if (candidate) source = "model";
391
+ } catch {
392
+ // Offline, unauthorized, timed out: fall back to the local name.
393
+ }
394
+ }
395
+ if (this.disposed) return;
396
+ candidate ??= localName(fingerprint);
397
+ let name =
398
+ candidate === currentBase
399
+ ? meta.name
400
+ : split && split.n > 1
401
+ ? withNumber(candidate, split.n)
402
+ : candidate;
403
+ if (name !== meta.name) {
404
+ const others = await this.options.target.otherNames().catch(() => []);
405
+ name = uniqueName(name, others);
406
+ }
407
+ const patch: MetaPatch = {
408
+ namedFingerprint: fingerprint.hash,
409
+ namedStructure: fingerprint.structure,
410
+ };
411
+ if (name !== meta.name) patch.name = name;
412
+ const result = await this.options.target.updateMeta(patch, {
413
+ name: meta.name,
414
+ nameSource: "auto",
415
+ });
416
+ if (result.status === "stale") {
417
+ this.stats.dropped += 1;
418
+ return;
419
+ }
420
+ this.turnsSinceName = 0;
421
+ if (patch.name !== undefined) {
422
+ this.stats.renames += 1;
423
+ this.options.onRename?.(patch.name, source);
424
+ }
425
+ }
426
+ }
427
+
428
+ function shortHash(text: string): string {
429
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
430
+ }