@audiofool/moondog 0.1.0 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { N_, screenTranslator } from "../../i18n/index.mjs";
|
|
2
|
+
|
|
1
3
|
export function sanitizeTerminalText(value) {
|
|
2
4
|
return String(value)
|
|
3
5
|
.replaceAll("\r\n", "\n")
|
|
@@ -10,13 +12,16 @@ function words(value) {
|
|
|
10
12
|
return String(value ?? "unknown").replaceAll("_", " ");
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
function formatSource(source) {
|
|
14
|
-
if (!source.latest) return "
|
|
15
|
+
function formatSource(source, tr) {
|
|
16
|
+
if (!source.latest) return `- ${tr("Apple Music library: not imported")}`;
|
|
15
17
|
return [
|
|
16
|
-
`- Apple Music library:
|
|
18
|
+
`- ${tr("Apple Music library: {tracks} and {playlists}, as of {date}", {
|
|
19
|
+
tracks: tr.n(source.latest.tracks, "{count} track", "{count} tracks"),
|
|
20
|
+
playlists: tr.n(source.latest.playlists, "{count} playlist", "{count} playlists"),
|
|
21
|
+
date: String(source.latest.captured_at).slice(0, 10) })}`,
|
|
17
22
|
source.semantics?.complete_listening_history_available
|
|
18
|
-
? "
|
|
19
|
-
: "
|
|
23
|
+
? `- ${tr("Includes a full listening history")}`
|
|
24
|
+
: `- ${tr("A library shows what you keep, not every play")}`,
|
|
20
25
|
].join("\n");
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -27,31 +32,29 @@ const serviceNames = {
|
|
|
27
32
|
apple_music: "Apple Music",
|
|
28
33
|
youtube_music: "YouTube Music",
|
|
29
34
|
qq_music: "QQ Music",
|
|
30
|
-
netease: "NetEase
|
|
35
|
+
netease: "NetEase Music",
|
|
31
36
|
};
|
|
32
37
|
|
|
33
|
-
function formatProfile(profile) {
|
|
38
|
+
function formatProfile(profile, tr) {
|
|
34
39
|
if (profile.state !== "ready") {
|
|
35
|
-
return `- Not ready yet
|
|
40
|
+
return `- ${profile.reason ? tr("Not ready yet: {reason}", { reason: profile.reason }) : tr("Not ready yet")}\n- ${tr("`/import` brings in your history")}`;
|
|
36
41
|
}
|
|
37
42
|
const lines = [];
|
|
38
43
|
const listening = [
|
|
39
|
-
Number.isInteger(profile.effective_listening_events)
|
|
40
|
-
|
|
41
|
-
: null,
|
|
42
|
-
typeof profile.listening_hours === "number" && profile.listening_hours > 0 ? `${profile.listening_hours} h` : null,
|
|
44
|
+
Number.isInteger(profile.effective_listening_events) ? tr.n(profile.effective_listening_events, "{count} play", "{count} plays") : null,
|
|
45
|
+
typeof profile.listening_hours === "number" && profile.listening_hours > 0 ? tr("{hours} h", { hours: tr.number(profile.listening_hours) }) : null,
|
|
43
46
|
Array.isArray(profile.listening_sources) && profile.listening_sources.length > 0
|
|
44
|
-
?
|
|
47
|
+
? tr("from {services}", { services: profile.listening_sources.map((source) => serviceNames[source] ?? words(source)).join(", ") })
|
|
45
48
|
: null,
|
|
46
49
|
].filter(Boolean);
|
|
47
50
|
if (listening.length > 0) lines.push(`- ${listening.join(" · ")}`);
|
|
48
51
|
if (Number.isInteger(profile.spotify_profile_evidence) && profile.spotify_profile_evidence > 0) {
|
|
49
|
-
lines.push(`- ${
|
|
52
|
+
lines.push(`- ${tr.n(profile.spotify_profile_evidence, "{count} saved song, follow, or playlist entry from Spotify", "{count} saved songs, follows, and playlist entries from Spotify")}`);
|
|
50
53
|
}
|
|
51
54
|
return lines.join("\n");
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
function safeInlineText(value) {
|
|
57
|
+
export function safeInlineText(value) {
|
|
55
58
|
return sanitizeTerminalText(value ?? "")
|
|
56
59
|
.replace(/\\/gu, "\\\\")
|
|
57
60
|
.replace(/([`*_[\]<>])/gu, "\\$1")
|
|
@@ -63,59 +66,44 @@ function formatInteger(value) {
|
|
|
63
66
|
return Number.isInteger(value) ? value.toLocaleString("en-US") : "unknown";
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
function formatListeningTime(minutes) {
|
|
67
|
-
if (!Number.isFinite(minutes) || minutes < 0) return "unknown duration";
|
|
68
|
-
if (minutes < 120) return
|
|
69
|
-
return
|
|
69
|
+
export function formatListeningTime(minutes, tr) {
|
|
70
|
+
if (!Number.isFinite(minutes) || minutes < 0) return tr("unknown duration");
|
|
71
|
+
if (minutes < 120) return tr("{minutes} min", { minutes: Math.round(minutes) });
|
|
72
|
+
return tr("{hours} h", { hours: tr.number(Math.round((minutes / 60) * 10) / 10) });
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
+
function plays(value, tr) { return tr.n(value, "{count} play", "{count} plays"); }
|
|
76
|
+
function tracks(value, tr) { return tr.n(value, "{count} track", "{count} tracks"); }
|
|
75
77
|
|
|
76
|
-
function formatArtistSignal(item) {
|
|
78
|
+
function formatArtistSignal(item, tr) {
|
|
77
79
|
const details = [];
|
|
78
|
-
if (Number.isInteger(item.play_count))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
}`;
|
|
80
|
+
if (Number.isInteger(item.play_count)) details.push(plays(item.play_count, tr));
|
|
81
|
+
if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
|
|
82
|
+
if (Number.isInteger(item.distinct_tracks)) details.push(tracks(item.distinct_tracks, tr));
|
|
83
|
+
return `- ${safeInlineText(item.name)}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
|
|
90
84
|
}
|
|
91
85
|
|
|
92
|
-
function formatTrackSignal(item) {
|
|
86
|
+
function formatTrackSignal(item, tr) {
|
|
93
87
|
const label = safeInlineText(item.label);
|
|
94
88
|
const artist = safeInlineText(item.artist_credit);
|
|
95
89
|
const details = [];
|
|
96
|
-
if (Number.isInteger(item.play_count))
|
|
97
|
-
|
|
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
|
-
}`;
|
|
90
|
+
if (Number.isInteger(item.play_count)) details.push(plays(item.play_count, tr));
|
|
91
|
+
if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
|
|
92
|
+
return `- ${label}${artist ? ` - ${artist}` : ""}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
|
|
105
93
|
}
|
|
106
94
|
|
|
107
95
|
// Attention alone is the default reading, so only stronger evidence is worth naming.
|
|
108
96
|
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",
|
|
97
|
+
"historical attention only": () => null,
|
|
98
|
+
"saved-library state": tr => tr("saved in your library"),
|
|
99
|
+
"private playlist curation": tr => tr("on one of your playlists"),
|
|
100
|
+
"explicit listener preference": tr => tr("you said you like it"),
|
|
113
101
|
};
|
|
114
102
|
|
|
115
|
-
function signalPhrase(value) {
|
|
103
|
+
function signalPhrase(value, tr) {
|
|
116
104
|
const text = safeInlineText(value);
|
|
117
105
|
if (!text) return null;
|
|
118
|
-
return Object.hasOwn(signalPhrases, text) ? signalPhrases[text] : text;
|
|
106
|
+
return Object.hasOwn(signalPhrases, text) ? signalPhrases[text](tr) : text;
|
|
119
107
|
}
|
|
120
108
|
|
|
121
109
|
function withDetails(base, details) {
|
|
@@ -123,105 +111,80 @@ function withDetails(base, details) {
|
|
|
123
111
|
return `${base}${present.length > 0 ? ` · ${present.join(", ")}` : ""}`;
|
|
124
112
|
}
|
|
125
113
|
|
|
126
|
-
function formatRediscoveryTrack(item) {
|
|
127
|
-
return withDetails(formatTrackSignal(item), [
|
|
128
|
-
Number.isInteger(item.quiet_days)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
Number.isInteger(item.peak_year) ? `biggest in ${item.peak_year}` : null,
|
|
132
|
-
signalPhrase(item.rediscovery_signal),
|
|
114
|
+
function formatRediscoveryTrack(item, tr) {
|
|
115
|
+
return withDetails(formatTrackSignal(item, tr), [
|
|
116
|
+
Number.isInteger(item.quiet_days) ? tr.n(item.quiet_days, "quiet for {count} day", "quiet for {count} days") : null,
|
|
117
|
+
Number.isInteger(item.peak_year) ? tr("biggest in {year}", { year: item.peak_year }) : null,
|
|
118
|
+
signalPhrase(item.rediscovery_signal, tr),
|
|
133
119
|
]);
|
|
134
120
|
}
|
|
135
121
|
|
|
136
|
-
function formatHistoricalReturnTrack(item) {
|
|
137
|
-
return withDetails(formatTrackSignal(item), [
|
|
122
|
+
function formatHistoricalReturnTrack(item, tr) {
|
|
123
|
+
return withDetails(formatTrackSignal(item, tr), [
|
|
138
124
|
Number.isInteger(item.return_count)
|
|
139
|
-
?
|
|
125
|
+
? item.return_count === 1 ? tr("came back once") : tr.n(item.return_count, "came back {count} time", "came back {count} times")
|
|
140
126
|
: null,
|
|
141
|
-
Number.isInteger(item.longest_gap_days)
|
|
142
|
-
|
|
143
|
-
: null,
|
|
144
|
-
signalPhrase(item.historical_return_signal),
|
|
127
|
+
Number.isInteger(item.longest_gap_days) ? tr.n(item.longest_gap_days, "longest time away {count} day", "longest time away {count} days") : null,
|
|
128
|
+
signalPhrase(item.historical_return_signal, tr),
|
|
145
129
|
]);
|
|
146
130
|
}
|
|
147
131
|
|
|
148
|
-
function formatBackToBackTrack(item) {
|
|
149
|
-
return withDetails(formatTrackSignal(item), [
|
|
150
|
-
Number.isInteger(item.maximum_consecutive_plays)
|
|
151
|
-
|
|
152
|
-
: null,
|
|
153
|
-
Number.isInteger(item.burst_count) && item.burst_count > 1
|
|
154
|
-
? `${formatInteger(item.burst_count)} separate runs`
|
|
155
|
-
: null,
|
|
132
|
+
function formatBackToBackTrack(item, tr) {
|
|
133
|
+
return withDetails(formatTrackSignal(item, tr), [
|
|
134
|
+
Number.isInteger(item.maximum_consecutive_plays) ? tr("up to {count} in a row", { count: tr.number(item.maximum_consecutive_plays) }) : null,
|
|
135
|
+
Number.isInteger(item.burst_count) && item.burst_count > 1 ? tr("{count} separate runs", { count: tr.number(item.burst_count) }) : null,
|
|
156
136
|
]);
|
|
157
137
|
}
|
|
158
138
|
|
|
159
|
-
function formatTimeCapsuleTrack(item) {
|
|
139
|
+
function formatTimeCapsuleTrack(item, tr) {
|
|
160
140
|
const label = safeInlineText(item.label);
|
|
161
141
|
const artist = safeInlineText(item.artist_credit);
|
|
162
142
|
return withDetails(
|
|
163
|
-
`- ${Number.isInteger(item.capsule_year) ? item.capsule_year : "Unknown year"} ${label}${artist ? ` - ${artist}` : ""}`,
|
|
143
|
+
`- ${Number.isInteger(item.capsule_year) ? item.capsule_year : tr("Unknown year")} ${label}${artist ? ` - ${artist}` : ""}`,
|
|
164
144
|
[
|
|
165
|
-
Number.isInteger(item.year_play_count)
|
|
166
|
-
|
|
167
|
-
: null,
|
|
168
|
-
signalPhrase(item.representative_signal),
|
|
145
|
+
Number.isInteger(item.year_play_count) ? tr.n(item.year_play_count, "{count} play that year", "{count} plays that year") : null,
|
|
146
|
+
signalPhrase(item.representative_signal, tr),
|
|
169
147
|
],
|
|
170
148
|
);
|
|
171
149
|
}
|
|
172
150
|
|
|
173
|
-
function formatHistoryArc(item) {
|
|
151
|
+
function formatHistoryArc(item, tr) {
|
|
174
152
|
const details = [];
|
|
175
|
-
if (Number.isFinite(item.listening_minutes))
|
|
176
|
-
|
|
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
|
-
}
|
|
153
|
+
if (Number.isFinite(item.listening_minutes)) details.push(formatListeningTime(item.listening_minutes, tr));
|
|
154
|
+
if (Number.isInteger(item.event_count)) details.push(plays(item.event_count, tr));
|
|
155
|
+
if (Number.isInteger(item.distinct_tracks)) details.push(tracks(item.distinct_tracks, tr));
|
|
184
156
|
if (Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0) {
|
|
185
|
-
details.push(
|
|
157
|
+
details.push(tr("{count} new", { count: tr.number(item.first_observed_tracks) }));
|
|
186
158
|
}
|
|
187
159
|
const topArtist = safeInlineText(item.top_artist?.name);
|
|
188
|
-
if (topArtist) details.push(
|
|
189
|
-
return `- ${Number.isInteger(item.year) ? item.year : "Unknown year"}${
|
|
190
|
-
details.length > 0 ? ` · ${details.join(", ")}` : ""
|
|
191
|
-
}`;
|
|
160
|
+
if (topArtist) details.push(tr("mostly {artist}", { artist: topArtist }));
|
|
161
|
+
return `- ${Number.isInteger(item.year) ? item.year : tr("Unknown year")}${details.length > 0 ? ` · ${details.join(", ")}` : ""}`;
|
|
192
162
|
}
|
|
193
163
|
|
|
194
|
-
function formatListeningSeason(item) {
|
|
164
|
+
function formatListeningSeason(item, tr) {
|
|
195
165
|
const key = safeInlineText(item.key).replace("-", " ");
|
|
196
166
|
if (!Number.isInteger(item.event_count) || item.event_count === 0) {
|
|
197
|
-
return `- ${key || "Unknown season"} · nothing in your history`;
|
|
167
|
+
return `- ${key || tr("Unknown season")} · ${tr("nothing in your history")}`;
|
|
198
168
|
}
|
|
199
169
|
const signature = safeInlineText(item.signature_track?.label);
|
|
200
170
|
const signatureArtist = safeInlineText(item.signature_track?.artist_credit);
|
|
201
|
-
return withDetails(`- ${key || "Unknown season"}`, [
|
|
202
|
-
Number.isFinite(item.listening_minutes)
|
|
203
|
-
|
|
204
|
-
: null,
|
|
205
|
-
plural(item.event_count, "play"),
|
|
171
|
+
return withDetails(`- ${key || tr("Unknown season")}`, [
|
|
172
|
+
Number.isFinite(item.listening_minutes) ? formatListeningTime(item.listening_minutes, tr) : null,
|
|
173
|
+
plays(item.event_count, tr),
|
|
206
174
|
Number.isInteger(item.first_observed_tracks) && item.first_observed_tracks > 0
|
|
207
|
-
?
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
? `mostly ${safeInlineText(item.leading_artist.name)}`
|
|
211
|
-
: null,
|
|
212
|
-
signature
|
|
213
|
-
? `top song ${signature}${signatureArtist ? ` - ${signatureArtist}` : ""}`
|
|
214
|
-
: null,
|
|
175
|
+
? tr("{count} new", { count: tr.number(item.first_observed_tracks) }) : null,
|
|
176
|
+
safeInlineText(item.leading_artist?.name) ? tr("mostly {artist}", { artist: safeInlineText(item.leading_artist.name) }) : null,
|
|
177
|
+
signature ? tr("top song {song}", { song: `${signature}${signatureArtist ? ` - ${signatureArtist}` : ""}` }) : null,
|
|
215
178
|
]);
|
|
216
179
|
}
|
|
217
180
|
|
|
218
|
-
function formatListenerAssertion(item) {
|
|
181
|
+
function formatListenerAssertion(item, tr) {
|
|
219
182
|
const label = safeInlineText(item.label);
|
|
220
183
|
const artist = safeInlineText(item.artist_credit);
|
|
221
184
|
const target = artist ? `${label} - ${artist}` : label;
|
|
222
185
|
const note = safeInlineText(item.note);
|
|
223
|
-
const entity = safeInlineText(item.entity_type);
|
|
224
|
-
return `- ${item.stance === "avoid" ? "Keep out" : "Like"}${entity ? ` (${entity})` : ""}: ${target}${
|
|
186
|
+
const entity = item.entity_type === "track" ? tr("track") : item.entity_type === "artist" ? tr("artist") : safeInlineText(item.entity_type);
|
|
187
|
+
return `- ${item.stance === "avoid" ? tr("Keep out") : tr("Like")}${entity ? ` (${entity})` : ""}: ${target}${
|
|
225
188
|
note ? ` · "${note}"` : ""
|
|
226
189
|
} · ${safeInlineText(item.correction_id)}`;
|
|
227
190
|
}
|
|
@@ -231,246 +194,167 @@ function appendTasteSection(lines, title, values, formatter, subtitle) {
|
|
|
231
194
|
lines.push("", `## ${title}`, "", ...(subtitle ? [`*${subtitle}*`, ""] : []), ...values.map(formatter));
|
|
232
195
|
}
|
|
233
196
|
|
|
234
|
-
function formatBehaviorContextLine(value, total, label) {
|
|
197
|
+
function formatBehaviorContextLine(value, total, label, tr) {
|
|
235
198
|
if (!Number.isInteger(value) || total === 0) return null;
|
|
236
199
|
const validTotal = Number.isInteger(total) && total >= value && total > 0;
|
|
237
|
-
if (!validTotal) return `- ${label}: ${
|
|
200
|
+
if (!validTotal) return `- ${label}: ${tr.number(value)}`;
|
|
238
201
|
const share = Math.round((value / total) * 100);
|
|
239
|
-
return `- ${label}: ${share}% (
|
|
202
|
+
return `- ${label}: ${tr("{share}% ({count} of {total})", { share, count: tr.number(value), total: tr.number(total) })}`;
|
|
240
203
|
}
|
|
241
204
|
|
|
242
|
-
|
|
205
|
+
// Section names are Pink Floyd titles and stay as they are; their subtitles are translated.
|
|
206
|
+
function formatTaste(profile, tr) {
|
|
243
207
|
const coverage = profile.coverage ?? {};
|
|
244
208
|
const durationUnavailable = coverage.events_with_played_duration === 0;
|
|
245
|
-
const listeningSignal = (formatter) => (item) => formatter(durationUnavailable ? { ...item, listening_minutes: undefined } : item);
|
|
209
|
+
const listeningSignal = (formatter) => (item) => formatter(durationUnavailable ? { ...item, listening_minutes: undefined } : item, tr);
|
|
210
|
+
const signal = (formatter) => (item) => formatter(item, tr);
|
|
246
211
|
const behavior = profile.listening_behavior ?? {};
|
|
247
212
|
const context = behavior.context ?? {};
|
|
248
213
|
const listeningRange = profile.listening_source?.listening_range;
|
|
249
214
|
const range =
|
|
250
215
|
typeof listeningRange?.earliest === "string" &&
|
|
251
216
|
typeof listeningRange?.latest === "string"
|
|
252
|
-
?
|
|
217
|
+
? tr("{first} to {last}", { first: listeningRange.earliest.slice(0, 10), last: listeningRange.latest.slice(0, 10) })
|
|
253
218
|
: null;
|
|
254
219
|
const oneOff = profile.preview_source?.persistent_import === false;
|
|
255
220
|
const hours = !durationUnavailable && Number.isFinite(coverage.listening_hours)
|
|
256
|
-
?
|
|
221
|
+
? tr("{hours} h", { hours: tr.number(coverage.listening_hours) })
|
|
257
222
|
: null;
|
|
258
223
|
const lines = [
|
|
259
|
-
"
|
|
224
|
+
`# ${tr("Your listening, so far")}`,
|
|
260
225
|
"",
|
|
261
226
|
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.",
|
|
227
|
+
? tr("A one-off reading of the file you chose. Nothing was saved to your profile.")
|
|
228
|
+
: tr("Read from your own files. It stays on this machine."),
|
|
264
229
|
"",
|
|
265
|
-
"
|
|
230
|
+
`## ${tr("What I'm reading from")}`,
|
|
266
231
|
"",
|
|
267
232
|
];
|
|
268
233
|
const summary = [
|
|
269
|
-
Number.isInteger(coverage.effective_listening_events) ?
|
|
234
|
+
Number.isInteger(coverage.effective_listening_events) ? plays(coverage.effective_listening_events, tr) : null,
|
|
270
235
|
hours,
|
|
271
|
-
Number.isInteger(coverage.listening_tracks) ?
|
|
236
|
+
Number.isInteger(coverage.listening_tracks) ? tracks(coverage.listening_tracks, tr) : null,
|
|
272
237
|
].filter(Boolean);
|
|
273
238
|
if (summary.length > 0) lines.push(`- ${summary.join(" · ")}`);
|
|
274
239
|
if (range) lines.push(`- ${range}`);
|
|
275
240
|
const saved = coverage.saved_tracks ?? coverage.spotify_saved_tracks;
|
|
276
|
-
if (Number.isInteger(saved) && saved > 0) lines.push(`- ${
|
|
241
|
+
if (Number.isInteger(saved) && saved > 0) lines.push(`- ${tr.n(saved, "{count} saved track", "{count} saved tracks")}`);
|
|
277
242
|
const memberships = coverage.playlist_memberships ?? coverage.spotify_playlist_memberships;
|
|
278
|
-
if (Number.isInteger(memberships) && memberships > 0) lines.push(`- ${
|
|
243
|
+
if (Number.isInteger(memberships) && memberships > 0) lines.push(`- ${tr.n(memberships, "{count} song on your playlists", "{count} songs on your playlists")}`);
|
|
279
244
|
for (const source of coverage.collection_sources ?? []) {
|
|
280
|
-
if (source.tracks > 0) lines.push(`- ${safeInlineText(source.label)}: ${
|
|
245
|
+
if (source.tracks > 0) lines.push(`- ${safeInlineText(source.label)}: ${tracks(source.tracks, tr)}`);
|
|
281
246
|
}
|
|
282
247
|
if (!durationUnavailable && Number.isInteger(coverage.events_with_played_duration) &&
|
|
283
248
|
Number.isInteger(coverage.effective_listening_events) &&
|
|
284
249
|
coverage.events_with_played_duration < coverage.effective_listening_events) {
|
|
285
|
-
lines.push(`- Listening time is known for
|
|
250
|
+
lines.push(`- ${tr("Listening time is known for {count} of those plays", { count: tr.number(coverage.events_with_played_duration) })}`);
|
|
286
251
|
}
|
|
287
252
|
if (Number.isInteger(coverage.cross_format_track_links) && coverage.cross_format_track_links > 0) {
|
|
288
|
-
lines.push(`- ${
|
|
253
|
+
lines.push(`- ${tr.n(coverage.cross_format_track_links, "{count} track matched across your two Spotify exports", "{count} tracks matched across your two Spotify exports")}`);
|
|
289
254
|
}
|
|
290
255
|
if (Number.isInteger(coverage.cross_format_ambiguous_tracks) && coverage.cross_format_ambiguous_tracks > 0) {
|
|
291
|
-
lines.push(`- ${
|
|
256
|
+
lines.push(`- ${tr.n(coverage.cross_format_ambiguous_tracks, "{count} track kept apart because the match was unclear", "{count} tracks kept apart because the match was unclear")}`);
|
|
292
257
|
}
|
|
293
258
|
if (Number.isInteger(context.incognito_events_excluded) && context.incognito_events_excluded > 0) {
|
|
294
|
-
lines.push(`- ${
|
|
259
|
+
lines.push(`- ${tr.n(context.incognito_events_excluded, "{count} private-session play left out", "{count} private-session plays left out")}`);
|
|
295
260
|
}
|
|
296
261
|
|
|
297
|
-
appendTasteSection(
|
|
298
|
-
|
|
299
|
-
"
|
|
300
|
-
|
|
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),
|
|
262
|
+
appendTasteSection(lines, tr("What you told me"), profile.listener_assertions?.active, signal(formatListenerAssertion));
|
|
263
|
+
appendTasteSection(lines, "Shine On", behavior.enduring_artists, listeningSignal(formatArtistSignal),
|
|
264
|
+
tr("Artists who stayed with you across the years"));
|
|
265
|
+
appendTasteSection(lines, tr("Lately"), behavior.recent_artists, listeningSignal(formatArtistSignal),
|
|
315
266
|
Number.isInteger(context.recent_window_days)
|
|
316
|
-
?
|
|
317
|
-
: "Who you've played most recently"
|
|
318
|
-
);
|
|
319
|
-
appendTasteSection(
|
|
320
|
-
lines,
|
|
321
|
-
"Year by year",
|
|
322
|
-
behavior.history_arc,
|
|
323
|
-
listeningSignal(formatHistoryArc),
|
|
324
|
-
);
|
|
267
|
+
? tr("Who you've played most in the last {count} days", { count: context.recent_window_days })
|
|
268
|
+
: tr("Who you've played most recently"));
|
|
269
|
+
appendTasteSection(lines, tr("Year by year"), behavior.history_arc, listeningSignal(formatHistoryArc));
|
|
325
270
|
const listeningSeasons = behavior.listening_seasons?.seasons;
|
|
326
|
-
appendTasteSection(
|
|
327
|
-
lines,
|
|
328
|
-
"Seasons",
|
|
329
|
-
Array.isArray(listeningSeasons) ? listeningSeasons.slice(-12) : [],
|
|
271
|
+
appendTasteSection(lines, tr("Seasons"), Array.isArray(listeningSeasons) ? listeningSeasons.slice(-12) : [],
|
|
330
272
|
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,
|
|
273
|
+
tr("Your last twelve quarters. \"New\" means new to your history, not necessarily new to you."));
|
|
274
|
+
appendTasteSection(lines, "Time", behavior.time_capsule_tracks, signal(formatTimeCapsuleTrack), tr("The years, one track each"));
|
|
275
|
+
appendTasteSection(lines, "Wish You Were Here", behavior.rediscovery_tracks, signal(formatRediscoveryTrack),
|
|
345
276
|
Number.isInteger(context.rediscovery_quiet_days)
|
|
346
|
-
?
|
|
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,
|
|
277
|
+
? tr("Songs you used to play a lot that have gone quiet for {count}+ days", { count: context.rediscovery_quiet_days })
|
|
278
|
+
: tr("Songs you used to play a lot that have gone quiet"));
|
|
279
|
+
appendTasteSection(lines, "Coming Back to Life", behavior.historical_return_tracks, signal(formatHistoricalReturnTrack),
|
|
354
280
|
Number.isInteger(context.historical_return_minimum_gap_days)
|
|
355
|
-
?
|
|
356
|
-
: "Songs that found their way back after long gaps"
|
|
357
|
-
);
|
|
358
|
-
appendTasteSection(
|
|
359
|
-
|
|
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
|
-
);
|
|
281
|
+
? tr("Songs that found their way back after {count}+ days away", { count: context.historical_return_minimum_gap_days })
|
|
282
|
+
: tr("Songs that found their way back after long gaps"));
|
|
283
|
+
appendTasteSection(lines, "Echoes", behavior.back_to_back_tracks, signal(formatBackToBackTrack), tr("Songs you played again right away"));
|
|
284
|
+
appendTasteSection(lines, tr("Most played"), behavior.repeat_tracks, listeningSignal(formatTrackSignal));
|
|
285
|
+
appendTasteSection(lines, tr("Recently"), behavior.recent_tracks, listeningSignal(formatTrackSignal));
|
|
377
286
|
|
|
378
287
|
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"),
|
|
288
|
+
formatBehaviorContextLine(context.direct_selection_starts, context.start_reason_events, tr("Songs you picked yourself"), tr),
|
|
289
|
+
formatBehaviorContextLine(context.trackdone_starts, context.start_reason_events, tr("Songs that followed on"), tr),
|
|
290
|
+
formatBehaviorContextLine(context.trackdone_endings, context.end_reason_events, tr("Played to the end"), tr),
|
|
291
|
+
formatBehaviorContextLine(context.explicit_skips, context.skip_state_events, tr("Skipped"), tr),
|
|
292
|
+
formatBehaviorContextLine(context.shuffle_events, context.shuffle_state_events, tr("On shuffle"), tr),
|
|
293
|
+
formatBehaviorContextLine(context.offline_events, context.offline_state_events, tr("Offline"), tr),
|
|
385
294
|
].filter(Boolean);
|
|
386
295
|
if (behaviorContextLines.length > 0) {
|
|
387
|
-
lines.push("", "
|
|
296
|
+
lines.push("", `## ${tr("How you listen")}`, "", ...behaviorContextLines);
|
|
388
297
|
}
|
|
389
298
|
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
);
|
|
299
|
+
if (Array.isArray(profile.strong_preferences) && profile.strong_preferences.length > 0) {
|
|
300
|
+
lines.push("", `## ${tr("Kept on purpose")}`, "",
|
|
301
|
+
...profile.strong_preferences.map((item) => `- ${safeInlineText(item.label)} · ${safeInlineText(item.signal).toLowerCase()}`));
|
|
403
302
|
}
|
|
404
303
|
|
|
405
304
|
lines.push(
|
|
406
305
|
"",
|
|
407
306
|
"## The dark side of the moon",
|
|
408
307
|
"",
|
|
409
|
-
"
|
|
308
|
+
`*${tr("What this reading can't see")}*`,
|
|
410
309
|
"",
|
|
411
|
-
"
|
|
412
|
-
...(behaviorContextLines.length > 0 ? ["
|
|
310
|
+
`- ${tr("Plays show attention, not love. A song can be on repeat because it was stuck in your head.")}`,
|
|
311
|
+
...(behaviorContextLines.length > 0 ? [`- ${tr("A skip is a moment, not a verdict.")}`] : []),
|
|
413
312
|
...((behavior.history_arc ?? []).length > 0 || (listeningSeasons ?? []).length > 0
|
|
414
|
-
? ["
|
|
415
|
-
"
|
|
313
|
+
? [`- ${tr("Years and seasons follow UTC, so a late night can land on the next day.")}`,
|
|
314
|
+
`- ${tr("A quiet stretch means no history was kept, not that you stopped listening.")}`]
|
|
416
315
|
: []),
|
|
417
|
-
"
|
|
316
|
+
`- ${tr("If something here is wrong, open the song or artist in /taste and tell me.")}`,
|
|
418
317
|
"",
|
|
419
318
|
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`.",
|
|
319
|
+
? tr("Add `--json` to the same `--from` command for the full data.")
|
|
320
|
+
: tr("For the full data with evidence IDs, run `moondog taste --json`."),
|
|
422
321
|
);
|
|
423
322
|
return lines.join("\n");
|
|
424
323
|
}
|
|
425
324
|
|
|
426
|
-
function formatMemory(memory) {
|
|
325
|
+
function formatMemory(memory, tr) {
|
|
427
326
|
const lines = [];
|
|
428
327
|
if (Number.isInteger(memory.sessions)) {
|
|
429
328
|
lines.push(
|
|
430
|
-
`- ${
|
|
431
|
-
`- ${
|
|
329
|
+
`- ${tr.n(memory.sessions, "{count} conversation saved on this machine", "{count} conversations saved on this machine")}`,
|
|
330
|
+
`- ${tr.n(memory.active_memories, "{count} thing I remember about you", "{count} things I remember about you")}`,
|
|
432
331
|
);
|
|
433
332
|
if (memory.reflection?.pending_episodes > 0) {
|
|
434
|
-
lines.push(`- ${
|
|
333
|
+
lines.push(`- ${tr.n(memory.reflection.pending_episodes, "{count} recent moment I haven't thought over yet", "{count} recent moments I haven't thought over yet")}`);
|
|
435
334
|
}
|
|
436
335
|
} else {
|
|
437
336
|
lines.push(`- ${words(memory.state)}`);
|
|
438
337
|
}
|
|
439
338
|
lines.push(
|
|
440
339
|
"",
|
|
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.",
|
|
340
|
+
tr("I keep what you tell me in conversation apart from your music profile."),
|
|
341
|
+
tr("I only remember something for good when you say it plainly, or when you ask with `/remember`."),
|
|
342
|
+
tr("`/forget <id>` removes it."),
|
|
444
343
|
);
|
|
445
344
|
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
|
-
);
|
|
345
|
+
lines.push("", `## ${tr("What I remember")}`, "",
|
|
346
|
+
...memory.memories.map((entry) => `- ${entry.text} · ${entry.kind} · \`${entry.memory_id}\``));
|
|
452
347
|
}
|
|
453
348
|
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
|
-
);
|
|
349
|
+
lines.push("", `## ${tr("Lately")}`, "",
|
|
350
|
+
...memory.recent_episodes.map((episode) => `- ${String(episode.occurred_at).slice(0, 10)} · ${episode.summary}`));
|
|
460
351
|
}
|
|
461
352
|
if (Array.isArray(memory.recent_sessions) && memory.recent_sessions.length > 0) {
|
|
462
|
-
lines.push(
|
|
463
|
-
"",
|
|
464
|
-
"## Recent conversations",
|
|
465
|
-
"",
|
|
353
|
+
lines.push("", `## ${tr("Recent conversations")}`, "",
|
|
466
354
|
...memory.recent_sessions.map((session) => {
|
|
467
|
-
const userTurns = session.turns
|
|
468
|
-
.filter((turn) => turn.role === "user")
|
|
469
|
-
.map((turn) => turn.text)
|
|
470
|
-
.join(" / ");
|
|
355
|
+
const userTurns = session.turns.filter((turn) => turn.role === "user").map((turn) => turn.text).join(" / ");
|
|
471
356
|
return `- ${String(session.started_at).slice(0, 10)} · ${userTurns}`;
|
|
472
|
-
})
|
|
473
|
-
);
|
|
357
|
+
}));
|
|
474
358
|
}
|
|
475
359
|
return lines.join("\n");
|
|
476
360
|
}
|
|
@@ -566,185 +450,112 @@ export function formatDemoResult(value) {
|
|
|
566
450
|
}
|
|
567
451
|
|
|
568
452
|
const capabilityStates = {
|
|
569
|
-
ready: "ready",
|
|
570
|
-
enabled: "ready",
|
|
571
|
-
available: "ready",
|
|
572
|
-
blocked: "not available yet",
|
|
573
|
-
unavailable: "not available",
|
|
574
|
-
disabled: "off",
|
|
453
|
+
ready: tr => tr("ready"),
|
|
454
|
+
enabled: tr => tr("ready"),
|
|
455
|
+
available: tr => tr("ready"),
|
|
456
|
+
blocked: tr => tr("not available yet"),
|
|
457
|
+
unavailable: tr => tr("not available"),
|
|
458
|
+
disabled: tr => tr("off"),
|
|
575
459
|
};
|
|
576
460
|
|
|
577
|
-
function formatTools(value) {
|
|
461
|
+
function formatTools(value, tr) {
|
|
578
462
|
const rows = value.capabilities.map((capability) => {
|
|
579
|
-
const label = capability.agent_tool?.label
|
|
580
|
-
const state = capabilityStates[capability.state] ?? words(capability.state);
|
|
463
|
+
const label = capability.agent_tool?.label ? tr.marked(capability.agent_tool.label) : capability.id;
|
|
464
|
+
const state = capabilityStates[capability.state]?.(tr) ?? words(capability.state);
|
|
581
465
|
return `- **${label}** · ${state}\n ${capability.description}${
|
|
582
|
-
capability.blocked_by ? `\n Waiting on:
|
|
466
|
+
capability.blocked_by ? `\n ${tr("Waiting on: {reason}", { reason: words(capability.blocked_by) })}` : ""
|
|
583
467
|
}`;
|
|
584
468
|
});
|
|
585
|
-
return `# What I can do\n\n${rows.join("\n")}`;
|
|
469
|
+
return `# ${tr("What I can do")}\n\n${rows.join("\n")}`;
|
|
586
470
|
}
|
|
587
471
|
|
|
588
472
|
const doctorLabels = {
|
|
589
|
-
"web.
|
|
473
|
+
"web.search": N_("Web search"),
|
|
474
|
+
"web.read": N_("Web page reading"),
|
|
590
475
|
"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",
|
|
476
|
+
"apple_music.source": N_("Apple Music library"),
|
|
477
|
+
"profile.projection": N_("Listening profile"),
|
|
478
|
+
"agent.runtime": N_("Model"),
|
|
479
|
+
"music.discovery.open_similarity": N_("Finding similar music"),
|
|
480
|
+
"external.effects": N_("Changes to your accounts"),
|
|
596
481
|
"spotify.connection": "Spotify",
|
|
597
|
-
"spotify.connected_action_scopes": "Spotify permissions",
|
|
482
|
+
"spotify.connected_action_scopes": N_("Spotify permissions"),
|
|
598
483
|
};
|
|
599
484
|
|
|
600
|
-
function formatDoctor(value) {
|
|
485
|
+
function formatDoctor(value, tr) {
|
|
601
486
|
return [
|
|
602
|
-
"
|
|
487
|
+
`# ${tr("Checkup")}`,
|
|
603
488
|
"",
|
|
604
489
|
...value.checks.map((check) => {
|
|
605
|
-
const state = check.ok ? "ok" : check.optional_for_local_commands ? "not set up (optional)" : "not working";
|
|
490
|
+
const state = check.ok ? tr("ok") : check.optional_for_local_commands ? tr("not set up (optional)") : tr("not working");
|
|
606
491
|
const detail = words(check.actual ?? check.state ?? "unknown");
|
|
607
|
-
|
|
492
|
+
const label = doctorLabels[check.id] ? tr.marked(doctorLabels[check.id]) : check.id;
|
|
493
|
+
return `- ${label}: ${state} · ${detail}`;
|
|
608
494
|
}),
|
|
609
495
|
].join("\n");
|
|
610
496
|
}
|
|
611
497
|
|
|
612
|
-
function formatRuntime(runtime) {
|
|
498
|
+
function formatRuntime(runtime, tr) {
|
|
613
499
|
if (runtime?.state === "configured") {
|
|
614
|
-
return `-
|
|
500
|
+
return `- ${runtime.provider && runtime.model ? tr("Talking through `{model}`", { model: `${runtime.provider}/${runtime.model}` }) : tr("Talking through your chosen model")}`;
|
|
615
501
|
}
|
|
616
502
|
return runtime?.reason === "provider_authentication_required"
|
|
617
|
-
? "
|
|
618
|
-
: "
|
|
503
|
+
? `- ${tr("Not signed in yet. `/auth` fixes that.")}`
|
|
504
|
+
: `- ${tr("No model connected. `/model` picks one. Your profile works without it.")}`;
|
|
619
505
|
}
|
|
620
506
|
|
|
621
|
-
function formatSpotifyStatus(spotify) {
|
|
507
|
+
function formatSpotifyStatus(spotify, tr) {
|
|
622
508
|
const state = spotify?.state ?? "not_configured";
|
|
623
|
-
if (state === "ready" || state === "connected") return "
|
|
509
|
+
if (state === "ready" || state === "connected") return `- ${tr("Connected")}`;
|
|
624
510
|
return state === "not_configured"
|
|
625
|
-
? "
|
|
511
|
+
? `- ${tr("Not connected. `/import` > Spotify walks you through it.")}`
|
|
626
512
|
: `- ${words(state)}`;
|
|
627
513
|
}
|
|
628
514
|
|
|
629
|
-
|
|
515
|
+
/** Local command output. The TUI passes its translator; the shell CLI uses English. */
|
|
516
|
+
export function formatLocalResult(command, value, tr = screenTranslator("en")) {
|
|
630
517
|
switch (command) {
|
|
631
518
|
case "status":
|
|
632
519
|
case "sources":
|
|
633
520
|
return [
|
|
634
|
-
"
|
|
521
|
+
`# ${tr("Where things stand")}`,
|
|
635
522
|
"",
|
|
636
|
-
"
|
|
523
|
+
`## ${tr("Your profile")}`,
|
|
637
524
|
"",
|
|
638
|
-
formatProfile(value.profile),
|
|
639
|
-
formatSource(value.source),
|
|
525
|
+
formatProfile(value.profile, tr),
|
|
526
|
+
formatSource(value.source, tr),
|
|
640
527
|
"",
|
|
641
|
-
"
|
|
528
|
+
`## ${tr("Talking")}`,
|
|
642
529
|
"",
|
|
643
|
-
formatRuntime(value.runtime),
|
|
530
|
+
formatRuntime(value.runtime, tr),
|
|
644
531
|
"",
|
|
645
|
-
"
|
|
532
|
+
`## ${tr("Memory")}`,
|
|
646
533
|
"",
|
|
647
|
-
formatMemory(value.memory),
|
|
534
|
+
formatMemory(value.memory, tr),
|
|
648
535
|
"",
|
|
649
536
|
"## Spotify",
|
|
650
537
|
"",
|
|
651
|
-
formatSpotifyStatus(value.spotify),
|
|
538
|
+
formatSpotifyStatus(value.spotify, tr),
|
|
652
539
|
"",
|
|
653
540
|
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.",
|
|
541
|
+
? tr("Right now I can't change anything in your music accounts.")
|
|
542
|
+
: tr("I can make changes in your connected accounts, and I always ask first."),
|
|
656
543
|
].join("\n");
|
|
657
544
|
case "profile":
|
|
658
|
-
return `# Your profile\n\n${formatProfile(value)}`;
|
|
545
|
+
return `# ${tr("Your profile")}\n\n${formatProfile(value, tr)}`;
|
|
659
546
|
case "taste":
|
|
660
|
-
return formatTaste(value);
|
|
547
|
+
return formatTaste(value, tr);
|
|
661
548
|
case "memory":
|
|
662
|
-
return `# What I remember\n\n${formatMemory(value)}`;
|
|
549
|
+
return `# ${tr("What I remember")}\n\n${formatMemory(value, tr)}`;
|
|
663
550
|
case "tools":
|
|
664
|
-
return formatTools(value);
|
|
551
|
+
return formatTools(value, tr);
|
|
665
552
|
case "doctor":
|
|
666
|
-
return formatDoctor(value);
|
|
553
|
+
return formatDoctor(value, tr);
|
|
667
554
|
default:
|
|
668
555
|
return JSON.stringify(value, null, 2);
|
|
669
556
|
}
|
|
670
557
|
}
|
|
671
558
|
|
|
672
|
-
export function helpText() {
|
|
673
|
-
return
|
|
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.`;
|
|
559
|
+
export function helpText(tr = screenTranslator("en")) {
|
|
560
|
+
return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `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\n- `moondog auth status [provider]` - see which sign-ins are saved\n- `moondog auth logout <provider>` - sign out of a provider\n- `moondog spotify help` - set up and control Spotify\n- `moondog spotify login [client-id]` - connect Spotify in your browser\n- `moondog spotify now|devices|queue` - see what Spotify is playing, where, and what's next\n- `moondog spotify recent` - see what you played lately\n- `moondog spotify sync-recent` - add your latest plays to your profile\n- `moondog spotify sync-library` - add your Spotify library to your profile\n- `moondog spotify play|pause|next|previous` - control Spotify playback\n- `moondog listenbrainz import-history <listen-history.json>` - add a ListenBrainz listens file to your profile\n- `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\n- `moondog status [--json]` - see where things stand\n- `moondog taste [--json]` - your listening report\n- `moondog taste --html [--output <file.html>]` - save your report as a web page that works offline\n- `moondog taste --card [--output <file.html>]` - save a short recap you can check before sharing\n- `moondog taste --from <spotify-history.zip> --html` - a one-off report from a Spotify ZIP, without saving it\n- `moondog taste --from <account-data.zip> --from <extended-history.zip> --html` - a one-off report from both Spotify exports together\n- `moondog taste --from <spotify-history.zip> --save --html` - save the Spotify history, then show your full report\n- `moondog data inspect --scope <listening|apple|profile>` - see what's stored, and get the code needed to reset it\n- `moondog data export --scope <listening|apple|profile> --output <directory>` - export a copy, leaving the original alone\n- `moondog data reset --scope <listening|apple|profile> --confirm <token>` - set stored data aside (it is archived, not deleted)\n- `moondog profile build [--force]` - build or resume a saved profile with your connected model\n- `moondog profile saved [offset]` - read your saved profile\n- `moondog profile explain <number>` - trace a saved finding to its evidence\n- `moondog profile corrections [--all]` - everything you've told me, including earlier choices\n- `moondog profile correct --artist <name> (--like|--avoid)` - like an artist, or keep them out\n- `moondog profile correct --track <title> --by <artist> (--like|--avoid)` - like a track, or keep it out\n- `moondog profile retract <correction-id>` - undo one of your choices\n- `moondog demo --offline [--json]` - a demo with made-up data, no sign-in needed\n- `moondog demo [prompt] [--json]` - the same demo, talking through your model\n- `moondog ask <prompt>` - ask one question and exit\n- `moondog remember <text>` - ask me to remember something\n- `moondog forget <memory-id>` - forget something\n- `moondog memory reflect [--dry-run] [--json]` - let me think over recent conversations now\n- `moondog` - open the listening room\n\nInside the listening room:\n\n- `/update [--check] [--channel latest|beta]` - check for updates, or save and close this session to install one\n- `/status` or `/sources` - see where things stand\n- `/profile` or `/taste` - your profile, and why each song or artist is there\n- `/taste report` - the whole report on one page\n- `/profile build [--force]` - build or resume a saved reading; Ctrl+C keeps progress\n- `/profile saved [offset]` - read your saved profile\n- `/profile explain <number>` - inspect a saved finding's evidence\n- `/profile corrections [--all]` - everything you've told me\n- `/profile correct --track \"<title>\" --by \"<artist>\" (--like|--avoid)` - like a track, or keep it out\n- `/profile correct --artist \"<name>\" (--like|--avoid)` - like an artist, or keep them out\n- `/profile retract <correction-id>` - undo a choice\n- `/memory` - what I remember\n- `/tools` - what I can do right now\n- `/doctor` - check your setup\n- `/model` - pick the model I talk through\n- `/model <provider> <model>` - switch straight to a model\n- `/model refresh [provider]` - update public model choices; keep the selected model\n- `/auth [provider]` - sign in to a model provider\n- `/web status|search|read` - look things up on public music sites\n- `/import [path]` - bring in your history, library, or a playlist\n- `/language` - choose the language Moondog uses\n- `/spotify` - Spotify commands\n- `/spotify login [client-id]` - connect Spotify\n- `/spotify now|devices|queue` - see what's playing, where, and what's next\n- `/spotify recent|sync-recent` - see or save your latest plays\n- `/spotify import-history \"/path/to/spotify-history.zip\"` - add a Spotify ZIP, no sign-in needed\n- `/skip` or `/stop` - move to the next song preview, or stop them\n- `/reload` - reload model settings and sign-ins\n- `/remember [kind] <text>` - ask me to remember something\n- `/forget <memory-id>` - forget something\n- `/rewind` or Alt+R - edit an earlier message on a new branch; the original stays saved\n- `/rewind latest` - edit the most recent message\n- `/resume` - pick up a saved conversation\n- `/resume <session-id>` - go straight to one\n- `/new` - start fresh; the last one stays saved\n- `/quit` - exit\n\nAnything 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`.\nSign in with `/auth`, or set the provider's API key in your environment. `/auth openai-codex` uses your ChatGPT sign-in.\nOnce your profile is ready, I can search your library, explain what I see, and plan playlists with you.\nI can only control Spotify after `moondog spotify login`, and I never post, send messages, delete anything, or spend money.\nAsk me to move playback onto a device by name, such as your iPhone.");
|
|
750
561
|
}
|