@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,750 @@
1
+ export function sanitizeTerminalText(value) {
2
+ return String(value)
3
+ .replaceAll("\r\n", "\n")
4
+ .replaceAll("\r", "\n")
5
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
6
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gi, "");
7
+ }
8
+
9
+ function words(value) {
10
+ return String(value ?? "unknown").replaceAll("_", " ");
11
+ }
12
+
13
+ function formatSource(source) {
14
+ if (!source.latest) return "- Apple Music library: not imported";
15
+ return [
16
+ `- Apple Music library: ${formatInteger(source.latest.tracks)} tracks and ${formatInteger(source.latest.playlists)} playlists, as of ${String(source.latest.captured_at).slice(0, 10)}`,
17
+ source.semantics?.complete_listening_history_available
18
+ ? "- Includes a full listening history"
19
+ : "- A library shows what you keep, not every play",
20
+ ].join("\n");
21
+ }
22
+
23
+ const serviceNames = {
24
+ spotify: "Spotify",
25
+ listenbrainz: "ListenBrainz",
26
+ lastfm: "Last.fm",
27
+ apple_music: "Apple Music",
28
+ youtube_music: "YouTube Music",
29
+ qq_music: "QQ Music",
30
+ netease: "NetEase Cloud Music",
31
+ };
32
+
33
+ function formatProfile(profile) {
34
+ if (profile.state !== "ready") {
35
+ return `- Not ready yet${profile.reason ? `: ${profile.reason}` : ""}\n- \`/import\` brings in your history`;
36
+ }
37
+ const lines = [];
38
+ const listening = [
39
+ Number.isInteger(profile.effective_listening_events)
40
+ ? `${formatInteger(profile.effective_listening_events)} ${profile.effective_listening_events === 1 ? "play" : "plays"}`
41
+ : null,
42
+ typeof profile.listening_hours === "number" && profile.listening_hours > 0 ? `${profile.listening_hours} h` : null,
43
+ Array.isArray(profile.listening_sources) && profile.listening_sources.length > 0
44
+ ? `from ${profile.listening_sources.map((source) => serviceNames[source] ?? words(source)).join(", ")}`
45
+ : null,
46
+ ].filter(Boolean);
47
+ if (listening.length > 0) lines.push(`- ${listening.join(" · ")}`);
48
+ if (Number.isInteger(profile.spotify_profile_evidence) && profile.spotify_profile_evidence > 0) {
49
+ lines.push(`- ${formatInteger(profile.spotify_profile_evidence)} saved songs, follows, and playlist entries from Spotify`);
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ function safeInlineText(value) {
55
+ return sanitizeTerminalText(value ?? "")
56
+ .replace(/\\/gu, "\\\\")
57
+ .replace(/([`*_[\]<>])/gu, "\\$1")
58
+ .replace(/\s+/gu, " ")
59
+ .trim();
60
+ }
61
+
62
+ function formatInteger(value) {
63
+ return Number.isInteger(value) ? value.toLocaleString("en-US") : "unknown";
64
+ }
65
+
66
+ function formatListeningTime(minutes) {
67
+ if (!Number.isFinite(minutes) || minutes < 0) return "unknown duration";
68
+ if (minutes < 120) return `${Math.round(minutes)} min`;
69
+ return `${Math.round((minutes / 60) * 10) / 10} h`;
70
+ }
71
+
72
+ function plural(value, one, many = `${one}s`) {
73
+ return `${formatInteger(value)} ${value === 1 ? one : many}`;
74
+ }
75
+
76
+ function formatArtistSignal(item) {
77
+ const details = [];
78
+ if (Number.isInteger(item.play_count)) {
79
+ details.push(plural(item.play_count, "play"));
80
+ }
81
+ if (Number.isFinite(item.listening_minutes)) {
82
+ details.push(formatListeningTime(item.listening_minutes));
83
+ }
84
+ if (Number.isInteger(item.distinct_tracks)) {
85
+ details.push(plural(item.distinct_tracks, "track"));
86
+ }
87
+ return `- ${safeInlineText(item.name)}${
88
+ details.length > 0 ? ` · ${details.join(", ")}` : ""
89
+ }`;
90
+ }
91
+
92
+ function formatTrackSignal(item) {
93
+ const label = safeInlineText(item.label);
94
+ const artist = safeInlineText(item.artist_credit);
95
+ const details = [];
96
+ if (Number.isInteger(item.play_count)) {
97
+ details.push(plural(item.play_count, "play"));
98
+ }
99
+ if (Number.isFinite(item.listening_minutes)) {
100
+ details.push(formatListeningTime(item.listening_minutes));
101
+ }
102
+ return `- ${label}${artist ? ` - ${artist}` : ""}${
103
+ details.length > 0 ? ` · ${details.join(", ")}` : ""
104
+ }`;
105
+ }
106
+
107
+ // Attention alone is the default reading, so only stronger evidence is worth naming.
108
+ const signalPhrases = {
109
+ "historical attention only": null,
110
+ "saved-library state": "saved in your library",
111
+ "private playlist curation": "on one of your playlists",
112
+ "explicit listener preference": "you said you like it",
113
+ };
114
+
115
+ function signalPhrase(value) {
116
+ const text = safeInlineText(value);
117
+ if (!text) return null;
118
+ return Object.hasOwn(signalPhrases, text) ? signalPhrases[text] : text;
119
+ }
120
+
121
+ function withDetails(base, details) {
122
+ const present = details.filter(Boolean);
123
+ return `${base}${present.length > 0 ? ` · ${present.join(", ")}` : ""}`;
124
+ }
125
+
126
+ function formatRediscoveryTrack(item) {
127
+ return withDetails(formatTrackSignal(item), [
128
+ Number.isInteger(item.quiet_days)
129
+ ? `quiet for ${plural(item.quiet_days, "day")}`
130
+ : null,
131
+ Number.isInteger(item.peak_year) ? `biggest in ${item.peak_year}` : null,
132
+ signalPhrase(item.rediscovery_signal),
133
+ ]);
134
+ }
135
+
136
+ function formatHistoricalReturnTrack(item) {
137
+ return withDetails(formatTrackSignal(item), [
138
+ Number.isInteger(item.return_count)
139
+ ? `came back ${item.return_count === 1 ? "once" : `${formatInteger(item.return_count)} times`}`
140
+ : null,
141
+ Number.isInteger(item.longest_gap_days)
142
+ ? `longest time away ${plural(item.longest_gap_days, "day")}`
143
+ : null,
144
+ signalPhrase(item.historical_return_signal),
145
+ ]);
146
+ }
147
+
148
+ function formatBackToBackTrack(item) {
149
+ return withDetails(formatTrackSignal(item), [
150
+ Number.isInteger(item.maximum_consecutive_plays)
151
+ ? `up to ${formatInteger(item.maximum_consecutive_plays)} in a row`
152
+ : null,
153
+ Number.isInteger(item.burst_count) && item.burst_count > 1
154
+ ? `${formatInteger(item.burst_count)} separate runs`
155
+ : null,
156
+ ]);
157
+ }
158
+
159
+ function formatTimeCapsuleTrack(item) {
160
+ const label = safeInlineText(item.label);
161
+ const artist = safeInlineText(item.artist_credit);
162
+ return withDetails(
163
+ `- ${Number.isInteger(item.capsule_year) ? item.capsule_year : "Unknown year"} ${label}${artist ? ` - ${artist}` : ""}`,
164
+ [
165
+ Number.isInteger(item.year_play_count)
166
+ ? `${plural(item.year_play_count, "play")} that year`
167
+ : null,
168
+ signalPhrase(item.representative_signal),
169
+ ],
170
+ );
171
+ }
172
+
173
+ function formatHistoryArc(item) {
174
+ const details = [];
175
+ if (Number.isFinite(item.listening_minutes)) {
176
+ details.push(formatListeningTime(item.listening_minutes));
177
+ }
178
+ if (Number.isInteger(item.event_count)) {
179
+ details.push(plural(item.event_count, "play"));
180
+ }
181
+ if (Number.isInteger(item.distinct_tracks)) {
182
+ details.push(plural(item.distinct_tracks, "track"));
183
+ }
184
+ if (Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0) {
185
+ details.push(`${formatInteger(item.first_observed_tracks)} new`);
186
+ }
187
+ const topArtist = safeInlineText(item.top_artist?.name);
188
+ if (topArtist) details.push(`mostly ${topArtist}`);
189
+ return `- ${Number.isInteger(item.year) ? item.year : "Unknown year"}${
190
+ details.length > 0 ? ` · ${details.join(", ")}` : ""
191
+ }`;
192
+ }
193
+
194
+ function formatListeningSeason(item) {
195
+ const key = safeInlineText(item.key).replace("-", " ");
196
+ if (!Number.isInteger(item.event_count) || item.event_count === 0) {
197
+ return `- ${key || "Unknown season"} · nothing in your history`;
198
+ }
199
+ const signature = safeInlineText(item.signature_track?.label);
200
+ const signatureArtist = safeInlineText(item.signature_track?.artist_credit);
201
+ return withDetails(`- ${key || "Unknown season"}`, [
202
+ Number.isFinite(item.listening_minutes)
203
+ ? formatListeningTime(item.listening_minutes)
204
+ : null,
205
+ plural(item.event_count, "play"),
206
+ Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0
207
+ ? `${formatInteger(item.first_observed_tracks)} new`
208
+ : null,
209
+ safeInlineText(item.leading_artist?.name)
210
+ ? `mostly ${safeInlineText(item.leading_artist.name)}`
211
+ : null,
212
+ signature
213
+ ? `top song ${signature}${signatureArtist ? ` - ${signatureArtist}` : ""}`
214
+ : null,
215
+ ]);
216
+ }
217
+
218
+ function formatListenerAssertion(item) {
219
+ const label = safeInlineText(item.label);
220
+ const artist = safeInlineText(item.artist_credit);
221
+ const target = artist ? `${label} - ${artist}` : label;
222
+ const note = safeInlineText(item.note);
223
+ const entity = safeInlineText(item.entity_type);
224
+ return `- ${item.stance === "avoid" ? "Keep out" : "Like"}${entity ? ` (${entity})` : ""}: ${target}${
225
+ note ? ` · "${note}"` : ""
226
+ } · ${safeInlineText(item.correction_id)}`;
227
+ }
228
+
229
+ function appendTasteSection(lines, title, values, formatter, subtitle) {
230
+ if (!Array.isArray(values) || values.length === 0) return;
231
+ lines.push("", `## ${title}`, "", ...(subtitle ? [`*${subtitle}*`, ""] : []), ...values.map(formatter));
232
+ }
233
+
234
+ function formatBehaviorContextLine(value, total, label) {
235
+ if (!Number.isInteger(value) || total === 0) return null;
236
+ const validTotal = Number.isInteger(total) && total >= value && total > 0;
237
+ if (!validTotal) return `- ${label}: ${formatInteger(value)}`;
238
+ const share = Math.round((value / total) * 100);
239
+ return `- ${label}: ${share}% (${formatInteger(value)} of ${formatInteger(total)})`;
240
+ }
241
+
242
+ function formatTaste(profile) {
243
+ const coverage = profile.coverage ?? {};
244
+ const durationUnavailable = coverage.events_with_played_duration === 0;
245
+ const listeningSignal = (formatter) => (item) => formatter(durationUnavailable ? { ...item, listening_minutes: undefined } : item);
246
+ const behavior = profile.listening_behavior ?? {};
247
+ const context = behavior.context ?? {};
248
+ const listeningRange = profile.listening_source?.listening_range;
249
+ const range =
250
+ typeof listeningRange?.earliest === "string" &&
251
+ typeof listeningRange?.latest === "string"
252
+ ? `${listeningRange.earliest.slice(0, 10)} to ${listeningRange.latest.slice(0, 10)}`
253
+ : null;
254
+ const oneOff = profile.preview_source?.persistent_import === false;
255
+ const hours = !durationUnavailable && Number.isFinite(coverage.listening_hours)
256
+ ? `${coverage.listening_hours.toLocaleString("en-US")} h`
257
+ : null;
258
+ const lines = [
259
+ "# Your listening, so far",
260
+ "",
261
+ oneOff
262
+ ? "A one-off reading of the file you chose. Nothing was saved to your profile."
263
+ : "Read from your own files. It stays on this machine.",
264
+ "",
265
+ "## What I'm reading from",
266
+ "",
267
+ ];
268
+ const summary = [
269
+ Number.isInteger(coverage.effective_listening_events) ? plural(coverage.effective_listening_events, "play") : null,
270
+ hours,
271
+ Number.isInteger(coverage.listening_tracks) ? plural(coverage.listening_tracks, "track") : null,
272
+ ].filter(Boolean);
273
+ if (summary.length > 0) lines.push(`- ${summary.join(" · ")}`);
274
+ if (range) lines.push(`- ${range}`);
275
+ const saved = coverage.saved_tracks ?? coverage.spotify_saved_tracks;
276
+ if (Number.isInteger(saved) && saved > 0) lines.push(`- ${plural(saved, "saved track")}`);
277
+ const memberships = coverage.playlist_memberships ?? coverage.spotify_playlist_memberships;
278
+ if (Number.isInteger(memberships) && memberships > 0) lines.push(`- ${plural(memberships, "song", "songs")} on your playlists`);
279
+ for (const source of coverage.collection_sources ?? []) {
280
+ if (source.tracks > 0) lines.push(`- ${safeInlineText(source.label)}: ${plural(source.tracks, "track")}`);
281
+ }
282
+ if (!durationUnavailable && Number.isInteger(coverage.events_with_played_duration) &&
283
+ Number.isInteger(coverage.effective_listening_events) &&
284
+ coverage.events_with_played_duration < coverage.effective_listening_events) {
285
+ lines.push(`- Listening time is known for ${formatInteger(coverage.events_with_played_duration)} of those plays`);
286
+ }
287
+ if (Number.isInteger(coverage.cross_format_track_links) && coverage.cross_format_track_links > 0) {
288
+ lines.push(`- ${plural(coverage.cross_format_track_links, "track")} matched across your two Spotify exports`);
289
+ }
290
+ if (Number.isInteger(coverage.cross_format_ambiguous_tracks) && coverage.cross_format_ambiguous_tracks > 0) {
291
+ lines.push(`- ${plural(coverage.cross_format_ambiguous_tracks, "track")} kept apart because the match was unclear`);
292
+ }
293
+ if (Number.isInteger(context.incognito_events_excluded) && context.incognito_events_excluded > 0) {
294
+ lines.push(`- ${plural(context.incognito_events_excluded, "private-session play")} left out`);
295
+ }
296
+
297
+ appendTasteSection(
298
+ lines,
299
+ "What you told me",
300
+ profile.listener_assertions?.active,
301
+ formatListenerAssertion,
302
+ );
303
+ appendTasteSection(
304
+ lines,
305
+ "Shine On",
306
+ behavior.enduring_artists,
307
+ listeningSignal(formatArtistSignal),
308
+ "Artists who stayed with you across the years",
309
+ );
310
+ appendTasteSection(
311
+ lines,
312
+ "Lately",
313
+ behavior.recent_artists,
314
+ listeningSignal(formatArtistSignal),
315
+ Number.isInteger(context.recent_window_days)
316
+ ? `Who you've played most in the last ${context.recent_window_days} days`
317
+ : "Who you've played most recently",
318
+ );
319
+ appendTasteSection(
320
+ lines,
321
+ "Year by year",
322
+ behavior.history_arc,
323
+ listeningSignal(formatHistoryArc),
324
+ );
325
+ const listeningSeasons = behavior.listening_seasons?.seasons;
326
+ appendTasteSection(
327
+ lines,
328
+ "Seasons",
329
+ Array.isArray(listeningSeasons) ? listeningSeasons.slice(-12) : [],
330
+ listeningSignal(formatListeningSeason),
331
+ "Your last twelve quarters. \"New\" means new to your history, not necessarily new to you.",
332
+ );
333
+ appendTasteSection(
334
+ lines,
335
+ "Time",
336
+ behavior.time_capsule_tracks,
337
+ formatTimeCapsuleTrack,
338
+ "The years, one track each",
339
+ );
340
+ appendTasteSection(
341
+ lines,
342
+ "Wish You Were Here",
343
+ behavior.rediscovery_tracks,
344
+ formatRediscoveryTrack,
345
+ Number.isInteger(context.rediscovery_quiet_days)
346
+ ? `Songs you used to play a lot that have gone quiet for ${context.rediscovery_quiet_days}+ days`
347
+ : "Songs you used to play a lot that have gone quiet",
348
+ );
349
+ appendTasteSection(
350
+ lines,
351
+ "Coming Back to Life",
352
+ behavior.historical_return_tracks,
353
+ formatHistoricalReturnTrack,
354
+ Number.isInteger(context.historical_return_minimum_gap_days)
355
+ ? `Songs that found their way back after ${context.historical_return_minimum_gap_days}+ days away`
356
+ : "Songs that found their way back after long gaps",
357
+ );
358
+ appendTasteSection(
359
+ lines,
360
+ "Echoes",
361
+ behavior.back_to_back_tracks,
362
+ formatBackToBackTrack,
363
+ "Songs you played again right away",
364
+ );
365
+ appendTasteSection(
366
+ lines,
367
+ "Most played",
368
+ behavior.repeat_tracks,
369
+ listeningSignal(formatTrackSignal),
370
+ );
371
+ appendTasteSection(
372
+ lines,
373
+ "Recently",
374
+ behavior.recent_tracks,
375
+ listeningSignal(formatTrackSignal),
376
+ );
377
+
378
+ const behaviorContextLines = [
379
+ formatBehaviorContextLine(context.direct_selection_starts, context.start_reason_events, "Songs you picked yourself"),
380
+ formatBehaviorContextLine(context.trackdone_starts, context.start_reason_events, "Songs that followed on"),
381
+ formatBehaviorContextLine(context.trackdone_endings, context.end_reason_events, "Played to the end"),
382
+ formatBehaviorContextLine(context.explicit_skips, context.skip_state_events, "Skipped"),
383
+ formatBehaviorContextLine(context.shuffle_events, context.shuffle_state_events, "On shuffle"),
384
+ formatBehaviorContextLine(context.offline_events, context.offline_state_events, "Offline"),
385
+ ].filter(Boolean);
386
+ if (behaviorContextLines.length > 0) {
387
+ lines.push("", "## How you listen", "", ...behaviorContextLines);
388
+ }
389
+
390
+ if (
391
+ Array.isArray(profile.strong_preferences) &&
392
+ profile.strong_preferences.length > 0
393
+ ) {
394
+ lines.push(
395
+ "",
396
+ "## Kept on purpose",
397
+ "",
398
+ ...profile.strong_preferences.map(
399
+ (item) =>
400
+ `- ${safeInlineText(item.label)} · ${safeInlineText(item.signal).toLowerCase()}`,
401
+ ),
402
+ );
403
+ }
404
+
405
+ lines.push(
406
+ "",
407
+ "## The dark side of the moon",
408
+ "",
409
+ "*What this reading can't see*",
410
+ "",
411
+ "- Plays show attention, not love. A song can be on repeat because it was stuck in your head.",
412
+ ...(behaviorContextLines.length > 0 ? ["- A skip is a moment, not a verdict."] : []),
413
+ ...((behavior.history_arc ?? []).length > 0 || (listeningSeasons ?? []).length > 0
414
+ ? ["- Years and seasons follow UTC, so a late night can land on the next day.",
415
+ "- A quiet stretch means no history was kept, not that you stopped listening."]
416
+ : []),
417
+ "- If something here is wrong, open the song or artist in /taste and tell me.",
418
+ "",
419
+ oneOff
420
+ ? "Add `--json` to the same `--from` command for the full data."
421
+ : "For the full data with evidence IDs, run `moondog taste --json`.",
422
+ );
423
+ return lines.join("\n");
424
+ }
425
+
426
+ function formatMemory(memory) {
427
+ const lines = [];
428
+ if (Number.isInteger(memory.sessions)) {
429
+ lines.push(
430
+ `- ${formatInteger(memory.sessions)} ${memory.sessions === 1 ? "conversation" : "conversations"} saved on this machine`,
431
+ `- ${formatInteger(memory.active_memories)} ${memory.active_memories === 1 ? "thing" : "things"} I remember about you`,
432
+ );
433
+ if (memory.reflection?.pending_episodes > 0) {
434
+ lines.push(`- ${formatInteger(memory.reflection.pending_episodes)} recent moments I haven't thought over yet`);
435
+ }
436
+ } else {
437
+ lines.push(`- ${words(memory.state)}`);
438
+ }
439
+ lines.push(
440
+ "",
441
+ "I keep what you tell me in conversation apart from your music profile.",
442
+ "I only remember something for good when you say it plainly, or when you ask with `/remember`.",
443
+ "`/forget <id>` removes it.",
444
+ );
445
+ if (Array.isArray(memory.memories) && memory.memories.length > 0) {
446
+ lines.push(
447
+ "",
448
+ "## What I remember",
449
+ "",
450
+ ...memory.memories.map((entry) => `- ${entry.text} · ${entry.kind} · \`${entry.memory_id}\``),
451
+ );
452
+ }
453
+ if (Array.isArray(memory.recent_episodes) && memory.recent_episodes.length > 0) {
454
+ lines.push(
455
+ "",
456
+ "## Lately",
457
+ "",
458
+ ...memory.recent_episodes.map((episode) => `- ${String(episode.occurred_at).slice(0, 10)} · ${episode.summary}`),
459
+ );
460
+ }
461
+ if (Array.isArray(memory.recent_sessions) && memory.recent_sessions.length > 0) {
462
+ lines.push(
463
+ "",
464
+ "## Recent conversations",
465
+ "",
466
+ ...memory.recent_sessions.map((session) => {
467
+ const userTurns = session.turns
468
+ .filter((turn) => turn.role === "user")
469
+ .map((turn) => turn.text)
470
+ .join(" / ");
471
+ return `- ${String(session.started_at).slice(0, 10)} · ${userTurns}`;
472
+ }),
473
+ );
474
+ }
475
+ return lines.join("\n");
476
+ }
477
+
478
+ function formatReflectionProposal(proposal) {
479
+ const evidence = proposal.episode_ids.map((id) => `\`${id}\``).join(", ");
480
+ if (proposal.action === "candidate_memory") {
481
+ return `- [memory/${proposal.assertion_mode}/${proposal.confidence}] ${proposal.statement}\n Evidence: ${evidence}\n Why: ${proposal.rationale}`;
482
+ }
483
+ if (proposal.action === "candidate_music_profile") {
484
+ return `- [music-profile/${proposal.direction}/${proposal.confidence}] ${proposal.dimension}: ${proposal.value}\n Evidence: ${evidence}\n Why: ${proposal.rationale}`;
485
+ }
486
+ return `- [ignore] ${proposal.reason}\n Episodes: ${evidence}`;
487
+ }
488
+
489
+ export function formatMemoryReflection(value) {
490
+ if (value.state === "no_work") {
491
+ return "Memory Agent: no unprocessed episodes.";
492
+ }
493
+ if (value.state === "busy") {
494
+ return `Memory Agent: another reflection is running (${value.active_run?.run_id ?? "unknown"}).`;
495
+ }
496
+ const counts = value.counts ?? {};
497
+ const dryRun = value.state === "dry_run";
498
+ const lines = [
499
+ `# Memory reflection ${value.state}`,
500
+ "",
501
+ `- Run: \`${value.run_id}\``,
502
+ `- Proposals: ${counts.proposals ?? value.proposals?.length ?? 0}`,
503
+ `- ${dryRun ? "Would promote durable memories" : "Durable memories promoted"}: ${counts.promoted_memories ?? 0}`,
504
+ `- ${dryRun ? "Would create durable memories" : "New durable memories"}: ${counts.created_memories ?? 0}`,
505
+ `- ${dryRun ? "Would become candidate episodes" : "Candidate episodes"}: ${counts.candidate_episodes ?? 0}`,
506
+ `- ${dryRun ? "Would ignore episodes" : "Ignored episodes"}: ${counts.ignored_episodes ?? 0}`,
507
+ ];
508
+ if (Array.isArray(value.proposals) && value.proposals.length > 0) {
509
+ lines.push(
510
+ "",
511
+ "## Decisions",
512
+ "",
513
+ ...value.proposals.map(formatReflectionProposal),
514
+ );
515
+ }
516
+ return lines.join("\n");
517
+ }
518
+
519
+ export function formatDemoResult(value) {
520
+ const lines = [
521
+ "# Moondog demo",
522
+ "",
523
+ "This walkthrough uses synthetic music data and has no music-service side effects.",
524
+ "",
525
+ "## Prompt",
526
+ "",
527
+ safeInlineText(value.prompt),
528
+ "",
529
+ "## Tool trace",
530
+ "",
531
+ ];
532
+ if (Array.isArray(value.tool_trace) && value.tool_trace.length > 0) {
533
+ lines.push(
534
+ ...value.tool_trace.map(
535
+ (entry) =>
536
+ `${entry.sequence}. ${safeInlineText(entry.capability)} - ${safeInlineText(entry.status)}`,
537
+ ),
538
+ );
539
+ } else {
540
+ lines.push("No tools were called.");
541
+ }
542
+ lines.push("", "## Result", "", value.result?.text ?? "No result text.");
543
+ if (Array.isArray(value.limitations) && value.limitations.length > 0) {
544
+ lines.push(
545
+ "",
546
+ "## Boundaries",
547
+ "",
548
+ ...value.limitations.map(
549
+ (limitation) => `- ${safeInlineText(limitation)}`,
550
+ ),
551
+ );
552
+ }
553
+ if (Array.isArray(value.next_steps) && value.next_steps.length > 0) {
554
+ lines.push("", "## Keep exploring", "");
555
+ for (const step of value.next_steps) {
556
+ lines.push(
557
+ `### ${safeInlineText(step.title)}`,
558
+ "",
559
+ `- From this checkout: \`${safeInlineText(step.checkout_command)}\``,
560
+ `- From an installed CLI: \`${safeInlineText(step.installed_command)}\``,
561
+ `- ${safeInlineText(step.data_boundary)}`,
562
+ );
563
+ }
564
+ }
565
+ return lines.join("\n");
566
+ }
567
+
568
+ const capabilityStates = {
569
+ ready: "ready",
570
+ enabled: "ready",
571
+ available: "ready",
572
+ blocked: "not available yet",
573
+ unavailable: "not available",
574
+ disabled: "off",
575
+ };
576
+
577
+ function formatTools(value) {
578
+ const rows = value.capabilities.map((capability) => {
579
+ const label = capability.agent_tool?.label ?? capability.id;
580
+ const state = capabilityStates[capability.state] ?? words(capability.state);
581
+ return `- **${label}** · ${state}\n ${capability.description}${
582
+ capability.blocked_by ? `\n Waiting on: ${words(capability.blocked_by)}` : ""
583
+ }`;
584
+ });
585
+ return `# What I can do\n\n${rows.join("\n")}`;
586
+ }
587
+
588
+ const doctorLabels = {
589
+ "web.codex_cli": "Web lookups (Codex CLI)",
590
+ "node.version": "Node.js",
591
+ "apple_music.source": "Apple Music library",
592
+ "profile.projection": "Listening profile",
593
+ "agent.runtime": "Model",
594
+ "music.discovery.open_similarity": "Finding similar music",
595
+ "external.effects": "Changes to your accounts",
596
+ "spotify.connection": "Spotify",
597
+ "spotify.connected_action_scopes": "Spotify permissions",
598
+ };
599
+
600
+ function formatDoctor(value) {
601
+ return [
602
+ "# Checkup",
603
+ "",
604
+ ...value.checks.map((check) => {
605
+ const state = check.ok ? "ok" : check.optional_for_local_commands ? "not set up (optional)" : "not working";
606
+ const detail = words(check.actual ?? check.state ?? "unknown");
607
+ return `- ${doctorLabels[check.id] ?? check.id}: ${state} · ${detail}`;
608
+ }),
609
+ ].join("\n");
610
+ }
611
+
612
+ function formatRuntime(runtime) {
613
+ if (runtime?.state === "configured") {
614
+ return `- Talking through ${runtime.provider && runtime.model ? `\`${runtime.provider}/${runtime.model}\`` : "your chosen model"}`;
615
+ }
616
+ return runtime?.reason === "provider_authentication_required"
617
+ ? "- Not signed in yet. `/auth` fixes that."
618
+ : "- No model connected. `/model` picks one. Your profile works without it.";
619
+ }
620
+
621
+ function formatSpotifyStatus(spotify) {
622
+ const state = spotify?.state ?? "not_configured";
623
+ if (state === "ready" || state === "connected") return "- Connected";
624
+ return state === "not_configured"
625
+ ? "- Not connected. `/import` > Spotify walks you through it."
626
+ : `- ${words(state)}`;
627
+ }
628
+
629
+ export function formatLocalResult(command, value) {
630
+ switch (command) {
631
+ case "status":
632
+ case "sources":
633
+ return [
634
+ "# Where things stand",
635
+ "",
636
+ "## Your profile",
637
+ "",
638
+ formatProfile(value.profile),
639
+ formatSource(value.source),
640
+ "",
641
+ "## Talking",
642
+ "",
643
+ formatRuntime(value.runtime),
644
+ "",
645
+ "## Memory",
646
+ "",
647
+ formatMemory(value.memory),
648
+ "",
649
+ "## Spotify",
650
+ "",
651
+ formatSpotifyStatus(value.spotify),
652
+ "",
653
+ value.external_effects === "disabled" && (value.spotify?.external_effects ?? "disabled") === "disabled"
654
+ ? "Right now I can't change anything in your music accounts."
655
+ : "I can make changes in your connected accounts, and I always ask first.",
656
+ ].join("\n");
657
+ case "profile":
658
+ return `# Your profile\n\n${formatProfile(value)}`;
659
+ case "taste":
660
+ return formatTaste(value);
661
+ case "memory":
662
+ return `# What I remember\n\n${formatMemory(value)}`;
663
+ case "tools":
664
+ return formatTools(value);
665
+ case "doctor":
666
+ return formatDoctor(value);
667
+ default:
668
+ return JSON.stringify(value, null, 2);
669
+ }
670
+ }
671
+
672
+ export function helpText() {
673
+ return `# Every command
674
+
675
+ From your shell:
676
+
677
+ - \`moondog web status|search|read [arguments] [--json]\` - look things up on public music sites through the Codex CLI
678
+ - \`moondog studio\` - open the browser Studio for dropping in a Spotify ZIP
679
+ - \`moondog studio --demo\` - try the Studio with made-up history; your own data is never read
680
+ - \`moondog studio --from <spotify-history.zip>\` - look at one Spotify ZIP in the Studio without saving it
681
+ - \`moondog studio --from <account-data.zip> --from <extended-history.zip>\` - combine two Spotify ZIPs in the Studio without saving them
682
+ - \`moondog demo-history --output <absolute-file.zip> [--json]\` - make a fictional Spotify history ZIP to try the importer with
683
+ - \`moondog auth login <provider>\` - sign in to a model provider; the key is typed out of sight, and openai-codex uses your ChatGPT sign-in
684
+ - \`moondog auth status [provider]\` - see which sign-ins are saved
685
+ - \`moondog auth logout <provider>\` - sign out of a provider
686
+ - \`moondog spotify help\` - set up and control Spotify
687
+ - \`moondog spotify login [client-id]\` - connect Spotify in your browser
688
+ - \`moondog spotify now|devices|queue\` - see what Spotify is playing, where, and what's next
689
+ - \`moondog spotify recent\` - see what you played lately
690
+ - \`moondog spotify sync-recent\` - add your latest plays to your profile
691
+ - \`moondog spotify play|pause|next|previous\` - control Spotify playback
692
+ - \`moondog listenbrainz import-history <listen-history.json>\` - add a ListenBrainz listens file to your profile
693
+ - \`moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]\` - find an artist's latest single on Apple Music (US), no model needed
694
+ - \`moondog status [--json]\` - see where things stand
695
+ - \`moondog taste [--json]\` - your listening report
696
+ - \`moondog taste --html [--output <file.html>]\` - save your report as a web page that works offline
697
+ - \`moondog taste --card [--output <file.html>]\` - save a short recap you can check before sharing
698
+ - \`moondog taste --from <spotify-history.zip> --html\` - a one-off report from a Spotify ZIP, without saving it
699
+ - \`moondog taste --from <account-data.zip> --from <extended-history.zip> --html\` - a one-off report from both Spotify exports together
700
+ - \`moondog taste --from <spotify-history.zip> --save --html\` - save the Spotify history, then show your full report
701
+ - \`moondog data inspect --scope <listening|apple|profile>\` - see what's stored, and get the code needed to reset it
702
+ - \`moondog data export --scope <listening|apple|profile> --output <directory>\` - export a copy, leaving the original alone
703
+ - \`moondog data reset --scope <listening|apple|profile> --confirm <token>\` - set stored data aside (it is archived, not deleted)
704
+ - \`moondog profile corrections [--all]\` - everything you've told me, including earlier choices
705
+ - \`moondog profile correct --artist <name> (--like|--avoid)\` - like an artist, or keep them out
706
+ - \`moondog profile correct --track <title> --by <artist> (--like|--avoid)\` - like a track, or keep it out
707
+ - \`moondog profile retract <correction-id>\` - undo one of your choices
708
+ - \`moondog demo --offline [--json]\` - a demo with made-up data, no sign-in needed
709
+ - \`moondog demo [prompt] [--json]\` - the same demo, talking through your model
710
+ - \`moondog ask <prompt>\` - ask one question and exit
711
+ - \`moondog remember <text>\` - ask me to remember something
712
+ - \`moondog forget <memory-id>\` - forget something
713
+ - \`moondog memory reflect [--dry-run] [--json]\` - let me think over recent conversations now
714
+ - \`moondog\` - open the listening room
715
+
716
+ Inside the listening room:
717
+
718
+ - \`/status\` or \`/sources\` - see where things stand
719
+ - \`/profile\` or \`/taste\` - your profile, and why each song or artist is there
720
+ - \`/taste report\` - the whole report on one page
721
+ - \`/profile corrections [--all]\` - everything you've told me
722
+ - \`/profile correct --track "<title>" --by "<artist>" (--like|--avoid)\` - like a track, or keep it out
723
+ - \`/profile correct --artist "<name>" (--like|--avoid)\` - like an artist, or keep them out
724
+ - \`/profile retract <correction-id>\` - undo a choice
725
+ - \`/memory\` - what I remember
726
+ - \`/tools\` - what I can do right now
727
+ - \`/doctor\` - check your setup
728
+ - \`/model\` - pick the model I talk through
729
+ - \`/model <provider> <model>\` - switch straight to a model
730
+ - \`/auth [provider]\` - sign in to a model provider
731
+ - \`/web status|search|read\` - look things up on public music sites
732
+ - \`/import [path]\` - bring in your history, library, or a playlist
733
+ - \`/spotify\` - Spotify commands
734
+ - \`/spotify login [client-id]\` - connect Spotify
735
+ - \`/spotify now|devices|queue\` - see what's playing, where, and what's next
736
+ - \`/spotify recent|sync-recent\` - see or save your latest plays
737
+ - \`/spotify import-history "/path/to/spotify-history.zip"\` - add a Spotify ZIP, no sign-in needed
738
+ - \`/reload\` - reload model settings and sign-ins
739
+ - \`/remember [kind] <text>\` - ask me to remember something
740
+ - \`/forget <memory-id>\` - forget something
741
+ - \`/resume\` - pick up a saved conversation
742
+ - \`/resume <session-id>\` - go straight to one
743
+ - \`/new\` - start fresh; the last one stays saved
744
+ - \`/quit\` - exit
745
+
746
+ Anything you type that isn't a command goes to the model you picked with \`/model\`, or the one set in \`MOONDOG_PROVIDER\` and \`MOONDOG_MODEL\`.
747
+ Sign in with \`/auth\`, or set the provider's API key in your environment. \`/auth openai-codex\` uses your ChatGPT sign-in.
748
+ Once your profile is ready, I can search your library, explain what I see, and plan playlists with you.
749
+ I can only control Spotify after \`moondog spotify login\`, and I never post, send messages, delete anything, or spend money.`;
750
+ }