@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
@@ -6,28 +6,58 @@ import { fileURLToPath } from "node:url";
6
6
  import { readSpotifyHistoryArchive } from "../integrations/spotify/history-archive.mjs";
7
7
  import { readListenBrainzHistoryFile } from "../integrations/listenbrainz/history-file.mjs";
8
8
  import { projectSpotifyRecentActivity } from "../integrations/spotify/recent-activity.mjs";
9
+ import { projectSpotifyLibrarySnapshot } from "../integrations/spotify/library-snapshot.mjs";
9
10
  import { buildAppleMusicLibraryImport } from "../importers/apple-music-library/index.mjs";
10
11
  import { readYouTubeMusicTakeout } from "../integrations/youtube-music/takeout.mjs";
11
12
  import { readPublicPlaylist } from "../integrations/public-playlists.mjs";
13
+ import { readLastfmHistory } from "../integrations/lastfm/history.mjs";
12
14
 
13
- export function prepareSpotifyRecentImport({ page, subjectId, capturedAt = new Date().toISOString() }) {
15
+ /**
16
+ * Spotify quick start: the latest plays plus the listener's library (saves,
17
+ * follows, own playlists and Spotify's top artists and tracks) in one preview.
18
+ * The library is optional so an older recent-plays-only sign-in still works.
19
+ */
20
+ export function prepareSpotifyQuickImport({ page, librarySnapshot = null, libraryUnavailable = null, subjectId, capturedAt = new Date().toISOString() }) {
14
21
  const bundle = projectSpotifyRecentActivity({ page, subjectId, capturedAt });
22
+ const library = librarySnapshot ? projectSpotifyLibrarySnapshot({ subjectId, snapshot: librarySnapshot, capturedAt }) : null;
15
23
  const dates = bundle.listening_events.map((event) => event.occurred_at).sort();
16
24
  return {
17
- provider: "spotify-recent",
25
+ provider: "spotify-quick",
18
26
  bundle,
27
+ libraryBundle: library?.bundle ?? null,
19
28
  preview: {
20
- sourceLabel: "Spotify recent listening",
29
+ kind: "spotify-quick",
30
+ source: library ? "spotify-quick-library" : "spotify-quick-recent",
31
+ sourceLabel: library ? "Your Spotify library and recent plays" : "Spotify recent listening",
32
+ capturedAt,
21
33
  listeningEvents: bundle.listening_events.length,
22
- tracks: bundle.track_refs.length,
34
+ tracks: new Set([...bundle.track_refs, ...(library?.bundle.track_refs ?? [])].map((ref) => ref.track_ref_id)).size,
23
35
  earliestListeningAt: dates[0],
24
36
  latestListeningAt: dates.at(-1),
25
37
  eventsWithPlayedMs: 0,
26
- scopeNote: "Your latest plays, up to 50. Spotify doesn't say how long you listened here. A history ZIP can add the older ones later.",
38
+ ...(library ? { library: library.preview } : {}),
39
+ ...(libraryUnavailable ? { libraryUnavailable } : {}),
40
+ scopeNote: library
41
+ ? "Your library shows what you keep and what Spotify ranks highest for you. Recent plays are your latest 50. A history ZIP can add years of listening later."
42
+ : "Your latest plays, up to 50. Spotify doesn't say how long you listened here. A history ZIP can add the older ones later.",
27
43
  },
28
44
  };
29
45
  }
30
46
 
47
+ /** Save both parts of an inspected quick import; returns one combined receipt. */
48
+ // Both writes are idempotent, so retrying after a partial failure is safe.
49
+ export function commitSpotifyQuickImport(store, prepared) {
50
+ const library = prepared.libraryBundle ? store.ingestImport(prepared.libraryBundle) : null;
51
+ const recent = store.ingest(prepared.bundle);
52
+ if (!library) return { ...recent, provider: "spotify-quick" };
53
+ return {
54
+ ...recent, provider: "spotify-quick",
55
+ inserted_track_refs: (recent.inserted_track_refs ?? 0) + (library.inserted_track_refs ?? 0),
56
+ inserted_profile_evidence: library.inserted_profile_evidence ?? 0,
57
+ library_already_imported: library.already_imported === true,
58
+ };
59
+ }
60
+
31
61
  function fail(code, message) {
32
62
  const error = new Error(message);
33
63
  error.code = code;
@@ -85,7 +115,11 @@ function literalPath(input) {
85
115
  return path.resolve(decoded);
86
116
  }
87
117
 
88
- export async function prepareHistoryImport({ filePath, subjectId, provider: selectedProvider, capturedAt = new Date().toISOString(), fetchImpl } = {}) {
118
+ export async function prepareHistoryImport({ filePath, subjectId, provider: selectedProvider, capturedAt = new Date().toISOString(), fetchImpl, onProgress, signal } = {}) {
119
+ // Last.fm is read by username (or profile link) rather than from a file.
120
+ if (selectedProvider === "lastfm") {
121
+ return readLastfmHistory({ username: filePath, subjectId, capturedAt, onProgress, signal, ...(fetchImpl ? { fetchImpl } : {}) });
122
+ }
89
123
  if (/https?:\/\//iu.test(filePath ?? "") && !String(filePath).startsWith("file:")) {
90
124
  return readPublicPlaylist({ input: filePath, subjectId, capturedAt, provider: selectedProvider, fetchImpl });
91
125
  }
@@ -115,6 +149,7 @@ export async function prepareHistoryImport({ filePath, subjectId, provider: sele
115
149
  bundle,
116
150
  preview: {
117
151
  kind: "library",
152
+ source: "apple-library",
118
153
  sourceLabel: "Apple Music library",
119
154
  fileName: path.basename(resolvedPath),
120
155
  tracks: bundle.trackRefs.length,
@@ -143,6 +178,7 @@ export async function prepareHistoryImport({ filePath, subjectId, provider: sele
143
178
  provider,
144
179
  bundle,
145
180
  preview: {
181
+ source: provider === "listenbrainz" ? "listenbrainz" : extended ? "spotify-extended" : "spotify-account",
146
182
  sourceLabel: provider === "listenbrainz" ? "ListenBrainz history" : extended ? "Spotify Extended Streaming History" : "Spotify Account Data",
147
183
  fileName: path.basename(resolvedPath),
148
184
  sourceFormat: bundle.import_batch.source_format,
@@ -0,0 +1,378 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { recordedShare } from "./recorded-measures.mjs";
4
+
5
+ export const LISTENER_PROFILE_ANALYSIS_VERSION = "listener-profile/2";
6
+ export const LISTENER_PROFILE_SYNTHESIS_VERSION = "listener-synthesis/4";
7
+ // A saved reading is current only after its whole-profile check has run.
8
+ export const CHECKED_PROFILE_STATES = new Set(["passed", "revised", "flagged"]);
9
+
10
+ const LISTENER_SECTIONS = new Set(["listener_preferences", "listener_avoids"]);
11
+ // Curation already appears in its own sections; these provider rows only repeat it.
12
+ const SPOTIFY_TOP_KINDS = new Set(["top_artist_ranked", "top_track_ranked"]);
13
+ const DUPLICATED_PROVIDER_KINDS = new Set([
14
+ "playlist_track_added", "library_track_saved", "library_album_saved", "library_artist_followed",
15
+ ]);
16
+
17
+ const RANKED_LISTENING_SECTIONS = new Set(["history_artists", "history_tracks", "recent_artists", "recent_tracks"]);
18
+ const PLAY_COUNT_SECTIONS = new Set([...RANKED_LISTENING_SECTIONS, "history_releases"]);
19
+ // Below this share of plays with a recorded duration, minutes can't compare artists, years or
20
+ // sources (Last.fm and many ListenBrainz files record none), so the build compares by plays.
21
+ const TIMED_PLAY_SHARE = 0.95;
22
+
23
+ function measurement(listening) {
24
+ const plays = listening?.coverage?.effective_listening_events ?? 0;
25
+ const timed = listening?.coverage?.events_with_played_duration;
26
+ const time = timed === undefined ? "all" : recordedShare(plays, timed, TIMED_PLAY_SHARE);
27
+ // Listening rows state skips only where some play recorded them, so no row with skips means
28
+ // no play in the history recorded one.
29
+ const rows = Object.entries(listening?.sections ?? {})
30
+ .filter(([section]) => RANKED_LISTENING_SECTIONS.has(section)).flatMap(([, rows]) => rows);
31
+ const unskipped = rows.length > 0 && !rows.some(row => row.explicit_skips !== undefined);
32
+ return { plays, timed, time, unskipped };
33
+ }
34
+
35
+ const without = (row, keys) => Object.fromEntries(Object.entries(row).filter(([key]) => !keys.includes(key)));
36
+
37
+ // Listening rows already leave out what their plays never recorded. Here a row also drops minutes
38
+ // that can't compare it with others, and an Apple facet drops a play count none of its tracks
39
+ // carry. Rows that lose a measure are marked, because the host's prose explanations still state it.
40
+ function measuredRows(section, rows, { time }, withheld) {
41
+ const unrecordedKeys = row => section.startsWith("apple_")
42
+ ? (recordedShare(row.library_tracks, row.tracks_with_play_count) === "none" ? ["play_count"] : [])
43
+ : time !== "all" ? ["listening_minutes"] : [];
44
+ const next = rows.map(row => {
45
+ const keys = unrecordedKeys(row).filter(key => key in row);
46
+ const topArtist = time !== "all" && row.top_artist && "listening_minutes" in row.top_artist;
47
+ if (!keys.length && !topArtist) return row;
48
+ const measured = { ...without(row, keys), ...(topArtist ? { top_artist: without(row.top_artist, ["listening_minutes"]) } : {}) };
49
+ withheld.add(measured);
50
+ return measured;
51
+ });
52
+ if (time === "all" || !PLAY_COUNT_SECTIONS.has(section)) return next;
53
+ // A row without skip data counts every play as engaged.
54
+ const engaged = row => row.engaged_play_count ?? row.play_count ?? 0;
55
+ return next.sort((left, right) => engaged(right) - engaged(left) || (right.play_count ?? 0) - (left.play_count ?? 0));
56
+ }
57
+
58
+ function sectionSemantics(section, { time = "all" } = {}) {
59
+ const common = {
60
+ ordering: "Catalog order, not chronology or preference strength unless explicitly stated.",
61
+ limitations: "Row position and repeated rows are not independent evidence of importance. Use the row's dated fields for time claims.",
62
+ };
63
+ if (RANKED_LISTENING_SECTIONS.has(section)) {
64
+ return { ordering: time === "all" ? "Ranked by measured listening duration, descending."
65
+ : "Ranked by plays, descending, because listening time is not known for every play.",
66
+ limitations: "Attention in the imported history is not a direct statement of liking. Recent and lifetime views overlap; do not add their totals." };
67
+ }
68
+ if (section === "history_years") return { ...common,
69
+ ordering: time === "all" ? "top_artist is selected by measured listening duration, not play count. Row position is not a preference ranking."
70
+ : "top_artist is selected by measured listening duration where the year records it, otherwise by plays. Row position is not a preference ranking.",
71
+ limitations: "Years have unequal coverage. Sparse or missing imported records do not establish a gap in the listener's actual listening." };
72
+ if (section === "saved_tracks") return {
73
+ ordering: time === "all" ? "Measured listening duration descending, then playlist membership count descending, then title."
74
+ : "Not a preference ranking; listening time is not known for every play.",
75
+ limitations: "Row order is not save or addition chronology. Missing listening minutes mean no matched duration in this import, not proof the listener never played a saved track.",
76
+ };
77
+ if (section === "playlist_tracks") return { ...common,
78
+ ordering: "Playlist membership count descending, then measured listening duration descending, then title." };
79
+ if (section.startsWith("apple_")) return {
80
+ ordering: section === "apple_tracks" ? "Imported preference strength descending, then snapshot play count descending, then internal stable identity."
81
+ : "Imported preference strength descending, then library track count descending, then name.",
82
+ limitations: "A library snapshot is not dated listening history or addition chronology. Missing play counts are unknown, not zero; aggregate counts cover only tracks_with_play_count. Versions and reissues are not independent evidence of attachment.",
83
+ };
84
+ return common;
85
+ }
86
+
87
+ export function stableJson(value) {
88
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
89
+ if (value && typeof value === "object") return `{${Object.keys(value).sort()
90
+ .filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
91
+ return JSON.stringify(value);
92
+ }
93
+
94
+ export function profileDigest(value) {
95
+ return createHash("sha256").update(stableJson(value)).digest("hex");
96
+ }
97
+
98
+ function text(value, maximum, field) {
99
+ if (typeof value !== "string" || !value.trim() || Array.from(value).length > maximum ||
100
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f‪-‮⁦-⁩]/u.test(value)) {
101
+ throw new TypeError(`Listener profile ${field} is invalid`);
102
+ }
103
+ return value.trim();
104
+ }
105
+
106
+ export function createListenerProfileInput({ subjectId, evidenceRevision, listening, apple }) {
107
+ const measured = measurement(listening);
108
+ const withheld = new Set();
109
+ const sections = Object.fromEntries(Object.entries({ ...listening?.sections, ...apple?.sections })
110
+ .map(([section, rows]) => [section, measuredRows(section, rows, measured, withheld)]));
111
+ const context = listening?.context ?? {};
112
+ const coverage = { ...listening?.coverage, ...apple?.coverage };
113
+ const evidence = new Map();
114
+ const unrecorded = new Set();
115
+ for (const [section, rows] of Object.entries(sections).sort(([a], [b]) => a.localeCompare(b))) {
116
+ for (const row of rows) {
117
+ const reference_id = `ev_${profileDigest([section, row]).slice(0, 32)}`;
118
+ evidence.set(reference_id, { reference_id, section, data: structuredClone(row) });
119
+ if (withheld.has(row)) unrecorded.add(reference_id);
120
+ }
121
+ }
122
+ const manifest = {
123
+ analysis_version: LISTENER_PROFILE_ANALYSIS_VERSION,
124
+ coverage,
125
+ sources: listening?.sources ?? [], context,
126
+ sections: Object.entries(sections).map(([section, rows]) => ({ section, rows: rows.length, ...sectionSemantics(section, measured) })),
127
+ evidence_rows: evidence.size,
128
+ limitations: [
129
+ "Every supported record contributes to local analysis before the dossier selects rows. Rows are aggregates or observations, not a count of distinct plays.",
130
+ "History measures attention. Apple snapshot counts are separate from dated plays; private-session plays and superseded overlaps do not become behavioral evidence.",
131
+ "Provider labels are not listener assertions or measured audio features.",
132
+ ...(measured.time === "none" ? ["No play in this history records how long it lasted, so listening time is unknown, not zero; rows rank by plays."] : []),
133
+ ...(measured.time === "some" ? [`Listening time is known for only ${measured.timed} of ${measured.plays} plays, so minutes and hours cannot compare artists, years or sources; rows rank by plays, and coverage.listening_hours covers only the timed plays.`] : []),
134
+ ...(measured.unskipped ? ["No play in this history is marked as skipped. These sources may not record skips, so skips and engaged plays are unknown, and having no skips says nothing about approval."] : []),
135
+ ],
136
+ };
137
+ // Rows are content-addressed, so the ordered references cover every evidence value.
138
+ const input_digest = profileDigest({ subjectId, evidenceRevision, manifest, evidence: [...evidence.keys()] });
139
+ // Unrecorded measures change the evidence rows, so the digest already covers this set.
140
+ return { subjectId, input_digest, manifest, evidence, unrecorded };
141
+ }
142
+
143
+ export function modelEvidence(entry, offset = 0, limit = 12) {
144
+ // Only music observations cross the model boundary. Local identities and
145
+ // provider handles remain in the private frozen evidence snapshot.
146
+ const fields = new Set([
147
+ "name", "label", "title", "artist_credit", "release", "genre", "entity_type", "stance", "asserted_at",
148
+ "first_played_at", "last_played_at", "source_label", "identity_status", "evidence_kind", "period",
149
+ "text", "playlist_name", "unit", "observed_at", "last_added_at", "play_count", "engaged_play_count",
150
+ "explicit_skips", "listening_minutes", "distinct_tracks", "year", "event_count", "first_observed_tracks",
151
+ "playlist_count", "library_tracks", "preferred_tracks", "tracks_with_play_count", "preference_strength",
152
+ "loved", "favorited", "rating_value", "rating_computed", "rank", "playlist_position", "value",
153
+ "stream_count", "played_seconds", "playlist_names", "top_artist", "first_year", "last_year", "active_years",
154
+ ]);
155
+ const data = Object.fromEntries(Object.entries(entry.data).filter(([key]) => fields.has(key)));
156
+ if (data.top_artist) data.top_artist = Object.fromEntries(Object.entries(data.top_artist)
157
+ .filter(([key]) => ["name", "play_count", "listening_minutes"].includes(key)));
158
+ const continuations = {};
159
+ for (const [key, value] of Object.entries(data)) {
160
+ if (Array.isArray(value)) {
161
+ data[key] = value.slice(offset, offset + limit);
162
+ if (value.length > offset + limit) continuations[key] = { total: value.length, next_offset: offset + limit };
163
+ }
164
+ }
165
+ return { ...entry, data, ...(Object.keys(continuations).length ? { continuations } : {}) };
166
+ }
167
+
168
+ const artistKey = value => typeof value === "string" ? value.normalize("NFKC").trim().toLocaleLowerCase() : null;
169
+
170
+ /**
171
+ * A compact, deterministic view of the complete evidence for one synthesis
172
+ * session. Every row keeps its reference so the model can cite it; the full
173
+ * catalog stays searchable for anything the dossier leaves out.
174
+ */
175
+ export function listenerProfileDossier(input) {
176
+ const rows = section => [...input.evidence.values()].filter(entry => entry.section === section);
177
+ const row = entry => ({ ref: entry.reference_id, ...modelEvidence(entry, 0, 6).data });
178
+ const take = (section, limit) => rows(section).slice(0, limit).map(row);
179
+ const lifetime = rows("history_artists");
180
+ const lifetimeRank = new Map(lifetime.map((entry, index) => [artistKey(entry.data.name), index + 1]));
181
+ const listened = new Set(lifetime.slice(0, 80).map(entry => artistKey(entry.data.name)));
182
+ const curated = new Map();
183
+ const curate = (name, entry, signal) => {
184
+ const key = artistKey(name);
185
+ if (!key || listened.has(key)) return;
186
+ const item = curated.get(key) ?? { artist: name, signals: [], refs: [] };
187
+ if (item.refs.length < 4) item.refs.push(entry.reference_id);
188
+ item.signals.push(signal);
189
+ curated.set(key, item);
190
+ };
191
+ for (const entry of rows("followed_artists")) curate(entry.data.name, entry, "followed");
192
+ for (const entry of rows("saved_albums")) curate(entry.data.artist_credit, entry, `saved album ${entry.data.label}`);
193
+ for (const entry of rows("apple_artists")) {
194
+ if ((entry.data.preferred_tracks ?? 0) >= 2 || (entry.data.library_tracks ?? 0) >= 20) {
195
+ curate(entry.data.name, entry, `Apple library: ${entry.data.library_tracks} tracks, ${entry.data.preferred_tracks ?? 0} preferred`);
196
+ }
197
+ }
198
+ for (const entry of rows("playlist_tracks").slice(0, 300)) curate(entry.data.artist_credit, entry, `playlist track ${entry.data.label}`);
199
+ const provider = rows("provider_evidence").filter(entry => !DUPLICATED_PROVIDER_KINDS.has(entry.data.evidence_kind) && !SPOTIFY_TOP_KINDS.has(entry.data.evidence_kind));
200
+ // Spotify ranks the same names across three periods; one row per name keeps them comparable.
201
+ const spotifyTop = (kind, limit) => {
202
+ const grouped = new Map();
203
+ for (const entry of rows("provider_evidence").filter(entry => entry.data.evidence_kind === kind)) {
204
+ const key = artistKey(`${entry.data.label}\0${entry.data.artist_credit ?? ""}`);
205
+ const item = grouped.get(key) ?? { ref: entry.reference_id, label: entry.data.label,
206
+ ...(entry.data.artist_credit ? { artist_credit: entry.data.artist_credit } : {}), ranks: {}, best: Infinity };
207
+ item.ranks[entry.data.period] = entry.data.rank;
208
+ if (entry.data.rank < item.best) Object.assign(item, { best: entry.data.rank, ref: entry.reference_id });
209
+ grouped.set(key, item);
210
+ }
211
+ return [...grouped.values()].sort((a, b) => a.best - b.best || Object.keys(b.ranks).length - Object.keys(a.ranks).length)
212
+ .slice(0, limit).map(({ best: _best, ...item }) => item);
213
+ };
214
+ const sections = Object.fromEntries(input.manifest.sections.map(({ section, rows: total, ordering, limitations }) =>
215
+ [section, { total, ordering, limitations }]));
216
+ return {
217
+ coverage: input.manifest.coverage, sources: input.manifest.sources, context: input.manifest.context,
218
+ sections,
219
+ lifetime_artists: lifetime.slice(0, 40).map(row),
220
+ next_lifetime_artists: lifetime.slice(40, 80).map(entry => ({ ref: entry.reference_id, name: entry.data.name,
221
+ listening_minutes: entry.data.listening_minutes, play_count: entry.data.play_count, distinct_tracks: entry.data.distinct_tracks })),
222
+ recent_artists: rows("recent_artists").slice(0, 25).map(entry => ({ ...row(entry),
223
+ lifetime_rank: lifetimeRank.get(artistKey(entry.data.name)) ?? null })),
224
+ years: take("history_years", 64),
225
+ lifetime_tracks: take("history_tracks", 25),
226
+ recent_tracks: take("recent_tracks", 15),
227
+ releases_in_depth: take("history_releases", 15),
228
+ curated_beyond_listening: [...curated.values()].sort((a, b) => b.signals.length - a.signals.length).slice(0, 20)
229
+ .map(item => ({ ...item, signals: item.signals.slice(0, 4) })),
230
+ saved_albums: take("saved_albums", 20),
231
+ saved_tracks: take("saved_tracks", 15),
232
+ playlist_tracks: take("playlist_tracks", 15),
233
+ followed_artists: rows("followed_artists").map(entry => ({ ref: entry.reference_id, name: entry.data.name,
234
+ listening_minutes: entry.data.listening_minutes ?? null })),
235
+ apple_artists: take("apple_artists", 20),
236
+ apple_genres: take("apple_genres", 20),
237
+ apple_tracks: take("apple_tracks", 15),
238
+ provider_genres: take("provider_genres", 15),
239
+ spotify_top: {
240
+ note: "Spotify's own affinity ranks (1 is highest) for the last four weeks, six months and about a year, read from the live account. Short-term ranks reflect recent attention; ranks held across all periods suggest something more enduring. Not play counts.",
241
+ artists: spotifyTop("top_artist_ranked", 40),
242
+ tracks: spotifyTop("top_track_ranked", 25),
243
+ },
244
+ provider_signals: provider.slice(0, 50).map(row),
245
+ explicit_choices: [...rows("listener_preferences"), ...rows("listener_avoids")].slice(0, 100).map(row),
246
+ };
247
+ }
248
+
249
+ function validateClaim(input, raw) {
250
+ if (!["observation", "hypothesis"].includes(raw?.kind)) {
251
+ throw new TypeError("Only the listener can establish a direct preference");
252
+ }
253
+ const references = (values, minimum, role) => {
254
+ if (!Array.isArray(values) || values.length < minimum || values.length > 8 || new Set(values).size !== values.length) {
255
+ throw new TypeError(`Listener profile evidence reference is invalid: ${role} needs ${minimum} to 8 unique references`);
256
+ }
257
+ const unknown = values.filter(id => !input.evidence.has(id));
258
+ if (unknown.length) throw new TypeError(`Listener profile evidence reference is invalid: ${role} contains unknown IDs ${JSON.stringify(unknown)}. Copy exact refs from the dossier or the evidence tools.`);
259
+ return [...values];
260
+ };
261
+ const supporting_refs = references(raw.supporting_refs, 1, "supporting_refs");
262
+ const contradicting_refs = references(raw.contradicting_refs ?? [], 0, "contradicting_refs");
263
+ if (contradicting_refs.some(id => supporting_refs.includes(id))) throw new TypeError("Supporting and conflicting evidence must differ");
264
+ const claim = {
265
+ kind: raw.kind, statement: text(raw.statement, 700, "statement"),
266
+ scope: text(raw.scope, 300, "scope"), uncertainty: text(raw.uncertainty, 500, "uncertainty"),
267
+ supporting_refs, contradicting_refs,
268
+ };
269
+ return { claim_id: `claim_${profileDigest(claim).slice(0, 32)}`, ...claim };
270
+ }
271
+
272
+ /** Direct choices come from the listener's own records, never from the model. */
273
+ export function listenerAssertionClaims(input) {
274
+ return [...input.evidence.values()].filter(entry => LISTENER_SECTIONS.has(entry.section)).map(({ reference_id, section, data }) => {
275
+ const stance = section === "listener_avoids" ? "avoid" : "like";
276
+ const direct = Boolean(data.correction_id);
277
+ const claim = {
278
+ kind: direct ? "listener_assertion" : "observation",
279
+ statement: `${data.label ?? data.name}${data.artist_credit ? ` by ${data.artist_credit}` : ""}: ${stance === "avoid" ? "keep out of suggestions" : "explicit positive choice"}.`,
280
+ scope: data.entity_type ?? "listener choice",
281
+ uncertainty: direct ? "An explicit listener choice; it does not rewrite past listening."
282
+ : "An exclusion or choice in imported source data; it is not a new statement by the listener.",
283
+ stance, supporting_refs: [reference_id], contradicting_refs: [],
284
+ };
285
+ return { claim_id: `claim_${profileDigest(claim).slice(0, 32)}`, ...claim };
286
+ });
287
+ }
288
+
289
+ export function compileListenerProfile(input, submission) {
290
+ const needsInsights = [...input.evidence.values()].some(entry => !LISTENER_SECTIONS.has(entry.section));
291
+ if (!Array.isArray(submission?.insights) || submission.insights.length > 12 || (needsInsights && !submission.insights.length)) {
292
+ throw new TypeError("Listener profile synthesis needs one to twelve evidence-linked insights");
293
+ }
294
+ const insights = submission.insights.map(raw => validateClaim(input, raw));
295
+ const claims = [...new Map([...insights, ...listenerAssertionClaims(input)].map(claim => [claim.claim_id, claim])).values()];
296
+ const referenced = new Set(claims.flatMap(claim => [...claim.supporting_refs, ...claim.contradicting_refs]));
297
+ return {
298
+ analysis_version: LISTENER_PROFILE_ANALYSIS_VERSION,
299
+ synthesis_version: LISTENER_PROFILE_SYNTHESIS_VERSION,
300
+ input_digest: input.input_digest,
301
+ summary: text(submission.summary, 1_600, "summary"),
302
+ coverage: input.manifest,
303
+ claims, highlight_claim_ids: [...new Set(insights.map(claim => claim.claim_id))],
304
+ evidence: Object.fromEntries([...referenced].map(id => [id, input.evidence.get(id)])),
305
+ };
306
+ }
307
+
308
+ // Whether two evidence maps hold the same Likes and Avoids. New plays change neither.
309
+ export function sameListenerChoices(evidence, otherEvidence) {
310
+ const choices = (entries) => listenerAssertionClaims({ evidence: entries }).map(claim => claim.claim_id).sort().join("\n");
311
+ return choices(evidence) === choices(otherEvidence);
312
+ }
313
+
314
+ export function readListenerProfile({ input, getInput, store }, { offset = 0, limit = 12, claimId, revisionId } = {}) {
315
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
316
+ throw new TypeError("Saved profile page is invalid");
317
+ }
318
+ const current = store.current();
319
+ const revision = revisionId ? store.revision(revisionId) : current;
320
+ if (revisionId && !revision) throw new Error("That saved profile version is unavailable");
321
+ if (!revision) return { state: "missing", total_claims: 0, claims: [], next_offset: null };
322
+ // Most new listeners have no saved reading. Do not scan their source archive
323
+ // merely to tell an ordinary conversation that the reading is missing.
324
+ input ??= getInput();
325
+ const analysisHolds = revision.synthesis_version === LISTENER_PROFILE_SYNTHESIS_VERSION &&
326
+ CHECKED_PROFILE_STATES.has(revision.verification?.state);
327
+ const state = analysisHolds && revision.input_digest === input.input_digest ? "current" : "stale";
328
+ const result = {
329
+ state,
330
+ // New plays alone change the input. A reading whose analysis and listener choices
331
+ // still hold stays usable as taste up to the listening it was built from.
332
+ ...(state === "stale" && analysisHolds && sameListenerChoices(new Map(Object.entries(revision.evidence ?? {})), input.evidence)
333
+ ? { newer_listening_only: true } : {}),
334
+ revision_id: revision.revision_id, sequence: revision.sequence,
335
+ parent_revision_id: revision.parent_revision_id, built_at: revision.built_at, model: revision.model,
336
+ synthesis_version: revision.synthesis_version,
337
+ verification: revision.verification,
338
+ summary: revision.summary, coverage: revision.coverage,
339
+ total_claims: revision.claims.length,
340
+ claims: revision.claims.slice(offset, offset + limit), offset,
341
+ next_offset: offset + limit < revision.claims.length ? offset + limit : null,
342
+ highlights: revision.highlight_claim_ids.map(id => {
343
+ const index = revision.claims.findIndex(claim => claim.claim_id === id);
344
+ return { ...revision.claims[index], finding_number: index + 1 };
345
+ }),
346
+ listener_assertions: revision.claims.flatMap((claim, index) => claim.kind === "listener_assertion"
347
+ ? [{ ...claim, finding_number: index + 1 }] : []),
348
+ };
349
+ if (claimId !== undefined) {
350
+ const claim = revision.claims.find(item => item.claim_id === claimId);
351
+ if (!claim) throw new Error("That finding is not part of this saved profile");
352
+ result.claim = claim;
353
+ result.supporting_evidence = claim.supporting_refs.map(ref => modelEvidence(revision.evidence[ref]));
354
+ result.contradicting_evidence = claim.contradicting_refs.map(ref => modelEvidence(revision.evidence[ref]));
355
+ }
356
+ return result;
357
+ }
358
+
359
+ export function compactListenerProfile(value) {
360
+ if (!value || value.state === "missing") return { state: "missing" };
361
+ const coverage = value.coverage?.coverage ?? {};
362
+ const flagged = value.verification?.state === "flagged"
363
+ ? value.verification.checks.at(-1).issues.slice(0, 3).map(issue => issue.problem) : [];
364
+ const behind = value.state === "stale" && value.newer_listening_only === true;
365
+ return {
366
+ state: behind ? "current_through" : value.state, revision_id: value.revision_id, sequence: value.sequence, built_at: value.built_at,
367
+ ...(value.state === "current" || behind ? {
368
+ ...(behind ? { note: "Built from listening up to coverage.last_played_at. Plays stored since then are not in this reading; listening coverage shows them. Listener choices and analysis are unchanged." } : {}),
369
+ summary: value.summary,
370
+ claims: value.highlights,
371
+ listener_assertions: value.listener_assertions,
372
+ coverage: { listening_events: coverage.effective_listening_events ?? null,
373
+ first_played_at: coverage.earliest_played_at ?? null, last_played_at: coverage.latest_played_at ?? null,
374
+ library_tracks: coverage.apple_library_tracks ?? null },
375
+ ...(flagged.length ? { unresolved_check_notes: flagged } : {}),
376
+ } : { message: "Source evidence, listener choices or analysis changed. Update the reading before using the previous interpretation as current taste." }),
377
+ };
378
+ }
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { profileDigest } from "./listener-profile-build.mjs";
3
+
4
+ // listener_profile_partitions holds page findings from builds before
5
+ // listener-synthesis/4. New builds no longer write it; it stays so existing
6
+ // databases open unchanged.
7
+ export const listenerProfileSchema = `
8
+ CREATE TABLE IF NOT EXISTS listener_profile_builds (
9
+ build_id TEXT PRIMARY KEY NOT NULL, subject_id TEXT NOT NULL,
10
+ input_digest TEXT NOT NULL, model_key TEXT NOT NULL,
11
+ lease_token TEXT NOT NULL, state TEXT NOT NULL, record_json TEXT NOT NULL
12
+ ) STRICT;
13
+ CREATE INDEX IF NOT EXISTS listener_profile_build_input
14
+ ON listener_profile_builds(subject_id, input_digest, model_key);
15
+ CREATE TABLE IF NOT EXISTS listener_profile_partitions (
16
+ build_id TEXT NOT NULL, partition_id TEXT NOT NULL,
17
+ review_json TEXT NOT NULL,
18
+ PRIMARY KEY (build_id, partition_id),
19
+ FOREIGN KEY (build_id) REFERENCES listener_profile_builds(build_id)
20
+ ) STRICT;
21
+ CREATE INDEX IF NOT EXISTS listener_profile_partition_cache ON listener_profile_partitions(partition_id);
22
+ CREATE TABLE IF NOT EXISTS listener_profile_revisions (
23
+ revision_id TEXT PRIMARY KEY NOT NULL, subject_id TEXT NOT NULL,
24
+ sequence INTEGER NOT NULL, input_digest TEXT NOT NULL, record_json TEXT NOT NULL,
25
+ UNIQUE (subject_id, sequence)
26
+ ) STRICT;
27
+ `;
28
+
29
+ export class ListenerProfileStore {
30
+ constructor(database, subjectId) {
31
+ this.database = database;
32
+ this.subjectId = subjectId;
33
+ }
34
+
35
+ current() {
36
+ const row = this.database.prepare(`SELECT record_json FROM listener_profile_revisions
37
+ WHERE subject_id = ? ORDER BY sequence DESC LIMIT 1`).get(this.subjectId);
38
+ return row ? JSON.parse(row.record_json) : null;
39
+ }
40
+
41
+ revision(id) {
42
+ const row = this.database.prepare(`SELECT record_json FROM listener_profile_revisions
43
+ WHERE subject_id = ? AND revision_id = ?`).get(this.subjectId, id);
44
+ return row ? JSON.parse(row.record_json) : null;
45
+ }
46
+
47
+ /** When a build last started or resumed, whether the listener asked for it or not. */
48
+ lastAttemptAt() {
49
+ return this.database.prepare(`SELECT MAX(COALESCE(json_extract(record_json, '$.attempted_at'),
50
+ json_extract(record_json, '$.started_at'))) AS attempted_at
51
+ FROM listener_profile_builds WHERE subject_id = ?`).get(this.subjectId)?.attempted_at ?? null;
52
+ }
53
+
54
+ /** Resume an unfinished build for the same input and model, or start a new one. */
55
+ begin(input, model, { force = false } = {}) {
56
+ const modelKey = profileDigest(model);
57
+ this.database.exec("BEGIN IMMEDIATE");
58
+ try {
59
+ const parent = this.current()?.revision_id ?? null;
60
+ const existing = force ? null : this.database.prepare(`SELECT record_json FROM listener_profile_builds
61
+ WHERE subject_id = ? AND input_digest = ? AND model_key = ? AND state <> 'complete'
62
+ ORDER BY rowid DESC LIMIT 1`).get(this.subjectId, input.input_digest, modelKey);
63
+ const saved = existing ? JSON.parse(existing.record_json) : null;
64
+ // A checkpoint written against an older current reading cannot replace the newer one.
65
+ const resumable = saved?.parent_revision_id === parent ? saved : null;
66
+ const build = resumable ?? {
67
+ build_id: randomUUID(), input_digest: input.input_digest, model,
68
+ parent_revision_id: parent, started_at: new Date().toISOString(),
69
+ };
70
+ build.lease_token = randomUUID();
71
+ build.attempted_at = new Date().toISOString();
72
+ this.database.prepare(`INSERT INTO listener_profile_builds
73
+ (build_id, subject_id, input_digest, model_key, lease_token, state, record_json)
74
+ VALUES (?, ?, ?, ?, ?, 'building', ?) ON CONFLICT(build_id) DO UPDATE
75
+ SET lease_token = excluded.lease_token, state = 'building', record_json = excluded.record_json`)
76
+ .run(build.build_id, this.subjectId, input.input_digest, modelKey, build.lease_token, JSON.stringify(build));
77
+ this.database.exec("COMMIT");
78
+ return { ...build, resumed: Boolean(resumable) };
79
+ } catch (error) { this.database.exec("ROLLBACK"); throw error; }
80
+ }
81
+
82
+ #assertLease(build) {
83
+ const row = this.database.prepare(`SELECT lease_token, state FROM listener_profile_builds
84
+ WHERE subject_id = ? AND build_id = ?`).get(this.subjectId, build.build_id);
85
+ if (row?.lease_token !== build.lease_token || row.state !== "building") {
86
+ throw new Error("Another profile build has taken over; continue from its saved progress");
87
+ }
88
+ }
89
+
90
+ checkpointFinalization(build, finalization) {
91
+ this.database.exec("BEGIN IMMEDIATE");
92
+ try {
93
+ this.#assertLease(build);
94
+ const row = this.database.prepare("SELECT record_json FROM listener_profile_builds WHERE build_id = ?")
95
+ .get(build.build_id);
96
+ const record = { ...JSON.parse(row.record_json), finalization };
97
+ this.database.prepare("UPDATE listener_profile_builds SET record_json = ? WHERE build_id = ?")
98
+ .run(JSON.stringify(record), build.build_id);
99
+ this.database.exec("COMMIT");
100
+ } catch (error) { this.database.exec("ROLLBACK"); throw error; }
101
+ }
102
+
103
+ interrupt(build) {
104
+ this.database.prepare(`UPDATE listener_profile_builds SET state = 'interrupted'
105
+ WHERE subject_id = ? AND build_id = ? AND lease_token = ? AND state = 'building'`)
106
+ .run(this.subjectId, build.build_id, build.lease_token);
107
+ }
108
+
109
+ complete(build, profile, assertCurrentInput = () => {}) {
110
+ this.database.exec("BEGIN IMMEDIATE");
111
+ try {
112
+ this.#assertLease(build);
113
+ assertCurrentInput();
114
+ const previous = this.current();
115
+ if ((previous?.revision_id ?? null) !== build.parent_revision_id) {
116
+ throw new Error("The current profile changed during this build; start again from the new version");
117
+ }
118
+ const revision = {
119
+ ...profile, revision_id: randomUUID(), sequence: (previous?.sequence ?? 0) + 1,
120
+ parent_revision_id: previous?.revision_id ?? null, built_at: new Date().toISOString(), model: build.model,
121
+ };
122
+ this.database.prepare(`INSERT INTO listener_profile_revisions
123
+ (revision_id, subject_id, sequence, input_digest, record_json) VALUES (?, ?, ?, ?, ?)`)
124
+ .run(revision.revision_id, this.subjectId, revision.sequence, revision.input_digest, JSON.stringify(revision));
125
+ this.database.prepare("UPDATE listener_profile_builds SET state = 'complete' WHERE build_id = ?").run(build.build_id);
126
+ this.database.exec("COMMIT");
127
+ return revision;
128
+ } catch (error) { this.database.exec("ROLLBACK"); throw error; }
129
+ }
130
+ }