@doitian/dsh-music 0.1.0 → 0.1.2

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/dj.js CHANGED
@@ -8,6 +8,14 @@
8
8
  * candidate pool plus a taste digest go to `ctx.llm.stream()`, and the
9
9
  * model returns the picks and a one-line vibe. This is what makes the DJ
10
10
  * reason about a mood brief rather than only follow similarity edges.
11
+ *
12
+ * The call carries a **session id** ({@link curationSessionId}). A plugin
13
+ * cannot set headers on a leaf call — `GenerateOptions` has no `headers`
14
+ * field — so the session id is the only identity lever it has, and each
15
+ * adapter maps it onto whatever its provider calls a per-conversation
16
+ * header (pi-ai emits `x-opencode-session` for the `opencode-go` route).
17
+ * Without it the request reaches such a provider looking like an anonymous
18
+ * client that ignores its conventions.
11
19
  * 2. **Heuristic tier** — always available, no model required: NetEase's own
12
20
  * signals (similar songs, daily recommendations, anonymous new-song feed,
13
21
  * charts, keyword search) scored by artist affinity, novelty, and the
@@ -20,6 +28,8 @@
20
28
  * @module dsh-music/dj
21
29
  */
22
30
 
31
+ import { randomUUID } from 'node:crypto';
32
+
23
33
  /**
24
34
  * Render a model-tier failure so its stable code survives.
25
35
  *
@@ -47,6 +57,59 @@ const MAX_POOL = 45;
47
57
  /** Only the first `MAX_SEEDS` history entries seed similarity expansion. */
48
58
  const MAX_SEEDS = 4;
49
59
 
60
+ /** Prefix for the minted curation identity, so logs and health read clearly. */
61
+ const SESSION_ID_PREFIX = 'music-dj-';
62
+
63
+ /**
64
+ * The provider-visible identity of the DJ's curation conversation.
65
+ *
66
+ * Minted once and persisted, so every plan of one installation is the *same*
67
+ * conversation to the provider: session affinity and prompt caching work, and a
68
+ * restart does not look like a new client. `config.dj.sessionId` overrides it,
69
+ * and a store that cannot be written still yields a usable in-memory id.
70
+ *
71
+ * @param {import('./session.js').SessionStore} [store] the plugin's store.
72
+ * @param {string} [configured] an operator-supplied id.
73
+ * @returns {string} a non-empty session id.
74
+ */
75
+ export function curationSessionId(store, configured) {
76
+ if (typeof configured === 'string' && configured.length > 0) return configured;
77
+ const persisted = store?.settings?.djSessionId;
78
+ if (typeof persisted === 'string' && persisted.length > 0) return persisted;
79
+ const minted = `${SESSION_ID_PREFIX}${randomUUID()}`;
80
+ try {
81
+ store?.updateSettings?.({ djSessionId: minted });
82
+ } catch {
83
+ // A read-only store costs stability across restarts, not correctness now.
84
+ }
85
+ return minted;
86
+ }
87
+
88
+ /**
89
+ * Build one model-tier request.
90
+ *
91
+ * The guard is the point: a leaf call with no `sessionId` still succeeds against
92
+ * a permissive provider, so the failure this prevents is silent — a request
93
+ * that quietly stops following the provider's conventions. Throwing here turns
94
+ * it into a recorded `dj.modelError` instead. No headers are set, because a
95
+ * leaf call cannot set any.
96
+ *
97
+ * @param {object} options
98
+ * @param {{provider: string, model: string}} options.target the resolved route.
99
+ * @param {object[]} options.messages the prompt.
100
+ * @param {string} options.sessionId the provider-visible conversation identity.
101
+ * @returns {object} the request to hand to `ctx.llm.stream()`.
102
+ */
103
+ export function curationRequest({ target, messages, sessionId }) {
104
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
105
+ throw new Error(
106
+ 'the DJ model call needs a non-empty sessionId: it is the only identity a leaf call can ' +
107
+ "carry, and the adapter maps it onto the provider's per-conversation header",
108
+ );
109
+ }
110
+ return { ...target, messages, sessionId };
111
+ }
112
+
50
113
  /**
51
114
  * @typedef {object} Plan
52
115
  * @property {object[]} tracks chosen tracks, in play order.
@@ -63,16 +126,41 @@ export class AiDj {
63
126
  * @param {import('./state.js').Player} options.player
64
127
  * @param {import('./session.js').SessionStore} options.store
65
128
  * @param {() => object | undefined} [options.resolveLlm] returns the LLM service when mounted.
129
+ * @param {() => {provider: string, model: string, reasoningEffort?: string} | null} [options.readAgentDefault]
130
+ * reads the deployment's default model selection — the model a fresh agent
131
+ * starts on, which is the same choice the composer picker writes.
132
+ * @param {string} [options.sessionId] configured curation identity, overriding the minted one.
66
133
  * @param {{warn: Function, info: Function, debug: Function}} [options.logger]
67
134
  * @param {object} [options.model] `{ provider, model }` enabling the model tier.
68
135
  */
