@audiofool/moondog 0.2.1 → 0.3.0-beta.1

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 (33) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +3 -2
  3. package/licenses/stability-ai-community-license.md +58 -0
  4. package/package.json +3 -1
  5. package/scripts/moondog.mjs +38 -2
  6. package/src/core/apple-projection-domain-services.mjs +14 -0
  7. package/src/core/capability-catalog.mjs +55 -0
  8. package/src/core/listening-profile-domain-services.mjs +12 -0
  9. package/src/core/moondog-application.mjs +195 -2
  10. package/src/i18n/messages/de.mjs +2 -0
  11. package/src/i18n/messages/en.mjs +2 -0
  12. package/src/i18n/messages/es.mjs +2 -0
  13. package/src/i18n/messages/fr.mjs +2 -0
  14. package/src/i18n/messages/it.mjs +2 -0
  15. package/src/i18n/messages/ja.mjs +2 -0
  16. package/src/i18n/messages/ko.mjs +2 -0
  17. package/src/i18n/messages/pt.mjs +2 -0
  18. package/src/i18n/messages/zh-Hant.mjs +2 -0
  19. package/src/i18n/messages/zh.mjs +2 -0
  20. package/src/i18n/screen/es.mjs +26 -0
  21. package/src/i18n/screen/ja.mjs +26 -0
  22. package/src/i18n/screen/pt.mjs +26 -0
  23. package/src/i18n/screen/zh.mjs +26 -0
  24. package/src/integrations/apple-music/terminal-preview-player.mjs +38 -11
  25. package/src/integrations/spotify/authentication.mjs +23 -18
  26. package/src/integrations/stable-audio/local-generator.mjs +683 -0
  27. package/src/profile/moondog-suggestions.mjs +9 -0
  28. package/src/profile/taste-compiler.mjs +197 -0
  29. package/src/runtime/pi/agent-runtime.mjs +278 -9
  30. package/src/runtime/pi/persistent-credential-store.mjs +6 -28
  31. package/src/surfaces/cli/command-completions.mjs +1 -0
  32. package/src/surfaces/cli/format-output.mjs +1 -0
  33. package/src/surfaces/cli/tui.mjs +70 -8
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
4
4
  import { LOCALES, messageLocale, translator } from "../../i18n/index.mjs";
5
5
  import { PROFILE_SECTIONS, TRAJECTORIES } from "../../profile/profile-exploration.mjs";
6
6
  import { compactListenerProfile } from "../../profile/listener-profile-build.mjs";
7
+ import { BPM_RANGE, DESCRIPTOR_FACETS, DESCRIPTOR_LIMITS, DESCRIPTOR_SOURCES } from "../../profile/taste-compiler.mjs";
8
+ import { GENERATION_SECONDS } from "../../integrations/stable-audio/local-generator.mjs";
7
9
  import { avoidKey } from "../../core/listener-avoids.mjs";
8
10
  import { foldedWords, isCjkText, textNames, textNamesArtist } from "../../core/track-title.mjs";
9
11
 
@@ -177,7 +179,9 @@ function agentCapabilityAllowed(descriptor) {
177
179
  spotifyCapabilityEffects.has(descriptor.effect)) ||
178
180
  (descriptor.capability_id.startsWith("netease.") &&
179
181
  spotifyCapabilityEffects.has(descriptor.effect)) ||
180
- (descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview")
182
+ (descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview") ||
183
+ (["music.generate.local", "music.generate.replay", "music.generate.stream", "music.generate.stream.stop"].includes(descriptor.capability_id) &&
184
+ descriptor.effect === "generate_local")
181
185
  );
182
186
  }
183
187
 
@@ -3181,6 +3185,52 @@ function renderMix(value, t) {
3181
3185
  return value?.mix ? t("queue.mix", value.mix) : null;
3182
3186
  }
3183
3187
 
