@mcptoolshop/claude-sfx 0.1.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/dist/synth.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Pure PCM synthesis engine. Zero dependencies.
3
+ * Generates audio from math — oscillators, envelopes, FM, and WAV encoding.
4
+ */
5
+ const SAMPLE_RATE = 44100;
6
+ const BIT_DEPTH = 16;
7
+ const NUM_CHANNELS = 1; // mono
8
+ /** Returns a sample value in [-1, 1] for a given phase (0–1 cycle). */
9
+ function oscillator(waveform, phase) {
10
+ const p = phase % 1;
11
+ switch (waveform) {
12
+ case "sine":
13
+ return Math.sin(2 * Math.PI * p);
14
+ case "square":
15
+ return p < 0.5 ? 1 : -1;
16
+ case "sawtooth":
17
+ return 2 * p - 1;
18
+ case "triangle":
19
+ return p < 0.5 ? 4 * p - 1 : 3 - 4 * p;
20
+ case "noise":
21
+ return Math.random() * 2 - 1;
22
+ }
23
+ }
24
+ /** Returns envelope amplitude (0–1) at a given time within a note of total duration. */
25
+ function envelopeAt(env, time, duration) {
26
+ const { attack, decay, sustain, release } = env;
27
+ const releaseStart = duration - release;
28
+ if (time < 0)
29
+ return 0;
30
+ if (time < attack) {
31
+ // Attack: ramp from 0 to 1
32
+ return time / attack;
33
+ }
34
+ if (time < attack + decay) {
35
+ // Decay: ramp from 1 to sustain level
36
+ const decayProgress = (time - attack) / decay;
37
+ return 1 - decayProgress * (1 - sustain);
38
+ }
39
+ if (time < releaseStart) {
40
+ // Sustain
41
+ return sustain;
42
+ }
43
+ if (time < duration) {
44
+ // Release: ramp from sustain to 0
45
+ const releaseProgress = (time - releaseStart) / release;
46
+ return sustain * (1 - releaseProgress);
47
+ }
48
+ return 0;
49
+ }
50
+ // --- PCM Generation ---
51
+ /** Generate a Float64 audio buffer from tone parameters. */
52
+ export function generateTone(params) {
53
+ const numSamples = Math.floor(params.duration * SAMPLE_RATE);
54
+ const buffer = new Float64Array(numSamples);
55
+ for (let i = 0; i < numSamples; i++) {
56
+ const t = i / SAMPLE_RATE; // time in seconds
57
+ // Frequency sweep (linear interpolation)
58
+ const freqEnd = params.frequencyEnd ?? params.frequency;
59
+ const progress = i / numSamples;
60
+ const freq = params.frequency + (freqEnd - params.frequency) * progress;
61
+ // FM synthesis: modulate the carrier phase
62
+ let fmOffset = 0;
63
+ if (params.fmRatio && params.fmDepth) {
64
+ const modFreq = freq * params.fmRatio;
65
+ fmOffset =
66
+ (params.fmDepth / (2 * Math.PI * freq)) *
67
+ Math.sin(2 * Math.PI * modFreq * t);
68
+ }
69
+ // Carrier phase
70
+ const phase = freq * t + fmOffset;
71
+ let sample = oscillator(params.waveform, phase);
72
+ // Octave harmonic
73
+ if (params.harmonicGain && params.harmonicGain > 0) {
74
+ sample += params.harmonicGain * oscillator(params.waveform, phase * 2);
75
+ // Normalize to prevent clipping
76
+ sample /= 1 + params.harmonicGain;
77
+ }
78
+ // Detune (second oscillator for beating effect)
79
+ if (params.detune && params.detune !== 0) {
80
+ const detunePhase = (freq + params.detune) * t + fmOffset;
81
+ const detuneSample = oscillator(params.waveform, detunePhase);
82
+ sample = (sample + detuneSample) / 2;
83
+ }
84
+ // Envelope
85
+ sample *= envelopeAt(params.envelope, t, params.duration);
86
+ // Tremolo (amplitude modulation)
87
+ if (params.tremoloRate && params.tremoloDepth) {
88
+ const tremoloMod = 1 - params.tremoloDepth * 0.5 * (1 + Math.sin(2 * Math.PI * params.tremoloRate * t));
89
+ sample *= tremoloMod;
90
+ }
91
+ // Master gain
92
+ sample *= params.gain;
93
+ buffer[i] = sample;
94
+ }
95
+ return buffer;
96
+ }
97
+ // --- Mixing ---
98
+ /** Mix multiple tone buffers into one (summed and normalized). */
99
+ export function mixBuffers(buffers) {
100
+ const maxLen = Math.max(...buffers.map((b) => b.length));
101
+ const mixed = new Float64Array(maxLen);
102
+ for (const buf of buffers) {
103
+ for (let i = 0; i < buf.length; i++) {
104
+ mixed[i] += buf[i];
105
+ }
106
+ }
107
+ // Find peak for normalization
108
+ let peak = 0;
109
+ for (let i = 0; i < mixed.length; i++) {
110
+ const abs = Math.abs(mixed[i]);
111
+ if (abs > peak)
112
+ peak = abs;
113
+ }
114
+ // Normalize if clipping
115
+ if (peak > 1) {
116
+ for (let i = 0; i < mixed.length; i++) {
117
+ mixed[i] /= peak;
118
+ }
119
+ }
120
+ return mixed;
121
+ }
122
+ /** Concatenate buffers sequentially with an optional gap (in seconds). */
123
+ export function concatBuffers(buffers, gapSeconds = 0) {
124
+ const gapSamples = Math.floor(gapSeconds * SAMPLE_RATE);
125
+ const totalLength = buffers.reduce((sum, b) => sum + b.length + gapSamples, -gapSamples // no gap after last buffer
126
+ );
127
+ const result = new Float64Array(Math.max(0, totalLength));
128
+ let offset = 0;
129
+ for (let bi = 0; bi < buffers.length; bi++) {
130
+ const buf = buffers[bi];
131
+ result.set(buf, offset);
132
+ offset += buf.length;
133
+ if (bi < buffers.length - 1) {
134
+ offset += gapSamples;
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+ // --- WAV Encoding ---
140
+ /** Encode a Float64 audio buffer as a WAV file (PCM 16-bit mono). */
141
+ export function encodeWav(buffer) {
142
+ const numSamples = buffer.length;
143
+ const byteRate = SAMPLE_RATE * NUM_CHANNELS * (BIT_DEPTH / 8);
144
+ const blockAlign = NUM_CHANNELS * (BIT_DEPTH / 8);
145
+ const dataSize = numSamples * NUM_CHANNELS * (BIT_DEPTH / 8);
146
+ const fileSize = 44 + dataSize; // 44-byte header + data
147
+ const wav = Buffer.alloc(fileSize);
148
+ let offset = 0;
149
+ // RIFF header
150
+ wav.write("RIFF", offset);
151
+ offset += 4;
152
+ wav.writeUInt32LE(fileSize - 8, offset);
153
+ offset += 4;
154
+ wav.write("WAVE", offset);
155
+ offset += 4;
156
+ // fmt sub-chunk
157
+ wav.write("fmt ", offset);
158
+ offset += 4;
159
+ wav.writeUInt32LE(16, offset); // sub-chunk size (PCM = 16)
160
+ offset += 4;
161
+ wav.writeUInt16LE(1, offset); // audio format (PCM = 1)
162
+ offset += 2;
163
+ wav.writeUInt16LE(NUM_CHANNELS, offset);
164
+ offset += 2;
165
+ wav.writeUInt32LE(SAMPLE_RATE, offset);
166
+ offset += 4;
167
+ wav.writeUInt32LE(byteRate, offset);
168
+ offset += 4;
169
+ wav.writeUInt16LE(blockAlign, offset);
170
+ offset += 2;
171
+ wav.writeUInt16LE(BIT_DEPTH, offset);
172
+ offset += 2;
173
+ // data sub-chunk
174
+ wav.write("data", offset);
175
+ offset += 4;
176
+ wav.writeUInt32LE(dataSize, offset);
177
+ offset += 4;
178
+ // PCM samples (float → 16-bit signed int)
179
+ for (let i = 0; i < numSamples; i++) {
180
+ let sample = buffer[i];
181
+ // Hard clip
182
+ sample = Math.max(-1, Math.min(1, sample));
183
+ // Convert to 16-bit signed integer
184
+ const int16 = Math.round(sample * 32767);
185
+ wav.writeInt16LE(int16, offset);
186
+ offset += 2;
187
+ }
188
+ return wav;
189
+ }
190
+ // --- Loudness Limiter ---
191
+ /** Apply a hard loudness cap with gentle compression to a buffer. */
192
+ export function limitLoudness(buffer, ceiling = 0.85) {
193
+ const result = new Float64Array(buffer.length);
194
+ for (let i = 0; i < buffer.length; i++) {
195
+ let s = buffer[i];
196
+ // Soft knee compression above 70% of ceiling
197
+ const knee = ceiling * 0.7;
198
+ const abs = Math.abs(s);
199
+ if (abs > knee) {
200
+ const over = abs - knee;
201
+ const range = ceiling - knee;
202
+ // Compress the overshoot with a sqrt curve
203
+ const compressed = knee + range * Math.sqrt(over / range);
204
+ s = s > 0 ? Math.min(compressed, ceiling) : Math.max(-compressed, -ceiling);
205
+ }
206
+ result[i] = s;
207
+ }
208
+ return result;
209
+ }
210
+ // --- Volume scaling ---
211
+ /** Apply a volume gain (0.0–1.0) to a buffer. */
212
+ export function applyVolume(buffer, gain) {
213
+ if (gain >= 1)
214
+ return buffer;
215
+ const result = new Float64Array(buffer.length);
216
+ for (let i = 0; i < buffer.length; i++) {
217
+ result[i] = buffer[i] * gain;
218
+ }
219
+ return result;
220
+ }
221
+ /**
222
+ * Generate a whoosh: bandpass-filtered noise with a sweeping center frequency.
223
+ * Uses a state-variable filter (SVF) for stable real-time coefficient changes.
224
+ */
225
+ export function generateWhoosh(params) {
226
+ const numSamples = Math.floor(params.duration * SAMPLE_RATE);
227
+ const buffer = new Float64Array(numSamples);
228
+ // SVF state
229
+ let low = 0;
230
+ let band = 0;
231
+ for (let i = 0; i < numSamples; i++) {
232
+ const t = i / SAMPLE_RATE;
233
+ const progress = i / numSamples;
234
+ // Sweep the center frequency
235
+ const centerFreq = params.freqStart + (params.freqEnd - params.freqStart) * progress;
236
+ // SVF coefficients
237
+ const f = 2 * Math.sin((Math.PI * centerFreq) / SAMPLE_RATE);
238
+ const q = params.bandwidth;
239
+ // White noise input
240
+ const input = Math.random() * 2 - 1;
241
+ // State-variable filter iteration (bandpass output)
242
+ low += f * band;
243
+ const high = input - low - q * band;
244
+ band += f * high;
245
+ let sample = band; // bandpass output
246
+ // Envelope
247
+ sample *= envelopeAt(params.envelope, t, params.duration);
248
+ // Gain
249
+ sample *= params.gain;
250
+ buffer[i] = sample;
251
+ }
252
+ return buffer;
253
+ }
254
+ export { SAMPLE_RATE, BIT_DEPTH, NUM_CHANNELS };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Verb generation engine — profile-driven.
3
+ * Reads synthesis parameters from a Profile, applies modifiers, generates audio.
4
+ */
5
+ import type { Profile } from "./profiles.js";
6
+ export type Status = "ok" | "err" | "warn";
7
+ export type Scope = "local" | "remote";
8
+ export type Direction = "up" | "down";
9
+ export interface PlayOptions {
10
+ status?: Status;
11
+ scope?: Scope;
12
+ direction?: Direction;
13
+ }
14
+ export type Verb = "intake" | "transform" | "commit" | "navigate" | "execute" | "move" | "sync";
15
+ export declare const VERB_LABELS: Record<Verb, string>;
16
+ export declare const VERB_DESCRIPTIONS: Record<Verb, string>;
17
+ export declare const ALL_VERBS: Verb[];
18
+ /** Generate a verb sound using the given profile. */
19
+ export declare function generateVerb(profile: Profile, verb: Verb, options?: PlayOptions): Float64Array;
20
+ export declare function generateSessionStart(profile: Profile): Float64Array;
21
+ export declare function generateSessionEnd(profile: Profile): Float64Array;
22
+ /** Generate a single chunk of the ambient thinking drone. */
23
+ export declare function generateAmbientChunk(profile: Profile): Float64Array;
24
+ /** Generate the resolution stinger (two ascending notes). */
25
+ export declare function generateAmbientResolve(profile: Profile): Float64Array;
package/dist/verbs.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Verb generation engine — profile-driven.
3
+ * Reads synthesis parameters from a Profile, applies modifiers, generates audio.
4
+ */
5
+ import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, } from "./synth.js";
6
+ export const VERB_LABELS = {
7
+ intake: "Intake",
8
+ transform: "Transform",
9
+ commit: "Commit",
10
+ navigate: "Navigate",
11
+ execute: "Execute",
12
+ move: "Move",
13
+ sync: "Sync",
14
+ };
15
+ export const VERB_DESCRIPTIONS = {
16
+ intake: "read / open / fetch",
17
+ transform: "edit / format / refactor",
18
+ commit: "write / save / apply",
19
+ navigate: "search / jump / list",
20
+ execute: "run / test / build",
21
+ move: "move / rename / relocate",
22
+ sync: "git push / pull / deploy",
23
+ };
24
+ export const ALL_VERBS = [
25
+ "intake",
26
+ "transform",
27
+ "commit",
28
+ "navigate",
29
+ "execute",
30
+ "move",
31
+ "sync",
32
+ ];
33
+ // --- Status modifier functions (tone-based) ---
34
+ function applyToneStatus(params, status) {
35
+ const p = { ...params };
36
+ switch (status) {
37
+ case "ok":
38
+ p.harmonicGain = 0.15;
39
+ break;
40
+ case "err":
41
+ p.frequency *= 0.7;
42
+ if (p.frequencyEnd)
43
+ p.frequencyEnd *= 0.7;
44
+ p.detune = 3;
45
+ p.duration *= 1.3;
46
+ break;
47
+ case "warn":
48
+ p.tremoloRate = 8;
49
+ p.tremoloDepth = 0.5;
50
+ break;
51
+ }
52
+ return p;
53
+ }
54
+ function applyToneScope(params, scope) {
55
+ if (scope === "remote") {
56
+ const p = { ...params };
57
+ p.envelope = { ...p.envelope, release: p.envelope.release * 2.5 };
58
+ p.duration *= 1.2;
59
+ return p;
60
+ }
61
+ return params;
62
+ }
63
+ // --- Status modifier functions (whoosh-based) ---
64
+ function applyWhooshStatus(params, status) {
65
+ const p = { ...params };
66
+ switch (status) {
67
+ case "ok":
68
+ p.freqEnd = Math.min(p.freqEnd * 1.2, 6000);
69
+ break;
70
+ case "err":
71
+ p.freqStart *= 0.6;
72
+ p.freqEnd *= 0.6;
73
+ p.duration *= 1.3;
74
+ p.bandwidth = 1.2;
75
+ break;
76
+ case "warn":
77
+ p.duration *= 0.8;
78
+ p.envelope = { ...p.envelope, attack: 0.01, release: 0.03 };
79
+ break;
80
+ }
81
+ return p;
82
+ }
83
+ function applyWhooshScope(params, scope) {
84
+ if (scope === "remote") {
85
+ const p = { ...params };
86
+ p.duration *= 1.3;
87
+ p.envelope = { ...p.envelope, release: p.envelope.release * 2 };
88
+ return p;
89
+ }
90
+ return params;
91
+ }
92
+ // --- Config → Params conversion ---
93
+ function toneConfigToParams(cfg) {
94
+ return {
95
+ waveform: cfg.waveform,
96
+ frequency: cfg.frequency,
97
+ frequencyEnd: cfg.frequencyEnd,
98
+ duration: cfg.duration,
99
+ envelope: { ...cfg.envelope },
100
+ gain: cfg.gain,
101
+ fmRatio: cfg.fmRatio,
102
+ fmDepth: cfg.fmDepth,
103
+ harmonicGain: cfg.harmonicGain,
104
+ };
105
+ }
106
+ function whooshConfigToParams(cfg, direction) {
107
+ const freqPair = direction === "down" ? cfg.freqDown : cfg.freqUp;
108
+ return {
109
+ duration: cfg.duration,
110
+ freqStart: freqPair[0],
111
+ freqEnd: freqPair[1],
112
+ bandwidth: cfg.bandwidth,
113
+ envelope: { ...cfg.envelope },
114
+ gain: cfg.gain,
115
+ };
116
+ }
117
+ // --- Main generation ---
118
+ /** Generate a verb sound using the given profile. */
119
+ export function generateVerb(profile, verb, options = {}) {
120
+ const cfg = profile.verbs[verb];
121
+ if (!cfg) {
122
+ throw new Error(`Profile "${profile.name}" has no config for verb "${verb}"`);
123
+ }
124
+ // Whoosh-based verbs
125
+ if (cfg.type === "whoosh") {
126
+ const dir = options.direction ?? "up";
127
+ let wp = whooshConfigToParams(cfg, dir);
128
+ if (options.status)
129
+ wp = applyWhooshStatus(wp, options.status);
130
+ if (options.scope)
131
+ wp = applyWhooshScope(wp, options.scope);
132
+ const whoosh = generateWhoosh(wp);
133
+ // Tonal anchor (sync)
134
+ if (cfg.tonalAnchor) {
135
+ const anchorFreqs = dir === "down" ? cfg.tonalAnchor.freqDown : cfg.tonalAnchor.freqUp;
136
+ const anchor = generateTone({
137
+ waveform: "sine",
138
+ frequency: anchorFreqs[0],
139
+ frequencyEnd: anchorFreqs[1],
140
+ duration: wp.duration,
141
+ envelope: { ...cfg.tonalAnchor.envelope },
142
+ gain: cfg.tonalAnchor.gain,
143
+ });
144
+ return limitLoudness(mixBuffers([whoosh, anchor]));
145
+ }
146
+ return limitLoudness(whoosh);
147
+ }
148
+ // Tone-based verbs
149
+ let params = toneConfigToParams(cfg);
150
+ if (options.status)
151
+ params = applyToneStatus(params, options.status);
152
+ if (options.scope)
153
+ params = applyToneScope(params, options.scope);
154
+ const primary = generateTone(params);
155
+ // Noise burst layer (execute)
156
+ if (cfg.noiseBurst) {
157
+ const noise = generateTone({
158
+ waveform: "noise",
159
+ frequency: 0,
160
+ duration: cfg.noiseBurst.duration,
161
+ envelope: { attack: 0.002, decay: 0.03, sustain: 0.0, release: 0.005 },
162
+ gain: cfg.noiseBurst.gain,
163
+ });
164
+ return limitLoudness(mixBuffers([primary, noise]));
165
+ }
166
+ return limitLoudness(primary);
167
+ }
168
+ // --- Session sounds ---
169
+ function generateChime(chime) {
170
+ const tone1 = generateTone({
171
+ waveform: chime.tone1.waveform,
172
+ frequency: chime.tone1.frequency,
173
+ duration: chime.tone1.duration,
174
+ envelope: { ...chime.tone1.envelope },
175
+ gain: chime.tone1.gain,
176
+ harmonicGain: chime.tone1.harmonicGain,
177
+ });
178
+ const tone2 = generateTone({
179
+ waveform: chime.tone2.waveform,
180
+ frequency: chime.tone2.frequency,
181
+ duration: chime.tone2.duration,
182
+ envelope: { ...chime.tone2.envelope },
183
+ gain: chime.tone2.gain,
184
+ harmonicGain: chime.tone2.harmonicGain,
185
+ });
186
+ const offset = Math.floor(chime.staggerSeconds * SAMPLE_RATE);
187
+ const total = new Float64Array(Math.max(tone1.length, offset + tone2.length));
188
+ total.set(tone1, 0);
189
+ for (let i = 0; i < tone2.length; i++) {
190
+ if (offset + i < total.length) {
191
+ total[offset + i] += tone2[i];
192
+ }
193
+ }
194
+ return limitLoudness(total);
195
+ }
196
+ export function generateSessionStart(profile) {
197
+ return generateChime(profile.sessionStart);
198
+ }
199
+ export function generateSessionEnd(profile) {
200
+ return generateChime(profile.sessionEnd);
201
+ }
202
+ // --- Ambient (long-running thinking) ---
203
+ /** Generate a single chunk of the ambient thinking drone. */
204
+ export function generateAmbientChunk(profile) {
205
+ const cfg = profile.ambient;
206
+ // Low drone with very gentle fade in/out to allow seamless looping
207
+ return generateTone({
208
+ waveform: cfg.droneWaveform,
209
+ frequency: cfg.droneFreq,
210
+ duration: cfg.chunkDuration,
211
+ envelope: {
212
+ attack: 0.3,
213
+ decay: 0.2,
214
+ sustain: 1.0,
215
+ release: 0.3,
216
+ },
217
+ gain: cfg.droneGain,
218
+ // Subtle tremolo gives the drone "life" instead of a dead flat tone
219
+ tremoloRate: 0.5,
220
+ tremoloDepth: 0.15,
221
+ });
222
+ }
223
+ /** Generate the resolution stinger (two ascending notes). */
224
+ export function generateAmbientResolve(profile) {
225
+ const cfg = profile.ambient;
226
+ const note1 = generateTone({
227
+ waveform: cfg.resolveWaveform,
228
+ frequency: cfg.resolveNote1,
229
+ duration: cfg.resolveDuration,
230
+ envelope: { attack: 0.008, decay: 0.06, sustain: 0.25, release: 0.04 },
231
+ gain: cfg.resolveGain,
232
+ harmonicGain: 0.1,
233
+ });
234
+ const note2 = generateTone({
235
+ waveform: cfg.resolveWaveform,
236
+ frequency: cfg.resolveNote2,
237
+ duration: cfg.resolveDuration * 1.3,
238
+ envelope: { attack: 0.008, decay: 0.08, sustain: 0.25, release: 0.06 },
239
+ gain: cfg.resolveGain * 0.9,
240
+ harmonicGain: 0.1,
241
+ });
242
+ const offset = Math.floor(cfg.resolveDuration * 0.6 * SAMPLE_RATE);
243
+ const total = new Float64Array(offset + note2.length);
244
+ total.set(note1, 0);
245
+ for (let i = 0; i < note2.length; i++) {
246
+ if (offset + i < total.length) {
247
+ total[offset + i] += note2[i];
248
+ }
249
+ }
250
+ return limitLoudness(total);
251
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@mcptoolshop/claude-sfx",
3
+ "version": "0.1.0",
4
+ "description": "Procedural audio feedback for Claude Code — UX for agentic coding",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-sfx": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "demo": "node dist/cli.js demo",
13
+ "play": "node dist/cli.js play",
14
+ "test": "vitest run",
15
+ "prepublishOnly": "npm run build && npm test"
16
+ },
17
+ "keywords": [
18
+ "claude",
19
+ "claude-code",
20
+ "sound",
21
+ "audio",
22
+ "feedback",
23
+ "hooks",
24
+ "accessibility",
25
+ "developer-experience",
26
+ "synth",
27
+ "procedural-audio"
28
+ ],
29
+ "author": "mcp-tool-shop",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/mcp-tool-shop-org/claude-sfx.git"
34
+ },
35
+ "homepage": "https://mcp-tool-shop-org.github.io/claude-sfx/",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "files": [
43
+ "dist/",
44
+ "profiles/",
45
+ "assets/logo.jpg",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "devDependencies": {
50
+ "@types/node": "^25.3.1",
51
+ "typescript": "^5.7.0",
52
+ "vitest": "^4.0.18"
53
+ }
54
+ }