69
- constructor({ api, player, store, resolveLlm, logger, model = {} } = {}) {
136
+ constructor({
137
+ api,
138
+ player,
139
+ store,
140
+ resolveLlm,
141
+ readAgentDefault,
142
+ sessionId,
143
+ logger,
144
+ model = {},
145
+ } = {}) {
70
146
  this.api = api;
71
147
  this.player = player;
72
148
  this.store = store;
73
149
  this.resolveLlm = resolveLlm;
150
+ this.readAgentDefault = readAgentDefault;
74
151
  this.logger = logger;
75
152
  this.model = { provider: model.provider ?? null, model: model.model ?? null };
153
+ /**
154
+ * The curation conversation's provider-visible identity. Minted once, then
155
+ * persisted, so every plan and every restart is the same conversation.
156
+ */
157
+ this.sessionId = curationSessionId(store, sessionId);
158
+ /**
159
+ * Where the last resolved route came from: `pin`, `agent-default`, or
160
+ * `discovered`, or `null` when none resolved. Reported by `/music/health`
161
+ * so "which model actually curated" never has to be inferred.
162
+ */
163
+ this.resolvedFrom = null;
76
164
  /**
77
165
  * Why the model tier last declined, or `null` when it last succeeded.
78
166
  *
@@ -105,11 +193,52 @@ export class AiDj {
105
193
  return this.model;
106
194
  }
107
195
 
196
+ /** The model tier's observable state, for `/music/health`. */
197
+ modelStatus() {
198
+ const sessionModel = this.sessionModel();
199
+ return {
200
+ /** The route pinned from the panel or the profile patch, if any. */
201
+ pinned: this.modelConfigured ? `${this.model.provider}/${this.model.model}` : null,
202
+ /** The deployment's own model, which an unpinned DJ follows. */
203
+ sessionModel: sessionModel ? `${sessionModel.provider}/${sessionModel.model}` : null,
204
+ /** Where the last plan's route came from: pin, agent-default, discovered. */
205
+ resolvedFrom: this.resolvedFrom,
206
+ /** The identity every model call carries; the provider sees it as a header. */
207
+ sessionId: this.sessionId,
208
+ };
209
+ }
210
+
108
211
  /** Whether a model tier is configured. */
109
212
  get modelConfigured() {
110
213
  return Boolean(this.model.provider && this.model.model);
111
214
  }
112
215
 
216
+ /**
217
+ * The model the session itself runs on, when the deployment publishes one.
218
+ *
219
+ * `agent-default-model` owns this selection, and the composer/command model
220
+ * picker writes it. Reading it here is what lets the DJ's model be a
221
+ * *separate* choice: pin `dj.provider`/`dj.model` to curate on something
222
+ * else, or leave both unset and follow the session.
223
+ *
224
+ * @returns {{provider: string, model: string, reasoningEffort?: string} | null}
225
+ */
226
+ sessionModel() {
227
+ try {
228
+ const selection = this.readAgentDefault?.();
229
+ if (!selection?.provider || !selection?.model) return null;
230
+ return {
231
+ provider: String(selection.provider),
232
+ model: String(selection.model),
233
+ ...(selection.reasoningEffort === undefined || selection.reasoningEffort === null
234
+ ? {}
235
+ : { reasoningEffort: String(selection.reasoningEffort) }),
236
+ };
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
113
242
  /**
114
243
  * Enumerate the provider routes and catalogued models the DJ could use, for
115
244
  * the player's picker.
@@ -256,18 +385,29 @@ export class AiDj {
256
385
  /**
257
386
  * Decide which provider route to call.
258
387
  *
259
- * An explicit pin always wins. Otherwise the gaps are filled from the
260
- * registered adapters' catalogues, preferring a pinned provider (or model)
261
- * over the first one. Discovery is a convenience, not a recommendation: the
262
- * catalogue's first entry is arbitrary, so pinning both fields is the only
263
- * way to know which model curates.
388
+ * An explicit pin always wins. Otherwise the deployment's own model selection
389
+ * is inherited — `agent-default-model`, which is the same choice the composer
390
+ * picker writes and therefore the model the agent loop runs on — so the
391
+ * session model and the DJ's model are two separable decisions that agree
392
+ * by default. Only when neither exists does discovery run.
393
+ *
394
+ * Discovery is a convenience, not a recommendation: the catalogue's first
395
+ * entry is arbitrary, so pinning both fields is the only way to know which
396
+ * model curates.
264
397
  *
265
- * @param {object} llm the mounted LLM service.
266
- * @returns {Promise<{provider: string, model: string} | null>}
398
+ * @param {object} llm the mounted LLM service, for the catalogue.
399
+ * @returns {Promise<{provider: string, model: string, source: string, reasoningEffort?: string} | null>}
267
400
  */
268
401
  async #resolveTarget(llm) {
269
402
  const pinned = { provider: this.model.provider, model: this.model.model };
270
- if (pinned.provider && pinned.model) return pinned;
403
+ if (pinned.provider && pinned.model) return { ...pinned, source: 'pin' };
404
+
405
+ if (!pinned.provider && !pinned.model) {
406
+ const inherited = this.sessionModel();
407
+ if (inherited?.provider && inherited.model) return { ...inherited, source: 'agent-default' };
408
+ }
409
+
410
+ if (!llm?.stream) return null;
271
411
 
272
412
  let providers = [];
273
413
  try {
@@ -289,32 +429,20 @@ export class AiDj {
289
429
  model = null;
290
430
  }
291
431
  }
292
- return model ? { provider, model } : null;
432
+ return model ? { provider, model, source: 'discovered' } : null;
293
433
  }
294
434
 
295
435
  /**
296
- * Ask the model to choose from the pool.
297
- * @returns {Promise<{picks: number[], vibe: string} | null>} `null` when the
298
- * tier is unavailable or answered with nothing usable.
436
+ * The candidate catalogue and the listener's taste, as one prompt.
437
+ *
438
+ * One place builds the prompt, so a leaf call and any future caller reason
439
+ * about exactly the same brief.
299
440
  */
300
- async #askModel({ pool, digest, prompt, count }) {
301
- const llm = this.resolveLlm?.();
302
- if (!llm?.stream) {
303
- return this.#decline('no LLM service is mounted (ctx.get("llm") returned nothing)');
304
- }
305
- const target = await this.#resolveTarget(llm);
306
- if (!target?.provider || !target?.model) {
307
- return this.#decline(
308
- this.model.provider || this.model.model
309
- ? `no usable route for the configured provider/model (${this.model.provider ?? '?'}/${this.model.model ?? '?'})`
310
- : 'no provider route is registered, and none could be discovered',
311
- );
312
- }
313
-
441
+ #brief({ pool, digest, prompt, count }) {
314
442
  const catalogue = pool
315
443
  .map((track, index) => `${index}. ${track.name} — ${(track.artists ?? []).join('/')}${track.album ? ` (${track.album})` : ''}`)
316
444
  .join('\n');
317
- const brief = [
445
+ return [
318
446
  `Mood or request: ${prompt?.trim() || '(none — continue the current listening session)'}`,
319
447
  `Currently playing: ${this.player.current() ? `${this.player.current().name} — ${this.player.current().artists.join('/')}` : '(nothing)'}`,
320
448
  digest.recentlyPlayed.length ? `Recently played: ${digest.recentlyPlayed.join('; ')}` : '',
@@ -328,8 +456,11 @@ export class AiDj {
328
456
  ]
329
457
  .filter(Boolean)
330
458
  .join('\n');
459
+ }
331
460
 
332
- const messages = [
461
+ /** The two-block user message both transports send: instruction, then brief. */
462
+ #messages(brief) {
463
+ return [
333
464
  {
334
465
  role: 'user',
335
466
  content: [
@@ -347,27 +478,82 @@ export class AiDj {
347
478
  ],
348
479
  },
349
480
  ];
481
+ }
482
+
483
+ /**
484
+ * Turn an answer into ordered candidate indices.
485
+ *
486
+ * Tolerant on purpose: a model may wrap the object in prose or a code fence,
487
+ * and indices outside the pool are dropped rather than trusted.
488
+ *
489
+ * @throws {Error} when the answer carries no JSON object or no usable index.
490
+ */
491
+ #parseAnswer(text, pool) {
492
+ const json = /\{[\s\S]*\}/.exec(text ?? '');
493
+ if (!json) throw new Error('response contained no JSON object');
494
+ const parsed = JSON.parse(json[0]);
495
+ const picks = (parsed.picks ?? [])
496
+ .map((pick) => Number(typeof pick === 'object' ? pick.index : pick))
497
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < pool.length);
498
+ if (picks.length === 0) throw new Error('the model returned no usable picks');
499
+ const seen = new Set();
500
+ const ordered = [];
501
+ for (const index of picks) {
502
+ if (seen.has(index)) continue;
503
+ seen.add(index);
504
+ ordered.push(index);
505
+ }
506
+ return { picks: ordered, vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '' };
507
+ }
508
+
509
+ /** Record a model-tier success: clear the stale reason and log what ran. */
510
+ #accept(answer, target) {
511
+ this.modelError = null;
512
+ this.player.dj.modelError = null;
513
+ this.logger?.info?.(
514
+ `[music] DJ model tier chose ${answer.picks.length} track(s) via ${target.provider}/${target.model}` +
515
+ `${target.source === 'discovered' ? ' (discovered)' : ''}`,
516
+ );
517
+ return { ...answer, target };
518
+ }
519
+
520
+ /**
521
+ * Ask the model to choose from the pool.
522
+ *
523
+ * One leaf call, carrying the DJ's session identity. A failure declines to the
524
+ * heuristic tier and records the coded reason rather than being retried: a
525
+ * second full prompt against a provider that just failed is how one outage
526
+ * becomes two.
527
+ *
528
+ * @returns {Promise<{picks: number[], vibe: string} | null>}
529
+ * `null` when the tier is unavailable or answered with nothing usable.
530
+ */
531
+ async #askModel({ pool, digest, prompt, count }) {
532
+ const llm = this.resolveLlm?.();
533
+ if (!llm?.stream) {
534
+ return this.#decline('no LLM service is mounted (ctx.get("llm") returned nothing)');
535
+ }
536
+
537
+ const target = await this.#resolveTarget(llm);
538
+ if (!target?.provider || !target?.model) {
539
+ this.resolvedFrom = null;
540
+ return this.#decline(
541
+ this.model.provider || this.model.model
542
+ ? `no usable route for the configured provider/model (${this.model.provider ?? '?'}/${this.model.model ?? '?'})`
543
+ : 'no provider route is registered, and none could be discovered',
544
+ );
545
+ }
546
+ this.resolvedFrom = target.source;
350
547
 
