@audiofool/moondog 0.3.0-beta.7 → 0.3.0-beta.8

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.
@@ -183,6 +183,7 @@ function agentCapabilityAllowed(descriptor) {
183
183
  (descriptor.capability_id.startsWith("netease.") &&
184
184
  spotifyCapabilityEffects.has(descriptor.effect)) ||
185
185
  (descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview") ||
186
+ (descriptor.capability_id === "collection.play" && descriptor.effect === "play_library") ||
186
187
  (["music.generate.local", "music.generate.replay", "music.generate.stream", "music.generate.stream.stop"].includes(descriptor.capability_id) &&
187
188
  descriptor.effect === "generate_local")
188
189
  );
@@ -3055,7 +3056,8 @@ function safeDomainFailure(error) {
3055
3056
  }
3056
3057
  const details = spotifyFailureDetails(error);
3057
3058
  const evidence = [details.status ? `HTTP ${details.status}` : "", details.reason].filter(Boolean);
3058
- return new Error(`${code}: ${message}${evidence.length ? ` (${evidence.join("; ")})` : ""}${details.not_sent ? " No Spotify write was dispatched for this attempt. Correct the arguments using retained host references; this is not an uncertain external effect." : ""}`);
3059
+ // The status is for the model's diagnosis; labelled so, it is not copied into a reply.
3060
+ return new Error(`${code}: ${message}${evidence.length ? ` [diagnosis only, never for the listener: ${evidence.join("; ")}]` : ""}${details.not_sent ? " No Spotify write was dispatched for this attempt. Correct the arguments using retained host references; this is not an uncertain external effect." : ""}`);
3059
3061
  }
3060
3062
 
3061
3063
  function spotifyFailureDetails(error) {
@@ -3487,7 +3489,7 @@ function projectSpotifyQueuePlan(value) {
3487
3489
  result.stopped = projectQueueTrackList([value.stopped], "spotify_queue_stopped")[0];
3488
3490
  if (value.failure) result.failure = { action: "playback.queue.add", ...spotifyFailureDetails(value.failure) };
3489
3491
  }
3490
- for (const field of ["skipped_duplicate_count", "skipped_avoided_count", "skipped_known_count", "skipped_uncertain_count", "not_added_count"]) {
3492
+ for (const field of ["skipped_duplicate_count", "skipped_avoided_count", "skipped_known_count", "skipped_uncertain_count", "skipped_unavailable_count", "not_added_count"]) {
3491
3493
  if (value[field] !== undefined) result[field] = safeNonnegativeInteger(value[field], field);
3492
3494
  }
3493
3495
  if (["queue.similar", "queue.batch"].includes(value.action)) {
@@ -6606,6 +6608,93 @@ function createToolFactories(
6606
6608
  ),
6607
6609
  }),
6608
6610
  ],
