@audiofool/moondog 0.1.0 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -0,0 +1,32 @@
1
+ export function parseUpdateArguments(args = []) {
2
+ const result = { checkOnly: false };
3
+ for (let index = 0; index < args.length; index++) {
4
+ if (args[index] === "--check" && !result.checkOnly) result.checkOnly = true;
5
+ else if (args[index] === "--channel" && !result.channel && ["latest", "beta"].includes(args[index + 1])) result.channel = args[++index];
6
+ else if (["help", "--help", "-h"].includes(args[index]) && args.length === 1) return { help: true };
7
+ else throw new Error("Usage: moondog update [--check] [--channel latest|beta]");
8
+ }
9
+ return result;
10
+ }
11
+
12
+ export function formatUpdateResult(result) {
13
+ if (result.state === "updated") return `Updated Moondog ${result.previous} → ${result.latest} (${result.channel}). Run moondog to start the new version.\nPrevious program: ${result.recoveryBackup}\nYour profile, sign-ins and conversation history were preserved.`;
14
+ if (result.state === "unavailable") return result.message;
15
+ if (result.state === "current") return `Moondog ${result.current} is current or newer than the ${result.channel} release (${result.latest}).`;
16
+ return `Moondog ${result.latest} is available (${result.channel}); installed: ${result.current}.\n${result.releaseUrl}\n${result.compatible ? "Run moondog update, or /update in the listening room." : `Update Node to ${result.minimumNode} or newer first.`}`;
17
+ }
18
+
19
+ export async function runUpdateCommand({ args = [], updater, signal, deferInstall = false, onProgress } = {}) {
20
+ const options = parseUpdateArguments(args);
21
+ if (options.help) return { text: "moondog update [--check] [--channel latest|beta]\n--check only reads release metadata. Updates preserve personal data and require other Moondog sessions to be closed." };
22
+ const result = options.checkOnly || deferInstall
23
+ ? await updater.check({ channel: options.channel, force: true, signal })
24
+ : await updater.apply({ channel: options.channel, signal, onProgress });
25
+ if (options.checkOnly || deferInstall) signal?.throwIfAborted();
26
+ if (deferInstall && !options.checkOnly && result.state === "available" && result.compatible) {
27
+ const install = await updater.installation();
28
+ if (install.mode === "unmanaged") throw new Error("Update this installation using its original installation method.");
29
+ return { text: "Closing this session before installing the update. Your conversation is saved.", update: { channel: result.channel } };
30
+ }
31
+ return { text: formatUpdateResult(result), result };
32
+ }
@@ -1,44 +1,83 @@
1
+ import { translator } from "../../i18n/index.mjs";
2
+ import { webProviderName } from "../../integrations/web/web-result.mjs";
3
+ import { webSetupText } from "../../integrations/web/web-research.mjs";
1
4
  import { sanitizeTerminalText } from "./format-output.mjs";
2
5
 