3188
+ // What the model learns of a track Moondog made: what shaped it and what was left out, and never
3189
+ // where the file is.
3190
+ function projectGeneratedTrack(value) {
3191
+ if (!isPlainObject(value) || !["playing", "not_made"].includes(value.state)) throw new Error("domain_result_invalid:generated_track");
3192
+ const leftOut = (Array.isArray(value.left_out ?? value.dropped) ? value.left_out ?? value.dropped : []).slice(0, 24).map((entry) => ({
3193
+ term: cleanOutputText(entry?.term || "-", 64, "generated_left_out_term"),
3194
+ reason: cleanOutputText(entry?.reason, 32, "generated_left_out_reason"),
3195
+ }));
3196
+ if (value.state === "not_made") return { state: "not_made", reason: cleanOutputText(value.reason, 64, "generated_reason"), left_out: leftOut };
3197
+ const source = (entry) => ({
3198
+ kind: cleanOutputText(entry?.kind, 16, "generated_source_kind"),
3199
+ ...(entry?.claim_id ? { claim_id: cleanOutputText(entry.claim_id, 64, "generated_claim") } : {}),
3200
+ ...(entry?.label ? { from: cleanOutputText(entry.label, 256, "generated_from") } : {}),
3201
+ ...(entry?.artist ? { artist: cleanOutputText(entry.artist, 256, "generated_artist") } : {}),
3202
+ ...(entry?.part_of_day ? { part_of_day: cleanOutputText(entry.part_of_day, 32, "generated_part_of_day") } : {}),
3203
+ });
3204
+ return {
3205
+ state: "playing",
3206
+ made_with: "Stable Audio 3 Medium, on this Mac",
3207
+ track: {
3208
+ id: cleanOutputText(value.track?.id, 64, "generated_id"),
3209
+ title: cleanOutputText(value.track?.title, 128, "generated_title"),
3210
+ seconds: safeNonnegativeInteger(Math.round(value.track?.seconds ?? 0), "generated_seconds"),
3211
+ seed: safeNonnegativeInteger(value.track?.seed ?? 0, "generated_seed"),
3212
+ },
3213
+ prompt: cleanOutputText(value.prompt, 1_024, "generated_prompt"),
3214
+ shaped_by: (Array.isArray(value.shaped_by) ? value.shaped_by : []).slice(0, DESCRIPTOR_LIMITS.total).map((entry) => ({
3215
+ term: cleanOutputText(entry?.term, 64, "generated_term"), facet: cleanOutputText(entry?.facet, 16, "generated_facet"), source: source(entry?.source),
3216
+ })),
3217
+ left_out: leftOut,
3218
+ ...(Number.isInteger(value.bpm) ? { bpm: value.bpm } : {}),
3219
+ seconds_to_make: Math.round((value.elapsed_ms ?? 0) / 100) / 10,
3220
+ };
3221
+ }
3222
+
3223
+ const trackLength = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
3224
+
3225
+ function renderGeneratedTrack(value, t) {
3226
+ if (value?.state === "streaming" && value.track) {
3227
+ return [t("generate.made", { title: value.track.title, length: trackLength(value.track.seconds), seed: value.track.seed }),
3228
+ t("generate.stream", { minutes: value.minutes })].join("\n");
3229
+ }
3230
+ return value?.state === "playing" && value.track
3231
+ ? t("generate.made", { title: value.track.title, length: trackLength(value.track.seconds), seed: value.track.seed }) : null;
3232
+ }
3233
+
3184
3234
  function projectSpotifyDiscovery(value) {
3185
3235
  if (!isPlainObject(value) || value.provider !== "spotify" || !["verified_candidates", "unavailable", "no_matches"].includes(value.state)) throw new Error("domain_result_invalid:spotify_discovery");
3186
3236
  const items = (value.items ?? []).slice(0, 36).map(item => {
@@ -4773,6 +4823,9 @@ function createToolFactories(
4773
4823
  onSpotifyPlaylistEditWrite,
4774
4824
  onExternalCandidateSet,
4775
4825
  onMusicPlayback,
4826
+ onGeneratedTrack,
4827
+ onMadeTrackPlayed,
4828
+ generationContext,
4776
4829
  onMusicCatalogArtistReleases,
4777
4830
  onWebResearch,
4778
4831
  onSpotifyQueuePlan,
@@ -5134,6 +5187,146 @@ function createToolFactories(
5134
5187
  };
5135
5188
  },
5136
5189
  ],
5190
+ [
5191
+ "music.generate.local",
5192
+ (descriptor) => {
5193
+ const literals = (values) => Type.Union(values.map((value) => Type.Literal(value)));
5194
+ const descriptorSchema = Type.Object(
5195
+ {
5196
+ term: Type.String({ minLength: 1, maxLength: DESCRIPTOR_LIMITS.characters }),
5197
+ facet: literals(DESCRIPTOR_FACETS),
5198
+ source: literals(DESCRIPTOR_SOURCES),
5199
+ from: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
5200
+ },
5201
+ { additionalProperties: false },
5202
+ );
5203
+ return {
5204
+ name: descriptor.tool_name,
5205
+ label: descriptor.label,
5206
+ description:
5207
+ "Make one instrumental track on this Mac with the listener's own Stable Audio 3, and play it at once in Moondog's player, whatever the music service. Use it only when the listener asks Moondog to make, compose or generate music; never to play existing songs. It needs no web research, search, candidate set or playlist plan: read the profile if it helps, then call this tool. Translate their taste into plain sound descriptors and give each its source: reading (from = a claim_id of the saved reading; none without one), choice (from = an artist or song they marked Like), recent (from = an artist in recent_attention.stored_plays, the last four weeks; not the profile's longer recent lists or Spotify's own top artists), moment (the listener_clock), or request (their words in this message). A term is one idea in a few plain words for a genre, instrument, texture, mood or rhythm, such as dream pop, rhodes piano, tape saturation, unhurried, brushed drums. Say what is in the sound, never what is absent (no drums): the model can't leave a named sound out, so naming it brings it in. moment is the listener's local day and hour as listener_clock gives them; a time the listener names, such as tonight, is request. Never put an artist or song name in a term or the title. The host leaves out any term that names one, cannot be traced, comes from something they Avoid, or asks for singing (the model makes instrumentals only); left_out says which and why. A track needs at least one genre, instrument, texture or mood. If state is not_made, fix the descriptors once from left_out and reason, or tell the listener what was missing. bpm is optional. seconds is 15 to 380, 120 when left out. title is Moondog's own name for the record, a few plain words. Making a track takes from a few seconds to about a minute; make at most one per message. Making one ends Moondog's stream if one runs. product.made_tracks lists Moondog's newest tracks with their sound and outcomes: lean away from the sound of tracks the listener skipped and toward what they played through or asked for again by name (asked_for_again). replayed_by_moondog counts Moondog's own replays and says nothing about the listener. A track with stream: true played in the background: its completions say little, but its skips still count. These are outcomes of Moondog's own picks, not the listener's taste; never call them likes. Once it plays, say in one or two sentences what shaped it in everyday words (their reading, a Like, what they play lately, the hour). Describe only what the result's prompt and shaped_by hold: the model takes no key, chord or melody, so never name one. Do not recite the descriptors, the prompt or the seed; the host writes its own line under the reply.",
5208
+ parameters: Type.Object(
5209
+ {
5210
+ descriptors: Type.Array(descriptorSchema, { minItems: 1, maxItems: DESCRIPTOR_LIMITS.total }),
5211
+ bpm: Type.Optional(Type.Integer({ minimum: BPM_RANGE.min, maximum: BPM_RANGE.max })),
5212
+ bpm_source: Type.Optional(literals(["request", "moment", "reading"])),
5213
+ seconds: Type.Optional(Type.Integer({ minimum: GENERATION_SECONDS.min, maximum: GENERATION_SECONDS.max })),
5214
+ title: Type.Optional(Type.String({ minLength: 1, maxLength: 60 })),
5215
+ },
5216
+ { additionalProperties: false },
5217
+ ),
5218
+ executionMode: "sequential",
5219
+ execute: executeDomain(
5220
+ async (_toolCallId, parameters, signal, onUpdate) => application.makeTrack({
5221
+ descriptors: parameters.descriptors, bpm: parameters.bpm, bpmSource: parameters.bpm_source,
5222
+ seconds: parameters.seconds ?? GENERATION_SECONDS.default, title: parameters.title,
5223
+ }, {
5224
+ // The moment and the listener's words come from the host, never from the model.
5225
+ ...(generationContext?.() ?? {}),
5226
+ signal,
5227
+ onProgress: ({ stage }) => onUpdate?.({ content: [{ type: "text", text: stage }], details: { stage } }),
5228
+ }),
5229
+ projectGeneratedTrack,
5230
+ onGeneratedTrack,
5231
+ ),
5232
+ };
5233
+ },
5234
+ ],
5235
+ [
5236
+ "music.generate.stream",
5237
+ (descriptor) => {
5238
+ const literals = (values) => Type.Union(values.map((value) => Type.Literal(value)));
5239
+ const descriptorSchema = Type.Object(
5240
+ {
5241
+ term: Type.String({ minLength: 1, maxLength: DESCRIPTOR_LIMITS.characters }),
5242
+ facet: literals(DESCRIPTOR_FACETS),
5243
+ source: literals(DESCRIPTOR_SOURCES),
5244
+ from: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
5245
+ },
5246
+ { additionalProperties: false },
5247
+ );
5248
+ return {
5249
+ name: descriptor.tool_name,
5250
+ label: descriptor.label,
5251
+ description:
5252
+ "Start Moondog's stream: instrumental tracks made on this Mac from the listener's taste, one after another, for background listening (work, reading, a long evening). The first plays at once; each next one is made while the last plays, and features a different few of the descriptors, so give a broad set: genres, plus several instruments, textures, moods and rhythms. Descriptors, sources and rules are the same as moondog_make_track. A track the listener skips within 30 seconds steers the stream away from what it featured. seconds is each track's length, 60 to 380, 150 when left out; minutes is how long the stream runs, 10 to 180, 60 when left out. Starting one replaces a stream already running, and making a single track ends it. Use it only when the listener asks for music to keep going in the background; for one track, use moondog_make_track. Say in one or two sentences what shapes it, and that they can stop it any time; the host writes its own line under the reply.",
5253
+ parameters: Type.Object(
5254
+ {
5255
+ descriptors: Type.Array(descriptorSchema, { minItems: 1, maxItems: DESCRIPTOR_LIMITS.total }),
5256
+ bpm: Type.Optional(Type.Integer({ minimum: BPM_RANGE.min, maximum: BPM_RANGE.max })),
5257
+ bpm_source: Type.Optional(literals(["request", "moment", "reading"])),
5258
+ seconds: Type.Optional(Type.Integer({ minimum: 60, maximum: GENERATION_SECONDS.max })),
5259
+ minutes: Type.Optional(Type.Integer({ minimum: 10, maximum: 180 })),
5260
+ title: Type.Optional(Type.String({ minLength: 1, maxLength: 60 })),
5261
+ },
5262
+ { additionalProperties: false },
5263
+ ),
5264
+ executionMode: "sequential",
5265
+ execute: executeDomain(
5266
+ async (_toolCallId, parameters, signal, onUpdate) => application.startStream({
5267
+ descriptors: parameters.descriptors, bpm: parameters.bpm, bpmSource: parameters.bpm_source,
5268
+ seconds: parameters.seconds ?? 150, minutes: parameters.minutes ?? 60, title: parameters.title,
5269
+ }, {
5270
+ ...(generationContext?.() ?? {}),
5271
+ signal,
5272
+ onProgress: ({ stage }) => onUpdate?.({ content: [{ type: "text", text: stage }], details: { stage } }),
5273
+ }),
5274
+ (value) => {
5275
+ if (value?.state === "not_made") return projectGeneratedTrack(value);
5276
+ if (value?.state !== "streaming") throw new Error("domain_result_invalid:stream");
5277
+ return { ...projectGeneratedTrack({ ...value, state: "playing" }), state: "streaming", minutes: safeNonnegativeInteger(value.minutes ?? 0, "stream_minutes") };
5278
+ },
5279
+ onGeneratedTrack,
5280
+ ),
5281
+ };
5282
+ },
5283
+ ],
5284
+ [
5285
+ "music.generate.stream.stop",
5286
+ (descriptor) => ({
5287
+ name: descriptor.tool_name,
5288
+ label: descriptor.label,
5289
+ description: "Stop Moondog's stream of tracks made on this Mac when the listener asks to stop it. The track playing stops too.",
5290
+ parameters: emptyParameters,
5291
+ executionMode: "sequential",
5292
+ execute: executeDomain(
5293
+ async () => application.stopStream(),
5294
+ (value) => ({ state: value?.state === "stopped" ? "stopped" : "not_running",
5295
+ ...(Number.isInteger(value?.tracks_made) ? { tracks_made: value.tracks_made } : {}) }),
5296
+ ),
5297
+ }),
5298
+ ],
5299
+ [
5300
+ "music.generate.replay",
5301
+ (descriptor) => ({
5302
+ name: descriptor.tool_name,
5303
+ label: descriptor.label,
5304
+ description:
5305
+ "Play again a track Moondog made on this Mac, by its id from product.made_tracks, in Moondog's own player. Use it when the listener asks to hear one of Moondog's tracks again; to make a new one, use moondog_make_track. A track with audio_kept: false has only its receipt left, so it can't play again. Playing one again ends Moondog's stream if one runs. chosen_by says whether the listener named it (their own pick) or Moondog chose it.",
5306
+ parameters: Type.Object(
5307
+ { id: Type.String({ minLength: 36, maxLength: 36 }) },
5308
+ { additionalProperties: false },
5309
+ ),
5310
+ executionMode: "sequential",
5311
+ execute: executeDomain(
5312
+ async (_toolCallId, parameters, signal) => application.playMadeTrack({ id: parameters.id },
5313
+ { request: generationContext?.({ replay: true })?.request ?? null, signal }),
5314
+ (value) => {
5315
+ if (!isPlainObject(value) || value.state !== "playing") throw new Error("domain_result_invalid:made_track");
5316
+ return {
5317
+ state: "playing",
5318
+ track: {
5319
+ id: cleanOutputText(value.track?.id, 64, "made_track_id"),
5320
+ title: cleanOutputText(value.track?.title, 128, "made_track_title"),
5321
+ ...(Number.isFinite(value.track?.seconds) ? { seconds: Math.round(value.track.seconds) } : {}),
5322
+ },
5323
+ chosen_by: value.chosen_by === "listener" ? "listener" : "moondog",
5324
+ };
5325
+ },
5326
+ onMadeTrackPlayed,
5327
+ ),
5328
+ }),
5329
+ ],
5137
5330
  [
5138
5331
  "netease.search",
5139
5332
  (descriptor) => ({
@@ -6426,8 +6619,11 @@ function toolForModel(tool) {
6426
6619
  // Spotify can be connected for reading while the listener chose another service to play music.
6427
6620
  const SERVICE_NAMES = { apple_music: "Apple Music", netease: "NetEase Music (网易云音乐)" };
6428
6621
 
6429
- function sessionAvailability({ spotifyReady, spotifyPlaybackReady = spotifyReady, previewReady, playbackService = "apple_music", neteaseReady = false }) {
6430
- if (spotifyPlaybackReady) return "";
6622
+ // Making a track plays in Moondog's own player under every music service.
6623
+ const GENERATION_LINE = "- moondog_make_track makes an instrumental track on this Mac from the listener's taste and plays it in Moondog's own player, whatever the music service. Use it only when they ask Moondog to make, compose or generate music, not to play songs that exist. moondog_play_made_track plays again one Moondog already made (product.made_tracks). When the stream tools are offered, moondog_start_stream keeps making tracks one after another for background listening, and moondog_stop_stream ends it.";
6624
+
6625
+ function sessionAvailability({ spotifyReady, spotifyPlaybackReady = spotifyReady, previewReady, playbackService = "apple_music", neteaseReady = false, localGenerationReady = false }) {
6626
+ if (spotifyPlaybackReady) return localGenerationReady ? `\n\nSession availability:\n${GENERATION_LINE}` : "";
6431
6627
  const lines = [
6432
6628
  "Session availability:",
6433
6629
  spotifyReady
@@ -6455,9 +6651,12 @@ function sessionAvailability({ spotifyReady, spotifyPlaybackReady = spotifyReady
6455
6651
  ? "- Do not suggest switching to Spotify or linking another account to play music here."
6456
6652
  : "- Do not suggest linking Spotify or another account to play music here.",
6457
6653
  );
6654
+ } else if (localGenerationReady) {
6655
+ lines.push("- No music service can play or queue songs in this session. Say so once, briefly, when the listener asks for songs; moondog_make_track can still make them a track of Moondog's own.");
6458
6656
  } else {
6459
6657
  lines.push("- You cannot play or queue music in this session. Say so once, briefly, then help with what you can: the song, its story, and what to try next.");
6460
6658
  }
6659
+ if (localGenerationReady) lines.push(GENERATION_LINE);
6461
6660
  if (previewReady || neteaseReady) {
6462
6661
  lines.push(
6463
6662
  "- Playback verification belongs to the selected music service. Its play tool resolves and checks recordings. Use the returned songs and outcome to answer directly; do not run another catalog search or playlist planner just to recheck a successful playback result. A Spotify player or queue read cannot verify playback on another service.",
@@ -6478,6 +6677,7 @@ function sessionSection(application) {
6478
6677
  previewReady: application.musicPreviewReady?.() ?? false,
6479
6678
  playbackService: application.playbackService ?? "apple_music",
6480
6679
  neteaseReady: application.neteaseReady?.() ?? false,
6680
+ localGenerationReady: application.localGenerationReady?.() ?? false,
6481
6681
  }).trim() || null;
6482
6682
  }
6483
6683
 
@@ -6490,6 +6690,7 @@ function playbackSurfaceKey(application) {
6490
6690
  application.musicPreviewReady?.() ?? false,
6491
6691
  application.neteaseReady?.() ?? false,
6492
6692
  application.listeningReadable?.() ?? false,
6693
+ application.localGenerationReady?.() ?? false,
6493
6694
  ]);
6494
6695
  }
6495
6696
 
@@ -6638,7 +6839,7 @@ Output rules:
6638
6839
  - For a playlist with a stated size, return that many tracks. When the candidates cannot fill it with good fits, say so and ask before planning fewer. For a queue or an open pick, see Fewer, better.
6639
6840
  - For a library or history plan, explain the order and give a concrete reason for every selected track. The host shows an external plan as the songs plus one source line.
6640
6841
  - State uncertainty or ask to broaden the search when results are sparse.
6641
- - Spotify playback controls, library saves, and private playlist writes may be used through the registered tools. Publishing, messaging, deletion, paid generation, and all other external effects remain disabled.
6842
+ - Spotify playback controls, library saves, and private playlist writes may be used through the registered tools. When the listener has turned it on, moondog_make_track makes music on this Mac, which nothing leaves. Publishing, messaging, deletion, paid or online generation, and all other external effects remain disabled.
6642
6843
 
6643
6844
  Curation craft:
6644
6845
  - Start from an anchor the listener already knows (a thread in the saved reading, an artist in recent attention, what is playing now) and move outward one step at a time: the same scene, era, producer, label, players or lineage. Name the anchor once.
@@ -7153,6 +7354,9 @@ function productContext(application, query, now, { runtime, source }) {
7153
7354
  spotify_playback_context: application.spotifyPlaybackContextStatus?.() ?? null,
7154
7355
  // A host that cannot know the listener's time zone passes no clock, and the time stays unknown.
7155
7356
  listener_clock: now ? listenerClock(now) : null,
7357
+ // The tracks Moondog made lately and how each landed, once it can make them, and its stream while one runs.
7358
+ ...(application.madeTracks?.() ? { made_tracks: application.madeTracks() } : {}),
7359
+ ...(application.streamStatus?.() ? { stream: application.streamStatus() } : {}),
7156
7360
  reply_language: replyLanguage(query, application),
7157
7361
  };
7158
7362
  }
@@ -7328,6 +7532,39 @@ export class PiAgentRuntime {
7328
7532
  if (value.playing_in_order?.length) this.activePromptState.previewsPlaying = true;
7329
7533
  }
7330
7534
  },
7535
+ // A track Moondog made, played again: a Like said while it plays is about Moondog's own record.
7536
+ onMadeTrackPlayed: (value) => {
7537
+ if (typeof value?.track?.title === "string") this.generatedTitles.add(avoidKey(value.track.title));
7538
+ },
7539
+ onGeneratedTrack: (value) => {
7540
+ if (!["playing", "streaming"].includes(value?.state)) return;
7541
+ this.generatedTitles.add(avoidKey(value.track.title));
7542
+ this.activePromptState?.generatedTracks.push(structuredClone(value));
7543
+ },
7544
+ // A track is shaped by the moment and the listener's own words, which the host supplies and the
7545
+ // model never does. One message makes at most one track, with one more try if nothing was made.
7546
+ generationContext: ({ replay = false } = {}) => {
7547
+ const state = this.activePromptState;
7548
+ const refuse = (code, message) => { throw Object.assign(new Error(message), { code }); };
7549
+ if (!state) refuse("generation_no_prompt", "A track can be made only while answering the listener.");
7550
+ // Playing a made track again needs only the listener's words, to tell their pick from Moondog's.
7551
+ if (replay) {
7552
+ if (state.replays >= 1) refuse("generation_turn_limit", "Nothing more was played. One message plays at most one of Moondog's tracks again.");
7553
+ state.replays += 1;
7554
+ return { request: state.promptText };
7555
+ }
7556
+ if (state.generatedTracks.length > 0 || state.generationAttempts >= 2) {
7557
+ refuse("generation_turn_limit", "Nothing more was made. One message makes at most one track; the listener can ask for another.");
7558
+ }
7559
+ state.generationAttempts += 1;
7560
+ const now = this.clock?.();
7561
+ return {
7562
+ moment: now instanceof Date && Number.isFinite(now.getTime()) ? listenerClock(now) : null,
7563
+ request: state.promptText,
7564
+ // A conversation that read Spotify keeps the listener's words out of the receipt, as out of memory.
7565
+ transient: this.transientSpotifyContext || this.application.transientSpotifyContext === true,
7566
+ };
7567
+ },
7331
7568
  onExternalCandidateSet: (value, { playbackLookup = false } = {}) => {
7332
7569
  if (this.activePromptState) {
7333
7570
  this.activePromptState.externalCandidateSetCreated = true;
@@ -7408,6 +7645,17 @@ export class PiAgentRuntime {
7408
7645
  }
7409
7646
  const track = parameters.subject === "track";
7410
7647
  if (track && !parameters.title) refuse("taste_choice_title_required", "Nothing was saved. A song choice needs its title and artist.");
7648
+ // Moondog's own tracks have no artist, and "Moondog" is also a real composer's name. A choice is
7649
+ // about a track Moondog made when it names one of their titles with Moondog as artist, puts such a
7650
+ // title in place of the artist, or points at "this" while it names no song. Anything else, the
7651
+ // real composer included, is saved as usual.
7652
+ // Titles made in this session, and the newest ones on this Mac, which a replay may be playing.
7653
+ const madeTitles = new Set([...this.generatedTitles, ...(application.madeTracks?.() ?? []).map((made) => avoidKey(made.title))]);
7654
+ const madeHere = (value) => madeTitles.has(avoidKey(value ?? ""));
7655
+ const moondog = avoidKey(parameters.artist ?? "") === "moondog";
7656
+ if (madeHere(parameters.artist) || (moondog && (track ? madeHere(parameters.title) : quoteIsDeictic(quote) && madeTitles.size > 0))) {
7657
+ refuse("taste_choice_generated_track", "Nothing was saved. This is a track Moondog made, and choices about Moondog's own tracks are not saved yet. Say so, and offer to make another one like it.");
7658
+ }
7411
7659
  if (!quoteLongEnough(quote)) {
7412
7660
  refuse("taste_choice_quote_too_short", "Nothing was saved. source_text must quote the listener's whole statement of the choice, not one word.");
7413
7661
  }
@@ -7445,6 +7693,8 @@ export class PiAgentRuntime {
7445
7693
  };
7446
7694
  // Every tool name this agent has offered. One that later goes away left with a change of music service.
7447
7695
  this.offeredTools = new Set();
7696
+ // The titles of the tracks this agent made, so a Like said while one plays is not saved as a real song's.
7697
+ this.generatedTitles = new Set();
7448
7698
  const surface = this.#buildSurface();
7449
7699
  this.surfaceKey = surface.key;
7450
7700
  this.capabilityByToolName = surface.capabilities;
@@ -7666,6 +7916,9 @@ export class PiAgentRuntime {
7666
7916
  presentationFailure: false,
7667
7917
  stagedMemoryMutations: [],
7668
7918
  tasteChoices: [],
7919
+ generatedTracks: [],
7920
+ generationAttempts: 0,
7921
+ replays: 0,
7669
7922
  };
7670
7923
  this.activePromptState = promptState;
7671
7924
  const profileDiscoveryUnavailable = () => Boolean(callbacks.profileSeed) &&
@@ -7742,6 +7995,8 @@ export class PiAgentRuntime {
7742
7995
  receipts.push(...unresolvedPlaybackFailures().map((failure) => renderSpotifyPlaybackFailure(failure, t)));
7743
7996
  return receipts;
7744
7997
  };
7998
+ // A track that was made stays made, so its line stays when the turn is cancelled or interrupted too.
7999
+ const generatedTexts = () => promptState.generatedTracks.map((track) => renderGeneratedTrack(track, t)).filter(Boolean);
7745
8000
  // A choice is saved at once, so its line stays even when the turn is cancelled or interrupted.
7746
8001
  const choiceTexts = () => promptState.tasteChoices.map((choice) => t(`choice.${choice.state === "retracted" ? "retract" : choice.stance}`, {
7747
8002
  title: choice.entity_type === "track" ? choice.label : "",
@@ -7787,7 +8042,7 @@ export class PiAgentRuntime {
7787
8042
  const queueMix = promptState.spotifyQueuePlan?.queued?.length ? renderMix(promptState.spotifyQueuePlan, t) : null;
7788
8043
  const playbackMix = renderMix(promptState.musicPlaybackResult, t);
7789
8044
  const leftOut = [promptState.spotifyQueuePlan, ...promptState.spotifyWriteReceipts].map((receipt) => renderSkippedAvoided(receipt, t));
7790
- for (const mix of [queueMix, playbackMix, ...leftOut]) {
8045
+ for (const mix of [queueMix, playbackMix, ...leftOut, ...generatedTexts()]) {
7791
8046
  if (!mix || resultText.includes(mix)) continue;
7792
8047
  const addition = resultText ? `\n\n${mix}` : mix;
7793
8048
  resultText += addition;
@@ -7889,6 +8144,8 @@ export class PiAgentRuntime {
7889
8144
  if (ownEntryId && this.nameConversations) {
7890
8145
  try { ownSessionId = this.application.ensureMemorySession?.()?.session_id ?? null; } catch { /* The conversation keeps its first message as its name. */ }
7891
8146
  }
8147
+ // The tracks Moondog made, read again, so the other surface's tracks and outcomes are in this reply's context.
8148
+ try { await this.application.refreshMadeTracks?.(); } catch { /* The last read stands. */ }
7892
8149
  this.application.beginPrompt({ text, recentTexts: recentListenerTexts(this.agent.state.messages.slice(0, historyStartIndex)) });
7893
8150
  promptScopeStarted = true;
7894
8151
  if (callbacks.profileSeed) this.application.setProfileDiscoverySeed(callbacks.profileSeed);
@@ -7949,6 +8206,18 @@ export class PiAgentRuntime {
7949
8206
  });
7950
8207
  }
7951
8208
 
8209
+ // A long tool, such as making a track, says which stage it is in.
8210
+ if (event.type === "tool_execution_update" && typeof event.partialResult?.details?.stage === "string") {
8211
+ const descriptor = this.capabilityByToolName.get(event.toolName);
8212
+ callbacks.onToolUpdate?.({
8213
+ toolCallId: event.toolCallId,
8214
+ toolName: event.toolName,
8215
+ capabilityId: descriptor?.capability_id ?? "unknown",
8216
+ label: descriptor?.label ?? event.toolName,
8217
+ stage: event.partialResult.details.stage,
8218
+ });
8219
+ }
8220
+
7952
8221
  if (event.type === "tool_execution_end") {
7953
8222
  const descriptor = this.capabilityByToolName.get(event.toolName);
7954
8223
  const resultText = event.result?.content?.map((part) => part.text ?? "").join("") ?? "";
@@ -8022,7 +8291,7 @@ export class PiAgentRuntime {
8022
8291
  this.application.cancelSpotifyQueueRequest?.();
8023
8292
  discardPromptHistory(this.agent, historyStartIndex);
8024
8293
  historyFinalized = true;
8025
- const receipts = [...spotifyEffectTexts(), ...choiceTexts()];
8294
+ const receipts = [...spotifyEffectTexts(), ...generatedTexts(), ...choiceTexts()];
8026
8295
  const abortedText = receipts.length > 0
8027
8296
  ? [t("turn.cancelled"), ...receipts].join("\n\n")
8028
8297
  : promptState.toolExecutionStarted ? "" : streamedText || finalText;
@@ -8137,8 +8406,8 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
8137
8406
  // Discovered songs need a validated plan; a catalog lookup, such as whether a song is there, is reported in the model's words.
8138
8407
  promptState.externalCandidateSets.some((set) => !set.playbackLookup) &&
8139
8408
  !promptState.playlistPlanAttempted &&
8140
- // A song the model played, queued or failed to play is reported in its own words.
8141
- !promptState.musicPlaybackResult &&
8409
+ // A song the model played, queued or failed to play is reported in its own words, as is a track it made.
8410
+ !promptState.musicPlaybackResult && !promptState.generatedTracks.length &&
8142
8411
  !promptState.spotifyPlayback.length && !promptState.spotifyWriteReceipts.length && !promptState.spotifyPlaybackFailures.length
8143
8412
  ) {
8144
8413
  const safeFailureText = [renderUnvalidatedPlaylistPlan(t), ...spotifyEffectTexts()].join("\n\n");
@@ -8183,7 +8452,7 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
8183
8452
  // its receipt. Keep only host evidence; discard model claims and staged
8184
8453
  // memory/plans. The caller can still display the returned receipt if its
8185
8454
  // streaming callback itself was the source of the exception.
8186
- const receipts = [...spotifyEffectTexts(), ...choiceTexts()];
8455
+ const receipts = [...spotifyEffectTexts(), ...generatedTexts(), ...choiceTexts()];
8187
8456
  if (!receipts.length) throw error;
8188
8457
  const interruptedText = [t("turn.interrupted"), ...receipts].join("\n\n");
8189
8458
  compactCompletedPromptHistory(this.agent, historyStartIndex, interruptedText);
@@ -668,38 +668,16 @@ export class PersistentCredentialStore {
668
668
  const release = await this.acquireLock(options);
669
669
  try {
670
670
  const document = await this.readDocument(options);
671
- let current = document.credentials[providerId];
672
- const persist = async (value) => {
673
- const validated = validateCredential(providerId, value);
674
- document.credentials[providerId] = validated;
675
- await this.writeDocument(document, options);
676
- current = validated;
677
- };
678
- let active = true;
679
- let pending = Promise.resolve();
680
- // A checkpoint is durable even if the callback later throws. Its
681
- // lifetime is fenced to this transaction; all started writes drain
682
- // before the lock is released, including accidentally unawaited ones.
683
- const checkpoint = (value) => {
684
- if (!active) throw safeError("credential_store_transaction_closed", "The credential transaction has ended.");
685
- const validated = validateCredential(providerId, value);
686
- pending = pending.then(() => persist(validated));
687
- pending.catch(() => {});
688
- return pending;
689
- };
690
- let next;
691
- try {
692
- next = await fn(current ? structuredClone(current) : undefined, { checkpoint });
693
- } finally {
694
- active = false;
695
- await pending;
696
- }
671
+ const current = document.credentials[providerId];
672
+ const next = await fn(current ? structuredClone(current) : undefined);
697
673
  assertNotAborted(options.signal);
698
674
  if (next === undefined) {
699
675
  return current ? structuredClone(current) : undefined;
700
676
  }
701
- await persist(next);
702
- return structuredClone(current);
677
+ const validated = validateCredential(providerId, next);
678
+ document.credentials[providerId] = validated;
679
+ await this.writeDocument(document, options);
680
+ return structuredClone(validated);
703
681
  } finally {
704
682
  await release();
705
683
  }
@@ -93,6 +93,7 @@ export function withCommandCompletions(commands, {
93
93
  art: { argumentHint: "[braille|ascii|off|auto]", choices: firstArgument(artChoices) },
94
94
  motion: { argumentHint: "[on|off]", choices: firstArgument(motionChoices) },
95
95
  service: { argumentHint: "[spotify|apple_music|netease]", choices: firstArgument(serviceChoices) },
96
+ generation: { argumentHint: "[on [folder]|off]", choices: firstArgument(choices([["on", N_("Make music on this Mac with Stable Audio 3")], ["off", N_("Stop making music here")]])) },
96
97
  lyrics: { argumentHint: "[sync]", choices: firstArgument(choices([["sync", N_("Check profile songs for lyrics")]])) },
97
98
  web: { argumentHint: "search <query> | read <url> | status", choices: firstArgument(webChoices) },
98
99
  spotify: {
@@ -472,6 +472,7 @@ function formatTools(value, tr) {
472
472
  const doctorLabels = {
473
473
  "web.search": N_("Web search"),
474
474
  "web.read": N_("Web page reading"),
475
+ "music.generate.local": N_("Making music on this Mac"),
475
476
  "node.version": "Node.js",
476
477
  "apple_music.source": N_("Apple Music library"),
477
478
  "profile.projection": N_("Listening profile"),