@audiofool/moondog 0.1.0

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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isUuid, uuidV5 } from "../core/uuid-v5.mjs";
3
+ import { MUSIC_PROVIDER_LABELS } from "./music-providers.mjs";
4
+
5
+ const namespace = "c9a85706-bf02-4f1d-b7e0-49e10d25a6be";
6
+ export const musicHash = (value) => createHash("sha256").update(value).digest("hex");
7
+ const id = (...parts) => uuidV5(JSON.stringify(parts), namespace);
8
+ export const musicText = (value) => typeof value === "string" ? Array.from(value.normalize("NFC")
9
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
10
+ .replace(/\s+/gu, " ").trim()).slice(0, 512).join("") : "";
11
+
12
+ /** Shared normalized boundary. Collections never manufacture listening events. */
13
+ export function prepareMusicBundle({ provider, subjectId, capturedAt, rows, digest, size, members, skipped = 0, scopeNote, fileName }) {
14
+ if (!MUSIC_PROVIDER_LABELS[provider] || !isUuid(subjectId)) throw new Error("Invalid music import identity.");
15
+ subjectId = subjectId.toLowerCase();
16
+ capturedAt = new Date(capturedAt).toISOString();
17
+ const sourceKey = `${provider}.music_import`;
18
+ const format = `${provider}_music_v1`;
19
+ const batchId = id(provider, subjectId, digest, "batch");
20
+ const profileId = id(provider, subjectId, digest, "profile");
21
+ const tracks = new Map();
22
+ const events = new Map();
23
+ const evidence = new Map();
24
+ for (const row of rows) {
25
+ const title = musicText(row.title);
26
+ const artist = musicText(row.artist);
27
+ const album = musicText(row.album);
28
+ if (!row.id || !title) { skipped += 1; continue; }
29
+ const trackId = id(provider, "track", row.id);
30
+ const track = {
31
+ schema_version: 1, track_ref_id: trackId, revision: 1, identity_status: "provisional",
32
+ display_label: musicText(artist ? `${title} - ${artist}` : title), title,
33
+ ...(artist ? { artist_credits: [{ name: artist, role: "unknown" }] } : {}),
34
+ ...(album ? { release: { title: album } } : {}),
35
+ external_refs: [{ system: provider, entity_type: `${provider}.track`, external_id: String(row.id) }],
36
+ created_at: capturedAt,
37
+ };
38
+ // Prefer supplied library metadata to a history record's video title.
39
+ if (!tracks.has(trackId) || (artist && !tracks.get(trackId).artist_credits)) tracks.set(trackId, track);
40
+ if (row.occurredAt) {
41
+ const at = new Date(row.occurredAt).toISOString();
42
+ const key = musicHash(JSON.stringify([provider, subjectId, row.id, at]));
43
+ events.set(key, {
44
+ schema_version: 1, listening_event_id: id(key, "event"), subject_id: subjectId,
45
+ track_ref_id: trackId, track_ref_revision: 1, event_type: "play_observed",
46
+ occurred_at: at, ingested_at: capturedAt, interaction_mode: "unknown", context: { context_type: "unknown" },
47
+ provenance: { source_kind: "import", captured_at: capturedAt, import_batch_id: batchId,
48
+ external_ref: { system: provider, entity_type: `${provider}.play`, external_id: key } },
49
+ deduplication_key: key,
50
+ });
51
+ } else {
52
+ const kind = row.playlistId ? "playlist_track_added" : "library_track_saved";
53
+ const key = musicHash(JSON.stringify([provider, subjectId, kind, row.playlistId ?? "library", row.id]));
54
+ evidence.set(key, {
55
+ schema_version: 1, profile_evidence_id: id(profileId, key), evidence_key: key, subject_id: subjectId,
56
+ evidence_kind: kind, direction: "supports", strength_class: row.playlistId ? "curated" : "explicit",
57
+ entity: { entity_type: "track", entity_ref_id: trackId, track_ref_id: trackId, track_ref_revision: 1,
58
+ label: title, ...(artist ? { artist_credit: artist } : {}), ...(album ? { release: album } : {}) },
59
+ observed_at: capturedAt,
60
+ attributes: row.playlistId ? { playlist_name: musicText(row.playlistName) || "Selected playlist",
61
+ playlist_position: row.position ?? 1, playlist_id: String(row.playlistId), selected_public_playlist: true } : {},
62
+ provenance: { source_kind: "import", source_system: provider, source_member: row.member,
63
+ profile_import_id: profileId, captured_at: capturedAt },
64
+ });
65
+ }
66
+ }
67
+ if (!events.size && !evidence.size) throw new Error("No supported music records found. Choose a music library CSV, YouTube Music watch-history JSON, or a non-empty public playlist.");
68
+ const dates = [...events.values()].map((event) => event.occurred_at).sort();
69
+ const manifest = { subject_id: subjectId, source_format: format, archive_sha256: digest,
70
+ archive_size_bytes: size, member_names: members, imported_at: capturedAt };
71
+ return {
72
+ provider,
73
+ bundle: {
74
+ source_key: sourceKey, captured_at: capturedAt, cursor_after_ms: null,
75
+ track_refs: [...tracks.values()], listening_events: [...events.values()],
76
+ import_batch: { ...manifest, import_batch_id: batchId, data_scope: "selected_music_records",
77
+ input_records: events.size, earliest_occurred_at: dates[0] ?? null, latest_occurred_at: dates.at(-1) ?? null },
78
+ ...(evidence.size ? {
79
+ profile_import: { ...manifest, profile_import_id: profileId, source_key: sourceKey, input_records: evidence.size },
80
+ profile_evidence: [...evidence.values()],
81
+ } : {}),
82
+ },
83
+ preview: {
84
+ kind: events.size ? "history" : "collection", sourceLabel: MUSIC_PROVIDER_LABELS[provider], fileName,
85
+ tracks: tracks.size, listeningEvents: events.size, profileEvidence: evidence.size,
86
+ skippedRecords: skipped, eventsWithPlayedMs: 0, earliestListeningAt: dates[0], latestListeningAt: dates.at(-1),
87
+ sampleTracks: [...tracks.values()].slice(0, 3).map((track) => track.display_label),
88
+ capturedAt, scopeNote,
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,25 @@
1
+ export const MUSIC_PROVIDER_LABELS = Object.freeze({
2
+ spotify_account_data: "Spotify",
3
+ youtube_music: "YouTube Music",
4
+ qq_music: "QQ Music",
5
+ netease: "NetEase Cloud Music",
6
+ });
7
+
8
+ export function musicProviderLabel(system) {
9
+ return MUSIC_PROVIDER_LABELS[system] ?? "Imported music";
10
+ }
11
+
12
+ export function collectionCoverage(coverage) {
13
+ const spotify = coverage.collection_sources?.find((source) => source.system === "spotify_account_data");
14
+ return {
15
+ collection_tracks: coverage.collection_tracks ?? 0,
16
+ collection_sources: coverage.collection_sources ?? [],
17
+ saved_tracks: coverage.saved_tracks,
18
+ playlist_memberships: coverage.playlist_memberships,
19
+ spotify_profile_evidence: spotify?.evidence_records ?? 0,
20
+ spotify_saved_tracks: spotify?.saved_tracks ?? 0,
21
+ spotify_saved_albums: spotify?.saved_albums ?? 0,
22
+ spotify_followed_artists: spotify?.followed_artists ?? 0,
23
+ spotify_playlist_memberships: spotify?.playlist_memberships ?? 0,
24
+ };
25
+ }
@@ -0,0 +1,210 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { readSpotifyHistoryArchive } from "../integrations/spotify/history-archive.mjs";
4
+
5
+ const supportedSourceFormats = new Set([
6
+ "spotify_account_data_streaming_history_v1",
7
+ "spotify_extended_streaming_history_music_v1",
8
+ ]);
9
+ const maximumImportedRecords = 500_000;
10
+ const maximumReleaseHints = 3;
11
+ const unsafeTextPattern =
12
+ /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
13
+
14
+ function fail(message) {
15
+ const error = new Error(message);
16
+ error.code = "spotify_archive_catalog_hint_invalid";
17
+ throw error;
18
+ }
19
+
20
+ function cleanText(value, maximum, field) {
21
+ if (typeof value !== "string") fail(`The Spotify archive ${field} is invalid.`);
22
+ const cleaned = value
23
+ .normalize("NFC")
24
+ .replace(unsafeTextPattern, " ")
25
+ .replace(/\s+/gu, " ")
26
+ .trim();
27
+ if (!cleaned || Array.from(cleaned).length > maximum) {
28
+ fail(`The Spotify archive ${field} is invalid.`);
29
+ }
30
+ return cleaned;
31
+ }
32
+
33
+ function normalizeIdentity(value) {
34
+ return cleanText(value, 512, "artist name")
35
+ .normalize("NFKC")
36
+ .toLocaleLowerCase("und");
37
+ }
38
+
39
+ function normalizedReleaseKey(value) {
40
+ return value === null
41
+ ? null
42
+ : value.normalize("NFKC").toLocaleLowerCase("und");
43
+ }
44
+
45
+ function boundedArray(value, field) {
46
+ if (!Array.isArray(value) || value.length > maximumImportedRecords) {
47
+ fail(`The Spotify archive ${field} is invalid.`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function timestampScore(value) {
53
+ if (typeof value !== "string") return 0;
54
+ const score = Date.parse(value);
55
+ return Number.isFinite(score) ? score : 0;
56
+ }
57
+
58
+ function numericPlayedMs(value) {
59
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
60
+ }
61
+
62
+ function compareHints(left, right) {
63
+ if (left.eventCount !== right.eventCount) {
64
+ return right.eventCount - left.eventCount;
65
+ }
66
+ if (left.playedMs !== right.playedMs) return right.playedMs - left.playedMs;
67
+ if (left.latestAt !== right.latestAt) return right.latestAt - left.latestAt;
68
+ if (left.trackIds.size !== right.trackIds.size) {
69
+ return right.trackIds.size - left.trackIds.size;
70
+ }
71
+ return left.title.localeCompare(right.title, "und");
72
+ }
73
+
74
+ export async function deriveArtistReleaseHintsFromSpotifyArchive({
75
+ archivePath,
76
+ artistName,
77
+ capturedAt = new Date().toISOString(),
78
+ maximumHints = maximumReleaseHints,
79
+ historyArchiveImporter = readSpotifyHistoryArchive,
80
+ } = {}) {
81
+ if (typeof archivePath !== "string" || !archivePath.trim()) {
82
+ throw new TypeError("A Spotify history ZIP path is required.");
83
+ }
84
+ const normalizedArtist = normalizeIdentity(artistName);
85
+ if (
86
+ typeof capturedAt !== "string" ||
87
+ !Number.isFinite(Date.parse(capturedAt))
88
+ ) {
89
+ throw new TypeError("The Spotify archive capture time is invalid.");
90
+ }
91
+ if (
92
+ !Number.isInteger(maximumHints) ||
93
+ maximumHints < 1 ||
94
+ maximumHints > maximumReleaseHints
95
+ ) {
96
+ throw new TypeError("The Spotify archive release-hint limit is invalid.");
97
+ }
98
+ if (typeof historyArchiveImporter !== "function") {
99
+ throw new TypeError("A Spotify history archive reader is required.");
100
+ }
101
+
102
+ const bundle = await historyArchiveImporter({
103
+ archivePath,
104
+ subjectId: randomUUID(),
105
+ capturedAt: new Date(capturedAt).toISOString(),
106
+ });
107
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
108
+ fail("The Spotify archive import result is invalid.");
109
+ }
110
+ const sourceFormat = cleanText(
111
+ bundle.import_batch?.source_format,
112
+ 128,
113
+ "source format",
114
+ );
115
+ if (!supportedSourceFormats.has(sourceFormat)) {
116
+ fail("The Spotify archive source format is unsupported.");
117
+ }
118
+
119
+ const matchingTracks = new Map();
120
+ for (const trackRef of boundedArray(bundle.track_refs, "track references")) {
121
+ if (!trackRef || typeof trackRef !== "object" || Array.isArray(trackRef)) {
122
+ fail("The Spotify archive track reference is invalid.");
123
+ }
124
+ const artistCredits = boundedArray(trackRef.artist_credits, "artist credits");
125
+ const exactArtist = artistCredits.some(
126
+ (credit) =>
127
+ credit &&
128
+ typeof credit === "object" &&
129
+ !Array.isArray(credit) &&
130
+ normalizeIdentity(credit.name) === normalizedArtist,
131
+ );
132
+ if (!exactArtist) continue;
133
+ const trackRefId = cleanText(trackRef.track_ref_id, 128, "track reference ID");
134
+ const releaseTitle = trackRef.release?.title;
135
+ const normalizedRelease =
136
+ typeof releaseTitle === "string" && releaseTitle.trim()
137
+ ? cleanText(releaseTitle, 512, "release title")
138
+ : null;
139
+ const existing = matchingTracks.get(trackRefId);
140
+ if (
141
+ existing &&
142
+ existing.releaseTitle !== null &&
143
+ normalizedRelease !== null &&
144
+ normalizedReleaseKey(existing.releaseTitle) !==
145
+ normalizedReleaseKey(normalizedRelease)
146
+ ) {
147
+ fail("The Spotify archive track identity is inconsistent.");
148
+ }
149
+ if (existing) {
150
+ existing.releaseTitle ??= normalizedRelease;
151
+ existing.createdAt = Math.max(
152
+ existing.createdAt,
153
+ timestampScore(trackRef.created_at),
154
+ );
155
+ continue;
156
+ }
157
+ matchingTracks.set(trackRefId, {
158
+ releaseTitle: normalizedRelease,
159
+ createdAt: timestampScore(trackRef.created_at),
160
+ eventCount: 0,
161
+ playedMs: 0,
162
+ latestAt: 0,
163
+ });
164
+ }
165
+
166
+ let exactArtistEventCount = 0;
167
+ for (const event of boundedArray(bundle.listening_events, "listening events")) {
168
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
169
+ fail("The Spotify archive listening event is invalid.");
170
+ }
171
+ const track = matchingTracks.get(event.track_ref_id);
172
+ if (!track) continue;
173
+ exactArtistEventCount += 1;
174
+ track.eventCount += 1;
175
+ track.playedMs += numericPlayedMs(event.played_ms);
176
+ track.latestAt = Math.max(track.latestAt, timestampScore(event.occurred_at));
177
+ }
178
+
179
+ const releases = new Map();
180
+ for (const [trackRefId, track] of matchingTracks) {
181
+ if (!track.releaseTitle) continue;
182
+ const key = normalizedReleaseKey(track.releaseTitle);
183
+ const release = releases.get(key) ?? {
184
+ title: track.releaseTitle,
185
+ trackIds: new Set(),
186
+ eventCount: 0,
187
+ playedMs: 0,
188
+ latestAt: 0,
189
+ };
190
+ release.trackIds.add(trackRefId);
191
+ release.eventCount += track.eventCount;
192
+ release.playedMs += track.playedMs;
193
+ release.latestAt = Math.max(
194
+ release.latestAt,
195
+ track.latestAt,
196
+ track.createdAt,
197
+ );
198
+ releases.set(key, release);
199
+ }
200
+
201
+ return {
202
+ source_format: sourceFormat,
203
+ exact_artist_track_count: matchingTracks.size,
204
+ exact_artist_event_count: exactArtistEventCount,
205
+ release_titles: [...releases.values()]
206
+ .sort(compareHints)
207
+ .slice(0, maximumHints)
208
+ .map((release) => release.title),
209
+ };
210
+ }