@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
@@ -0,0 +1,180 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ readCurrentSessionId,
5
+ sessionPaths,
6
+ loadSession,
7
+ stateDir,
8
+ } from "./store.ts";
9
+ import { daemonLockPath } from "./protocol.ts";
10
+
11
+ export type SessionSummary = {
12
+ sessionId: string;
13
+ revision: number;
14
+ updatedAt: string;
15
+ current: boolean;
16
+ daemonPid: number | null;
17
+ tracks: number | null;
18
+ name: string;
19
+ nameSource: "auto" | "user";
20
+ forkOf?: { sessionId: string; revision: number };
21
+ error?: string;
22
+ };
23
+
24
+ /** Lists sessions under `<workspace>/.dawg/sessions`, newest first. */
25
+ export async function listSessions(
26
+ workspace: string,
27
+ ): Promise<SessionSummary[]> {
28
+ let names: string[];
29
+ try {
30
+ names = await readdir(join(stateDir(workspace), "sessions"));
31
+ } catch {
32
+ return [];
33
+ }
34
+ const current = await readCurrentSessionId(workspace).catch(() => undefined);
35
+ const summaries: SessionSummary[] = [];
36
+ for (const name of names
37
+ .filter((entry) => entry.endsWith(".json"))
38
+ .slice(0, 512)) {
39
+ const sessionId = name.slice(0, -".json".length);
40
+ let paths;
41
+ try {
42
+ paths = sessionPaths(workspace, sessionId);
43
+ } catch {
44
+ continue;
45
+ }
46
+ const daemonPid = await liveDaemonPid(daemonLockPath(paths));
47
+ try {
48
+ const record = await loadSession<{ tracks?: unknown }>(paths);
49
+ summaries.push({
50
+ sessionId,
51
+ revision: record.revision,
52
+ updatedAt: record.updatedAt,
53
+ current: sessionId === current,
54
+ daemonPid,
55
+ tracks: Array.isArray(record.composition?.tracks)
56
+ ? record.composition.tracks.length
57
+ : null,
58
+ name: record.meta.name,
59
+ nameSource: record.meta.nameSource,
60
+ ...(record.meta.forkOf ? { forkOf: record.meta.forkOf } : {}),
61
+ });
62
+ } catch (error) {
63
+ summaries.push({
64
+ sessionId,
65
+ revision: -1,
66
+ updatedAt: "",
67
+ current: sessionId === current,
68
+ daemonPid,
69
+ tracks: null,
70
+ name: "",
71
+ nameSource: "auto",
72
+ error: error instanceof Error ? error.message : String(error),
73
+ });
74
+ }
75
+ }
76
+ return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
77
+ }
78
+
79
+ export async function printSessions(
80
+ workspace: string,
81
+ out: { write(text: string): unknown },
82
+ ): Promise<void> {
83
+ const sessions = await listSessions(workspace);
84
+ if (sessions.length === 0) {
85
+ out.write("no sessions in .dawg (run `dawg` to create one)\n");
86
+ return;
87
+ }
88
+ for (const session of sessions) {
89
+ const marker = session.current ? "*" : " ";
90
+ const detail = session.error
91
+ ? `unreadable · ${session.error}`
92
+ : `rev ${session.revision} · ${session.tracks ?? "?"} tracks · updated ${session.updatedAt}`;
93
+ const daemon = session.daemonPid ? ` · dawgd pid ${session.daemonPid}` : "";
94
+ out.write(`${marker} ${formatSessionLine(session, sessions)}${daemon}\n`);
95
+ }
96
+ }
97
+
98
+ /** One `/sessions` row: name, short id, tracks, revision, age, fork parent. */
99
+ export function formatSessionLine(
100
+ session: SessionSummary,
101
+ all: readonly SessionSummary[] = [],
102
+ now = Date.now(),
103
+ ): string {
104
+ const id = session.sessionId.slice(0, 8);
105
+ if (session.error) return `${id} unreadable · ${session.error}`;
106
+ const parent = session.forkOf
107
+ ? all.find((entry) => entry.sessionId === session.forkOf!.sessionId)
108
+ : undefined;
109
+ const fork = session.forkOf
110
+ ? ` · fork of ${parent?.name || session.forkOf.sessionId.slice(0, 8)}@${session.forkOf.revision}`
111
+ : "";
112
+ return `${session.name.padEnd(24)} ${id} ${session.tracks ?? "?"} tracks · rev ${session.revision} · ${relativeTime(session.updatedAt, now)}${fork}`;
113
+ }
114
+
115
+ export function relativeTime(iso: string, now = Date.now()): string {
116
+ const at = Date.parse(iso);
117
+ if (!Number.isFinite(at)) return "unknown";
118
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
119
+ if (seconds < 60) return "just now";
120
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
121
+ if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`;
122
+ return `${Math.floor(seconds / 86_400)}d ago`;
123
+ }
124
+
125
+ export type SessionResolution =
126
+ | { status: "found"; session: SessionSummary }
127
+ | { status: "missing" }
128
+ | { status: "ambiguous"; candidates: SessionSummary[] };
129
+
130
+ /**
131
+ * Resolves `--session <name|id>`: an exact id, then an exact name
132
+ * (case-insensitive), then a unique id prefix of at least 4 characters.
133
+ */
134
+ export function resolveSession(
135
+ sessions: readonly SessionSummary[],
136
+ query: string,
137
+ ): SessionResolution {
138
+ const readable = sessions.filter((session) => !session.error);
139
+ const byId = readable.find((session) => session.sessionId === query);
140
+ if (byId) return { status: "found", session: byId };
141
+ const lower = query.trim().toLowerCase();
142
+ const byName = readable.filter(
143
+ (session) => session.name.toLowerCase() === lower,
144
+ );
145
+ if (byName.length === 1) return { status: "found", session: byName[0]! };
146
+ if (byName.length > 1) return { status: "ambiguous", candidates: byName };
147
+ if (query.length >= 4) {
148
+ const byPrefix = readable.filter((session) =>
149
+ session.sessionId.startsWith(query),
150
+ );
151
+ if (byPrefix.length === 1)
152
+ return { status: "found", session: byPrefix[0]! };
153
+ if (byPrefix.length > 1)
154
+ return { status: "ambiguous", candidates: byPrefix };
155
+ }
156
+ return { status: "missing" };
157
+ }
158
+
159
+ export function ambiguousSessionMessage(
160
+ query: string,
161
+ candidates: readonly SessionSummary[],
162
+ ): string {
163
+ return [
164
+ `session "${query}" is ambiguous; use an id:`,
165
+ ...candidates.map((session) => ` ${formatSessionLine(session)}`),
166
+ ].join("\n");
167
+ }
168
+
169
+ async function liveDaemonPid(lockPath: string): Promise<number | null> {
170
+ try {
171
+ const text = await readFile(join(lockPath, "owner"), "utf8");
172
+ if (text.length > 256) return null;
173
+ const pid = (JSON.parse(text) as { pid?: unknown }).pid;
174
+ if (!Number.isSafeInteger(pid) || (pid as number) <= 0) return null;
175
+ process.kill(pid as number, 0);
176
+ return pid as number;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ type LockOwner = { pid: number; token: string };
6
+
7
+ /** Never expire an active writer's lock merely because a write takes time. */
8
+ export async function acquireSessionLock(
9
+ path: string,
10
+ timeoutMs = 3_000,
11
+ ): Promise<() => Promise<void>> {
12
+ const owner: LockOwner = { pid: process.pid, token: randomUUID() };
13
+ const started = Date.now();
14
+ while (true) {
15
+ try {
16
+ await mkdir(path);
17
+ } catch (error) {
18
+ if (!hasCode(error, "EEXIST")) throw error;
19
+ await reclaimDeadOwner(path);
20
+ if (Date.now() - started >= timeoutMs)
21
+ throw new Error("timed out waiting for the dawg session lock");
22
+ await new Promise((resolve) => setTimeout(resolve, 8));
23
+ continue;
24
+ }
25
+ try {
26
+ await writeFile(join(path, "owner"), JSON.stringify(owner), {
27
+ encoding: "utf8",
28
+ flag: "wx",
29
+ });
30
+ } catch (error) {
31
+ await rm(path, { recursive: true, force: true });
32
+ throw error;
33
+ }
34
+ return async () => {
35
+ const current = await readOwner(path);
36
+ if (current?.pid === owner.pid && current.token === owner.token)
37
+ await rm(path, { recursive: true, force: true });
38
+ };
39
+ }
40
+ }
41
+
42
+ async function reclaimDeadOwner(path: string): Promise<void> {
43
+ // Serialize reclaimers separately. Otherwise two readers of a dead PID can
44
+ // remove a new writer's lock after the first reclaimer removes the old one.
45
+ const reclaimPath = `${path}.reclaim`;
46
+ try {
47
+ await mkdir(reclaimPath);
48
+ } catch (error) {
49
+ if (hasCode(error, "EEXIST")) return;
50
+ throw error;
51
+ }
52
+ try {
53
+ const owner = await readOwner(path);
54
+ if (!owner) return; // A writer may still be publishing its owner record.
55
+ try {
56
+ process.kill(owner.pid, 0);
57
+ } catch (error) {
58
+ if (hasCode(error, "ESRCH"))
59
+ await rm(path, { recursive: true, force: true });
60
+ }
61
+ } finally {
62
+ await rm(reclaimPath, { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ async function readOwner(path: string): Promise<LockOwner | undefined> {
67
+ try {
68
+ const text = await readFile(join(path, "owner"), "utf8");
69
+ if (text.length > 256) return undefined;
70
+ const value: unknown = JSON.parse(text);
71
+ // Read locks left by earlier versions (Track 0.1), which stored only a PID.
72
+ if (Number.isSafeInteger(value) && (value as number) > 0)
73
+ return { pid: value as number, token: "legacy" };
74
+ if (
75
+ typeof value === "object" &&
76
+ value !== null &&
77
+ "pid" in value &&
78
+ Number.isSafeInteger(value.pid) &&
79
+ (value.pid as number) > 0 &&
80
+ "token" in value &&
81
+ typeof value.token === "string"
82
+ )
83
+ return { pid: value.pid as number, token: value.token };
84
+ } catch {
85
+ // Missing, unreadable, or incomplete owner records cannot prove death.
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function hasCode(error: unknown, code: string): boolean {
91
+ return error instanceof Error && "code" in error && error.code === code;
92
+ }
@@ -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
+ }