548
+ const brief = this.#brief({ pool, digest, prompt, count });
351
549
  try {
352
- const text = await this.#collect(llm.stream({ ...target, messages }));
353
- const json = /\{[\s\S]*\}/.exec(text);
354
- if (!json) throw new Error('response contained no JSON object');
355
- const parsed = JSON.parse(json[0]);
356
- const picks = (parsed.picks ?? [])
357
- .map((pick) => Number(typeof pick === 'object' ? pick.index : pick))
358
- .filter((index) => Number.isInteger(index) && index >= 0 && index < pool.length);
359
- if (picks.length === 0) return this.#decline('the model returned no usable picks');
360
- const seen = new Set();
361
- const ordered = [];
362
- for (const index of picks) {
363
- if (seen.has(index)) continue;
364
- seen.add(index);
365
- ordered.push(index);
366
- }
367
- this.logger?.info?.(`[music] DJ model tier chose ${ordered.length} track(s) via ${target.provider}/${target.model}`);
368
- this.modelError = null;
369
- this.player.dj.modelError = null;
370
- return { picks: ordered, vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '', target };
550
+ const request = curationRequest({
551
+ target,
552
+ messages: this.#messages(brief),
553
+ sessionId: this.sessionId,
554
+ });
555
+ const text = await this.#collect(llm.stream(request));
556
+ return this.#accept(this.#parseAnswer(text, pool), target);
371
557
  } catch (error) {
372
558
  return this.#decline(describeFailure(error));
373
559
  }
