@audiofool/moondog 0.1.0 → 0.2.0-beta.3

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 (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -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 +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -0,0 +1,101 @@
1
+ // One Avoid check for every play path. Names fold case, accents and spacing, so "Drake " and
2
+ // "Drake" are one artist, and an artist Avoid matches a whole credit or any artist in it.
3
+
4
+ // Collaboration separators in artist credits. Full-width forms fold to these before splitting,
5
+ // but callers that do not fold text first can still split on them. A word separator needs a
6
+ // space before it, which a separator before it may already have taken.
7
+ export const ARTIST_CREDIT_SEPARATORS =
8
+ /\s*(?:,|,|;|;|&|&|\/|、|和|与)\s*|(?<=\s)(?:featuring|feat\.?|ft\.?|with)\s+/iu;
9
+
10
+ // "x" also joins artists ("Future x Drake"), but it ends some names ("Lil Nas X"). Only this
11
+ // check splits on it, because it also reads each run of artists as one name.
12
+ const separated = new RegExp(`(${ARTIST_CREDIT_SEPARATORS.source}|(?<=\\s)(?:x|×)\\s+)`, "iu");
13
+
14
+ // Features named in a title: "Song (feat. Drake)", "Song [with Drake]" or "Song feat. Drake".
15
+ const titleFeature =
16
+ /\s*[((【[]\s*(?:featuring|feat\.?|ft\.?|with)\s+([^))】\]]+)[))】\]]|\s+(?:-\s+)?(?:featuring|feat\.|ft\.)\s+(.+)$/giu;
17
+
18
+ export function avoidKey(value) {
19
+ if (typeof value !== "string") return "";
20
+ // Only Latin, Greek and Cyrillic accents fold: kana voicing marks change the word.
21
+ return value.normalize("NFKD").replace(/[̀-ͯ]/gu, "")
22
+ .toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
23
+ }
24
+
25
+ // A title without its features, and the artists those features name.
26
+ function titleParts(title) {
27
+ const featured = [];
28
+ const core = typeof title === "string"
29
+ ? title.replace(titleFeature, (_whole, bracketed, trailing) => { featured.push(bracketed ?? trailing); return ""; })
30
+ : title;
31
+ return { core: avoidKey(core), featured };
32
+ }
33
+
34
+ // Every way to read free-text credits as one artist: the whole credit, each credited artist, and
35
+ // each run of them, so a band named "Simon & Garfunkel" stays one name inside a longer credit.
36
+ // A source's own list of artists names each one whole, so its names are not split.
37
+ function creditKeys(credit, artists = [], featured = []) {
38
+ const listed = (Array.isArray(artists) ? artists : []).map(avoidKey).filter(Boolean);
39
+ const keys = new Set(listed);
40
+ if (listed.length) keys.add(avoidKey(credit));
41
+ for (const name of [...(listed.length ? [] : [credit]), ...featured]) {
42
+ const pieces = avoidKey(name).split(separated);
43
+ for (let start = 0; start < pieces.length; start += 2) {
44
+ for (let end = start; end < pieces.length; end += 2) {
45
+ const key = pieces.slice(start, end + 1).join("").trim();
46
+ if (key) keys.add(key);
47
+ }
48
+ }
49
+ }
50
+ keys.delete("");
51
+ return keys;
52
+ }
53
+
54
+ const overlaps = (left, right) => [...left].some((key) => right.has(key));
55
+
56
+ /**
57
+ * Builds the check from Avoid records ({ entity_type, label, artist_credit }). A track Avoid
58
+ * matches its title, without any features, with any of its credited artists.
59
+ */
60
+ export function createAvoidGate(avoids = []) {
61
+ const artists = new Set();
62
+ const tracks = new Map();
63
+ for (const avoid of Array.isArray(avoids) ? avoids : []) {
64
+ const label = avoidKey(avoid?.label);
65
+ if (!label) continue;
66
+ if (avoid.entity_type === "artist") artists.add(label);
67
+ else if (avoid.entity_type === "track") {
68
+ const { core, featured } = titleParts(avoid.label);
69
+ const title = core || label;
70
+ tracks.set(title, [...(tracks.get(title) ?? []), creditKeys(avoid.artist_credit, [], featured)]);
71
+ }
72
+ }
73
+ const avoidsArtist = (keys) => overlaps(keys, artists);
74
+ return {
75
+ size: artists.size + tracks.size,
76
+ avoidsArtist: (name, names = []) => avoidsArtist(creditKeys(name, names)),
77
+ // A track as { title, artist_credit, artists }, where artists is a source's own list.
78
+ avoidsTrack({ title, artist_credit: credit, artists: names } = {}) {
79
+ const { core, featured } = titleParts(title);
80
+ const keys = creditKeys(credit, names, featured);
81
+ if (avoidsArtist(keys)) return true;
82
+ return (tracks.get(core || avoidKey(title)) ?? []).some((avoided) =>
83
+ avoided.size ? overlaps(avoided, keys) : keys.size === 0);
84
+ },
85
+ };
86
+ }
87
+
88
+ // The gate for the store's current data, rebuilt only when that data changes.
89
+ export function cachedAvoidGate(cache, version, readAvoids) {
90
+ if (version === undefined || cache.version !== version) {
91
+ cache.version = version;
92
+ cache.gate = createAvoidGate(readAvoids());
93
+ }
94
+ return cache.gate;
95
+ }
96
+
97
+ // The store's data version without its own ledger writes, which change no projection until
98
+ // their plays arrive (listeningDataVersion in listening-history-store.mjs).
99
+ export function listeningVersion(store) {
100
+ return store?.listeningDataVersion?.() ?? store?.profileDataVersion?.();
101
+ }
@@ -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,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
+ import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
2
4
  import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