3
6
  function inline(value) {
4
7
  return sanitizeTerminalText(value).replace(/\s+/gu, " ").replace(/[\\`*_[\]<>]/gu, "\\$&");
5
8
  }
6
9
 
7
- export function formatWebSources(sources) {
10
+ function link(source) {
11
+ return `[${inline(source.title)}](<${source.url.replace(/[<>]/gu, encodeURIComponent)}>)`;
12
+ }
13
+
14
+ /** The sources under an agent reply, in the reply's language, naming the services that found them. */
15
+ export function formatWebSources(sources, t = translator("en")) {
16
+ const providers = [...new Set(sources.map((source) => webProviderName(source.provider)).filter(Boolean))];
8
17
  return [
9
- "### Public web sources (Codex)",
18
+ `### ${providers.length ? t("web.sources.heading", { providers: providers.join(", ") }) : t("web.sources.headingUnnamed")}`,
10
19
  "",
11
- ...sources.map((source) => `- [${inline(source.title)}](<${source.url.replace(/[<>]/gu, encodeURIComponent)}>)${source.published_at ? ` - source date: ${inline(source.published_at)}` : ""}${source.retrieved_at ? ` - retrieved: ${source.retrieved_at}` : ""}`),
20
+ ...sources.map((source) => [
21
+ `- ${link(source)}`,
22
+ ...(source.published_at ? [t("web.sources.published", { date: inline(source.published_at) })] : []),
23
+ ...(source.retrieved_at ? [t("web.sources.retrieved", { date: source.retrieved_at.slice(0, 10) })] : []),
24
+ ].join(" - ")),
12
25
  ].join("\n");
13
26
  }
14
27
 
28
+ const contentNotes = {
29
+ model_generated_web_summary: () => "A Codex summary of public pages. The dates are when each page was published, which may not be when things happened.",
30
+ search_engine_results: (name) => `Search results from ${name}, as the search engine gives them. The dates are when each page was published, which may not be when things happened.`,
31
+ fetched_page_text: (name, result) => `The page's own text, fetched by ${name}${result.truncated ? " and cut short" : ""}.`,
32
+ };
33
+
15
34
  export function formatWebResearch(result) {
35
+ const name = webProviderName(result.provider) ?? "the web service";
36
+ const snippet = (text) => text.length > 320 ? `${text.slice(0, 319).trimEnd()}…` : text;
16
37
  return [
17
- `# Web ${result.kind}: ${result.status}`,
38
+ `# Web ${result.kind}: ${result.status.replaceAll("_", " ")}`,
18
39
  "",
19
40
  `Retrieved: ${result.retrieved_at}${result.from_cache ? " (cached)" : ""}`,
20
- "A Codex summary of public pages. The dates are when each page was published, which may not be when things happened.",
21
- "",
22
- sanitizeTerminalText(result.summary),
23
- "",
24
- "## Sources",
25
- "",
41
+ contentNotes[result.content_kind]?.(name, result) ?? `From ${name}.`,
42
+ ...(result.summary ? ["", sanitizeTerminalText(result.summary)] : []),
43
+ ...(result.sources.length ? ["", "## Sources", ""] : []),
26
44
  ...result.sources.flatMap((source) => [
27
- `- [${inline(source.title)}](<${source.url.replace(/[<>]/gu, encodeURIComponent)}>)${source.published_at ? ` - source date: ${inline(source.published_at)}` : " - source date unavailable"}`,
28
- ` ${inline(source.snippet)}`,
45
+ `- ${link(source)}${source.published_at ? ` - source date: ${inline(source.published_at)}` : " - source date unavailable"}`,
46
+ ...(source.snippet ? [` ${inline(snippet(source.snippet))}`] : []),
29
47
  ]),
30
48
  ].join("\n");
31
49
  }
32
50
 
51
+ function formatStatus(status) {
52
+ const line = (label, entry) => {
53
+ const name = webProviderName(entry.provider);
54
+ if (entry.state === "configured") {
55
+ return `- ${label}: ${name}${entry.credential ? `, with the key from ${entry.credential === "stored credential" ? "/auth" : entry.credential}` : ""}${entry.selected_by ? `, chosen by ${entry.selected_by}` : ""}`;
56
+ }
57
+ const reason = entry.requested ? `${entry.requested} is not a web backend` : entry.reason?.replaceAll("_", " ") ?? "not set up";
58
+ return `- ${label}: not set up${name ? ` (${name}: ${reason})` : ` (${reason})`}`;
59
+ };
60
+ return [
61
+ "# Web lookups",
62
+ "",
63
+ // A single backend reports one status for both.
64
+ line("Search", status.search ?? status),
65
+ line("Page reading", status.read ?? status),
66
+ "",
67
+ ...(status.state === "configured" ? [] : ["To set one up:", "", webSetupText, "", "From the shell, `moondog auth login deepseek` or `moondog auth login brave` does the same.", ""]),
68
+ "Checking this didn't search anything.",
69
+ ].join("\n");
70
+ }
71
+
33
72
  export async function runWebCommand({ args = [], webResearch, json = false, signal } = {}) {
34
73
  const [action = "help", ...values] = args;
35
74
  if (action === "help" || action === "--help") {
36
- return "# Moondog public web\n\n- `/web status` - inspect Codex CLI readiness without a model request\n- `/web search <query>` - search reviews, news, interviews or concert information\n- `/web read <https://public-page>` - read and summarize a public page\n\nThese commands use your existing Codex login and usage allowance. Results stay in the TUI. A 5-minute process-local cache avoids repeat requests. Ctrl+C cancels a request. No music-profile import is needed.\n\nShell equivalents: `moondog web status|search|read [arguments] [--json]`.";
75
+ return `# Moondog public web\n\n- \`/web status\` - see which service searches and reads pages, without a request\n- \`/web search <query>\` - search reviews, news, interviews or concert information\n- \`/web read <https://public-page>\` - read a public page\n\nSearch uses DeepSeek when \`/auth deepseek\` has a key, then Brave Search with \`/auth brave\`, then your signed-in Codex CLI. Reading uses DeepSeek, then Codex. \`MOONDOG_WEB_SEARCH_PROVIDER\` and \`MOONDOG_WEB_READ_PROVIDER\` (deepseek, brave or codex) choose one.\n\nResults stay in the TUI. A 5-minute process-local cache avoids repeat requests. Ctrl+C cancels a request. No music-profile import is needed.\n\nShell equivalents: \`moondog web status|search|read [arguments] [--json]\`.`;
37
76
  }
38
77
  if (!webResearch) throw new Error("Web lookups aren't available right now.");
39
78
  if (action === "status" && values.length === 0) {
40
79
  const status = webResearch.publicStatus();
41
- return json ? JSON.stringify(status, null, 2) : `Web lookups through Codex: ${status.state.replaceAll("_", " ")}${status.reason ? ` (${status.reason.replaceAll("_", " ")})` : ""}. Checking this didn't search anything.`;
80
+ return json ? JSON.stringify(status, null, 2) : formatStatus(status);
42
81
  }
43
82
  if (action === "search" && values.length > 0) {
44
83
  const result = await webResearch.search({ query: values.join(" ") }, { signal });
@@ -26,6 +26,16 @@ function optionalNumber(value, { integer = false, minimum = 0 } = {}) {
26
26
  return value;
27
27
  }
28
28
 
29
+ // The projection leaves out a measure that no play recorded: an absent value
30
+ // is unknown, while a present value must still be valid.
31
+ function recordedNumber(value, options) {
32
+ return value === undefined ? undefined : optionalNumber(value, options);
33
+ }
34
+
35
+ function exceeds(part, whole) {
36
+ return part !== undefined && whole !== undefined && part > whole;
37
+ }
38
+
29
39
  function list(value, maximum, projector) {
30
40
  if (!Array.isArray(value)) return [];
31
41
  return value.slice(0, maximum).map(projector).filter(Boolean);
@@ -186,10 +196,10 @@ function listeningSeasonArtistSignal(item) {
186
196
  integer: true,
187
197
  minimum: 1,
188
198
  });
189
- const engagedPlayCount = optionalNumber(item.engaged_play_count, {
199
+ const engagedPlayCount = recordedNumber(item.engaged_play_count, {
190
200
  integer: true,
191
201
  });
192
- const listeningMinutes = optionalNumber(item.listening_minutes);
202
+ const listeningMinutes = recordedNumber(item.listening_minutes);
193
203
  const distinctTracks = optionalNumber(item.distinct_tracks, {
194
204
  integer: true,
195
205
  minimum: 1,
@@ -198,19 +208,19 @@ function listeningSeasonArtistSignal(item) {
198
208
  !name ||
199
209
  eventCount === null ||
200
210
  engagedPlayCount === null ||
201
- engagedPlayCount > eventCount ||
211
+ exceeds(engagedPlayCount, eventCount) ||
202
212
  listeningMinutes === null ||
203
213
  distinctTracks === null
204
214
  ) {
205
215
  return null;
206
216
  }
207
- return {
217
+ return compactObject({
208
218
  name,
209
219
  event_count: eventCount,
210
220
  engaged_play_count: engagedPlayCount,
211
221
  listening_minutes: listeningMinutes,
212
222
  distinct_tracks: distinctTracks,
213
- };
223
+ });
214
224
  }
215
225
 
216
226
  function listeningSeasonTrackSignal(item) {
@@ -221,11 +231,11 @@ function listeningSeasonTrackSignal(item) {
221
231
  integer: true,
222
232
  minimum: 1,
223
233
  });
224
- const engagedPlayCount = optionalNumber(item.engaged_play_count, {
234
+ const engagedPlayCount = recordedNumber(item.engaged_play_count, {
225
235
  integer: true,
226
236
  });
227
- const listeningMinutes = optionalNumber(item.listening_minutes);
228
- const explicitSkips = optionalNumber(item.explicit_skips, {
237
+ const listeningMinutes = recordedNumber(item.listening_minutes);
238
+ const explicitSkips = recordedNumber(item.explicit_skips, {
229
239
  integer: true,
230
240
  });
231
241
  if (
@@ -233,10 +243,12 @@ function listeningSeasonTrackSignal(item) {
233
243
  !artistCredit ||
234
244
  playCount === null ||
235
245
  engagedPlayCount === null ||
236
- engagedPlayCount > playCount ||
246
+ exceeds(engagedPlayCount, playCount) ||
237
247
  listeningMinutes === null ||
238
248
  explicitSkips === null ||
239
- engagedPlayCount + explicitSkips !== playCount
249
+ (engagedPlayCount === undefined) !== (explicitSkips === undefined) ||
250
+ (engagedPlayCount !== undefined &&
251
+ engagedPlayCount + explicitSkips !== playCount)
240
252
  ) {
241
253
  return null;
242
254
  }
@@ -267,10 +279,10 @@ function listeningSeasonSignal(item) {
267
279
  integer: true,
268
280
  });
269
281
  const eventCount = optionalNumber(item.event_count, { integer: true });
270
- const engagedPlayCount = optionalNumber(item.engaged_play_count, {
282
+ const engagedPlayCount = recordedNumber(item.engaged_play_count, {
271
283
  integer: true,
272
284
  });
273
- const listeningMinutes = optionalNumber(item.listening_minutes);
285
+ const listeningMinutes = recordedNumber(item.listening_minutes);
274
286
  const distinctTracks = optionalNumber(item.distinct_tracks, {
275
287
  integer: true,
276
288
  });
@@ -296,7 +308,7 @@ function listeningSeasonSignal(item) {
296
308
  activeMonthCount > retainedMonthCount ||
297
309
  eventCount === null ||
298
310
  engagedPlayCount === null ||
299
- engagedPlayCount > eventCount ||
311
+ exceeds(engagedPlayCount, eventCount) ||
300
312
  listeningMinutes === null ||
301
313
  distinctTracks === null ||
302
314
  distinctTracks > eventCount ||
@@ -305,8 +317,8 @@ function listeningSeasonSignal(item) {
305
317
  firstObservedTracks + returningTracks !== distinctTracks ||
306
318
  (eventCount === 0 &&
307
319
  (activeMonthCount !== 0 ||
308
- engagedPlayCount !== 0 ||
309
- listeningMinutes !== 0 ||
320
+ (engagedPlayCount ?? 0) !== 0 ||
321
+ (listeningMinutes ?? 0) !== 0 ||
310
322
  distinctTracks !== 0 ||
311
323
  leadingArtist !== null ||
312
324
  signatureTrack !== null)) ||
@@ -317,13 +329,13 @@ function listeningSeasonSignal(item) {
317
329
  signatureTrack === null)) ||
318
330
  (leadingArtist &&
319
331
  (leadingArtist.event_count > eventCount ||
320
- leadingArtist.engaged_play_count > engagedPlayCount ||
321
- leadingArtist.listening_minutes > listeningMinutes ||
332
+ exceeds(leadingArtist.engaged_play_count, engagedPlayCount) ||
333
+ exceeds(leadingArtist.listening_minutes, listeningMinutes) ||
322
334
  leadingArtist.distinct_tracks > distinctTracks)) ||
323
335
  (signatureTrack &&
324
336
  (signatureTrack.play_count > eventCount ||
325
- signatureTrack.engaged_play_count > engagedPlayCount ||
326
- signatureTrack.listening_minutes > listeningMinutes))
337
+ exceeds(signatureTrack.engaged_play_count, engagedPlayCount) ||
338
+ exceeds(signatureTrack.listening_minutes, listeningMinutes)))
327
339
  ) {
328
340
  return null;
329
341
  }
@@ -814,7 +826,6 @@ export function createTasteprintView(
814
826
  profile,
815
827
  {
816
828
  generatedAt = new Date().toISOString(),
817
- syntheticDemo = false,
818
829
  } = {},
819
830
  ) {
820
831
  if (!isPlainObject(profile)) {
@@ -823,9 +834,6 @@ export function createTasteprintView(
823
834
  if (typeof generatedAt !== "string" || Number.isNaN(Date.parse(generatedAt))) {
824
835
  throw new TypeError("Tasteprint generatedAt must be an ISO timestamp.");
825
836
  }
826
- if (typeof syntheticDemo !== "boolean") {
827
- throw new TypeError("Tasteprint syntheticDemo must be a boolean.");
828
- }
829
837
  const coverage = isPlainObject(profile.coverage) ? profile.coverage : {};
830
838
  const behavior = isPlainObject(profile.listening_behavior)
831
839
  ? profile.listening_behavior
@@ -860,12 +868,9 @@ export function createTasteprintView(
860
868
  return {
861
869
  tasteprint_version: "moondog-tasteprint/1",
862
870
  generated_at: new Date(generatedAt).toISOString(),
863
- synthetic_demo: syntheticDemo,
864
871
  privacy: {
865
- classification: syntheticDemo
866
- ? "public_synthetic_demo"
867
- : "private_local_tasteprint",
868
- contains_personal_music_context: !syntheticDemo,
872
+ classification: "private_local_tasteprint",
873
+ contains_personal_music_context: true,
869
874
  network_requests: "none",
870
875
  },
871
876
  profile_version: cleanText(profile.profile_version, 80) || "unknown",
@@ -1830,7 +1835,6 @@ function crossFormatIdentityCoverage(coverage) {
1830
1835
 
1831
1836
  export function renderTasteprintHtml(profile, options = {}) {
1832
1837
  const view = createTasteprintView(profile, options);
1833
- const syntheticDemo = view.synthetic_demo === true;
1834
1838
  const coverage = view.coverage;
1835
1839
  const context = view.behavior.context;
1836
1840
  const profiledEvents =
@@ -2000,7 +2004,7 @@ export function renderTasteprintHtml(profile, options = {}) {
2000
2004
  <meta name="robots" content="noindex, nofollow, noarchive">
2001
2005
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
2002
2006
  <meta name="color-scheme" content="light">
2003
- <title>${syntheticDemo ? "Moondog Synthetic Tasteprint Demo" : "Private Moondog Tasteprint"}</title>
2007
+ <title>Private Moondog Tasteprint</title>
2004
2008
  <style>
2005
2009
  :root {
2006
2010
  --paper: #eee8de;
@@ -2302,20 +2306,6 @@ export function renderTasteprintHtml(profile, options = {}) {
2302
2306
 
2303
2307
  .section.dark { color: #f5f1e9; background: var(--night); border-color: #10100f; }
2304
2308
 
2305
- .demo-chip {
2306
- display: inline-flex;
2307
- margin: 0 0 22px;
2308
- padding: 7px 10px;
2309
- border: 1px solid #5f7182;
2310
- border-radius: 999px;
2311
- color: #c8d9e8;
2312
- background: rgba(111, 140, 168, 0.12);
2313
- font-size: 9px;
2314
- font-weight: 850;
2315
- letter-spacing: 0.14em;
2316
- text-transform: uppercase;
2317
- }
2318
-
2319
2309
  .section-heading {
2320
2310
  display: grid;
2321
2311
  grid-template-columns: minmax(0, 1fr) minmax(220px, 360px);
@@ -2490,20 +2480,6 @@ export function renderTasteprintHtml(profile, options = {}) {
2490
2480
  rgba(248, 244, 237, 0.94);
2491
2481
  }
2492
2482
 
2493
- .pattern-demo-chip {
2494
- display: inline-flex;
2495
- margin: 0 0 22px;
2496
- padding: 7px 10px;
2497
- border: 1px solid #a8bac9;
2498
- border-radius: 999px;
2499
- color: #4f708f;
2500
- background: rgba(237, 243, 247, 0.86);
2501
- font-size: 9px;
2502
- font-weight: 850;
2503
- letter-spacing: 0.14em;
2504
- text-transform: uppercase;
2505
- }
2506
-
2507
2483
  .pattern-grid {
2508
2484
  align-items: start;
2509
2485
  grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr));
@@ -2981,7 +2957,7 @@ export function renderTasteprintHtml(profile, options = {}) {
2981
2957
  <body>
2982
2958
  <main class="shell">
2983
2959
  <header class="hero">
2984
- <p class="eyebrow">${syntheticDemo ? "Moondog listening report, fictional demo" : "Moondog listening report, private"}</p>
2960
+ <p class="eyebrow">Moondog listening report, private</p>
2985
2961
  <h1>${escapeHtml(spanLabel(view.timeline.days))}, seen clearly.</h1>
2986
2962
  <p class="hero-copy">${escapeHtml(heroCopy)}</p>
2987
2963
  <div class="hero-meta" aria-label="Tasteprint snapshot metadata">
@@ -2991,9 +2967,9 @@ export function renderTasteprintHtml(profile, options = {}) {
2991
2967
  </div>
2992
2968
  </header>
2993
2969
 
2994
- <aside class="privacy" aria-label="${syntheticDemo ? "Synthetic demo boundary" : "Privacy boundary"}">
2970
+ <aside class="privacy" aria-label="Privacy boundary">
2995
2971
  <span class="privacy-mark" aria-hidden="true">L</span>
2996
- <div><strong>${syntheticDemo ? "A fictional demo" : "Private, made on this machine"}</strong><p>${syntheticDemo ? "Every artist, track, and number on this page is made up. The page loads nothing from the internet." : "This page is about you. It loads nothing from the internet. Share it only if you are happy for others to see the artists, tracks, and numbers on it."}</p></div>
2972
+ <div><strong>Private, made on this machine</strong><p>This page is about you. It loads nothing from the internet. Share it only if you are happy for others to see the artists, tracks, and numbers on it.</p></div>
2997
2973
  </aside>
2998
2974
 
2999
2975
  <section class="overview" aria-label="Listening coverage">
@@ -3008,7 +2984,6 @@ export function renderTasteprintHtml(profile, options = {}) {
3008
2984
  ${sectionNavigator}
3009
2985
 
3010
2986
  <section class="section dark" id="taste-shape">
3011
- ${syntheticDemo ? '<p class="demo-chip">Fictional public profile</p>' : ""}
3012
2987
  <div class="section-heading"><h2>Shine On</h2><p>The artists who stayed with you across the years, and the ones you have played in the last ${escapeHtml(number(recentWindow))} days. Bars compare within each list only.</p></div>
3013
2988
  <div class="two-column">
3014
2989
  <article class="panel"><span class="panel-label">All of your history</span><h3>Across the years</h3>${artistRows(view.behavior.enduring_artists)}</article>
@@ -3033,7 +3008,6 @@ export function renderTasteprintHtml(profile, options = {}) {
3033
3008
  </section>` : ""}
3034
3009
 
3035
3010
  ${hasListeningPatterns ? `<section class="section pattern-section" id="listening-patterns">
3036
- ${syntheticDemo ? '<p class="pattern-demo-chip">Fictional archive preview</p>' : ""}
3037
3011
  <div class="section-heading"><h2>Inside a listening stretch</h2><p>Spotify's extended history shows how plays cluster, which songs you played again right away, and which records you went deep on. These are patterns, not a read on your mood, routine, or taste.</p></div>
3038
3012
  <div class="two-column pattern-grid">
3039
3013
  ${view.behavior.session_summary ? `<article class="panel session-panel"><span class="panel-label">${escapeHtml(number(view.behavior.session_summary.gap_minutes))}-minute breaks</span><h3>Listening stretches</h3><p class="panel-intro">Plays are grouped together until a longer break starts a new stretch.</p>${sessionShape(view.behavior.session_summary)}</article>` : ""}
@@ -3082,7 +3056,7 @@ export function renderTasteprintHtml(profile, options = {}) {
3082
3056
  <div class="limitations">${view.limitations.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>
3083
3057
  </section>
3084
3058
 
3085
- <footer><span>Moondog listening report / ${syntheticDemo ? "fictional demo" : "private, made on this machine"} / works offline</span><span>Generated ${escapeHtml(view.generated_at)}</span></footer>
3059
+ <footer><span>Moondog listening report / private, made on this machine / works offline</span><span>Generated ${escapeHtml(view.generated_at)}</span></footer>
3086
3060
  </main>
3087
3061
  </body>
3088
3062
  </html>
@@ -3091,7 +3065,6 @@ export function renderTasteprintHtml(profile, options = {}) {
3091
3065
 
3092
3066
  export function renderTasteprintCardHtml(profile, options = {}) {
3093
3067
  const view = createTasteprintView(profile, options);
3094
- const syntheticDemo = view.synthetic_demo === true;
3095
3068
  const coverage = view.coverage;
3096
3069
  const recentWindow = view.behavior.context.recent_window_days ?? 90;
3097
3070
  const enduringArtists = view.behavior.enduring_artists.slice(0, 3);
@@ -3156,7 +3129,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
3156
3129
  <meta name="robots" content="noindex, nofollow, noarchive">
3157
3130
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
3158
3131
  <meta name="color-scheme" content="light">
3159
- <title>${syntheticDemo ? "Moondog Synthetic Tasteprint Card Demo" : "Private Moondog Tasteprint Card"}</title>
3132
+ <title>Private Moondog Tasteprint Card</title>
3160
3133
  <style>
3161
3134
  :root {
3162
3135
  color: #1b1b19;
@@ -3520,7 +3493,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
3520
3493
  <body>
3521
3494
  <main class="taste-card" data-artifact="moondog-tasteprint-card/1">
3522
3495
  <section class="identity" aria-labelledby="card-title">
3523
- <div class="brand-row"><span>Moondog</span><span>${syntheticDemo ? "Fictional demo" : "Private recap"}</span></div>
3496
+ <div class="brand-row"><span>Moondog</span><span>Private recap</span></div>
3524
3497
  <p class="card-kicker">Tasteprint card / across the years</p>
3525
3498
  <h1 id="card-title">${escapeHtml(headline)}</h1>
3526
3499
  <p class="span">${escapeHtml(spanLabel(view.timeline.days))}, read from your own listening history.</p>
@@ -3533,7 +3506,7 @@ export function renderTasteprintCardHtml(profile, options = {}) {
3533
3506
 
3534
3507
  <section class="content" aria-label="Tasteprint recap">
3535
3508
  <header class="content-header">
3536
- <p class="content-title">${syntheticDemo ? "Fictional public profile" : "Private listening recap"}</p>
3509
+ <p class="content-title">Private listening recap</p>
3537
3510
  <p class="content-range">${escapeHtml(rangeLabel)}</p>
3538
3511
  </header>
3539
3512
 
@@ -3559,8 +3532,8 @@ export function renderTasteprintCardHtml(profile, options = {}) {
3559
3532
  <aside class="boundary" aria-label="Interpretation and privacy boundary">
3560
3533
  <span class="boundary-mark" aria-hidden="true">L</span>
3561
3534
  <div>
3562
- <strong>${syntheticDemo ? "A fictional demo" : "Read it before you share it"}</strong>
3563
- <p>${syntheticDemo ? "Every artist, track, date, and number on this card is made up." : "This card is about you. Check every artist, track, date, and number on it before you share it."}</p>
3535
+ <strong>Read it before you share it</strong>
3536
+ <p>This card is about you. Check every artist, track, date, and number on it before you share it.</p>
3564
3537
  <p>${escapeHtml(boundary)}</p>
3565
3538
  ${correctionCount > 0 ? `<span class="direct-signal">${escapeHtml(number(correctionCount))} of your own ${correctionCount === 1 ? "choice" : "choices"} applied</span>` : ""}
3566
3539
  </div>