package/lib/index.js CHANGED
@@ -141,11 +141,39 @@ export function apply(ctx, config = {}) {
141
141
  }
142
142
  };
143
143
 
144
+ /**
145
+ * The deployment's default model selection: the route a freshly created agent
146
+ * starts on, and the same choice the composer/command model picker writes.
147
+ *
148
+ * This is the seam that makes the agent-loop model and the DJ's model two
149
+ * separate decisions. `agent-default-model` owns the first; `dj.provider` /
150
+ * `dj.model` owns the second; and the DJ follows the session model when the
151
+ * plugin pins nothing, instead of picking an arbitrary first route.
152
+ */
153
+ const readAgentDefault = () => {
154
+ try {
155
+ const service = ctx.get?.('agentDefaultModel') ?? ctx.agentDefaultModel;
156
+ const selection = service?.currentSelection?.();
157
+ if (!selection?.provider || !selection?.model) return null;
158
+ return {
159
+ provider: String(selection.provider),
160
+ model: String(selection.model),
161
+ ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
162
+ };
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
167
+
144
168
  const dj = new AiDj({
145
169
  api,
146
170
  player,
147
171
  store,
148
172
  resolveLlm,
173
+ readAgentDefault,
174
+ // An operator-supplied identity for the DJ's model calls; otherwise one is
175
+ // minted on first use and persisted in session.json.
176
+ sessionId: config.dj?.sessionId,
149
177
  logger,
150
178
  // The picker's saved choice wins over the profile patch, so a route chosen
151
179
  // in the GUI is not silently overridden by a stale `config.dj` pin.
@@ -206,16 +234,21 @@ export function apply(ctx, config = {}) {
206
234
  busy: player.dj.busy,
207
235
  source: player.dj.source,
208
236
  model: dj.modelConfigured ? `${dj.model.provider}/${dj.model.model}` : null,
209
- /** Where the live route came from: the player, the patch, or discovery. */
237
+ /** Where the live route came from: the player, the patch, or a default. */
210
238
  modelSource: store.settings.djProvider || store.settings.djModel
211
239
  ? 'panel'
212
240
  : config.dj?.provider || config.dj?.model
213
241
  ? 'config'
214
- : 'discovered',
242
+ : readAgentDefault()
243
+ ? 'agent-default'
244
+ : 'discovered',
215
245
  /** Route that actually curated the last batch, model tier or not. */
216
246
  lastRoute: player.dj.lastPlan?.route ?? null,
217
247
  /** Why the model tier last declined; null once it succeeds. */
218
248
  modelError: player.dj.modelError ?? null,
249
+ /** Where the route comes from, what the last plan resolved, and the
250
+ * identity every call carries (the provider sees it as a header). */
251
+ ...dj.modelStatus(),
219
252
  lastPlanAt: player.dj.lastPlanAt,
220
253
  error: player.dj.error,
221
254
  },
package/lib/netease.js CHANGED
@@ -294,21 +294,37 @@ export class Netease {
294
294
 
295
295
  /**
296
296
  * Raw streaming fetch, used by the audio proxy. The caller owns the body.
297
+ *
298
+ * The deadline covers only the connect and the response headers. The body
299
+ * then streams for as long as the browser keeps reading — potentially the
300
+ * whole track, because the element pauses and resumes its reads as its
301
+ * buffer fills and drains. A whole-request timeout here (`AbortSignal`
302
+ * watches the body too) used to cut every stream still open at that point;
303
+ * the panel read the truncation as a failed track and skipped mid-song. A
304
+ * body that stops flowing is still bounded by undici's own idle timeout.
305
+ *
297
306
  * @returns {Promise<Response>}
298
307
  */
299
- async fetchAudio(url, { range, method = 'GET' } = {}) {
308
+ async fetchAudio(url, { range, method = 'GET', headerTimeoutMs = 30_000 } = {}) {
300
309
  const headers = {
301
310
  Referer: `${API_ORIGIN}/`,
302
311
  'User-Agent': UA,
303
312
  Cookie: this.jar.header(),
304
313
  };
305
314
  if (range) headers.Range = range;
306
- const response = await fetch(url, {
307
- method,
308
- headers,
309
- redirect: 'follow',
310
- signal: AbortSignal.timeout(30_000),
311
- });
315
+ const controller = new AbortController();
316
+ const deadline = setTimeout(() => controller.abort(), headerTimeoutMs);
317
+ let response;
318
+ try {
319
+ response = await fetch(url, {
320
+ method,
321
+ headers,
322
+ redirect: 'follow',
323
+ signal: controller.signal,
324
+ });
325
+ } finally {
326
+ clearTimeout(deadline);
327
+ }
312
328
  this.jar.absorb(response);
313
329
  return response;
314
330
  }