@audiofool/moondog 0.1.0 → 0.2.0-beta.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.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -0,0 +1,108 @@
1
+ // What the listener plays right now, wherever it plays: the Music app on this Mac, Spotify on its
2
+ // active device, or NetEase through ncm-cli, with the playlist, album or queue it plays from.
3
+ // Two players rarely play at once. When they do, one that plays wins over a paused one, then the
4
+ // listener's chosen service, then this order.
5
+ export const LISTENING_ORDER = Object.freeze(["apple_music", "spotify", "netease"]);
6
+
7
+ export function activeListening(sources, chosen) {
8
+ const order = [...new Set([chosen, ...LISTENING_ORDER])];
9
+ const heard = sources.filter((source) => source.song && (source.state === "playing" || source.state === "paused"));
10
+ return heard.sort((a, b) => (a.state === "playing" ? 0 : 1) - (b.state === "playing" ? 0 : 1) ||
11
+ order.indexOf(a.service) - order.indexOf(b.service))[0] ?? null;
12
+ }
13
+
14
+ const songOf = (item) => ({
15
+ title: item.name ?? item.title ?? "",
16
+ artists: Array.isArray(item.artists) ? item.artists : [],
17
+ album: item.album ?? null,
18
+ duration_ms: Number.isInteger(item.duration_ms) ? item.duration_ms : null,
19
+ ...(item.track_ref_id ? { track_ref_id: item.track_ref_id } : {}),
20
+ });
21
+
22
+ // What the Music app plays, as one source. Its playlist's songs come only from a read that asked for them.
23
+ export function musicAppSource(now) {
24
+ if (!now.song) return { state: now.state, ...(now.reason ? { reason: now.reason } : {}) };
25
+ const playlist = now.playlist;
26
+ return {
27
+ state: now.state,
28
+ player: "Music app on this Mac",
29
+ song: songOf(now.song),
30
+ position_ms: now.position_ms,
31
+ shuffle: now.shuffle,
32
+ repeat: now.repeat,
33
+ from: playlist ? {
34
+ kind: playlist.library ? "library" : "playlist",
35
+ name: playlist.name,
36
+ tracks_total: playlist.tracks_total ?? null,
37
+ position: playlist.position ?? null,
38
+ ...(playlist.tracks ? { first_position: playlist.first_position, tracks: playlist.tracks.map(songOf) } : {}),
39
+ } : null,
40
+ };
41
+ }
42
+
43
+ const musicApp = async (application) => musicAppSource(await application.musicApp.nowPlaying({ tracks: true }));
44
+
45
+ // Spotify reports only the device that plays; reading it costs three or four requests of the quota.
46
+ async function spotify(application, { signal }) {
47
+ const player = await application.spotifyNowPlaying({ signal });
48
+ if (player.state !== "available" || !player.item) return { state: "stopped" };
49
+ const context = player.context ?? null;
50
+ const [queue, playlist] = await Promise.all([
51
+ application.spotifyQueueStatus({ signal }).catch((error) => (signal?.aborted ? Promise.reject(error) : null)),
52
+ context?.type === "playlist"
53
+ ? application.requireSpotifyService().playingPlaylist({ uri: context.uri }, { signal })
54
+ .catch((error) => (signal?.aborted ? Promise.reject(error) : { unavailable: String(error?.message ?? error).slice(0, 200) }))
55
+ : null,
56
+ ]);
57
+ const from = context?.type === "playlist"
58
+ ? { kind: "playlist", name: playlist.name ?? null, tracks_total: playlist.tracks_total ?? null,
59
+ ...(playlist.tracks ? { first_position: 1, tracks: playlist.tracks.map(songOf) } : {}),
60
+ ...(playlist.unavailable ? { unavailable: playlist.unavailable } : {}) }
61
+ : context?.type === "album" ? { kind: "album", name: player.item.album ?? null }
62
+ : context?.type === "artist" ? { kind: "artist", name: player.item.artists?.[0] ?? null }
63
+ : context?.type === "collection" ? { kind: "liked_songs", name: "Liked Songs" }
64
+ : null;
65
+ return {
66
+ state: player.is_playing === true ? "playing" : "paused",
67
+ player: player.device?.name ? `Spotify on ${player.device.name}` : "Spotify",
68
+ ...(player.device ? { device: { name: player.device.name ?? null, type: player.device.type ?? null } } : {}),
69
+ song: songOf(player.item),
70
+ position_ms: player.progress_ms ?? 0,
71
+ shuffle: player.shuffle_state === true,
72
+ repeat: player.repeat_state ?? "off",
73
+ from,
74
+ next: (queue?.queue ?? []).map(songOf),
75
+ };
76
+ }
77
+
78
+ async function netease(application) {
79
+ const now = await application.neteaseNowPlaying();
80
+ if (now.state === "stopped" || !now.song) return { state: "stopped" };
81
+ return {
82
+ state: now.state,
83
+ player: "NetEase Music through ncm-cli on this Mac",
84
+ song: songOf(now.song),
85
+ position_ms: now.position_ms ?? 0,
86
+ from: { kind: "moondog_queue", name: null },
87
+ next: (now.next ?? []).map(songOf),
88
+ };
89
+ }
90
+
91
+ /** Every player Moondog can read, the one the listener hears first: { active, sources }. */
92
+ export async function readListeningNow(application, { signal } = {}) {
93
+ const readers = [
94
+ application.musicApp?.available ? ["apple_music", () => musicApp(application)] : null,
95
+ application.spotifyReady() ? ["spotify", () => spotify(application, { signal })] : null,
96
+ application.neteaseReady() ? ["netease", () => netease(application)] : null,
97
+ ].filter(Boolean);
98
+ const sources = await Promise.all(readers.map(async ([service, read]) => {
99
+ try {
100
+ return { service, ...(await read()) };
101
+ } catch (error) {
102
+ if (signal?.aborted) throw error;
103
+ return { service, state: "unreadable", reason: String(error?.message ?? error).slice(0, 200) };
104
+ }
105
+ }));
106
+ const active = activeListening(sources, application.playbackService);
107
+ return { active: active?.service ?? null, sources: active ? [active, ...sources.filter((source) => source !== active)] : sources };
108
+ }
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
+ import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
2
4
  import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