6611
+ [
6612
+ "collection.list",
6613
+ (descriptor) => ({
6614
+ name: descriptor.tool_name,
6615
+ label: descriptor.label,
6616
+ description:
6617
+ "Find playlists in the listener's own library on this Mac by words of their name, or list the newest ones without words. Saved is the built-in playlist of single songs and made tracks they kept. Each playlist says how many entries it holds and how many of them Moondog made. With playlist_id, it reads that playlist's entries in order.",
6618
+ parameters: Type.Object(
6619
+ {
6620
+ query: Type.Optional(Type.String({ maxLength: 120 })),
6621
+ playlist_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
6622
+ },
6623
+ { additionalProperties: false },
6624
+ ),
6625
+ execute: executeDomain(
6626
+ async (_toolCallId, parameters) => {
6627
+ if (parameters.playlist_id) {
6628
+ const playlist = await application.libraryPlaylist(parameters.playlist_id);
6629
+ if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
6630
+ return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, origin: playlist.origin,
6631
+ entries: playlist.entries.slice(0, 60).map((entry) => ({ title: entry.title, artist: entry.artist_credit, ...(entry.kind === "made" ? { made_by_moondog: true } : {}) })),
6632
+ total: playlist.entries.length } };
6633
+ }
6634
+ return { playlists: application.findLibraryPlaylists(parameters.query ?? "") };
6635
+ },
6636
+ (value) => structuredClone(value),
6637
+ ),
6638
+ }),
6639
+ ],
6640
+ [
6641
+ "collection.save",
6642
+ (descriptor) => ({
6643
+ name: descriptor.tool_name,
6644
+ label: descriptor.label,
6645
+ description:
6646
+ "Keep music in the listener's own library on this Mac, only when they ask to save, keep, like or collect it. from now_playing keeps the song or made track playing now; from queue keeps it and what plays after it (the queue, or the rest of the playlist it plays from). Or give songs by title and artist, or made_track_id for a track Moondog made (product.made_tracks). With no playlist it goes in Saved; playlist_id adds to one of their playlists (from moondog_collection_list); playlist_name makes a new playlist with that name, in the listener's language. Saved holds each song once; already says how many were there.",
6647
+ parameters: Type.Object(
6648
+ {
6649
+ from: Type.Optional(Type.Union([Type.Literal("now_playing"), Type.Literal("queue")])),
6650
+ songs: Type.Optional(Type.Array(Type.Object({
6651
+ title: Type.String({ minLength: 1, maxLength: 200 }),
6652
+ artist: Type.String({ minLength: 1, maxLength: 200 }),
6653
+ album: Type.Optional(Type.String({ maxLength: 200 })),
6654
+ }, { additionalProperties: false }), { minItems: 1, maxItems: 50 })),
6655
+ made_track_id: Type.Optional(Type.String({ minLength: 36, maxLength: 36 })),
6656
+ playlist_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
6657
+ playlist_name: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
6658
+ },
6659
+ { additionalProperties: false },
6660
+ ),
6661
+ executionMode: "sequential",
6662
+ execute: executeDomain(
6663
+ async (_toolCallId, parameters, signal) => application.saveToLibrary({
6664
+ from: parameters.from, songs: parameters.songs, madeTrackId: parameters.made_track_id,
6665
+ playlistId: parameters.playlist_id, playlistName: parameters.playlist_name,
6666
+ }, { by: "listener", signal }),
6667
+ (value) => ({
6668
+ playlist: { id: cleanOutputText(value.playlist?.id, 128, "playlist_id"), name: cleanOutputText(value.playlist?.name, 120, "playlist_name"),
6669
+ ...(value.playlist?.created ? { created: true } : {}) },
6670
+ added: Number(value.added) || 0,
6671
+ already: Number(value.already) || 0,
6672
+ titles: (value.titles ?? []).slice(0, 50).map((title) => cleanOutputText(title, 200, "title")),
6673
+ }),
6674
+ ),
6675
+ }),
6676
+ ],
6677
+ [
6678
+ "collection.play",
6679
+ (descriptor) => ({
6680
+ name: descriptor.tool_name,
6681
+ label: descriptor.label,
6682
+ description:
6683
+ "Play a playlist from the listener's own library, by its id from moondog_collection_list, on the music service they chose. It is the listener's own music, so say its name and let it play; don't describe it as Moondog's pick. With Apple Music, songs and Moondog's tracks play in one queue in Moondog's player. With Spotify or NetEase its songs play there, and left_out_made says how many of Moondog's tracks in it could not play there. not_found counts songs no service found.",
6684
+ parameters: Type.Object(
6685
+ {
6686
+ playlist_id: Type.String({ minLength: 1, maxLength: 128 }),
6687
+ shuffle: Type.Optional(Type.Boolean()),
6688
+ },
6689
+ { additionalProperties: false },
6690
+ ),
6691
+ executionMode: "sequential",
6692
+ execute: executeDomain(
6693
+ async (_toolCallId, parameters, signal) => application.playLibrary({ playlistId: parameters.playlist_id, shuffle: parameters.shuffle === true }, { signal }),
6694
+ (value) => structuredClone(value),
6695
+ ),
6696
+ }),
6697
+ ],
6609
6698
  [
6610
6699
  "playlist.plan",
6611
6700
  (descriptor) => ({
@@ -6823,6 +6912,7 @@ Public web research rules:
6823
6912
 
6824
6913
  Grounding and evidence rules:
6825
6914
  - Before naming or selecting tracks as present in the user's library, call moondog_library_search.
6915
+ - The listener's collection in Moondog (Saved and their own playlists, which can hold songs and tracks Moondog made) is separate from their imported library and from Spotify. When they ask to save, keep, collect or like music (收藏, 存下来) without naming Spotify, use moondog_collection_save; use moondog_spotify_library_save only when they name Spotify or Liked Songs. To play one of their playlists, find it with moondog_collection_list and play it with moondog_collection_play.
6826
6916
  - When personalization matters, call moondog_profile_summary instead of assuming a profile.
6827
6917
  - Before saying how often, when, or whether the listener played something, check the listening coverage. Plays inside a gap, outside every source's span, or after recent_plays_checked_at are unknown, not zero. When a gap could hold the plays in question, lead with that boundary, for example "I have no records from August 30 to October 21", and do not conclude that the listener rarely plays it or only just found it.
6828
6918
  - The saved reading is enduring taste up to its listening date. Recent attention is the present: stored plays from the last four weeks, and Spotify's four-week top artists, which count listening on every device, including plays Moondog never stored and songs Moondog itself played or queued; stored plays count those apart as moondog_started_plays. When they disagree about what the listener plays lately, trust recent attention, and say which one you read.
@@ -7250,8 +7340,11 @@ function mergedQueueReceipt(plan, singles) {
7250
7340
  // A batch's mix line does not cover the single adds beside it.
7251
7341
  const { mix: _mix, ...base } = plan ?? { provider: "spotify", action: "playback.queue.add", state: "accepted", queued: [], unmatched: [], not_added: [] };
7252
7342
  const checks = [base.verification, ...singles.map(receipt => receipt.verification)].filter(Boolean);
7343
+ const queued = [...base.queued, ...targets];
7253
7344
  return { ...base, state: ["no_candidates", "no_playback"].includes(base.state) ? "accepted" : base.state,
7254
- queued: [...base.queued, ...targets],
7345
+ queued,
7346
+ // Single adds that filled a short batch count against its shortfall too.
7347
+ ...(Number.isInteger(base.requested) ? { shortfall: Math.max(0, base.requested - queued.length) } : {}),
7255
7348
  ...(base.device ?? singles.find(receipt => receipt.device)?.device ? { device: base.device ?? singles.find(receipt => receipt.device).device } : {}),
7256
7349
  ...(base.started_playback ? {} : singles[0]?.started_playback ? { started_playback: targets[0] } : {}),
7257
7350
  ...(checks.length ? { verification: { checked: checks.every(check => check.checked),
@@ -7915,9 +8008,21 @@ export class PiAgentRuntime {
7915
8008
  };
7916
8009
  }
7917
8010
  const state = this.activePromptState;
7918
- if (state?.spotifyQueuePlan?.action === "queue.batch" && state.spotifyQueuePlan.queued.length > 0 &&
8011
+ const batch = state?.spotifyQueuePlan?.action === "queue.batch" && state.spotifyQueuePlan.queued.length > 0 ? state.spotifyQueuePlan : null;
8012
+ // A batch that came up short may still be filled by single adds of other songs, one per
8013
+ // missing song; a song the batch queued is never added again.
8014
+ // The application counts the fills and checks each against the queue; this only lets one through.
8015
+ const shortfall = batch ? Math.max(0, (batch.requested ?? 0) - batch.queued.length) : 0;
8016
+ const fillRef = toolCall.arguments?.item_ref_id ?? toolCall.arguments?.track_ref_id;
8017
+ const fillItem = batch && descriptor.capability_id === "spotify.queue.add" && typeof fillRef === "string" && !toolCall.arguments?.pending_plan
8018
+ ? application.spotifyHostReadItems?.().find((item) => item.item_ref_id === fillRef) : null;
8019
+ // Which song is a duplicate is the application's call, by title and artist, against the queue too.
8020
+ const fillsBatch = Boolean(fillItem?.type === "track" && shortfall > 0);
8021
+ if (batch && !fillsBatch &&
7919
8022
  ["spotify.player.control", "spotify.queue.add", "spotify.queue.batch", "spotify.device.transfer", "spotify.queue.similar"].includes(descriptor.capability_id)) {
7920
- return { block: true, reason: "The requested queue batch already has a receipt. Do not add, replay or replace its writes in this turn." };
8023
+ return { block: true, reason: shortfall > 0 && descriptor.capability_id === "spotify.queue.add"
8024
+ ? "That song is already in the batch's receipt, or its reference is unknown. Add only a missing song by its item_ref_id."
8025
+ : "The requested queue batch already has a receipt. Do not add, replay or replace its writes in this turn." };
7921
8026
  }
7922
8027
  const listeningContext = application.spotifyPlaybackContextStatus?.();
7923
8028
  if (listeningContext?.queue_request && state) {
@@ -7927,7 +8032,7 @@ export class PiAgentRuntime {
7927
8032
  if (listeningContext?.queue_request?.clarification_only && descriptor.effect === "write_external") {
7928
8033
  return { block: true, reason: "The earlier queue already has an accepted, failed or uncertain receipt. A clarification about queue versus playlist must not replay it. Report the existing receipt." };
7929
8034
  }
7930
- if (listeningContext?.queue_request?.requested > 1 && descriptor.capability_id === "spotify.queue.add" && !toolCall.arguments?.pending_plan) {
8035
+ if (listeningContext?.queue_request?.requested > 1 && descriptor.capability_id === "spotify.queue.add" && !toolCall.arguments?.pending_plan && !fillsBatch) {
7931
8036
  return { block: true, reason: "Use moondog_spotify_queue_batch for this multi-song queue request so Avoid, deduplication, count and partial receipts are preserved." };
7932
8037
  }
7933
8038
  // A refused or uncertain write ends the turn's playback writes; the model explains it.
@@ -89,11 +89,27 @@ function replayableBody(input, init) {
89
89
  ArrayBuffer.isView(body) || body instanceof URLSearchParams || body instanceof Blob;
90
90
  }
91
91
 
92
+ // Statuses that say the provider is busy right now, not that the request is wrong.
93
+ const overloadStatuses = new Set([502, 503, 504, 529]);
94
+ const maximumOverloadRetries = 3;
95
+
96
+ // How long to wait before sending an overloaded request again, or null to return the response.
97
+ // A 429 is retried only when the provider asks for a short wait; a long one is usually a quota.
98
+ function overloadRetryDelay(response, retries) {
99
+ if (!response || typeof response.status !== "number") return null;
100
+ const value = response.headers?.get?.("retry-after");
101
+ const header = value === null || value === undefined || value === "" ? NaN : Number(value);
102
+ const asked = Number.isFinite(header) && header >= 0 ? header * 1000 : null;
103
+ if (response.status === 429) return asked !== null && asked <= 10_000 ? asked : null;
104
+ if (!overloadStatuses.has(response.status)) return null;
105
+ return Math.min(asked ?? 1000 * 2 ** retries, 10_000);
106
+ }
107
+
92
108
  async function retryDelay(milliseconds, signal) {
93
109
  await delay(milliseconds, undefined, { signal });
94
110
  }
95
111
 
96
- /** Recover only rejected model HTTP fetches, before a Response or stream exists. */
112
+ /** Recover rejected model HTTP fetches, and overloaded answers, before a stream exists. */
97
113
  export function createModelFetch({
98
114
  provider,
99
115
  fetchImpl = globalThis.fetch,
@@ -104,11 +120,24 @@ export function createModelFetch({
104
120
  return async (input, init) => {
105
121
  const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
106
122
  const canReplay = replayableBody(input, init);
107
- for (let attempt = 0; ; attempt += 1) {
123
+ // Connection failures and overloaded answers each have their own small budget.
124
+ let overloadAttempts = 0;
125
+ let attempt = 0;
126
+ for (;;) {
108
127
  signal?.throwIfAborted();
109
128
  try {
110
- // Status codes and response-body errors remain Pi's responsibility.
111
- return await fetchImpl(input, init);
129
+ const response = await fetchImpl(input, init);
130
+ // A provider that is overloaded answers before it produces anything, so the same request
131
+ // is sent again after a short wait. Other status codes and body errors stay Pi's.
132
+ const retryAfter = overloadRetryDelay(response, overloadAttempts);
133
+ if (canReplay && retryAfter !== null && overloadAttempts < maximumOverloadRetries) {
134
+ overloadAttempts += 1;
135
+ await response.body?.cancel().catch(() => {});
136
+ onRetry?.({ attempt: overloadAttempts, maxRetries: maximumOverloadRetries, status: response.status });
137
+ await wait(retryAfter, signal);
138
+ continue;
139
+ }
140
+ return response;
112
141
  } catch (cause) {
113
142
  if (signal?.aborted || cause?.name === "AbortError") throw cause;
114
143
  const failure = connectionFailure(cause);
@@ -116,6 +145,7 @@ export function createModelFetch({
116
145
  if (canReplay && failure.retryable && attempt < maximumRetries) {
117
146
  onRetry?.({ attempt: attempt + 1, maxRetries: maximumRetries });
118
147
  await wait(250 * 2 ** attempt, signal);
148
+ attempt += 1;
119
149
  continue;
120
150
  }
121
151
  const error = createModelConnectionError({ provider, cause });
@@ -15,8 +15,12 @@ function passes(tool, args) {
15
15
  }
16
16
  }
17
17
 
18
+ // A key that is no identifier ("/invoke", "invoke name=\"…\"") is a model's own tool-call markup
19
+ // leaking into the arguments. No tool takes such a key, so it carries no meaning to refuse.
20
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
21
+
18
22
  function withoutEmpty(args) {
19
- return Object.fromEntries(Object.entries(args).filter(([, value]) =>
23
+ return Object.fromEntries(Object.entries(args).filter(([key, value]) => IDENTIFIER.test(key) &&
20
24
  value !== null && value !== undefined && !(typeof value === "string" && !value.trim())));
21
25
  }
22
26
 
@@ -125,6 +125,7 @@ const slashCommands = [
125
125
  { name: "endpoint", description: N_("Add a model service by its address") },
126
126
  { name: "web", description: N_("Look things up on public music sites") },
127
127
  { name: "service", description: N_("Choose where Moondog plays music") },
128
+ { name: "library", description: N_("Your saved songs and playlists") },
128
129
  { name: "generation", description: N_("Let Moondog make music on this Mac") },
129
130
  { name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
130
131
  { name: "spotify", description: N_("Connect and control Spotify") },
@@ -250,7 +251,8 @@ export async function runMoondogTui({
250
251
  // /onair needs both: the room's own player and music making here.
251
252
  const onAirControls = previewControls && typeof generation?.save === "function";
252
253
  const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
253
- (name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls));
254
+ (name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls) &&
255
+ (name !== "library" || application.libraryReady?.() === true));
254
256
  const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
255
257
  let activeRuntime = runtime;
256
258
  application.startNewSession?.("tui_start");
@@ -1037,6 +1039,59 @@ export async function runMoondogTui({
1037
1039
  generationFooter(status);
1038
1040
  };
1039
1041
 
1042
+ // /library lists Saved and the listener's folders and playlists, plays one by name, or keeps the song
1043
+ // playing now, in Saved or in a playlist by name (made when there is none). The window shares this library.
1044
+ const manageLibrary = async (args) => {
1045
+ if (!application.libraryReady?.()) throw new Error(tr("The library isn't available here."));
1046
+ const [action, ...rest] = args;
1047
+ const words = rest.join(" ").trim();
1048
+ const nameOf = (playlist) => (playlist.saved ? tr("Saved") : playlist.name);
1049
+ if (!action) {
1050
+ const { folders, playlists } = application.libraryView();
1051
+ const count = (playlist) => tr.n(playlist.count, "{count} song", "{count} songs");
1052
+ const lines = [];
1053
+ const saved = playlists.find((playlist) => playlist.saved);
1054
+ if (saved) lines.push(`- **${nameOf(saved)}** · ${count(saved)}`);
1055
+ const walk = (parentId, depth) => {
1056
+ const nodes = [...folders.filter((folder) => folder.parentId === parentId).map((node) => ["folder", node]),
1057
+ ...playlists.filter((playlist) => !playlist.saved && playlist.folderId === parentId).map((node) => ["playlist", node])]
1058
+ .sort((a, b) => a[1].position - b[1].position);
1059
+ for (const [kind, node] of nodes) {
1060
+ const indent = " ".repeat(depth);
1061
+ if (kind === "folder") { lines.push(`${indent}- ${node.name}/`); walk(node.id, depth + 1); }
1062
+ else lines.push(`${indent}- **${node.name}** · ${count(node)}`);
1063
+ }
1064
+ };
1065
+ walk(null, 0);
1066
+ addMoondogMessage(`${lines.join("\n")}\n\n${tr("Play one with `/library play <name>`, or keep the song playing now with `/library save`.")}`);
1067
+ return;
1068
+ }
1069
+ if (action === "play") {
1070
+ const [playlist] = application.findLibraryPlaylists(words);
1071
+ if (!playlist || !words) throw new Error(tr("No playlist is called “{name}”.", { name: words }));
1072
+ const played = await application.playLibrary({ playlistId: playlist.id });
1073
+ if (played.state !== "playing") {
1074
+ addMoondogMessage(played.state === "empty" ? tr("{name} has nothing in it yet.", { name: nameOf(playlist) }) : tr("None of its songs could be found."));
1075
+ return;
1076
+ }
1077
+ addMoondogMessage([tr("Playing {name}.", { name: nameOf(playlist) }),
1078
+ played.left_out_made ? tr("Moondog's tracks were left out. They play with Apple Music chosen.") : "",
1079
+ played.not_found ? tr("Some songs couldn't be found.") : ""].filter(Boolean).join(" "));
1080
+ setFooter(tr("Playing {name}.", { name: nameOf(playlist) }), success);
1081
+ return;
1082
+ }
1083
+ if (action === "save") {
1084
+ const target = words ? application.findLibraryPlaylists(words).find((playlist) => playlist.name.toLocaleLowerCase() === words.toLocaleLowerCase() || (playlist.saved && /^saved$/iu.test(words))) : null;
1085
+ const saved = await application.saveToLibrary({ from: "now_playing", ...(target ? { playlistId: target.id } : words ? { playlistName: words } : {}) });
1086
+ const where = saved.playlist.saved ? tr("Saved") : saved.playlist.name;
1087
+ const text = !saved.added ? tr("Already in {name}.", { name: where }) : saved.playlist.saved ? tr("Saved.") : tr("Saved to {name}.", { name: where });
1088
+ addMoondogMessage(text);
1089
+ setFooter(text, success);
1090
+ return;
1091
+ }
1092
+ throw new Error(tr("Use /library, /library play <name>, or /library save [playlist]."));
1093
+ };
1094
+
1040
1095
  const removeLifecycleListeners = () => {
1041
1096
  signalTarget.removeListener("SIGTERM", handleSigterm);
1042
1097
  signalTarget.removeListener("SIGHUP", handleSighup);
@@ -2499,6 +2554,10 @@ export async function runMoondogTui({
2499
2554
  await manageGeneration(args);
2500
2555
  return;
2501
2556
  }
2557
+ if (command === "library") {
2558
+ await manageLibrary(args);
2559
+ return;
2560
+ }
2502
2561
  if (command === "spotify") {
2503
2562
  await runSpotifyCommand(args);
2504
2563
  return;