@jxgame2020/dsh-token-quota 0.1.21 → 0.1.23
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/lib/client.js +503 -76
- package/lib/index.js +123 -18
- package/lib/types/client/TokenQuotaPanel.d.ts +8 -2
- package/lib/types/client/TokenQuotaPanel.js +7 -4
- package/lib/types/client/index.js +59 -1
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/music.d.ts +54 -0
- package/lib/types/client/music.js +231 -0
- package/lib/types/client/store.d.ts +8 -1
- package/lib/types/client/store.js +6 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +92 -18
- package/lib/types/types.d.ts +54 -0
- package/lib/types/types.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-composed soundtrack driven by host actions (turns, steps, tool calls,
|
|
3
|
+
* etc.). Outputs through Web Audio (built-in synthesis) by default, and also
|
|
4
|
+
* sends MIDI NoteOn/NoteOff to the first available MIDI output when one is
|
|
5
|
+
* detected (both play simultaneously when MIDI is present — use the panel
|
|
6
|
+
* volume for overall level, the MIDI output has its own gain on the receiver).
|
|
7
|
+
*
|
|
8
|
+
* The composition is intentionally simple: a fixed chord progression under a
|
|
9
|
+
* scale-constrained melody, with one short musical gesture per host action.
|
|
10
|
+
* It is meant as gentle ambience while waiting for LLM responses, not as a
|
|
11
|
+
* full piece — everything stays in key so you can never hit a wrong note.
|
|
12
|
+
*
|
|
13
|
+
* @module @jxgame2020/dsh-token-quota/music
|
|
14
|
+
*/
|
|
15
|
+
/** Scale degrees in semitones from the root. */
|
|
16
|
+
const SCALES = {
|
|
17
|
+
major: [0, 2, 4, 5, 7, 9, 11],
|
|
18
|
+
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
19
|
+
pentatonic: [0, 2, 4, 7, 9],
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Chord progression roots in semitones from the key centre. Loops — every
|
|
23
|
+
* step advance moves one position forward.
|
|
24
|
+
*/
|
|
25
|
+
const CHORD_PROGRESSIONS = {
|
|
26
|
+
major: [0, 9, 5, 7], // I - vi - IV - V
|
|
27
|
+
minor: [0, 9, 4, 11], // i - VI - III - VII
|
|
28
|
+
pentatonic: [0, 7, 2, 9], // open fifths feel — root + 5th
|
|
29
|
+
};
|
|
30
|
+
const CHORD_QUALITIES = {
|
|
31
|
+
major: ['major', 'minor', 'major', 'major'],
|
|
32
|
+
minor: ['minor', 'major', 'major', 'major'],
|
|
33
|
+
pentatonic: ['major', 'major', 'major', 'major'],
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Turn a MIDI note number into a frequency (Hz) using equal temperament at
|
|
37
|
+
* A4 = 440 Hz.
|
|
38
|
+
*/
|
|
39
|
+
function midiToFreq(midi) {
|
|
40
|
+
return 440 * Math.pow(2, (midi - 69) / 12);
|
|
41
|
+
}
|
|
42
|
+
/** Live-composed music engine driven by host actions. */
|
|
43
|
+
export class TokenQuotaMusic {
|
|
44
|
+
audio = null;
|
|
45
|
+
midiOutput = null;
|
|
46
|
+
midiDetected = false;
|
|
47
|
+
enabled = false;
|
|
48
|
+
volume = 0.5;
|
|
49
|
+
style = 'major';
|
|
50
|
+
/** Current position in the chord progression (advances per step). */
|
|
51
|
+
chordIndex = 0;
|
|
52
|
+
/** Step counter — used for melody degree selection. */
|
|
53
|
+
stepCount = 0;
|
|
54
|
+
/** Tool counter — used for tool-note pitch. */
|
|
55
|
+
toolCount = 0;
|
|
56
|
+
/** Base MIDI note of the current key. C4 = middle C. */
|
|
57
|
+
static BASE_MIDI = 60;
|
|
58
|
+
/** True once the audio context has been started (needs a user gesture). */
|
|
59
|
+
get started() {
|
|
60
|
+
return this.audio !== null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Start the audio context and try to find a MIDI output.
|
|
64
|
+
* Must be called inside a user gesture (the panel switch click) so the
|
|
65
|
+
* AudioContext can resume. Safe to call multiple times.
|
|
66
|
+
*/
|
|
67
|
+
async ensureStarted() {
|
|
68
|
+
if (this.audio !== null)
|
|
69
|
+
return true;
|
|
70
|
+
if (typeof window === 'undefined')
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
this.audio = new AudioContext();
|
|
74
|
+
await this.audio.resume();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
this.audio = null;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// MIDI: try once; we don't re-probe (browser asks for permission only once).
|
|
81
|
+
if (!this.midiDetected && 'requestMIDIAccess' in navigator) {
|
|
82
|
+
this.midiDetected = true;
|
|
83
|
+
try {
|
|
84
|
+
const access = await navigator.requestMIDIAccess();
|
|
85
|
+
for (const output of access.outputs.values()) {
|
|
86
|
+
this.midiOutput = output;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Permission denied or no MIDI hardware — stick to Web Audio.
|
|
92
|
+
this.midiOutput = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
setEnabled(value) {
|
|
98
|
+
this.enabled = value;
|
|
99
|
+
}
|
|
100
|
+
setVolume(value) {
|
|
101
|
+
this.volume = Math.max(0, Math.min(1, value));
|
|
102
|
+
}
|
|
103
|
+
setStyle(value) {
|
|
104
|
+
this.style = value;
|
|
105
|
+
}
|
|
106
|
+
/** Handle one incoming host action and turn it into sound. */
|
|
107
|
+
onAction(action) {
|
|
108
|
+
if (!this.enabled || this.audio === null)
|
|
109
|
+
return;
|
|
110
|
+
const base = TokenQuotaMusic.BASE_MIDI;
|
|
111
|
+
switch (action.type) {
|
|
112
|
+
case 'turn/start':
|
|
113
|
+
this.chordIndex = 0;
|
|
114
|
+
this.stepCount = 0;
|
|
115
|
+
this.toolCount = 0;
|
|
116
|
+
this.playChord(base, this.currentChordQuality(), 1.6, 0.55);
|
|
117
|
+
break;
|
|
118
|
+
case 'step/start': {
|
|
119
|
+
this.chordIndex = (this.chordIndex + 1) % CHORD_PROGRESSIONS[this.style].length;
|
|
120
|
+
this.stepCount += 1;
|
|
121
|
+
// bass note of the new chord
|
|
122
|
+
this.playNote(this.currentChordRoot(base) - 12, 1.2, 0.35);
|
|
123
|
+
// ascending arpeggio of the chord (3 notes)
|
|
124
|
+
const root = this.currentChordRoot(base);
|
|
125
|
+
const [d1, d2, d3] = this.arpeggioDegrees();
|
|
126
|
+
setTimeout(() => this.playNote(root + d1, 0.2, 0.4), 0);
|
|
127
|
+
setTimeout(() => this.playNote(root + d2, 0.2, 0.4), 80);
|
|
128
|
+
setTimeout(() => this.playNote(root + d3, 0.35, 0.4), 160);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'request/header': {
|
|
132
|
+
// short melodic flicker — pitch follows step + tool positions
|
|
133
|
+
const degree = (this.stepCount * 2 + this.toolCount) % SCALES[this.style].length;
|
|
134
|
+
const note = base + 12 + SCALES[this.style][degree];
|
|
135
|
+
this.playNote(note, 0.18, 0.35);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'tool/call':
|
|
139
|
+
this.toolCount += 1;
|
|
140
|
+
// high plink + a low percussive tap
|
|
141
|
+
this.playNote(base + 24 + (this.toolCount % 3) * 2, 0.12, 0.45);
|
|
142
|
+
this.playNote(base - 12, 0.08, 0.25);
|
|
143
|
+
break;
|
|
144
|
+
case 'tool/result': {
|
|
145
|
+
if (action.error) {
|
|
146
|
+
// dissonant downward glint (two half-steps)
|
|
147
|
+
this.playNote(base + 13, 0.18, 0.4);
|
|
148
|
+
setTimeout(() => this.playNote(base + 12, 0.25, 0.4), 90);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// upward resolution
|
|
152
|
+
this.playNote(base + 9, 0.14, 0.3);
|
|
153
|
+
setTimeout(() => this.playNote(base + 11, 0.22, 0.3), 70);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'assistant/message':
|
|
158
|
+
if (action.interrupted) {
|
|
159
|
+
// interrupted turn: diminished tension chord
|
|
160
|
+
this.playChord(base + 7, 'dim', 0.9, 0.5);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// full cadence chord of the current step
|
|
164
|
+
this.playChord(this.currentChordRoot(base), this.currentChordQuality(), 1.1, 0.55);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 'turn/end':
|
|
168
|
+
// final tonic chord, longer release
|
|
169
|
+
this.playChord(base, this.style === 'minor' ? 'minor' : 'major', 2.2, 0.65);
|
|
170
|
+
this.chordIndex = 0;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ── internal helpers ──────────────────────────────────────────────
|
|
175
|
+
currentChordRoot(base) {
|
|
176
|
+
const prog = CHORD_PROGRESSIONS[this.style];
|
|
177
|
+
return base + prog[this.chordIndex % prog.length];
|
|
178
|
+
}
|
|
179
|
+
currentChordQuality() {
|
|
180
|
+
const q = CHORD_QUALITIES[this.style];
|
|
181
|
+
return q[this.chordIndex % q.length] ?? 'major';
|
|
182
|
+
}
|
|
183
|
+
/** Intervals of an ascending arpeggio from the chord root. */
|
|
184
|
+
arpeggioDegrees() {
|
|
185
|
+
return this.style === 'pentatonic'
|
|
186
|
+
? [0, 7, 12]
|
|
187
|
+
: this.currentChordQuality() === 'major'
|
|
188
|
+
? [0, 4, 7]
|
|
189
|
+
: [0, 3, 7];
|
|
190
|
+
}
|
|
191
|
+
/** Play one note: Web Audio + MIDI simultaneously when available. */
|
|
192
|
+
playNote(midi, durationSec, velocity) {
|
|
193
|
+
if (!this.audio)
|
|
194
|
+
return;
|
|
195
|
+
const now = this.audio.currentTime;
|
|
196
|
+
const peak = this.volume * velocity * 0.5;
|
|
197
|
+
if (peak <= 0)
|
|
198
|
+
return;
|
|
199
|
+
// Web Audio: sine oscillator with a quick attack and exponential release.
|
|
200
|
+
const osc = this.audio.createOscillator();
|
|
201
|
+
osc.type = 'sine';
|
|
202
|
+
osc.frequency.value = midiToFreq(midi);
|
|
203
|
+
const gain = this.audio.createGain();
|
|
204
|
+
gain.gain.setValueAtTime(0, now);
|
|
205
|
+
gain.gain.linearRampToValueAtTime(peak, now + 0.008);
|
|
206
|
+
gain.gain.exponentialRampToValueAtTime(0.001, now + durationSec);
|
|
207
|
+
osc.connect(gain);
|
|
208
|
+
gain.connect(this.audio.destination);
|
|
209
|
+
osc.start(now);
|
|
210
|
+
osc.stop(now + durationSec + 0.05);
|
|
211
|
+
// MIDI: same note on the first output, if any. Velocity 1..127.
|
|
212
|
+
if (this.midiOutput !== null) {
|
|
213
|
+
const vel = Math.max(1, Math.floor(velocity * 127));
|
|
214
|
+
this.midiOutput.send([0x90, midi & 0x7F, vel]);
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
this.midiOutput?.send([0x80, midi & 0x7F, 0]);
|
|
217
|
+
}, durationSec * 1000);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Play a three-note chord plus a bass octave below. */
|
|
221
|
+
playChord(rootMidi, quality, durationSec, velocity) {
|
|
222
|
+
const third = quality === 'major' ? 4 : 3;
|
|
223
|
+
const fifth = quality === 'dim' ? 6 : 7;
|
|
224
|
+
this.playNote(rootMidi, durationSec, velocity * 0.45);
|
|
225
|
+
this.playNote(rootMidi + third, durationSec, velocity * 0.35);
|
|
226
|
+
this.playNote(rootMidi + fifth, durationSec, velocity * 0.35);
|
|
227
|
+
// bass in the octave below — slightly longer for warmth
|
|
228
|
+
this.playNote(rootMidi - 12, durationSec * 1.15, velocity * 0.45);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=music.js.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
|
|
11
11
|
import type { ModelProviderGroup, ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types';
|
|
12
|
-
import type { TokenQuotaFullAction, TokenQuotaLog, TokenQuotaReset, TokenQuotaSnapshot, TokenQuotaUpgrade } from '@jxgame2020/dsh-token-quota/types';
|
|
12
|
+
import type { TokenQuotaFullAction, TokenQuotaLog, TokenQuotaMusicStyle, TokenQuotaReset, TokenQuotaSnapshot, TokenQuotaUpgrade } from '@jxgame2020/dsh-token-quota/types';
|
|
13
13
|
/** One rendered model row: directory identity merged with the quota snapshot. */
|
|
14
14
|
export interface ModelQuotaRow {
|
|
15
15
|
/** Stable key `provider/model`. */
|
|
@@ -50,6 +50,10 @@ export interface TokenQuotaPanelState {
|
|
|
50
50
|
checkUpdates: boolean;
|
|
51
51
|
/** Whether the panel dims while the pointer is away / while typing. */
|
|
52
52
|
dimWhenIdle: boolean;
|
|
53
|
+
/** Live music settings (mirrored from the settings document). */
|
|
54
|
+
musicEnabled: boolean;
|
|
55
|
+
musicVolume: number;
|
|
56
|
+
musicStyle: TokenQuotaMusicStyle;
|
|
53
57
|
/** Latest upgrade availability from the Host; null = up to date or unknown. */
|
|
54
58
|
upgrade: TokenQuotaUpgrade | null;
|
|
55
59
|
/** Whether the one-shot upgrade banner was dismissed for the current latest version. */
|
|
@@ -92,6 +96,9 @@ export type TokenQuotaPanelActions = {
|
|
|
92
96
|
setSettings: (d: TokenQuotaPanelState, monitored: string[] | null, onFull: TokenQuotaFullAction) => void;
|
|
93
97
|
setCheckUpdates: (d: TokenQuotaPanelState, checkUpdates: boolean) => void;
|
|
94
98
|
setDimWhenIdle: (d: TokenQuotaPanelState, dimWhenIdle: boolean) => void;
|
|
99
|
+
setMusicEnabled: (d: TokenQuotaPanelState, enabled: boolean) => void;
|
|
100
|
+
setMusicVolume: (d: TokenQuotaPanelState, volume: number) => void;
|
|
101
|
+
setMusicStyle: (d: TokenQuotaPanelState, style: TokenQuotaMusicStyle) => void;
|
|
95
102
|
setUpgrade: (d: TokenQuotaPanelState, upgrade: TokenQuotaUpgrade | null) => void;
|
|
96
103
|
setUpgradeDismissed: (d: TokenQuotaPanelState, dismissed: boolean) => void;
|
|
97
104
|
setCheckingUpdates: (d: TokenQuotaPanelState, checking: boolean) => void;
|
|
@@ -28,6 +28,9 @@ export function createTokenQuotaPanelStore() {
|
|
|
28
28
|
onFull: 'stop',
|
|
29
29
|
checkUpdates: true,
|
|
30
30
|
dimWhenIdle: false,
|
|
31
|
+
musicEnabled: false,
|
|
32
|
+
musicVolume: 0.5,
|
|
33
|
+
musicStyle: 'major',
|
|
31
34
|
upgrade: null,
|
|
32
35
|
upgradeDismissed: false,
|
|
33
36
|
checkingUpdates: false,
|
|
@@ -53,6 +56,9 @@ export function createTokenQuotaPanelStore() {
|
|
|
53
56
|
setSettings: (d, monitored, onFull) => { d.monitored = monitored; d.onFull = onFull; },
|
|
54
57
|
setCheckUpdates: (d, checkUpdates) => { d.checkUpdates = checkUpdates; },
|
|
55
58
|
setDimWhenIdle: (d, dimWhenIdle) => { d.dimWhenIdle = dimWhenIdle; },
|
|
59
|
+
setMusicEnabled: (d, enabled) => { d.musicEnabled = enabled; },
|
|
60
|
+
setMusicVolume: (d, volume) => { d.musicVolume = volume; },
|
|
61
|
+
setMusicStyle: (d, style) => { d.musicStyle = style; },
|
|
56
62
|
setUpgrade: (d, upgrade) => {
|
|
57
63
|
// Guard against host/client version skew (e.g. the host process still
|
|
58
64
|
// runs an older bundle after an upgrade): when the reported "latest"
|
package/lib/types/index.d.ts
CHANGED
|
@@ -81,6 +81,10 @@ export declare class TokenQuotaService extends Service {
|
|
|
81
81
|
private disposeRouteCheck;
|
|
82
82
|
/** Disposer for the optional clear-log route (`POST /token-quota/clear-log`). */
|
|
83
83
|
private disposeRouteClear;
|
|
84
|
+
/** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
|
|
85
|
+
private disposeRouteEvents;
|
|
86
|
+
/** Live SSE subscribers receiving streamed music actions. */
|
|
87
|
+
private readonly musicClients;
|
|
84
88
|
/** Disposer for the webServer-arrival watcher when the service mounts later. */
|
|
85
89
|
private disposeRouteWatcher;
|
|
86
90
|
/**
|
|
@@ -90,6 +94,8 @@ export declare class TokenQuotaService extends Service {
|
|
|
90
94
|
constructor(ctx: Context, config?: TokenQuotaConfig);
|
|
91
95
|
/** Serve the current snapshot over the plugin-owned HTTP route. */
|
|
92
96
|
private tryMountRoute;
|
|
97
|
+
/** Broadcast one action to every open SSE music subscriber. */
|
|
98
|
+
private pushMusicAction;
|
|
93
99
|
/** Query the npm registry and rebuild the cached upgrade info (coalesced). */
|
|
94
100
|
private refreshUpgrade;
|
|
95
101
|
private doRefreshUpgrade;
|
package/lib/types/index.js
CHANGED
|
@@ -38,7 +38,7 @@ import z from '@deepseek-ai/schemastery';
|
|
|
38
38
|
// the service listens to without importing any runtime value.
|
|
39
39
|
import '@deepseek-ai/dsh-agent';
|
|
40
40
|
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
41
|
-
import { TOKEN_QUOTA_EXCEEDED_CODE, TOKEN_QUOTA_NAMESPACE, tokenQuotaKey, } from "./types.js";
|
|
41
|
+
import { TOKEN_QUOTA_EXCEEDED_CODE, TOKEN_QUOTA_NAMESPACE, tokenQuotaKey, TOKEN_QUOTA_DEFAULT_MUSIC, } from "./types.js";
|
|
42
42
|
import { assertTokenQuotaLimit, splitTokenQuotaKey } from "./invariant.js";
|
|
43
43
|
/** The plugin's package name, used to locate the profile dependency and the registry endpoint. */
|
|
44
44
|
const PACKAGE_NAME = '@jxgame2020/dsh-token-quota';
|
|
@@ -206,6 +206,12 @@ const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
|
|
|
206
206
|
// panel writes it and the host validates the shape at runtime. `z.any` with
|
|
207
207
|
// a null default keeps it out of the strict fields above.
|
|
208
208
|
reset: z.any().default(null),
|
|
209
|
+
// Live music: host persists the document, the browser half plays it.
|
|
210
|
+
music: z.object({
|
|
211
|
+
enabled: z.boolean().default(TOKEN_QUOTA_DEFAULT_MUSIC.enabled),
|
|
212
|
+
volume: z.number().min(0).max(1).default(TOKEN_QUOTA_DEFAULT_MUSIC.volume),
|
|
213
|
+
style: z.union(['major', 'minor', 'pentatonic']).default(TOKEN_QUOTA_DEFAULT_MUSIC.style),
|
|
214
|
+
}).default({ ...TOKEN_QUOTA_DEFAULT_MUSIC }),
|
|
209
215
|
});
|
|
210
216
|
/** Two-digit zero-pad helper. */
|
|
211
217
|
function pad2(value) {
|
|
@@ -266,7 +272,7 @@ export class TokenQuotaService extends Service {
|
|
|
266
272
|
history = {};
|
|
267
273
|
limits = {};
|
|
268
274
|
monitored = undefined;
|
|
269
|
-
settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false });
|
|
275
|
+
settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop', checkUpdates: true, dimWhenIdle: false, music: { ...TOKEN_QUOTA_DEFAULT_MUSIC } });
|
|
270
276
|
/** Whether update checks are enabled (mirrors the settings document). */
|
|
271
277
|
checkUpdates = true;
|
|
272
278
|
/** Cached upgrade availability; recomputed by {@link refreshUpgrade}. */
|
|
@@ -293,6 +299,10 @@ export class TokenQuotaService extends Service {
|
|
|
293
299
|
disposeRouteCheck;
|
|
294
300
|
/** Disposer for the optional clear-log route (`POST /token-quota/clear-log`). */
|
|
295
301
|
disposeRouteClear;
|
|
302
|
+
/** Disposer for the optional music-event route (`GET /token-quota/events`, SSE). */
|
|
303
|
+
disposeRouteEvents;
|
|
304
|
+
/** Live SSE subscribers receiving streamed music actions. */
|
|
305
|
+
musicClients = new Set();
|
|
296
306
|
/** Disposer for the webServer-arrival watcher when the service mounts later. */
|
|
297
307
|
disposeRouteWatcher;
|
|
298
308
|
/**
|
|
@@ -327,6 +337,11 @@ export class TokenQuotaService extends Service {
|
|
|
327
337
|
checkUpdates: doc.checkUpdates ?? true,
|
|
328
338
|
dimWhenIdle: doc.dimWhenIdle ?? false,
|
|
329
339
|
reset: doc.reset ?? undefined,
|
|
340
|
+
music: {
|
|
341
|
+
enabled: doc.music?.enabled ?? TOKEN_QUOTA_DEFAULT_MUSIC.enabled,
|
|
342
|
+
volume: doc.music?.volume ?? TOKEN_QUOTA_DEFAULT_MUSIC.volume,
|
|
343
|
+
style: doc.music?.style ?? TOKEN_QUOTA_DEFAULT_MUSIC.style,
|
|
344
|
+
},
|
|
330
345
|
};
|
|
331
346
|
};
|
|
332
347
|
},
|
|
@@ -463,6 +478,34 @@ export class TokenQuotaService extends Service {
|
|
|
463
478
|
});
|
|
464
479
|
},
|
|
465
480
|
});
|
|
481
|
+
this.disposeRouteEvents = server.register({
|
|
482
|
+
kind: 'exact',
|
|
483
|
+
path: '/token-quota/events',
|
|
484
|
+
handler: (req, res) => {
|
|
485
|
+
res.writeHead(200, {
|
|
486
|
+
'content-type': 'text/event-stream',
|
|
487
|
+
'cache-control': 'no-store',
|
|
488
|
+
'connection': 'keep-alive',
|
|
489
|
+
});
|
|
490
|
+
this.musicClients.add(res);
|
|
491
|
+
res.write(': token-quota music stream\n\n');
|
|
492
|
+
req.on('close', () => { this.musicClients.delete(res); });
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/** Broadcast one action to every open SSE music subscriber. */
|
|
497
|
+
pushMusicAction(action) {
|
|
498
|
+
if (this.musicClients.size === 0)
|
|
499
|
+
return;
|
|
500
|
+
const data = `event: action\ndata: ${JSON.stringify(action)}\n\n`;
|
|
501
|
+
for (const res of this.musicClients) {
|
|
502
|
+
try {
|
|
503
|
+
res.write(data);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
this.musicClients.delete(res);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
466
509
|
}
|
|
467
510
|
/** Query the npm registry and rebuild the cached upgrade info (coalesced). */
|
|
468
511
|
refreshUpgrade() {
|
|
@@ -624,23 +667,45 @@ export class TokenQuotaService extends Service {
|
|
|
624
667
|
return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 0;
|
|
625
668
|
}
|
|
626
669
|
onSessionEvent(session, event) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
670
|
+
const sessionId = String(session.id);
|
|
671
|
+
switch (event.type) {
|
|
672
|
+
case 'turn/start':
|
|
673
|
+
this.pushMusicAction({ type: 'turn/start', sessionId });
|
|
674
|
+
break;
|
|
675
|
+
case 'turn/end':
|
|
676
|
+
this.pushMusicAction({ type: 'turn/end', sessionId, reason: event.data.reason.kind });
|
|
677
|
+
break;
|
|
678
|
+
case 'step/start':
|
|
679
|
+
this.pushMusicAction({ type: 'step/start', sessionId });
|
|
680
|
+
break;
|
|
681
|
+
case 'request/header': {
|
|
682
|
+
const { provider, model } = event.data.header.config;
|
|
683
|
+
this.headerKeys.set(session, tokenQuotaKey(provider, model));
|
|
684
|
+
this.pushMusicAction({ type: 'request/header', sessionId, provider, model });
|
|
640
685
|
return;
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
686
|
+
}
|
|
687
|
+
case 'tool/call':
|
|
688
|
+
this.pushMusicAction({ type: 'tool/call', sessionId, name: event.data.name });
|
|
689
|
+
break;
|
|
690
|
+
case 'tool/result':
|
|
691
|
+
this.pushMusicAction({ type: 'tool/result', sessionId, error: event.data.error !== undefined });
|
|
692
|
+
break;
|
|
693
|
+
case 'assistant/message':
|
|
694
|
+
this.pushMusicAction({ type: 'assistant/message', sessionId, interrupted: event.data.interrupted === true });
|
|
695
|
+
if (event.data.usage !== undefined) {
|
|
696
|
+
const key = this.headerKeys.get(session);
|
|
697
|
+
if (key === undefined)
|
|
698
|
+
return;
|
|
699
|
+
if (!this.isMonitored(key))
|
|
700
|
+
return;
|
|
701
|
+
const tokens = usageTokens(event.data.usage);
|
|
702
|
+
if (tokens <= 0)
|
|
703
|
+
return;
|
|
704
|
+
this.rollCycleIfNeeded();
|
|
705
|
+
this.usage[key] = (this.usage[key] ?? 0) + tokens;
|
|
706
|
+
this.scheduleWrite();
|
|
707
|
+
}
|
|
708
|
+
break;
|
|
644
709
|
}
|
|
645
710
|
}
|
|
646
711
|
/**
|
|
@@ -849,6 +914,15 @@ export class TokenQuotaService extends Service {
|
|
|
849
914
|
this.disposeRouteCheck();
|
|
850
915
|
if (this.disposeRouteClear !== undefined)
|
|
851
916
|
this.disposeRouteClear();
|
|
917
|
+
if (this.disposeRouteEvents !== undefined)
|
|
918
|
+
this.disposeRouteEvents();
|
|
919
|
+
for (const res of this.musicClients) {
|
|
920
|
+
try {
|
|
921
|
+
res.end();
|
|
922
|
+
}
|
|
923
|
+
catch { /* ignore */ }
|
|
924
|
+
}
|
|
925
|
+
this.musicClients.clear();
|
|
852
926
|
this.flush();
|
|
853
927
|
}
|
|
854
928
|
safeSplit(key) {
|
package/lib/types/types.d.ts
CHANGED
|
@@ -38,6 +38,54 @@ export interface TokenQuotaReset {
|
|
|
38
38
|
}
|
|
39
39
|
/** Default reset: machine-local midnight (offset = host timezone, 00:00). */
|
|
40
40
|
export declare const TOKEN_QUOTA_DEFAULT_RESET: TokenQuotaReset;
|
|
41
|
+
/** Harmonic palette of the live-composed soundtrack. */
|
|
42
|
+
export declare const TOKEN_QUOTA_MUSIC_STYLES: readonly ["major", "minor", "pentatonic"];
|
|
43
|
+
/** One of the {@link TOKEN_QUOTA_MUSIC_STYLES} values. */
|
|
44
|
+
export type TokenQuotaMusicStyle = typeof TOKEN_QUOTA_MUSIC_STYLES[number];
|
|
45
|
+
/** Music playback settings (host-persisted; the browser half plays). */
|
|
46
|
+
export interface TokenQuotaMusicSettings {
|
|
47
|
+
/** Master switch — off by default so the plugin stays silent until enabled. */
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
/** Output volume 0..1. */
|
|
50
|
+
volume: number;
|
|
51
|
+
/** Harmonic style of the live composition. */
|
|
52
|
+
style: TokenQuotaMusicStyle;
|
|
53
|
+
}
|
|
54
|
+
/** Default music settings: silent until the user flips the panel switch. */
|
|
55
|
+
export declare const TOKEN_QUOTA_DEFAULT_MUSIC: TokenQuotaMusicSettings;
|
|
56
|
+
/**
|
|
57
|
+
* One host action streamed to the browser over the plugin's SSE route
|
|
58
|
+
* (`GET /token-quota/events`); the panel's music engine turns it into a note.
|
|
59
|
+
* `sessionId` lets the client filter to the currently-visible session.
|
|
60
|
+
*/
|
|
61
|
+
export type TokenQuotaMusicAction = {
|
|
62
|
+
type: 'turn/start';
|
|
63
|
+
sessionId: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'turn/end';
|
|
66
|
+
sessionId: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'step/start';
|
|
70
|
+
sessionId: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: 'request/header';
|
|
73
|
+
sessionId: string;
|
|
74
|
+
provider: string;
|
|
75
|
+
model: string;
|
|
76
|
+
} | {
|
|
77
|
+
type: 'tool/call';
|
|
78
|
+
sessionId: string;
|
|
79
|
+
name: string;
|
|
80
|
+
} | {
|
|
81
|
+
type: 'tool/result';
|
|
82
|
+
sessionId: string;
|
|
83
|
+
error: boolean;
|
|
84
|
+
} | {
|
|
85
|
+
type: 'assistant/message';
|
|
86
|
+
sessionId: string;
|
|
87
|
+
interrupted: boolean;
|
|
88
|
+
};
|
|
41
89
|
/**
|
|
42
90
|
* Per-model daily-limit settings document shape. Keys are `provider/model`
|
|
43
91
|
* and a value of `0` (or an absent key) means unlimited for that model.
|
|
@@ -79,6 +127,12 @@ export interface TokenQuotaSettings {
|
|
|
79
127
|
* midnight (the historical behaviour).
|
|
80
128
|
*/
|
|
81
129
|
reset?: TokenQuotaReset;
|
|
130
|
+
/**
|
|
131
|
+
* Live music output settings: a soundtrack composed from host actions
|
|
132
|
+
* (request starts, tool calls, turn ends) and played through Web Audio or
|
|
133
|
+
* an attached MIDI output. Off by default.
|
|
134
|
+
*/
|
|
135
|
+
music: TokenQuotaMusicSettings;
|
|
82
136
|
}
|
|
83
137
|
/** Token-quota plugin configuration. */
|
|
84
138
|
export interface TokenQuotaConfig {
|
package/lib/types/types.js
CHANGED
|
@@ -22,6 +22,14 @@ export const TOKEN_QUOTA_EXCEEDED_CODE = 'TOKEN_QUOTA_EXCEEDED';
|
|
|
22
22
|
export const TOKEN_QUOTA_FULL_ACTIONS = ['stop', 'switchQuota', 'switchAll'];
|
|
23
23
|
/** Default reset: machine-local midnight (offset = host timezone, 00:00). */
|
|
24
24
|
export const TOKEN_QUOTA_DEFAULT_RESET = { offsetHours: 0, hour: 0, minute: 0 };
|
|
25
|
+
/** Harmonic palette of the live-composed soundtrack. */
|
|
26
|
+
export const TOKEN_QUOTA_MUSIC_STYLES = ['major', 'minor', 'pentatonic'];
|
|
27
|
+
/** Default music settings: silent until the user flips the panel switch. */
|
|
28
|
+
export const TOKEN_QUOTA_DEFAULT_MUSIC = {
|
|
29
|
+
enabled: false,
|
|
30
|
+
volume: 0.5,
|
|
31
|
+
style: 'major',
|
|
32
|
+
};
|
|
25
33
|
/** Build the stable per-model key shared by the counter, settings, and snapshot. */
|
|
26
34
|
export function tokenQuotaKey(provider, model) {
|
|
27
35
|
return `${provider}/${model}`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxgame2020/dsh-token-quota",
|
|
3
3
|
"description": "Daily per-model token quota accounting, enforcement, and a floating quota panel for the DeepSeek Harness — a fully self-contained plugin (no core-Harness wiring, no generated RPC contract)",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.23",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|