@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.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- 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)
|
|
98
|
+
if (!Array.isArray(value)) {
|
|
90
99
|
throw new TypeError("Listening-history provider identity is invalid");
|
|
91
100
|
}
|
|
92
|
-
|
|
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
|
-
|
|
192
|
-
context
|
|
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
|
|
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
|
-
|
|
221
|
-
context
|
|
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 =
|
|
264
|
-
context
|
|
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
|
-
|
|
283
|
-
context
|
|
295
|
+
...optionalCount(
|
|
296
|
+
context,
|
|
297
|
+
"listening_minutes",
|
|
284
298
|
"Historical-return listening minutes",
|
|
285
299
|
),
|
|
286
300
|
engaged_play_count: engagedPlayCount,
|
|
287
|
-
|
|
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
|
-
|
|
314
|
-
context
|
|
327
|
+
...optionalCount(
|
|
328
|
+
context,
|
|
329
|
+
"year_listening_minutes",
|
|
315
330
|
"Time Machine year minutes",
|
|
316
331
|
),
|
|
317
|
-
year_explicit_skips
|
|
318
|
-
|
|
319
|
-
|
|
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 =
|
|
339
|
-
context
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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:
|
|
978
|
-
candidate_scope: "
|
|
979
|
-
result_count:
|
|
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
|
-
|
|
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
|
+
}
|