@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,355 @@
1
+ import { MoondogApplication } from "../core/moondog-application.mjs";
2
+ import { createSyntheticDomainServices } from "../core/synthetic-domain-services.mjs";
3
+ import { createConfiguredRuntime } from "../runtime/pi/configured-runtime.mjs";
4
+
5
+ export const MOONDOG_DEMO_PROMPT =
6
+ "Build a six-track late-night drive playlist from the demo library. Start familiar, then add a measured amount of surprise. Inspect and explain at least one key profile evidence record before planning. Explain the ordering. Do not save or play anything.";
7
+
8
+ const demoSubjectId = "10000000-0000-4000-8000-000000000000";
9
+
10
+ const offlineSelection = Object.freeze([
11
+ Object.freeze({
12
+ title: "Midnight Lines",
13
+ reason:
14
+ "An explicit loved and rated signal plus high familiarity makes this a grounded opening anchor.",
15
+ }),
16
+ Object.freeze({
17
+ title: "Glass Highway",
18
+ reason:
19
+ "A favorited track with high familiarity keeps the opening inside established evidence.",
20
+ }),
21
+ Object.freeze({
22
+ title: "Blue Exit",
23
+ reason:
24
+ "Medium familiarity makes this a measured bridge away from the two strongest anchors.",
25
+ }),
26
+ Object.freeze({
27
+ title: "Soft Static",
28
+ reason:
29
+ "Low familiarity introduces a bounded surprise without claiming that the track is new or preferred.",
30
+ }),
31
+ Object.freeze({
32
+ title: "Empty Overpass",
33
+ reason:
34
+ "One observed play makes this the largest familiarity stretch in the sequence.",
35
+ }),
36
+ Object.freeze({
37
+ title: "First Light Behind Us",
38
+ reason:
39
+ "Medium familiarity and a favorited artist return the close toward established evidence.",
40
+ }),
41
+ ]);
42
+
43
+ const offlineSteps = Object.freeze({
44
+ profile: Object.freeze({
45
+ tool: "moondog_profile_summary",
46
+ capability: "profile.summary",
47
+ label: "Read the synthetic music profile",
48
+ }),
49
+ evidence: Object.freeze({
50
+ tool: "moondog_profile_explain",
51
+ capability: "profile.explain",
52
+ label: "Inspect one profile evidence record",
53
+ }),
54
+ search: Object.freeze({
55
+ tool: "moondog_library_search",
56
+ capability: "library.search",
57
+ label: "Search the synthetic library",
58
+ }),
59
+ plan: Object.freeze({
60
+ tool: "moondog_playlist_plan",
61
+ capability: "playlist.plan",
62
+ label: "Validate the playlist plan",
63
+ }),
64
+ });
65
+
66
+ const demoNextSteps = Object.freeze([
67
+ Object.freeze({
68
+ id: "open_visual_tour",
69
+ title: "Open the visual Listening Time Machine",
70
+ checkout_command: "npm run demo:studio",
71
+ installed_command: "moondog studio --demo",
72
+ data_boundary:
73
+ "The fictional tour stays on 127.0.0.1, reads no private history, rejects imports, and keeps corrections only in process memory.",
74
+ }),
75
+ Object.freeze({
76
+ id: "open_session_studio",
77
+ title: "Open your history without importing it",
78
+ checkout_command:
79
+ "npm --silent run demo:studio -- --from /absolute/path/to/spotify-history.zip",
80
+ installed_command:
81
+ "moondog studio --from /absolute/path/to/spotify-history.zip",
82
+ data_boundary:
83
+ "No dependency installation is required. The archive stays unchanged, the private profile and corrections live only in process memory, and no model, provider, cloud, or network request is made.",
84
+ }),
85
+ Object.freeze({
86
+ id: "open_private_studio",
87
+ title: "Bring your own listening history",
88
+ checkout_command: "npm run studio",
89
+ installed_command: "moondog studio",
90
+ data_boundary:
91
+ "Studio stays on 127.0.0.1. The fictional preview saves nothing. Import changes private local state only after an explicit button press.",
92
+ }),
93
+ ]);
94
+
95
+ function createDemoNextSteps() {
96
+ return demoNextSteps.map((step) => ({ ...step }));
97
+ }
98
+
99
+ class DemoApplication extends MoondogApplication {
100
+ async sourceStatus() {
101
+ return {
102
+ source: "synthetic_demo_library",
103
+ state: "ready",
104
+ valid_batches: 1,
105
+ invalid_batches: 0,
106
+ latest: {
107
+ captured_at: "2026-08-25T00:00:00.000Z",
108
+ tracks: 8,
109
+ playlists: 0,
110
+ playlist_item_references: 0,
111
+ aggregate_track_snapshots: 8,
112
+ listening_events: 0,
113
+ taste_events: 0,
114
+ loved_or_favorited_coverage: 4,
115
+ aggregate_play_count_coverage: 7,
116
+ },
117
+ semantics: {
118
+ library_snapshot_available: true,
119
+ complete_listening_history_available: false,
120
+ profile_materialization_ready: true,
121
+ },
122
+ };
123
+ }
124
+
125
+ async profileStatus() {
126
+ return {
127
+ state: "ready",
128
+ projection_version: "profile_projection/0",
129
+ evidence_records: 5,
130
+ claims: 0,
131
+ reason:
132
+ "The public demo uses an explicitly synthetic, bounded music profile.",
133
+ };
134
+ }
135
+ }
136
+
137
+ function cleanDemoPrompt(value) {
138
+ if (value === undefined) return MOONDOG_DEMO_PROMPT;
139
+ if (typeof value !== "string") {
140
+ throw new TypeError("The demo prompt must be text.");
141
+ }
142
+ const cleaned = value
143
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
144
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
145
+ .replace(/\s+/gu, " ")
146
+ .trim();
147
+ if (!cleaned || Array.from(cleaned).length > 1_000) {
148
+ throw new TypeError("The demo prompt must contain 1 to 1000 characters.");
149
+ }
150
+ return cleaned;
151
+ }
152
+
153
+ function createDemoApplication() {
154
+ return new DemoApplication({
155
+ importsRoot: "/moondog/synthetic-demo/does-not-read-local-imports",
156
+ domainServices: createSyntheticDomainServices({
157
+ subjectScope: { subjectId: demoSubjectId },
158
+ }),
159
+ });
160
+ }
161
+
162
+ async function captureOfflineStep(trace, step, action) {
163
+ const entry = {
164
+ sequence: trace.length + 1,
165
+ tool: step.tool,
166
+ capability: step.capability,
167
+ label: step.label,
168
+ status: "running",
169
+ };
170
+ trace.push(entry);
171
+ try {
172
+ const result = await action();
173
+ entry.status = "completed";
174
+ return result;
175
+ } catch (error) {
176
+ entry.status = "failed";
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ function offlineResultText(profile, evidence, plan) {
182
+ const preference = profile.strong_preferences[0];
183
+ return [
184
+ `Profile anchor: ${preference.label} - ${preference.signal}.`,
185
+ `Evidence checked: ${evidence.basis_summary}`,
186
+ "",
187
+ ...plan.tracks.map(
188
+ (track) =>
189
+ `${track.position}. ${track.title} - ${track.artist_credit}\n ${track.selection_reason}`,
190
+ ),
191
+ "",
192
+ `Ordering: ${plan.ordering_rationale}`,
193
+ "Grounding boundary: play count supports familiarity, not liking; low familiarity does not prove novelty.",
194
+ "Action boundary: the host validated track identity, candidate membership, count, and order without saving or playing anything.",
195
+ ].join("\n");
196
+ }
197
+
198
+ async function runOfflineDemo(application, prompt) {
199
+ const trace = [];
200
+ application.beginPrompt();
201
+ try {
202
+ const profile = await captureOfflineStep(
203
+ trace,
204
+ offlineSteps.profile,
205
+ () => application.getProfileSummary({ maxItems: 6 }),
206
+ );
207
+ const evidenceId = profile.strong_preferences[0]?.evidence_id;
208
+ if (!evidenceId) {
209
+ throw new Error("The synthetic demo profile has no explainable preference evidence.");
210
+ }
211
+ const evidence = await captureOfflineStep(
212
+ trace,
213
+ offlineSteps.evidence,
214
+ () => application.explainProfileEvidence({ evidenceId }),
215
+ );
216
+ const search = await captureOfflineStep(
217
+ trace,
218
+ offlineSteps.search,
219
+ () => application.searchLibrary({ query: "night drive", limit: 8 }),
220
+ );
221
+ const tracksByTitle = new Map(
222
+ search.tracks.map((track) => [track.title, track]),
223
+ );
224
+ const selections = offlineSelection.map((selection) => {
225
+ const track = tracksByTitle.get(selection.title);
226
+ if (!track) {
227
+ throw new Error(
228
+ `The synthetic demo library is missing ${selection.title}.`,
229
+ );
230
+ }
231
+ return {
232
+ trackRefId: track.track_ref_id,
233
+ selectionReason: selection.reason,
234
+ };
235
+ });
236
+ const plan = await captureOfflineStep(
237
+ trace,
238
+ offlineSteps.plan,
239
+ () =>
240
+ application.buildPlaylistPlan({
241
+ intent: prompt,
242
+ requestedTrackCount: selections.length,
243
+ candidateSetIds: [search.candidate_set_id],
244
+ trackRefs: selections,
245
+ orderingNotes:
246
+ "Start with two explicit preference anchors, cross a medium-familiarity bridge, introduce two low-familiarity tracks, then close with a favorited artist.",
247
+ }),
248
+ );
249
+ return {
250
+ demo_version: "moondog-demo/3",
251
+ mode: "synthetic_offline_read_only",
252
+ prompt,
253
+ runtime: {
254
+ provider: "none",
255
+ model: "deterministic-showcase/1",
256
+ },
257
+ coverage: {
258
+ library_tracks: 8,
259
+ profile_evidence: 5,
260
+ external_effects: "disabled",
261
+ },
262
+ tool_trace: trace,
263
+ result: {
264
+ status: "completed",
265
+ text: offlineResultText(profile, evidence, plan),
266
+ playlist_plan: structuredClone(plan),
267
+ },
268
+ limitations: [
269
+ "The library and profile are synthetic demonstration data.",
270
+ "The offline walkthrough is deterministic and does not call a language model or music service.",
271
+ "The demo cannot write playlists, control playback, or access private local music data.",
272
+ "This walkthrough proves the grounded product path, not subjective curation quality.",
273
+ ],
274
+ next_steps: createDemoNextSteps(),
275
+ };
276
+ } finally {
277
+ application.endPrompt();
278
+ }
279
+ }
280
+
281
+ export async function runMoondogDemo({
282
+ prompt,
283
+ offline = false,
284
+ runtimeBuilder = createConfiguredRuntime,
285
+ } = {}) {
286
+ const demoPrompt = cleanDemoPrompt(prompt);
287
+ const application = createDemoApplication();
288
+ let runtime;
289
+ try {
290
+ if (offline) return await runOfflineDemo(application, demoPrompt);
291
+ runtime = await runtimeBuilder(application);
292
+ const runtimeStatus = runtime.publicStatus();
293
+ if (runtimeStatus.state !== "configured") {
294
+ const error = new Error(
295
+ "The Moondog demo needs a configured model. Run moondog, then use /auth and /model before retrying moondog demo.",
296
+ );
297
+ error.code = runtimeStatus.reason ?? "model_not_configured";
298
+ throw error;
299
+ }
300
+
301
+ const trace = [];
302
+ const traceByCallId = new Map();
303
+ const result = await runtime.prompt(demoPrompt, {
304
+ onToolStart(event) {
305
+ const entry = {
306
+ sequence: trace.length + 1,
307
+ tool: event.toolName,
308
+ capability: event.capabilityId,
309
+ label: event.label,
310
+ status: "running",
311
+ };
312
+ trace.push(entry);
313
+ traceByCallId.set(event.toolCallId, entry);
314
+ },
315
+ onToolEnd(event) {
316
+ const entry = traceByCallId.get(event.toolCallId);
317
+ if (!entry) return;
318
+ entry.status = event.isError ? "failed" : "completed";
319
+ },
320
+ });
321
+
322
+ return {
323
+ demo_version: "moondog-demo/2",
324
+ mode: "synthetic_read_only",
325
+ prompt: demoPrompt,
326
+ runtime: {
327
+ provider: runtimeStatus.provider,
328
+ model: runtimeStatus.model,
329
+ },
330
+ coverage: {
331
+ library_tracks: 8,
332
+ profile_evidence: 5,
333
+ external_effects: "disabled",
334
+ },
335
+ tool_trace: trace,
336
+ result: {
337
+ status: result.status,
338
+ text: result.text,
339
+ ...(result.playlist_plan
340
+ ? { playlist_plan: structuredClone(result.playlist_plan) }
341
+ : {}),
342
+ },
343
+ limitations: [
344
+ "The library and profile are synthetic demonstration data.",
345
+ "The demo calls the configured model provider but no music service.",
346
+ "The demo cannot write playlists, control playback, or access private local music data.",
347
+ "One model response is a product walkthrough, not a curation benchmark.",
348
+ ],
349
+ next_steps: createDemoNextSteps(),
350
+ };
351
+ } finally {
352
+ runtime?.abort?.();
353
+ application.close();
354
+ }
355
+ }
@@ -0,0 +1,293 @@
1
+ import {
2
+ createListenerCorrection,
3
+ createListenerCorrectionRetraction,
4
+ listenerCorrectionTargetKey,
5
+ } from "../profile/listener-corrections.mjs";
6
+ import { createPublicTasteprintDemoProfile } from "./moondog-tasteprint-demo.mjs";
7
+
8
+ const DEMO_SUBJECT_ID = "70000000-0000-4000-8000-000000000001";
9
+
10
+ function demoCorrectionError(code, message) {
11
+ const error = new Error(message);
12
+ error.name = "ListenerCorrectionError";
13
+ error.code = code;
14
+ return error;
15
+ }
16
+
17
+ function activeAssertions(records) {
18
+ const retracted = new Set(
19
+ records
20
+ .filter((record) => record.operation === "retract")
21
+ .map((record) => record.retracts_taste_event_id),
22
+ );
23
+ const superseded = new Set(
24
+ records
25
+ .filter((record) => record.operation === "assert")
26
+ .map((record) => record.supersedes_taste_event_id)
27
+ .filter(Boolean),
28
+ );
29
+ return records
30
+ .filter(
31
+ (record) =>
32
+ record.operation === "assert" &&
33
+ !retracted.has(record.taste_event_id) &&
34
+ !superseded.has(record.taste_event_id),
35
+ )
36
+ .sort((left, right) => {
37
+ const time = right.recorded_at.localeCompare(left.recorded_at);
38
+ return time || right.taste_event_id.localeCompare(left.taste_event_id);
39
+ });
40
+ }
41
+
42
+ function publicCorrection(record) {
43
+ const artistCredit = record.extensions?.["moondog.artist_credit"];
44
+ return {
45
+ correction_id: record.taste_event_id,
46
+ state: "active",
47
+ entity_type: record.target.entity_type,
48
+ label: record.target.label,
49
+ ...(artistCredit ? { artist_credit: artistCredit } : {}),
50
+ stance: record.signal_type === "avoidance" ? "avoid" : "like",
51
+ occurred_at: record.occurred_at,
52
+ ...(record.note ? { note: record.note } : {}),
53
+ };
54
+ }
55
+
56
+ function projectedAssertion(record) {
57
+ const correction = publicCorrection(record);
58
+ return {
59
+ correction_id: correction.correction_id,
60
+ evidence_id: correction.correction_id,
61
+ entity_type: correction.entity_type,
62
+ label: correction.label,
63
+ ...(correction.artist_credit
64
+ ? { artist_credit: correction.artist_credit }
65
+ : {}),
66
+ stance: correction.stance,
67
+ strength: 1,
68
+ asserted_at: correction.occurred_at,
69
+ ...(correction.note ? { note: correction.note } : {}),
70
+ };
71
+ }
72
+
73
+ function normalizedText(value) {
74
+ return String(value).normalize("NFKC").toLocaleLowerCase("und").trim();
75
+ }
76
+
77
+ function strongPreferenceAvoided(item, avoids) {
78
+ const label = normalizedText(item.label);
79
+ return avoids.some((avoid) => {
80
+ if (normalizedText(avoid.label) !== label) return false;
81
+ if (avoid.entity_type === "track") return true;
82
+ return /artist/iu.test(item.signal ?? "");
83
+ });
84
+ }
85
+
86
+ function behaviorTrackAvoided(item, avoids) {
87
+ const label = normalizedText(item?.label);
88
+ const artist = normalizedText(item?.artist_credit);
89
+ return avoids.some((avoid) => {
90
+ if (avoid.entity_type === "artist") {
91
+ return artist && artist === normalizedText(avoid.label);
92
+ }
93
+ if (avoid.entity_type !== "track" || label !== normalizedText(avoid.label)) {
94
+ return false;
95
+ }
96
+ return !avoid.artist_credit ||
97
+ artist === normalizedText(avoid.artist_credit);
98
+ });
99
+ }
100
+
101
+ function directPreference(assertion, kind) {
102
+ return {
103
+ label: assertion.artist_credit
104
+ ? `${assertion.label} / ${assertion.artist_credit}`
105
+ : assertion.label,
106
+ signal: `Direct ${kind === "demo" ? "demo" : "session"} ${assertion.entity_type} preference`,
107
+ };
108
+ }
109
+
110
+ export class InteractiveTasteprintDemoSession {
111
+ #records = [];
112
+ #baseProfileFactory;
113
+ #kind;
114
+
115
+ constructor({
116
+ baseProfileFactory = createPublicTasteprintDemoProfile,
117
+ kind = "demo",
118
+ } = {}) {
119
+ if (typeof baseProfileFactory !== "function") {
120
+ throw new TypeError("The interactive demo profile factory is invalid.");
121
+ }
122
+ if (!new Set(["demo", "private_session"]).has(kind)) {
123
+ throw new TypeError("The interactive profile session kind is invalid.");
124
+ }
125
+ this.#baseProfileFactory = baseProfileFactory;
126
+ this.#kind = kind;
127
+ }
128
+
129
+ get activeCount() {
130
+ return activeAssertions(this.#records).length;
131
+ }
132
+
133
+ corrections() {
134
+ return activeAssertions(this.#records).map(publicCorrection);
135
+ }
136
+
137
+ profile() {
138
+ const profile = this.#baseProfileFactory();
139
+ if (!profile || typeof profile !== "object" || Array.isArray(profile)) {
140
+ throw new TypeError("The interactive demo base profile is invalid.");
141
+ }
142
+ const active = activeAssertions(this.#records).map(projectedAssertion);
143
+ const preferences = active.filter((item) => item.stance === "like");
144
+ const avoids = active.filter((item) => item.stance === "avoid");
145
+ const assertions = this.#records.filter(
146
+ (record) => record.operation === "assert",
147
+ ).length;
148
+ const retractions = this.#records.filter(
149
+ (record) => record.operation === "retract",
150
+ ).length;
151
+ profile.coverage = {
152
+ ...profile.coverage,
153
+ listener_assertion_events: assertions,
154
+ active_listener_assertions: active.length,
155
+ listener_retractions: retractions,
156
+ };
157
+ profile.listener_assertions = {
158
+ active,
159
+ preferences,
160
+ avoids,
161
+ retractions,
162
+ };
163
+ profile.strong_preferences = [
164
+ ...preferences.map((item) => directPreference(item, this.#kind)),
165
+ ...profile.strong_preferences.filter(
166
+ (item) => !strongPreferenceAvoided(item, avoids),
167
+ ),
168
+ ].slice(0, 10);
169
+ profile.curated_preferences = {
170
+ ...profile.curated_preferences,
171
+ avoids: avoids.map((item) => ({
172
+ entity_type: item.entity_type,
173
+ label: item.label,
174
+ ...(item.artist_credit
175
+ ? { artist_credit: item.artist_credit }
176
+ : {}),
177
+ stance: "avoid",
178
+ })),
179
+ };
180
+ if (profile.listening_behavior) {
181
+ // Refilter every listen-again selection from the original bounded profile.
182
+ // Descriptive history remains evidence even when the listener avoids it.
183
+ // Retraction restores the original candidates without inventing replacements.
184
+ profile.listening_behavior = { ...profile.listening_behavior };
185
+ for (const key of [
186
+ "rediscovery_tracks",
187
+ "time_capsule_tracks",
188
+ "historical_return_tracks",
189
+ "back_to_back_tracks",
190
+ ]) {
191
+ const candidates = profile.listening_behavior[key];
192
+ if (Array.isArray(candidates)) {
193
+ profile.listening_behavior[key] = candidates.filter(
194
+ (item) => !behaviorTrackAvoided(item, avoids),
195
+ );
196
+ }
197
+ }
198
+ }
199
+ profile.limitations = [
200
+ this.#kind === "demo"
201
+ ? "Interactive demo corrections exist only in this Studio process and disappear when it stops."
202
+ : "Session-only corrections exist only in this Studio process and disappear when it stops.",
203
+ ...profile.limitations,
204
+ ].slice(0, 8);
205
+ return profile;
206
+ }
207
+
208
+ record({
209
+ entityType,
210
+ label,
211
+ artistCredit,
212
+ stance,
213
+ note,
214
+ occurredAt,
215
+ recordedAt = occurredAt,
216
+ } = {}) {
217
+ const initial = createListenerCorrection({
218
+ subjectId: DEMO_SUBJECT_ID,
219
+ entityType,
220
+ label,
221
+ artistCredit,
222
+ stance,
223
+ note,
224
+ occurredAt,
225
+ recordedAt,
226
+ });
227
+ const previous = activeAssertions(this.#records).find(
228
+ (record) => listenerCorrectionTargetKey(record) === initial.targetKey,
229
+ );
230
+ const value = previous
231
+ ? createListenerCorrection({
232
+ subjectId: DEMO_SUBJECT_ID,
233
+ entityType,
234
+ label,
235
+ artistCredit,
236
+ stance,
237
+ note,
238
+ occurredAt,
239
+ recordedAt,
240
+ correctionId: initial.record.taste_event_id,
241
+ supersedesCorrectionId: previous.taste_event_id,
242
+ })
243
+ : initial;
244
+ this.#records.push(value.record);
245
+ return publicCorrection(value.record);
246
+ }
247
+
248
+ retract({
249
+ correctionId,
250
+ occurredAt,
251
+ recordedAt = occurredAt,
252
+ } = {}) {
253
+ const assertion = this.#records.find(
254
+ (record) =>
255
+ record.operation === "assert" &&
256
+ record.taste_event_id === correctionId?.toLocaleLowerCase("en-US"),
257
+ );
258
+ if (!assertion) {
259
+ throw demoCorrectionError(
260
+ "listener_correction_not_found",
261
+ this.#kind === "demo"
262
+ ? "The demo listener correction was not found."
263
+ : "The session-only listener correction was not found.",
264
+ );
265
+ }
266
+ if (
267
+ !activeAssertions(this.#records).some(
268
+ (record) => record.taste_event_id === assertion.taste_event_id,
269
+ )
270
+ ) {
271
+ throw demoCorrectionError(
272
+ "listener_correction_inactive",
273
+ this.#kind === "demo"
274
+ ? "Only an active demo listener correction can be retracted."
275
+ : "Only an active session-only listener correction can be retracted.",
276
+ );
277
+ }
278
+ const retraction = createListenerCorrectionRetraction({
279
+ subjectId: DEMO_SUBJECT_ID,
280
+ retractsCorrectionId: assertion.taste_event_id,
281
+ occurredAt,
282
+ recordedAt,
283
+ });
284
+ this.#records.push(retraction);
285
+ return {
286
+ state: "retracted",
287
+ correction_id: assertion.taste_event_id,
288
+ retraction_id: retraction.taste_event_id,
289
+ entity_type: assertion.target.entity_type,
290
+ label: assertion.target.label,
291
+ };
292
+ }
293
+ }