@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.
- package/CHANGELOG.md +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,464 @@
|
|
|
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 type { ProviderSelection } from "../agent/provider.ts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_SESSION_NAME,
|
|
19
|
+
splitNameNumber,
|
|
20
|
+
uniqueName,
|
|
21
|
+
withNumber,
|
|
22
|
+
type MetaExpect,
|
|
23
|
+
type MetaPatch,
|
|
24
|
+
type SessionMeta,
|
|
25
|
+
} from "./meta.ts";
|
|
26
|
+
|
|
27
|
+
export const NAME_MAX_TOKENS = 12;
|
|
28
|
+
export const NAME_TEMPERATURE = 0.3;
|
|
29
|
+
const PROMPT_SNIPPET_CHARS = 60;
|
|
30
|
+
const NOTE_NAMES = [
|
|
31
|
+
"c",
|
|
32
|
+
"c#",
|
|
33
|
+
"d",
|
|
34
|
+
"eb",
|
|
35
|
+
"e",
|
|
36
|
+
"f",
|
|
37
|
+
"f#",
|
|
38
|
+
"g",
|
|
39
|
+
"ab",
|
|
40
|
+
"a",
|
|
41
|
+
"bb",
|
|
42
|
+
"b",
|
|
43
|
+
] as const;
|
|
44
|
+
const MAJOR = [0, 2, 4, 5, 7, 9, 11];
|
|
45
|
+
const MINOR = [0, 2, 3, 5, 7, 8, 10];
|
|
46
|
+
|
|
47
|
+
export type TrackRole = "drums" | "bass" | "keys" | "lead";
|
|
48
|
+
|
|
49
|
+
export type Fingerprint = {
|
|
50
|
+
/** Human-readable summary sent to the model. */
|
|
51
|
+
line: string;
|
|
52
|
+
/** Hash of `line`; equal scores always hash equally. */
|
|
53
|
+
hash: string;
|
|
54
|
+
/** Hash of the track-and-instrument set only. */
|
|
55
|
+
structure: string;
|
|
56
|
+
bpm: number;
|
|
57
|
+
key: string | null;
|
|
58
|
+
roles: TrackRole[];
|
|
59
|
+
busy: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pure and order-independent: note ids, note order and track names do not
|
|
64
|
+
* affect it, so the same music always yields the same hash.
|
|
65
|
+
*/
|
|
66
|
+
export function musicalFingerprint(score: TrackScore): Fingerprint {
|
|
67
|
+
const bpm = Math.round(score.tempoBpm);
|
|
68
|
+
const bars = Math.max(1, score.bars);
|
|
69
|
+
const histogram = new Array<number>(12).fill(0);
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
const roles: TrackRole[] = [];
|
|
72
|
+
const effects = new Set<string>();
|
|
73
|
+
let busy = false;
|
|
74
|
+
const tracks = [...score.tracks].sort((a, b) => a.id.localeCompare(b.id));
|
|
75
|
+
for (const track of tracks) {
|
|
76
|
+
const notes = score.notes.filter((note) => note.trackId === track.id);
|
|
77
|
+
const drums = isDrumInstrument(track.instrument);
|
|
78
|
+
if (!drums)
|
|
79
|
+
for (const note of notes)
|
|
80
|
+
histogram[note.pitch % 12]! += note.durationTicks / score.ticksPerBeat;
|
|
81
|
+
const perBar = notes.length / bars;
|
|
82
|
+
const density =
|
|
83
|
+
notes.length === 0
|
|
84
|
+
? "empty"
|
|
85
|
+
: perBar < 2
|
|
86
|
+
? "sparse"
|
|
87
|
+
: perBar < 6
|
|
88
|
+
? "steady"
|
|
89
|
+
: "busy";
|
|
90
|
+
if (density === "busy") busy = true;
|
|
91
|
+
const pitches = notes.map((note) => note.pitch).sort((a, b) => a - b);
|
|
92
|
+
const median = pitches.length ? pitches[pitches.length >> 1]! : 60;
|
|
93
|
+
const register = median < 48 ? "low" : median <= 72 ? "mid" : "high";
|
|
94
|
+
const role: TrackRole = drums
|
|
95
|
+
? "drums"
|
|
96
|
+
: register === "low"
|
|
97
|
+
? "bass"
|
|
98
|
+
: register === "high"
|
|
99
|
+
? "lead"
|
|
100
|
+
: "keys";
|
|
101
|
+
if (notes.length > 0) roles.push(role);
|
|
102
|
+
if (track.filter) effects.add("filter");
|
|
103
|
+
if (track.delay) effects.add("delay");
|
|
104
|
+
if (track.reverb) effects.add("reverb");
|
|
105
|
+
if (track.volumeAutomation.length || track.panAutomation.length)
|
|
106
|
+
effects.add("automation");
|
|
107
|
+
if (
|
|
108
|
+
track.filterAutomation?.length ||
|
|
109
|
+
track.resonanceAutomation?.length ||
|
|
110
|
+
track.delayFeedbackAutomation?.length ||
|
|
111
|
+
track.delayMixAutomation?.length
|
|
112
|
+
)
|
|
113
|
+
effects.add("sweep");
|
|
114
|
+
const instrument = track.instrument.toLowerCase().slice(0, 24);
|
|
115
|
+
parts.push(
|
|
116
|
+
drums
|
|
117
|
+
? `drums(${instrument},${density})`
|
|
118
|
+
: `${role}(${instrument},${register},${density})`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const key = score.key ? score.key.toLowerCase() : estimateKey(histogram);
|
|
122
|
+
const line = [
|
|
123
|
+
`${bpm} bpm`,
|
|
124
|
+
key ?? "no key",
|
|
125
|
+
parts.join(" ") || "no tracks",
|
|
126
|
+
effects.size ? `fx ${[...effects].sort().join(" ")}` : "",
|
|
127
|
+
]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(" · ");
|
|
130
|
+
const structure = tracks
|
|
131
|
+
.map((track) => `${track.id}=${track.instrument.toLowerCase()}`)
|
|
132
|
+
.join(",");
|
|
133
|
+
return {
|
|
134
|
+
line,
|
|
135
|
+
hash: shortHash(line),
|
|
136
|
+
structure: shortHash(structure),
|
|
137
|
+
bpm,
|
|
138
|
+
key,
|
|
139
|
+
roles: [...new Set(roles)],
|
|
140
|
+
busy,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Best-fitting major or minor scale for a pitch-class histogram, or null. */
|
|
145
|
+
export function estimateKey(histogram: readonly number[]): string | null {
|
|
146
|
+
const total = histogram.reduce((sum, value) => sum + value, 0);
|
|
147
|
+
if (total <= 0) return null;
|
|
148
|
+
let best: { key: string; score: number } | undefined;
|
|
149
|
+
for (let tonic = 0; tonic < 12; tonic += 1) {
|
|
150
|
+
for (const [mode, scale] of [
|
|
151
|
+
["major", MAJOR],
|
|
152
|
+
["minor", MINOR],
|
|
153
|
+
] as const) {
|
|
154
|
+
let fit = 0;
|
|
155
|
+
for (const step of scale) fit += histogram[(tonic + step) % 12]!;
|
|
156
|
+
// Tonic and fifth weigh extra so relative keys separate.
|
|
157
|
+
fit += histogram[tonic]! * 0.75 + histogram[(tonic + 7) % 12]! * 0.25;
|
|
158
|
+
if (!best || fit > best.score + 1e-9)
|
|
159
|
+
best = { key: `${NOTE_NAMES[tonic]} ${mode}`, score: fit };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best!.key;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Deterministic offline name, e.g. "a minor bass groove" or "128 bpm drums + keys". */
|
|
166
|
+
export function localName(fingerprint: Fingerprint): string {
|
|
167
|
+
const roles = fingerprint.roles;
|
|
168
|
+
if (roles.length === 0) return DEFAULT_SESSION_NAME;
|
|
169
|
+
const pitched = roles.filter((role) => role !== "drums");
|
|
170
|
+
if (fingerprint.key && pitched.length > 0) {
|
|
171
|
+
const lead = pitched.includes("bass") ? "bass" : pitched[0]!;
|
|
172
|
+
const vibe =
|
|
173
|
+
roles.includes("drums") || fingerprint.busy ? "groove" : "loop";
|
|
174
|
+
return `${fingerprint.key} ${lead} ${vibe}`;
|
|
175
|
+
}
|
|
176
|
+
return `${fingerprint.bpm} bpm ${roles.join(" + ")}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validates untrusted model output: lowercase, only `[a-z0-9 '-]`, 3 to 32
|
|
181
|
+
* characters, at most 4 words. Returns undefined when it does not fit.
|
|
182
|
+
*/
|
|
183
|
+
export function sanitizeGeneratedName(text: unknown): string | undefined {
|
|
184
|
+
if (typeof text !== "string") return undefined;
|
|
185
|
+
const firstLine = text.slice(0, 256).split(/\r?\n/)[0] ?? "";
|
|
186
|
+
const name = firstLine
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.replace(/^name\s*:\s*/, "")
|
|
189
|
+
.replace(/[^a-z0-9 '-]/g, " ")
|
|
190
|
+
.replace(/\s+/g, " ")
|
|
191
|
+
.replace(/^['\s-]+|['\s-]+$/g, "")
|
|
192
|
+
.trim();
|
|
193
|
+
if (name.length < 3 || name.length > 32) return undefined;
|
|
194
|
+
if (name.split(" ").length > 4) return undefined;
|
|
195
|
+
return name;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function namingPrompt(
|
|
199
|
+
fingerprint: Fingerprint,
|
|
200
|
+
recentPrompts: readonly string[],
|
|
201
|
+
currentName: string,
|
|
202
|
+
): string {
|
|
203
|
+
const recent = recentPrompts
|
|
204
|
+
.slice(-2)
|
|
205
|
+
.map((prompt) => {
|
|
206
|
+
const flat = prompt.replace(/\s+/g, " ").trim();
|
|
207
|
+
return `"${flat.length > PROMPT_SNIPPET_CHARS ? flat.slice(0, PROMPT_SNIPPET_CHARS) : flat}"`;
|
|
208
|
+
})
|
|
209
|
+
.join(" | ");
|
|
210
|
+
return [
|
|
211
|
+
"Name this music session: 2-4 lowercase words evoking the vibe; reply with the current name exactly if it still fits; no quotes.",
|
|
212
|
+
`music: ${fingerprint.line}`,
|
|
213
|
+
`recent: ${recent || "none"}`,
|
|
214
|
+
`current name: ${currentName === DEFAULT_SESSION_NAME ? "none" : currentName}`,
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The model seam. Implementations must not throw synchronously. */
|
|
219
|
+
export interface NameGenerator {
|
|
220
|
+
generate(
|
|
221
|
+
prompt: string,
|
|
222
|
+
options: { maxTokens: number; temperature: number; signal: AbortSignal },
|
|
223
|
+
): Promise<string>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Default generator: `generateText` on the configured provider (gateway
|
|
228
|
+
* haiku-4.5 or the selected xcb account). It throws when offline, which the
|
|
229
|
+
* namer turns into the local fingerprint name. `generateText` has no
|
|
230
|
+
* temperature knob, so `temperature` is advisory here.
|
|
231
|
+
*/
|
|
232
|
+
export function providerNameGenerator(
|
|
233
|
+
selection: () => Promise<ProviderSelection>,
|
|
234
|
+
timeoutMs = 15_000,
|
|
235
|
+
): NameGenerator {
|
|
236
|
+
return {
|
|
237
|
+
async generate(prompt, options) {
|
|
238
|
+
const selected = await selection();
|
|
239
|
+
if (selected.kind === "offline") throw new Error(selected.reason);
|
|
240
|
+
const { generateText } = await import("../agent/provider.ts");
|
|
241
|
+
return generateText(prompt, {
|
|
242
|
+
maxTokens: options.maxTokens,
|
|
243
|
+
signal: options.signal,
|
|
244
|
+
timeoutMs,
|
|
245
|
+
selection: selected,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Where the namer reads and conditionally writes session metadata. */
|
|
252
|
+
export interface NamingTarget {
|
|
253
|
+
meta(): SessionMeta;
|
|
254
|
+
updateMeta(
|
|
255
|
+
patch: MetaPatch,
|
|
256
|
+
expect: MetaExpect,
|
|
257
|
+
): Promise<{ status: "applied" | "stale"; meta: SessionMeta }>;
|
|
258
|
+
/** Names of other sessions in the workspace, for collision suffixes. */
|
|
259
|
+
otherNames(): Promise<string[]>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type AutoNamerOptions = {
|
|
263
|
+
target: NamingTarget;
|
|
264
|
+
/** Omit for offline: names come from the local fingerprint only. */
|
|
265
|
+
generator?: NameGenerator | undefined;
|
|
266
|
+
delayMs?: number;
|
|
267
|
+
minTurns?: number;
|
|
268
|
+
timeoutMs?: number;
|
|
269
|
+
onRename?: (name: string, source: "model" | "local") => void;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export type NamerStats = {
|
|
273
|
+
/** Model requests issued. */
|
|
274
|
+
calls: number;
|
|
275
|
+
/** Runs that wrote a name (model or local). */
|
|
276
|
+
renames: number;
|
|
277
|
+
/** Runs skipped because the fingerprint was unchanged. */
|
|
278
|
+
unchanged: number;
|
|
279
|
+
/** Runs deferred by the every-N-turns limit. */
|
|
280
|
+
deferred: number;
|
|
281
|
+
/** In-flight results dropped because the name changed meanwhile. */
|
|
282
|
+
dropped: number;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Debounced, single-flight auto-namer. `noteTurn` is synchronous and never
|
|
287
|
+
* blocks the prompt; work happens on a timer.
|
|
288
|
+
*/
|
|
289
|
+
export class AutoNamer {
|
|
290
|
+
public readonly stats: NamerStats = {
|
|
291
|
+
calls: 0,
|
|
292
|
+
renames: 0,
|
|
293
|
+
unchanged: 0,
|
|
294
|
+
deferred: 0,
|
|
295
|
+
dropped: 0,
|
|
296
|
+
};
|
|
297
|
+
private readonly delayMs: number;
|
|
298
|
+
private readonly minTurns: number;
|
|
299
|
+
private readonly timeoutMs: number;
|
|
300
|
+
private readonly recent: string[] = [];
|
|
301
|
+
private score: TrackScore | undefined;
|
|
302
|
+
private turnsSinceName = 0;
|
|
303
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
304
|
+
private running: Promise<void> | undefined;
|
|
305
|
+
private rerun = false;
|
|
306
|
+
private disposed = false;
|
|
307
|
+
private readonly controller = new AbortController();
|
|
308
|
+
|
|
309
|
+
public constructor(private readonly options: AutoNamerOptions) {
|
|
310
|
+
this.delayMs = options.delayMs ?? 3_000;
|
|
311
|
+
this.minTurns = Math.max(1, options.minTurns ?? 3);
|
|
312
|
+
this.timeoutMs = options.timeoutMs ?? 20_000;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Call after a turn; `accepted` is false when it committed nothing. */
|
|
316
|
+
public noteTurn(turn: {
|
|
317
|
+
score: TrackScore;
|
|
318
|
+
prompt: string;
|
|
319
|
+
accepted: boolean;
|
|
320
|
+
}): void {
|
|
321
|
+
if (this.disposed || !turn.accepted) return;
|
|
322
|
+
this.recent.push(turn.prompt.slice(0, 256));
|
|
323
|
+
if (this.recent.length > 2) this.recent.shift();
|
|
324
|
+
this.score = turn.score;
|
|
325
|
+
this.turnsSinceName += 1;
|
|
326
|
+
if (this.options.target.meta().nameSource !== "auto") return;
|
|
327
|
+
// Coalesce bursts: each accepted turn restarts the quiet period.
|
|
328
|
+
if (this.timer) clearTimeout(this.timer);
|
|
329
|
+
this.timer = setTimeout(() => {
|
|
330
|
+
this.timer = undefined;
|
|
331
|
+
void this.kick();
|
|
332
|
+
}, this.delayMs);
|
|
333
|
+
this.timer.unref?.();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** After `/rename --auto`: name soon from `score`, bypassing the turn limit. */
|
|
337
|
+
public request(score: TrackScore): void {
|
|
338
|
+
if (this.disposed) return;
|
|
339
|
+
this.score = score;
|
|
340
|
+
this.turnsSinceName = this.minTurns;
|
|
341
|
+
if (this.timer) clearTimeout(this.timer);
|
|
342
|
+
this.timer = setTimeout(() => {
|
|
343
|
+
this.timer = undefined;
|
|
344
|
+
void this.kick();
|
|
345
|
+
}, this.delayMs);
|
|
346
|
+
this.timer.unref?.();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Resolves when no run is pending or in flight (for tests and exit). */
|
|
350
|
+
public async idle(): Promise<void> {
|
|
351
|
+
if (this.timer) {
|
|
352
|
+
clearTimeout(this.timer);
|
|
353
|
+
this.timer = undefined;
|
|
354
|
+
await this.kick();
|
|
355
|
+
}
|
|
356
|
+
while (this.running) await this.running;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
public dispose(): void {
|
|
360
|
+
this.disposed = true;
|
|
361
|
+
if (this.timer) clearTimeout(this.timer);
|
|
362
|
+
this.timer = undefined;
|
|
363
|
+
this.controller.abort();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private kick(): Promise<void> {
|
|
367
|
+
if (this.running) {
|
|
368
|
+
// At most one request in flight; fold this trigger into a rerun.
|
|
369
|
+
this.rerun = true;
|
|
370
|
+
return this.running;
|
|
371
|
+
}
|
|
372
|
+
this.running = this.run()
|
|
373
|
+
.catch(() => undefined)
|
|
374
|
+
.finally(() => {
|
|
375
|
+
this.running = undefined;
|
|
376
|
+
if (this.rerun && !this.disposed) {
|
|
377
|
+
this.rerun = false;
|
|
378
|
+
void this.kick();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
return this.running;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private async run(): Promise<void> {
|
|
385
|
+
const score = this.score;
|
|
386
|
+
if (!score || this.disposed) return;
|
|
387
|
+
const meta = this.options.target.meta();
|
|
388
|
+
if (meta.nameSource !== "auto") return;
|
|
389
|
+
const fingerprint = musicalFingerprint(score);
|
|
390
|
+
if (fingerprint.hash === meta.namedFingerprint) {
|
|
391
|
+
this.stats.unchanged += 1;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const structureChanged = fingerprint.structure !== meta.namedStructure;
|
|
395
|
+
if (
|
|
396
|
+
meta.namedFingerprint !== undefined &&
|
|
397
|
+
!structureChanged &&
|
|
398
|
+
this.turnsSinceName < this.minTurns
|
|
399
|
+
) {
|
|
400
|
+
this.stats.deferred += 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Forks keep their ` N` suffix; the model sees only the base.
|
|
404
|
+
const split = meta.forkOf ? splitNameNumber(meta.name) : undefined;
|
|
405
|
+
const currentBase = split && split.n > 1 ? split.base : meta.name;
|
|
406
|
+
let candidate: string | undefined;
|
|
407
|
+
let source: "model" | "local" = "local";
|
|
408
|
+
if (this.options.generator) {
|
|
409
|
+
this.stats.calls += 1;
|
|
410
|
+
try {
|
|
411
|
+
const signal = AbortSignal.any([
|
|
412
|
+
this.controller.signal,
|
|
413
|
+
AbortSignal.timeout(this.timeoutMs),
|
|
414
|
+
]);
|
|
415
|
+
const text = await this.options.generator.generate(
|
|
416
|
+
namingPrompt(fingerprint, this.recent, currentBase),
|
|
417
|
+
{
|
|
418
|
+
maxTokens: NAME_MAX_TOKENS,
|
|
419
|
+
temperature: NAME_TEMPERATURE,
|
|
420
|
+
signal,
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
candidate = sanitizeGeneratedName(text);
|
|
424
|
+
if (candidate) source = "model";
|
|
425
|
+
} catch {
|
|
426
|
+
// Offline, unauthorized, timed out: fall back to the local name.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (this.disposed) return;
|
|
430
|
+
candidate ??= localName(fingerprint);
|
|
431
|
+
let name =
|
|
432
|
+
candidate === currentBase
|
|
433
|
+
? meta.name
|
|
434
|
+
: split && split.n > 1
|
|
435
|
+
? withNumber(candidate, split.n)
|
|
436
|
+
: candidate;
|
|
437
|
+
if (name !== meta.name) {
|
|
438
|
+
const others = await this.options.target.otherNames().catch(() => []);
|
|
439
|
+
name = uniqueName(name, others);
|
|
440
|
+
}
|
|
441
|
+
const patch: MetaPatch = {
|
|
442
|
+
namedFingerprint: fingerprint.hash,
|
|
443
|
+
namedStructure: fingerprint.structure,
|
|
444
|
+
};
|
|
445
|
+
if (name !== meta.name) patch.name = name;
|
|
446
|
+
const result = await this.options.target.updateMeta(patch, {
|
|
447
|
+
name: meta.name,
|
|
448
|
+
nameSource: "auto",
|
|
449
|
+
});
|
|
450
|
+
if (result.status === "stale") {
|
|
451
|
+
this.stats.dropped += 1;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
this.turnsSinceName = 0;
|
|
455
|
+
if (patch.name !== undefined) {
|
|
456
|
+
this.stats.renames += 1;
|
|
457
|
+
this.options.onRename?.(patch.name, source);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function shortHash(text: string): string {
|
|
463
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
464
|
+
}
|