3
5
 
4
6
  import { isUuid } from "./uuid-v5.mjs";
@@ -6,6 +8,7 @@ import { createListeningHistoryProfileProjection } from "../profile/spotify-arch
6
8
  import {
7
9
  assertExternalPlanDiversity,
8
10
  exactTitleArtistMatch,
11
+ listenerAssertionAvoids,
9
12
  safeExternalSource,
10
13
  safeExternalTrack,
11
14
  } from "./apple-projection-domain-services.mjs";
@@ -75,6 +78,11 @@ function safeCount(value, label) {
75
78
  return value;
76
79
  }
77
80
 
81
+ // A pick leaves out minutes or skips that its plays never recorded.
82
+ function optionalCount(context, key, label) {
83
+ return context[key] === undefined ? {} : { [key]: safeCount(context[key], label) };
84
+ }
85
+
78
86
  function safeTimestamp(value, label) {
79
87
  const cleaned = cleanText(value, 64, label);
80
88
  const parsed = Date.parse(cleaned);
@@ -86,15 +94,19 @@ function safeTimestamp(value, label) {
86
94
 
87
95
  function safeExternalRefs(value) {
88
96
  if (value === undefined) return [];
89
- if (!Array.isArray(value) || value.length > 1) {
97
+ if (!Array.isArray(value)) {
98
+ throw new TypeError("Listening-history provider identity is invalid");
99
+ }
100
+ // Only a Spotify track ID is kept for playback. Last.fm, ListenBrainz and
101
+ // Spotify account-data identities stay in the store.
102
+ const references = value.filter((reference) => reference?.system === "spotify");
103
+ if (references.length > 1) {
90
104
  throw new TypeError("Listening-history provider identity is invalid");
91
105
  }
92
- return value.map((reference) => {
106
+ return references.map((reference) => {
93
107
  if (
94
- !reference ||
95
108
  typeof reference !== "object" ||
96
109
  Array.isArray(reference) ||
97
- reference.system !== "spotify" ||
98
110
  reference.entity_type !== "spotify.track" ||
99
111
  typeof reference.external_id !== "string" ||
100
112
  !SPOTIFY_TRACK_ID.test(reference.external_id)
@@ -188,18 +200,16 @@ function safeHistoryTrack(value) {
188
200
  throw new TypeError("Rediscovery signal is invalid");
189
201
  }
190
202
  track.rediscovery = {
191
- listening_minutes: safeCount(
192
- context.listening_minutes,
203
+ ...optionalCount(
204
+ context,
205
+ "listening_minutes",
193
206
  "Rediscovery listening minutes",
194
207
  ),
195
208
  engaged_play_count: safeCount(
196
209
  context.engaged_play_count,
197
210
  "Rediscovery engaged plays",
198
211
  ),
199
- explicit_skips: safeCount(
200
- context.explicit_skips,
201
- "Rediscovery skips",
202
- ),
212
+ ...optionalCount(context, "explicit_skips", "Rediscovery skips"),
203
213
  first_played_at: safeTimestamp(
204
214
  context.first_played_at,
205
215
  "Rediscovery first play",
@@ -217,8 +227,9 @@ function safeHistoryTrack(value) {
217
227
  context.peak_year_play_count,
218
228
  "Rediscovery peak-year plays",
219
229
  ),
220
- peak_year_listening_minutes: safeCount(
221
- context.peak_year_listening_minutes,
230
+ ...optionalCount(
231
+ context,
232
+ "peak_year_listening_minutes",
222
233
  "Rediscovery peak-year minutes",
223
234
  ),
224
235
  }
@@ -260,8 +271,9 @@ function safeHistoryTrack(value) {
260
271
  context.engaged_play_count,
261
272
  "Historical-return engaged plays",
262
273
  );
263
- const explicitSkips = safeCount(
264
- context.explicit_skips,
274
+ const explicitSkips = optionalCount(
275
+ context,
276
+ "explicit_skips",
265
277
  "Historical-return skips",
266
278
  );
267
279
  if (
@@ -270,7 +282,7 @@ function safeHistoryTrack(value) {
270
282
  latestReturnGapDays < 1 ||
271
283
  latestReturnGapDays > longestGapDays ||
272
284
  engagedPlayCount > playCount ||
273
- explicitSkips > playCount ||
285
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
274
286
  Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
275
287
  Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
276
288
  !historySupportSignals.has(historicalReturnSignal) ||
@@ -279,12 +291,13 @@ function safeHistoryTrack(value) {
279
291
  throw new TypeError("Historical-return candidate is invalid");
280
292
  }
281
293
  track.historical_return = {
282
- listening_minutes: safeCount(
283
- context.listening_minutes,
294
+ ...optionalCount(
295
+ context,
296
+ "listening_minutes",
284
297
  "Historical-return listening minutes",
285
298
  ),
286
299
  engaged_play_count: engagedPlayCount,
287
- explicit_skips: explicitSkips,
300
+ ...explicitSkips,
288
301
  first_played_at: firstPlayedAt,
289
302
  last_played_at: lastPlayedAt,
290
303
  return_count: returnCount,
@@ -310,16 +323,15 @@ function safeHistoryTrack(value) {
310
323
  context.year_engaged_play_count,
311
324
  "Time Machine engaged plays",
312
325
  ),
313
- year_listening_minutes: safeCount(
314
- context.year_listening_minutes,
326
+ ...optionalCount(
327
+ context,
328
+ "year_listening_minutes",
315
329
  "Time Machine year minutes",
316
330
  ),
317
- year_explicit_skips: safeCount(
318
- context.year_explicit_skips,
319
- "Time Machine year skips",
320
- ),
321
- lifetime_listening_minutes: safeCount(
322
- context.lifetime_listening_minutes,
331
+ ...optionalCount(context, "year_explicit_skips", "Time Machine year skips"),
332
+ ...optionalCount(
333
+ context,
334
+ "lifetime_listening_minutes",
323
335
  "Time Machine lifetime minutes",
324
336
  ),
325
337
  representative_signal: cleanText(
@@ -335,8 +347,9 @@ function safeHistoryTrack(value) {
335
347
  context.engaged_play_count,
336
348
  "Back-to-back engaged plays",
337
349
  );
338
- const explicitSkips = safeCount(
339
- context.explicit_skips,
350
+ const explicitSkips = optionalCount(
351
+ context,
352
+ "explicit_skips",
340
353
  "Back-to-back skips",
341
354
  );
342
355
  const burstCount = safeCount(
@@ -363,7 +376,7 @@ function safeHistoryTrack(value) {
363
376
  playsInBursts < burstCount * 2 ||
364
377
  playsInBursts > playCount ||
365
378
  engagedPlayCount > playCount ||
366
- explicitSkips > playCount ||
379
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
367
380
  !backToBackSignals.has(sequenceSignal) ||
368
381
  !isUuid(context.evidence_id)
369
382
  ) {
@@ -371,7 +384,7 @@ function safeHistoryTrack(value) {
371
384
  }
372
385
  track.back_to_back = {
373
386
  engaged_play_count: engagedPlayCount,
374
- explicit_skips: explicitSkips,
387
+ ...explicitSkips,
375
388
  burst_count: burstCount,
376
389
  maximum_consecutive_plays: maximumConsecutivePlays,
377
390
  plays_in_bursts: playsInBursts,
@@ -409,6 +422,8 @@ export class ListeningProfileDomainServices {
409
422
  #store;
410
423
  #subjectId;
411
424
  #candidateSets = new Map();
425
+ #profileBuildInput;
426
+ #ownsStore = true;
412
427
 
413
428
  constructor({ listeningHistoryStore, subjectId } = {}) {
414
429
  if (
@@ -426,6 +441,18 @@ export class ListeningProfileDomainServices {
426
441
  this.#subjectId = subjectId.toLowerCase();
427
442
  }
428
443
 
444
+ // Each conversation that runs beside another gets a fork: the same store with its own
445
+ // candidate sets, so one conversation's prompt never clears another's. Only the
446
+ // original closes the store.
447
+ fork() {
448
+ const fork = new ListeningProfileDomainServices({
449
+ listeningHistoryStore: this.#store,
450
+ subjectId: this.#subjectId,
451
+ });
452
+ fork.#ownsStore = false;
453
+ return fork;
454
+ }
455
+
429
456
  status() {
430
457
  return {
431
458
  state: "ready",
@@ -458,6 +485,14 @@ export class ListeningProfileDomainServices {
458
485
  };
459
486
  }
460
487
 
488
+ listeningCoverage() {
489
+ return this.#store.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
490
+ }
491
+
492
+ recentAttention() {
493
+ return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
494
+ }
495
+
461
496
  rediscoveryReady() {
462
497
  return typeof this.#store.rediscoveryCandidates === "function";
463
498
  }
@@ -518,7 +553,19 @@ export class ListeningProfileDomainServices {
518
553
  return { candidateSetId, tracks: prepared };
519
554
  }
520
555
 
521
- registerExternalCandidateSet({ tracks, source } = {}) {
556
+ filterDiscoveryTracks(tracks) {
557
+ const avoids = this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
558
+ this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
559
+ return tracks.filter((track) => !listenerAssertionAvoids(
560
+ { listener_assertions: { avoids } }, "track", track.title, track.artist_credit,
561
+ ));
562
+ }
563
+
564
+ isKnownDiscoveryTrack(track) {
565
+ return (this.#store.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
566
+ }
567
+
568
+ registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
522
569
  if (!Array.isArray(tracks) || tracks.length < 1 || tracks.length > HISTORY_MAX_ITEMS) {
523
570
  throw new TypeError("External catalog candidates are invalid");
524
571
  }
@@ -530,14 +577,16 @@ export class ListeningProfileDomainServices {
530
577
  if (new Set(prepared.map((track) => track.track_ref_id)).size !== prepared.length) {
531
578
  throw new TypeError("External catalog candidates are duplicated");
532
579
  }
533
- // Outside suggestions should be new to the listener, so drop anything already played.
580
+ // Familiarity is useful evidence, not an automatic discovery exclusion.
534
581
  const listened = typeof this.#store.listenedTracks === "function"
535
582
  ? this.#store.listenedTracks({ subjectId: this.#subjectId })
536
583
  : [];
537
584
  const accepted = [];
538
585
  let excludedHistoryMatches = 0;
539
- for (const track of prepared) {
540
- if (listened.some((heard) => exactTitleArtistMatch(track, heard))) {
586
+ const allowed = this.filterDiscoveryTracks(prepared);
587
+ for (const track of allowed) {
588
+ const heard = listened.some((heard) => exactTitleArtistMatch(track, heard));
589
+ if (excludeKnown && heard) {
541
590
  excludedHistoryMatches += 1;
542
591
  continue;
543
592
  }
@@ -545,7 +594,7 @@ export class ListeningProfileDomainServices {
545
594
  ...track,
546
595
  knownness: {
547
596
  imported_library: "not_imported",
548
- listening_history: "not_found_by_exact_title_artist",
597
+ listening_history: heard ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
549
598
  },
550
599
  });
551
600
  }
@@ -560,6 +609,7 @@ export class ListeningProfileDomainServices {
560
609
  candidate_set_id: registered,
561
610
  candidate_scope: "external_catalog",
562
611
  result_count: accepted.length,
612
+ excluded_avoided_matches: prepared.length - allowed.length,
563
613
  excluded_library_matches: excludedHistoryMatches,
564
614
  expires_on: "prompt_end",
565
615
  source: trustedSource,
@@ -584,6 +634,35 @@ export class ListeningProfileDomainServices {
584
634
  return createListeningHistoryProfileProjection(listening, { maxItems });
585
635
  }
586
636
 
637
+ profileExplorationReady() {
638
+ return typeof this.#store.profileCatalog === "function";
639
+ }
640
+
641
+ async exploreProfile(argumentsValue) {
642
+ const input = profileExplorationInput(argumentsValue);
643
+ return exploreProfileCatalog({ listening: this.#store.profileCatalog({ subjectId: this.#subjectId }), input });
644
+ }
645
+
646
+ profileBuildReady() {
647
+ return typeof this.#store.listenerProfileStorage === "function";
648
+ }
649
+
650
+ getListenerProfileStore() {
651
+ return this.#store.listenerProfileStorage({ subjectId: this.#subjectId });
652
+ }
653
+
654
+ getProfileBuildContext() {
655
+ const version = this.#store.profileDataVersion();
656
+ if (this.#profileBuildInput?.version !== version) {
657
+ this.#profileBuildInput = { version, input: createListenerProfileInput({
658
+ subjectId: this.#subjectId,
659
+ evidenceRevision: this.#store.profileEvidenceRevision({ subjectId: this.#subjectId }),
660
+ listening: this.#store.profileCatalog({ subjectId: this.#subjectId }),
661
+ }) };
662
+ }
663
+ return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
664
+ }
665
+
587
666
  async getRediscoveryCandidates(argumentsValue) {
588
667
  const input = modelArguments(argumentsValue, new Set(["limit"]));
589
668
  const limit = boundedHistoryItems(input.limit);
@@ -896,6 +975,11 @@ export class ListeningProfileDomainServices {
896
975
  ) {
897
976
  throw new TypeError("A playlist plan cannot contain duplicate tracks");
898
977
  }
978
+ const external = selected.map(({ track }) => track)
979
+ .filter((track) => track.candidate_scope === "external_catalog");
980
+ if (this.filterDiscoveryTracks(external).length !== external.length) {
981
+ throw new Error("An external discovery is currently marked Avoid. Choose another track.");
982
+ }
899
983
  const tracks = selected.map(({ track, selectionReason }, index) => ({
900
984
  position: index + 1,
901
985
  track_ref_id: track.track_ref_id,
@@ -972,13 +1056,41 @@ export class ListeningProfileDomainServices {
972
1056
  ) {
973
1057
  throw new TypeError("Retained playlist candidates are invalid");
974
1058
  }
975
- const registered = this.#registerHistoryTracks(tracks);
1059
+ if (this.#candidateSets.size >= CANDIDATE_SETS_MAX) {
1060
+ throw new Error("The prompt has reached its candidate set limit");
1061
+ }
1062
+ const prepared = tracks.map((track) =>
1063
+ track?.candidate_scope === "external_catalog"
1064
+ ? {
1065
+ ...safeExternalTrack(track),
1066
+ knownness: {
1067
+ imported_library: "not_imported",
1068
+ listening_history: "not_found_by_exact_title_artist",
1069
+ },
1070
+ }
1071
+ : safeHistoryTrack(track),
1072
+ );
1073
+ if (
1074
+ new Set(prepared.map((track) => track.track_ref_id)).size !==
1075
+ prepared.length
1076
+ ) {
1077
+ throw new TypeError("Retained playlist candidates are duplicated");
1078
+ }
1079
+ const candidateSetId = randomUUID();
1080
+ this.#candidateSets.set(
1081
+ candidateSetId,
1082
+ new Map(
1083
+ prepared.map((track) => [track.track_ref_id, structuredClone(track)]),
1084
+ ),
1085
+ );
1086
+ const scopes = new Set(prepared.map((track) => track.candidate_scope));
976
1087
  return {
977
- candidate_set_id: registered.candidateSetId,
978
- candidate_scope: "private_history",
979
- result_count: registered.tracks.length,
1088
+ candidate_set_id: candidateSetId,
1089
+ candidate_scope: scopes.size === 1 ? [...scopes][0] : "mixed",
1090
+ result_count: prepared.length,
980
1091
  expires_on: "prompt_end",
981
- tracks: registered.tracks.map(publicHistoryTrack),
1092
+ source: "retained_validated_playlist",
1093
+ tracks: prepared.map(publicHistoryTrack),
982
1094
  };
983
1095
  }
984
1096
 
@@ -1001,7 +1113,7 @@ export class ListeningProfileDomainServices {
1001
1113
 
1002
1114
  close() {
1003
1115
  this.resetCandidateSets();
1004
- this.#store.close();
1116
+ if (this.#ownsStore) this.#store.close();
1005
1117
  }
1006
1118
  }
1007
1119
 
@@ -0,0 +1,31 @@
1
+ // Spotify keeps only a listener's last 50 plays, about three hours of listening. The
2
+ // room runs its listening upkeep when it opens and every half hour while it runs: it
3
+ // reads recent plays before they fall out, then rebuilds the saved reading when due.
4
+ export const LISTENING_UPKEEP_INTERVAL_MS = 30 * 60_000;
5
+
6
+ export function startListeningUpkeep({ run, intervalMs = LISTENING_UPKEEP_INTERVAL_MS }) {
7
+ let running = null;
8
+ let stopped = false;
9
+ const tick = async () => {
10
+ // A rebuild can outlast the half hour; the next tick waits for the one after.
11
+ if (stopped || running) return;
12
+ running = new AbortController();
13
+ try {
14
+ await run({ signal: running.signal });
15
+ } catch {
16
+ // Signed out, offline or rate limited: the next tick tries again, and nothing waits on this one.
17
+ } finally {
18
+ running = null;
19
+ }
20
+ };
21
+ const timer = setInterval(tick, intervalMs);
22
+ timer.unref?.();
23
+ tick();
24
+ return {
25
+ stop() {
26
+ stopped = true;
27
+ clearInterval(timer);
28
+ running?.abort();
29
+ },
30
+ };
31
+ }