5
+ import { cachedAvoidGate, listeningVersion } from "./listener-avoids.mjs";
6
+ import { recordListenerChoice } from "../profile/listener-corrections.mjs";
3
7
 
4
8
  import { isUuid } from "./uuid-v5.mjs";
5
9
  import { createListeningHistoryProfileProjection } from "../profile/spotify-archive-taste.mjs";
@@ -75,6 +79,11 @@ function safeCount(value, label) {
75
79
  return value;
76
80
  }
77
81
 
82
+ // A pick leaves out minutes or skips that its plays never recorded.
83
+ function optionalCount(context, key, label) {
84
+ return context[key] === undefined ? {} : { [key]: safeCount(context[key], label) };
85
+ }
86
+
78
87
  function safeTimestamp(value, label) {
79
88
  const cleaned = cleanText(value, 64, label);
80
89
  const parsed = Date.parse(cleaned);
@@ -86,15 +95,19 @@ function safeTimestamp(value, label) {
86
95
 
87
96
  function safeExternalRefs(value) {
88
97
  if (value === undefined) return [];
89
- if (!Array.isArray(value) || value.length > 1) {
98
+ if (!Array.isArray(value)) {
90
99
  throw new TypeError("Listening-history provider identity is invalid");
91
100
  }
92
- return value.map((reference) => {
101
+ // Only a Spotify track ID is kept for playback. Last.fm, ListenBrainz and
102
+ // Spotify account-data identities stay in the store.
103
+ const references = value.filter((reference) => reference?.system === "spotify");
104
+ if (references.length > 1) {
105
+ throw new TypeError("Listening-history provider identity is invalid");
106
+ }
107
+ return references.map((reference) => {
93
108
  if (
94
- !reference ||
95
109
  typeof reference !== "object" ||
96
110
  Array.isArray(reference) ||
97
- reference.system !== "spotify" ||
98
111
  reference.entity_type !== "spotify.track" ||
99
112
  typeof reference.external_id !== "string" ||
100
113
  !SPOTIFY_TRACK_ID.test(reference.external_id)
@@ -188,18 +201,16 @@ function safeHistoryTrack(value) {
188
201
  throw new TypeError("Rediscovery signal is invalid");
189
202
  }
190
203
  track.rediscovery = {
191
- listening_minutes: safeCount(
192
- context.listening_minutes,
204
+ ...optionalCount(
205
+ context,
206
+ "listening_minutes",
193
207
  "Rediscovery listening minutes",
194
208
  ),
195
209
  engaged_play_count: safeCount(
196
210
  context.engaged_play_count,
197
211
  "Rediscovery engaged plays",
198
212
  ),
199
- explicit_skips: safeCount(
200
- context.explicit_skips,
201
- "Rediscovery skips",
202
- ),
213
+ ...optionalCount(context, "explicit_skips", "Rediscovery skips"),
203
214
  first_played_at: safeTimestamp(
204
215
  context.first_played_at,
205
216
  "Rediscovery first play",
@@ -217,8 +228,9 @@ function safeHistoryTrack(value) {
217
228
  context.peak_year_play_count,
218
229
  "Rediscovery peak-year plays",
219
230
  ),
220
- peak_year_listening_minutes: safeCount(
221
- context.peak_year_listening_minutes,
231
+ ...optionalCount(
232
+ context,
233
+ "peak_year_listening_minutes",
222
234
  "Rediscovery peak-year minutes",
223
235
  ),
224
236
  }
@@ -260,8 +272,9 @@ function safeHistoryTrack(value) {
260
272
  context.engaged_play_count,
261
273
  "Historical-return engaged plays",
262
274
  );
263
- const explicitSkips = safeCount(
264
- context.explicit_skips,
275
+ const explicitSkips = optionalCount(
276
+ context,
277
+ "explicit_skips",
265
278
  "Historical-return skips",
266
279
  );
267
280
  if (
@@ -270,7 +283,7 @@ function safeHistoryTrack(value) {
270
283
  latestReturnGapDays < 1 ||
271
284
  latestReturnGapDays > longestGapDays ||
272
285
  engagedPlayCount > playCount ||
273
- explicitSkips > playCount ||
286
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
274
287
  Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
275
288
  Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
276
289
  !historySupportSignals.has(historicalReturnSignal) ||
@@ -279,12 +292,13 @@ function safeHistoryTrack(value) {
279
292
  throw new TypeError("Historical-return candidate is invalid");
280
293
  }
281
294
  track.historical_return = {
282
- listening_minutes: safeCount(
283
- context.listening_minutes,
295
+ ...optionalCount(
296
+ context,
297
+ "listening_minutes",
284
298
  "Historical-return listening minutes",
285
299
  ),
286
300
  engaged_play_count: engagedPlayCount,
287
- explicit_skips: explicitSkips,
301
+ ...explicitSkips,
288
302
  first_played_at: firstPlayedAt,
289
303
  last_played_at: lastPlayedAt,
290
304
  return_count: returnCount,
@@ -310,16 +324,15 @@ function safeHistoryTrack(value) {
310
324
  context.year_engaged_play_count,
311
325
  "Time Machine engaged plays",
312
326
  ),
313
- year_listening_minutes: safeCount(
314
- context.year_listening_minutes,
327
+ ...optionalCount(
328
+ context,
329
+ "year_listening_minutes",
315
330
  "Time Machine year minutes",
316
331
  ),
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,
332
+ ...optionalCount(context, "year_explicit_skips", "Time Machine year skips"),
333
+ ...optionalCount(
334
+ context,
335
+ "lifetime_listening_minutes",
323
336
  "Time Machine lifetime minutes",
324
337
  ),
325
338
  representative_signal: cleanText(
@@ -335,8 +348,9 @@ function safeHistoryTrack(value) {
335
348
  context.engaged_play_count,
336
349
  "Back-to-back engaged plays",
337
350
  );
338
- const explicitSkips = safeCount(
339
- context.explicit_skips,
351
+ const explicitSkips = optionalCount(
352
+ context,
353
+ "explicit_skips",
340
354
  "Back-to-back skips",
341
355
  );
342
356
  const burstCount = safeCount(
@@ -363,7 +377,7 @@ function safeHistoryTrack(value) {
363
377
  playsInBursts < burstCount * 2 ||
364
378
  playsInBursts > playCount ||
365
379
  engagedPlayCount > playCount ||
366
- explicitSkips > playCount ||
380
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
367
381
  !backToBackSignals.has(sequenceSignal) ||
368
382
  !isUuid(context.evidence_id)
369
383
  ) {
@@ -371,7 +385,7 @@ function safeHistoryTrack(value) {
371
385
  }
372
386
  track.back_to_back = {
373
387
  engaged_play_count: engagedPlayCount,
374
- explicit_skips: explicitSkips,
388
+ ...explicitSkips,
375
389
  burst_count: burstCount,
376
390
  maximum_consecutive_plays: maximumConsecutivePlays,
377
391
  plays_in_bursts: playsInBursts,
@@ -409,6 +423,10 @@ export class ListeningProfileDomainServices {
409
423
  #store;
410
424
  #subjectId;
411
425
  #candidateSets = new Map();
426
+ #profileBuildInput;
427
+ #avoids = {};
428
+ #status = {};
429
+ #ownsStore = true;
412
430
 
413
431
  constructor({ listeningHistoryStore, subjectId } = {}) {
414
432
  if (
@@ -426,6 +444,18 @@ export class ListeningProfileDomainServices {
426
444
  this.#subjectId = subjectId.toLowerCase();
427
445
  }
428
446
 
447
+ // Each conversation that runs beside another gets a fork: the same store with its own
448
+ // candidate sets, so one conversation's prompt never clears another's. Only the
449
+ // original closes the store.
450
+ fork() {
451
+ const fork = new ListeningProfileDomainServices({
452
+ listeningHistoryStore: this.#store,
453
+ subjectId: this.#subjectId,
454
+ });
455
+ fork.#ownsStore = false;
456
+ return fork;
457
+ }
458
+
429
459
  status() {
430
460
  return {
431
461
  state: "ready",
@@ -435,7 +465,16 @@ export class ListeningProfileDomainServices {
435
465
  };
436
466
  }
437
467
 
468
+ // Every model request reads the status, so it is kept until the listening data changes.
438
469
  profileStatus() {
470
+ const version = listeningVersion(this.#store);
471
+ if (version === undefined || this.#status.version !== version) {
472
+ this.#status = { version, value: this.#readProfileStatus() };
473
+ }
474
+ return structuredClone(this.#status.value);
475
+ }
476
+
477
+ #readProfileStatus() {
439
478
  const source = this.#store.subjectDataStatus({
440
479
  subjectId: this.#subjectId,
441
480
  });
@@ -458,6 +497,27 @@ export class ListeningProfileDomainServices {
458
497
  };
459
498
  }
460
499
 
500
+ listeningCoverage() {
501
+ return this.#store.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
502
+ }
503
+
504
+ recentAttention() {
505
+ return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
506
+ }
507
+
508
+ // Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
509
+ recordMoondogSuggestions(entries) {
510
+ return this.#store.recordMoondogSuggestions?.({ entries }) ?? null;
511
+ }
512
+
513
+ moondogSuggestionHistory(tracks) {
514
+ return this.#store.moondogSuggestionHistory?.({ subjectId: this.#subjectId, tracks }) ?? null;
515
+ }
516
+
517
+ listeningMix(tracks) {
518
+ return this.#store.listeningMix?.({ subjectId: this.#subjectId, tracks }) ?? null;
519
+ }
520
+
461
521
  rediscoveryReady() {
462
522
  return typeof this.#store.rediscoveryCandidates === "function";
463
523
  }
@@ -518,7 +578,30 @@ export class ListeningProfileDomainServices {
518
578
  return { candidateSetId, tracks: prepared };
519
579
  }
520
580
 
521
- registerExternalCandidateSet({ tracks, source } = {}) {
581
+ // Every play path asks for each candidate, so the gate is kept until the data changes.
582
+ filterDiscoveryTracks(tracks) {
583
+ const gate = cachedAvoidGate(this.#avoids, listeningVersion(this.#store), () =>
584
+ this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
585
+ this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? []);
586
+ return tracks.filter((track) => !gate.avoidsTrack(track));
587
+ }
588
+
589
+ recordListenerChoice(choice) {
590
+ return recordListenerChoice(this.#store, { ...choice, subjectId: this.#subjectId });
591
+ }
592
+
593
+ // The choices in force, cheap enough to read when there is no saved reading.
594
+ listenerChoices() {
595
+ if (typeof this.#store.countActiveListenerCorrections !== "function") return null;
596
+ return savedListenerChoices(this.#store.listListenerCorrections({ subjectId: this.#subjectId, limit: 30 }),
597
+ this.#store.countActiveListenerCorrections({ subjectId: this.#subjectId }));
598
+ }
599
+
600
+ isKnownDiscoveryTrack(track) {
601
+ return (this.#store.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
602
+ }
603
+
604
+ registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
522
605
  if (!Array.isArray(tracks) || tracks.length < 1 || tracks.length > HISTORY_MAX_ITEMS) {
523
606
  throw new TypeError("External catalog candidates are invalid");
524
607
  }
@@ -530,14 +613,16 @@ export class ListeningProfileDomainServices {
530
613
  if (new Set(prepared.map((track) => track.track_ref_id)).size !== prepared.length) {
531
614
  throw new TypeError("External catalog candidates are duplicated");
532
615
  }
533
- // Outside suggestions should be new to the listener, so drop anything already played.
616
+ // Familiarity is useful evidence, not an automatic discovery exclusion.
534
617
  const listened = typeof this.#store.listenedTracks === "function"
535
618
  ? this.#store.listenedTracks({ subjectId: this.#subjectId })
536
619
  : [];
537
620
  const accepted = [];
538
621
  let excludedHistoryMatches = 0;
539
- for (const track of prepared) {
540
- if (listened.some((heard) => exactTitleArtistMatch(track, heard))) {
622
+ const allowed = this.filterDiscoveryTracks(prepared);
623
+ for (const track of allowed) {
624
+ const heard = listened.some((heard) => exactTitleArtistMatch(track, heard));
625
+ if (excludeKnown && heard) {
541
626
  excludedHistoryMatches += 1;
542
627
  continue;
543
628
  }
@@ -545,7 +630,7 @@ export class ListeningProfileDomainServices {
545
630
  ...track,
546
631
  knownness: {
547
632
  imported_library: "not_imported",
548
- listening_history: "not_found_by_exact_title_artist",
633
+ listening_history: heard ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
549
634
  },
550
635
  });
551
636
  }
@@ -560,6 +645,7 @@ export class ListeningProfileDomainServices {
560
645
  candidate_set_id: registered,
561
646
  candidate_scope: "external_catalog",
562
647
  result_count: accepted.length,
648
+ excluded_avoided_matches: prepared.length - allowed.length,
563
649
  excluded_library_matches: excludedHistoryMatches,
564
650
  expires_on: "prompt_end",
565
651
  source: trustedSource,
@@ -584,6 +670,35 @@ export class ListeningProfileDomainServices {
584
670
  return createListeningHistoryProfileProjection(listening, { maxItems });
585
671
  }
586
672
 
673
+ profileExplorationReady() {
674
+ return typeof this.#store.profileCatalog === "function";
675
+ }
676
+
677
+ async exploreProfile(argumentsValue) {
678
+ const input = profileExplorationInput(argumentsValue);
679
+ return exploreProfileCatalog({ listening: this.#store.profileCatalog({ subjectId: this.#subjectId }), input });
680
+ }
681
+
682
+ profileBuildReady() {
683
+ return typeof this.#store.listenerProfileStorage === "function";
684
+ }
685
+
686
+ getListenerProfileStore() {
687
+ return this.#store.listenerProfileStorage({ subjectId: this.#subjectId });
688
+ }
689
+
690
+ getProfileBuildContext() {
691
+ const version = listeningVersion(this.#store);
692
+ if (this.#profileBuildInput?.version !== version) {
693
+ this.#profileBuildInput = { version, input: createListenerProfileInput({
694
+ subjectId: this.#subjectId,
695
+ evidenceRevision: this.#store.profileEvidenceRevision({ subjectId: this.#subjectId }),
696
+ listening: this.#store.profileCatalog({ subjectId: this.#subjectId }),
697
+ }) };
698
+ }
699
+ return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
700
+ }
701
+
587
702
  async getRediscoveryCandidates(argumentsValue) {
588
703
  const input = modelArguments(argumentsValue, new Set(["limit"]));
589
704
  const limit = boundedHistoryItems(input.limit);
@@ -896,6 +1011,11 @@ export class ListeningProfileDomainServices {
896
1011
  ) {
897
1012
  throw new TypeError("A playlist plan cannot contain duplicate tracks");
898
1013
  }
1014
+ const external = selected.map(({ track }) => track)
1015
+ .filter((track) => track.candidate_scope === "external_catalog");
1016
+ if (this.filterDiscoveryTracks(external).length !== external.length) {
1017
+ throw new Error("An external discovery is currently marked Avoid. Choose another track.");
1018
+ }
899
1019
  const tracks = selected.map(({ track, selectionReason }, index) => ({
900
1020
  position: index + 1,
901
1021
  track_ref_id: track.track_ref_id,
@@ -972,13 +1092,41 @@ export class ListeningProfileDomainServices {
972
1092
  ) {
973
1093
  throw new TypeError("Retained playlist candidates are invalid");
974
1094
  }
975
- const registered = this.#registerHistoryTracks(tracks);
1095
+ if (this.#candidateSets.size >= CANDIDATE_SETS_MAX) {
1096
+ throw new Error("The prompt has reached its candidate set limit");
1097
+ }
1098
+ const prepared = tracks.map((track) =>
1099
+ track?.candidate_scope === "external_catalog"
1100
+ ? {
1101
+ ...safeExternalTrack(track),
1102
+ knownness: {
1103
+ imported_library: "not_imported",
1104
+ listening_history: "not_found_by_exact_title_artist",
1105
+ },
1106
+ }
1107
+ : safeHistoryTrack(track),
1108
+ );
1109
+ if (
1110
+ new Set(prepared.map((track) => track.track_ref_id)).size !==
1111
+ prepared.length
1112
+ ) {
1113
+ throw new TypeError("Retained playlist candidates are duplicated");
1114
+ }
1115
+ const candidateSetId = randomUUID();
1116
+ this.#candidateSets.set(
1117
+ candidateSetId,
1118
+ new Map(
1119
+ prepared.map((track) => [track.track_ref_id, structuredClone(track)]),
1120
+ ),
1121
+ );
1122
+ const scopes = new Set(prepared.map((track) => track.candidate_scope));
976
1123
  return {
977
- candidate_set_id: registered.candidateSetId,
978
- candidate_scope: "private_history",
979
- result_count: registered.tracks.length,
1124
+ candidate_set_id: candidateSetId,
1125
+ candidate_scope: scopes.size === 1 ? [...scopes][0] : "mixed",
1126
+ result_count: prepared.length,
980
1127
  expires_on: "prompt_end",
981
- tracks: registered.tracks.map(publicHistoryTrack),
1128
+ source: "retained_validated_playlist",
1129
+ tracks: prepared.map(publicHistoryTrack),
982
1130
  };
983
1131
  }
984
1132
 
@@ -1001,7 +1149,7 @@ export class ListeningProfileDomainServices {
1001
1149
 
1002
1150
  close() {
1003
1151
  this.resetCandidateSets();
1004
- this.#store.close();
1152
+ if (this.#ownsStore) this.#store.close();
1005
1153
  }
1006
1154
  }
1007
1155
 
@@ -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
+ }