@audiofool/moondog 0.3.0-beta.10 → 0.3.0-beta.12
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/README.md +34 -9
- package/package.json +2 -2
- package/scripts/moondog.mjs +10 -4
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +11 -0
- package/src/core/library-store.mjs +48 -4
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +105 -4
- package/src/i18n/screen/es.mjs +18 -4
- package/src/i18n/screen/ja.mjs +18 -4
- package/src/i18n/screen/pt.mjs +18 -4
- package/src/i18n/screen/zh.mjs +18 -4
- package/src/integrations/playlist-links.mjs +27 -9
- package/src/integrations/public-playlists.mjs +32 -5
- package/src/integrations/spotify/service.mjs +6 -1
- package/src/integrations/spotify/web-api-client.mjs +8 -2
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +54 -0
- package/src/runtime/pi/agent-runtime.mjs +73 -11
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/surfaces/cli/brand-components.mjs +1 -1
- package/src/surfaces/cli/command-completions.mjs +42 -3
- package/src/surfaces/cli/format-output.mjs +1 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +17 -3
|
@@ -14,6 +14,7 @@ import { spotifyErrorReason } from "../../integrations/spotify/web-api-client.mj
|
|
|
14
14
|
import { appleMusicStorefront } from "../../integrations/apple-music/storefront.mjs";
|
|
15
15
|
import { formatWebSources } from "../../surfaces/cli/web-command.mjs";
|
|
16
16
|
import { chooseConversationName } from "./conversation-name.mjs";
|
|
17
|
+
import { prepareImageInput } from "./image-input.mjs";
|
|
17
18
|
import { withArgumentRepair } from "./tool-arguments.mjs";
|
|
18
19
|
import { restateInLanguage } from "./reply-language.mjs";
|
|
19
20
|
import { chooseRecordName, RECORD_NAME_RULES } from "./record-name.mjs";
|
|
@@ -4957,11 +4958,11 @@ function createToolFactories(
|
|
|
4957
4958
|
name: descriptor.tool_name,
|
|
4958
4959
|
label: descriptor.label,
|
|
4959
4960
|
description: kind === "search"
|
|
4960
|
-
? "Search public reviews, music news, interviews and concert information. Send only a concise public query under 128 characters, never private history or profile data; site:, quoted phrases and -exclusions narrow it. Results come with source links; content_kind says whether they are search-engine snippets or a generated summary."
|
|
4961
|
-
: "Read a public HTTP(S) page. Send only its URL. The result is the page's own text
|
|
4961
|
+
? "Search public reviews, music news, interviews and concert information. Send only a concise public query under 128 characters, never private history or profile data; site:, quoted phrases and -exclusions narrow it. Returns 5 sources by default; ask for up to 10 with limit when the first ones aren't enough. Results come with source links; content_kind says whether they are search-engine snippets or a generated summary."
|
|
4962
|
+
: "Read a public HTTP(S) page. Send only its URL. The result is the page's own text or a generated summary, as content_kind says. A long page comes about 15,000 characters at a time: when next_offset is set, call again with that offset to read on. Inaccessible pages are reported; this does not log in or operate an interactive browser.",
|
|
4962
4963
|
parameters: Type.Object(kind === "search"
|
|
4963
|
-
? { query: Type.String({ minLength: 1, maxLength: 500 }) }
|
|
4964
|
-
: { url: Type.String({ minLength: 1, maxLength: 2048 }) }, { additionalProperties: false }),
|
|
4964
|
+
? { query: Type.String({ minLength: 1, maxLength: 500 }), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })) }
|
|
4965
|
+
: { url: Type.String({ minLength: 1, maxLength: 2048 }), offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 200_000 })) }, { additionalProperties: false }),
|
|
4965
4966
|
executionMode: "sequential",
|
|
4966
4967
|
execute: async (_toolCallId, parameters, signal) => executeDomain(
|
|
4967
4968
|
() => kind === "search"
|
|
@@ -6614,11 +6615,12 @@ function createToolFactories(
|
|
|
6614
6615
|
name: descriptor.tool_name,
|
|
6615
6616
|
label: descriptor.label,
|
|
6616
6617
|
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
|
+
"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, 200 at a time; offset reads on.",
|
|
6618
6619
|
parameters: Type.Object(
|
|
6619
6620
|
{
|
|
6620
6621
|
query: Type.Optional(Type.String({ maxLength: 120 })),
|
|
6621
6622
|
playlist_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
6623
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
6622
6624
|
},
|
|
6623
6625
|
{ additionalProperties: false },
|
|
6624
6626
|
),
|
|
@@ -6628,8 +6630,8 @@ function createToolFactories(
|
|
|
6628
6630
|
const playlist = await application.libraryPlaylist(parameters.playlist_id);
|
|
6629
6631
|
if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
|
|
6630
6632
|
return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, origin: playlist.origin,
|
|
6631
|
-
entries: playlist.entries.slice(0,
|
|
6632
|
-
total: playlist.entries.length } };
|
|
6633
|
+
entries: playlist.entries.slice(parameters.offset ?? 0, (parameters.offset ?? 0) + 200).map((entry) => ({ title: entry.title, artist: entry.artist_credit, ...(entry.kind === "made" ? { made_by_moondog: true } : {}) })),
|
|
6634
|
+
offset: parameters.offset ?? 0, total: playlist.entries.length } };
|
|
6633
6635
|
}
|
|
6634
6636
|
return { playlists: application.findLibraryPlaylists(parameters.query ?? "") };
|
|
6635
6637
|
},
|
|
@@ -6674,6 +6676,31 @@ function createToolFactories(
|
|
|
6674
6676
|
),
|
|
6675
6677
|
}),
|
|
6676
6678
|
],
|
|
6679
|
+
[
|
|
6680
|
+
"collection.edit",
|
|
6681
|
+
(descriptor) => ({
|
|
6682
|
+
name: descriptor.tool_name,
|
|
6683
|
+
label: descriptor.label,
|
|
6684
|
+
description:
|
|
6685
|
+
"Change one of the listener's own playlists on this Mac, only when they ask: name renames it, remove takes songs out, move puts songs before another song (before), or at the end without it, in the order given. To reorder a whole playlist, move every song in the new order. Songs go by title as listed; give the artist when two share a title. To add songs use moondog_collection_save. Say what changed; not_found names songs it did not find.",
|
|
6686
|
+
parameters: Type.Object(
|
|
6687
|
+
{
|
|
6688
|
+
playlist_id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
6689
|
+
name: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
|
|
6690
|
+
remove: Type.Optional(Type.Array(LIBRARY_SONG, { minItems: 1, maxItems: 200 })),
|
|
6691
|
+
move: Type.Optional(Type.Array(LIBRARY_SONG, { minItems: 1, maxItems: 200 })),
|
|
6692
|
+
before: Type.Optional(LIBRARY_SONG),
|
|
6693
|
+
},
|
|
6694
|
+
{ additionalProperties: false },
|
|
6695
|
+
),
|
|
6696
|
+
executionMode: "sequential",
|
|
6697
|
+
execute: executeDomain(
|
|
6698
|
+
async (_toolCallId, parameters) => application.editLibraryPlaylist({ playlistId: parameters.playlist_id, name: parameters.name,
|
|
6699
|
+
remove: parameters.remove, move: parameters.move, before: parameters.before }),
|
|
6700
|
+
(value) => structuredClone(value),
|
|
6701
|
+
),
|
|
6702
|
+
}),
|
|
6703
|
+
],
|
|
6677
6704
|
[
|
|
6678
6705
|
"collection.import",
|
|
6679
6706
|
(descriptor) => ({
|
|
@@ -6890,6 +6917,11 @@ const isOnAirRequest = (text) => {
|
|
|
6890
6917
|
};
|
|
6891
6918
|
const ON_AIR_NOTE = "The listener pressed On Air. Answer this message by starting Moondog's stream with moondog_start_stream: instrumental tracks made on this Mac from their taste and the moment, one after another, with a broad set of descriptors as that tool asks. Do not search, play or queue catalog music for it. If making music isn't available here, say so in one sentence.";
|
|
6892
6919
|
|
|
6920
|
+
const LIBRARY_SONG = Type.Object({
|
|
6921
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
6922
|
+
artist: Type.Optional(Type.String({ maxLength: 200 })),
|
|
6923
|
+
}, { additionalProperties: false });
|
|
6924
|
+
|
|
6893
6925
|
function sessionSection(application) {
|
|
6894
6926
|
return sessionAvailability({
|
|
6895
6927
|
spotifyReady: application.spotifyReady?.() ?? false,
|
|
@@ -6941,7 +6973,7 @@ Public web research rules:
|
|
|
6941
6973
|
|
|
6942
6974
|
Grounding and evidence rules:
|
|
6943
6975
|
- Before naming or selecting tracks as present in the user's library, call moondog_library_search.
|
|
6944
|
-
- 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. When they paste a playlist link and ask to import or add it, use moondog_collection_import.
|
|
6976
|
+
- 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. To rename one, take songs out or reorder it, use moondog_collection_edit. When they paste a playlist link and ask to import or add it, use moondog_collection_import.
|
|
6945
6977
|
- When personalization matters, call moondog_profile_summary instead of assuming a profile.
|
|
6946
6978
|
- 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.
|
|
6947
6979
|
- 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.
|
|
@@ -7671,8 +7703,11 @@ export class PiAgentRuntime {
|
|
|
7671
7703
|
};
|
|
7672
7704
|
|
|
7673
7705
|
constructor({ application, models, model, provider, modelId, effort, effortLevels, thinkingLevel, modelFetch, modelRetryDelay, toolTrace = null,
|
|
7674
|
-
nameConversations = false, clock = () => new Date() }) {
|
|
7706
|
+
nameConversations = false, clock = () => new Date(), visionChoice = null }) {
|
|
7675
7707
|
this.application = application;
|
|
7708
|
+
// The vision model chosen in Settings, read at each message with images, so a change there
|
|
7709
|
+
// needs no new agent; null leaves images to the chat model.
|
|
7710
|
+
this.visionChoice = typeof visionChoice === "function" ? visionChoice : null;
|
|
7676
7711
|
// The command passes a local trace of tool calls; tests and other hosts leave it out.
|
|
7677
7712
|
this.toolTrace = toolTrace;
|
|
7678
7713
|
this.clock = clock;
|
|
@@ -8114,7 +8149,7 @@ export class PiAgentRuntime {
|
|
|
8114
8149
|
// A finished reply keeps only its text, so the transcript, not this agent, says what was declared.
|
|
8115
8150
|
#sessionUpdate(messages) {
|
|
8116
8151
|
// An On Air reply carries the host's note in its session section; the next reply drops it again.
|
|
8117
|
-
const session = [sessionSection(this.application), this.onAirTurn ? ON_AIR_NOTE : null].filter(Boolean).join("\n\n") || null;
|
|
8152
|
+
const session = [sessionSection(this.application), this.onAirTurn ? ON_AIR_NOTE : null, this.libraryNote].filter(Boolean).join("\n\n") || null;
|
|
8118
8153
|
if ((getCurrentSystemMessage(messages)?.sections?.session ?? null) === session) return [];
|
|
8119
8154
|
return [{ role: "system", content: "", sections: { session }, timestamp: Date.now() }];
|
|
8120
8155
|
}
|
|
@@ -8129,6 +8164,28 @@ export class PiAgentRuntime {
|
|
|
8129
8164
|
throw Object.assign(new Error("Moondog is answering in this conversation in another window or terminal."), { code: "conversation_busy" });
|
|
8130
8165
|
}
|
|
8131
8166
|
if ((this.application?.latestConversationEntryId?.() ?? null) !== this.syncedEntryId) this.restoreSession();
|
|
8167
|
+
// Attached images become text the conversation keeps, and, for a chat model that sees, the
|
|
8168
|
+
// images themselves. A vision model's description can take a few seconds, so the prompt counts
|
|
8169
|
+
// as running from here; Ctrl+C stops it.
|
|
8170
|
+
let nativeImages = [];
|
|
8171
|
+
if (callbacks.images?.length) {
|
|
8172
|
+
this.promptInFlight = true;
|
|
8173
|
+
this.imageAbort = new AbortController();
|
|
8174
|
+
try {
|
|
8175
|
+
const visionChoice = await Promise.resolve(this.visionChoice?.()).catch(() => null);
|
|
8176
|
+
callbacks.onImagesStart?.({ count: callbacks.images.length, vision: Boolean(visionChoice) });
|
|
8177
|
+
const prepared = await prepareImageInput({ text, images: callbacks.images, models: this.models, model: this.model,
|
|
8178
|
+
visionChoice, signal: this.imageAbort.signal });
|
|
8179
|
+
text = prepared.text;
|
|
8180
|
+
nativeImages = prepared.images;
|
|
8181
|
+
callbacks.onImagesReady?.({ mode: prepared.mode, text, ...(prepared.vision ? { vision: prepared.vision } : {}) });
|
|
8182
|
+
} catch (error) {
|
|
8183
|
+
this.promptInFlight = false;
|
|
8184
|
+
throw error;
|
|
8185
|
+
} finally {
|
|
8186
|
+
this.imageAbort = null;
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
8132
8189
|
// What the listener sees of the reply is saved as it grows, so the other one shows it too.
|
|
8133
8190
|
const host = callbacks;
|
|
8134
8191
|
let shownText = "";
|
|
@@ -8153,6 +8210,8 @@ export class PiAgentRuntime {
|
|
|
8153
8210
|
this.#refreshSurface();
|
|
8154
8211
|
this.loopSurfaceVersion = this.surfaceVersion;
|
|
8155
8212
|
this.onAirTurn = isOnAirRequest(text);
|
|
8213
|
+
// What this conversation's messages name with @, read fresh for this reply.
|
|
8214
|
+
this.libraryNote = this.application?.libraryMentionNote?.([...this.agent.state.messages.filter((message) => message.role === "user").map(extractMessageText), text]) ?? null;
|
|
8156
8215
|
const sessionUpdate = this.#sessionUpdate(this.agent.state.messages);
|
|
8157
8216
|
if (sessionUpdate.length) this.agent.state.messages = [...this.agent.state.messages, ...sessionUpdate];
|
|
8158
8217
|
const historyStartIndex = this.agent.state.messages.length;
|
|
@@ -8544,7 +8603,8 @@ export class PiAgentRuntime {
|
|
|
8544
8603
|
}
|
|
8545
8604
|
});
|
|
8546
8605
|
|
|
8547
|
-
await this.agent.prompt(text);
|
|
8606
|
+
if (nativeImages.length) await this.agent.prompt(text, nativeImages);
|
|
8607
|
+
else await this.agent.prompt(text);
|
|
8548
8608
|
// One bounded continuation keeps an explicit queue request from stopping
|
|
8549
8609
|
// at a plan/empty adjacency result or a redundant confirmation. It cannot
|
|
8550
8610
|
// grant new authority, retry a write, or outlive cancellation.
|
|
@@ -8797,6 +8857,8 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
|
|
|
8797
8857
|
}
|
|
8798
8858
|
|
|
8799
8859
|
abort() {
|
|
8860
|
+
// A vision model may still be describing the message's images.
|
|
8861
|
+
this.imageAbort?.abort();
|
|
8800
8862
|
if (this.activePromptState) {
|
|
8801
8863
|
this.activePromptState.abortRequested = true;
|
|
8802
8864
|
this.activePromptState.restatement?.abort();
|
|
@@ -2,6 +2,7 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
|
2
2
|
|
|
3
3
|
import { PiAgentRuntime } from "./agent-runtime.mjs";
|
|
4
4
|
import { createPiAuthContext } from "./authentication.mjs";
|
|
5
|
+
import { readModelRoles } from "./model-roles.mjs";
|
|
5
6
|
import { getPublicPiModelCatalog } from "./public-model-catalog.mjs";
|
|
6
7
|
import { createPersistentCredentialStore } from "./persistent-credential-store.mjs";
|
|
7
8
|
import { clampEffort, EFFORT_LEVELS, readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
@@ -110,5 +111,7 @@ export async function createConfiguredRuntime(
|
|
|
110
111
|
effortLevels: getSupportedThinkingLevels(model),
|
|
111
112
|
thinkingLevel: effort ? clampEffort(model, effort) : undefined,
|
|
112
113
|
...(toolTrace ? { toolTrace } : {}),
|
|
114
|
+
// Read at each message with images: the vision model from Settings, or null for the chat model.
|
|
115
|
+
visionChoice: async () => (await readModelRoles(environment).catch(() => ({}))).vision ?? null,
|
|
113
116
|
});
|
|
114
117
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
+
|
|
7
|
+
// Images the listener attaches to a message, after Hermes Agent's image routing
|
|
8
|
+
// (agent/image_routing.py, image_input_mode "auto"):
|
|
9
|
+
//
|
|
10
|
+
// - A vision model chosen in Settings describes each image first ("text"): its words go into the
|
|
11
|
+
// message, and the chat model reads them. A chosen auxiliary model wins over the chat model, as in
|
|
12
|
+
// Hermes, so the listener can keep a cheap chat model and a good eye.
|
|
13
|
+
// - Otherwise a chat model that takes images gets them as they are ("native").
|
|
14
|
+
// - Otherwise nothing can see them, and the message is refused with what to change.
|
|
15
|
+
//
|
|
16
|
+
// Either way the conversation keeps only text: a short stand-in for a native image, or the vision
|
|
17
|
+
// model's description, so a conversation picked up later still knows what was shown.
|
|
18
|
+
|
|
19
|
+
export const IMAGE_LIMITS = Object.freeze({
|
|
20
|
+
count: 4,
|
|
21
|
+
// Base64 characters of one image, about 6 MB of image; the window scales larger ones down first.
|
|
22
|
+
dataChars: 8_000_000,
|
|
23
|
+
mimeTypes: Object.freeze(["image/png", "image/jpeg", "image/webp", "image/gif"]),
|
|
24
|
+
nameChars: 120,
|
|
25
|
+
// Conversation entries hold at most 8,000 characters, the listener's own words first.
|
|
26
|
+
entryChars: 8_000,
|
|
27
|
+
descriptionChars: 1_600,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const VISION_PROMPT = [
|
|
31
|
+
"You describe an image for another model, which answers a music listener in Moondog and cannot see it.",
|
|
32
|
+
"Read any text exactly as written: track lists, artist and album names, playlists, setlists, posters,",
|
|
33
|
+
"streaming app screenshots, chat messages. Name album covers, artists, instruments or places you can recognise,",
|
|
34
|
+
"and say how sure you are when you guess. Describe mood and setting briefly.",
|
|
35
|
+
"Write plain text without a preamble, under 180 words.",
|
|
36
|
+
].join(" ");
|
|
37
|
+
|
|
38
|
+
function failure(code, message) {
|
|
39
|
+
return Object.assign(new Error(message), { code });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const takesImages = (model) => Array.isArray(model?.input) && model.input.includes("image");
|
|
43
|
+
|
|
44
|
+
/** The attached images as Pi takes them, checked; an empty list when there are none. */
|
|
45
|
+
export function normalizeImages(images) {
|
|
46
|
+
if (images === undefined || images === null) return [];
|
|
47
|
+
if (!Array.isArray(images)) throw failure("image_input_invalid", "Images must come as a list.");
|
|
48
|
+
if (images.length > IMAGE_LIMITS.count) throw failure("image_input_too_many", `Attach at most ${IMAGE_LIMITS.count} images to one message.`);
|
|
49
|
+
return images.map((image, index) => {
|
|
50
|
+
const mimeType = String(image?.mimeType ?? "").toLowerCase();
|
|
51
|
+
const data = typeof image?.data === "string" ? image.data.replace(/^data:[^,]*,/u, "") : "";
|
|
52
|
+
if (!IMAGE_LIMITS.mimeTypes.includes(mimeType)) throw failure("image_input_unsupported", "Moondog takes PNG, JPEG, WebP and GIF images.");
|
|
53
|
+
if (!data || data.length > IMAGE_LIMITS.dataChars || !/^[A-Za-z0-9+/]+={0,2}$/u.test(data)) {
|
|
54
|
+
throw failure("image_input_too_large", "That image is too large or unreadable. Try a smaller one.");
|
|
55
|
+
}
|
|
56
|
+
const name = typeof image?.name === "string" && image.name.trim()
|
|
57
|
+
? image.name.trim().replace(/[\p{C}[\]]/gu, "").slice(0, IMAGE_LIMITS.nameChars) : `image ${index + 1}`;
|
|
58
|
+
return { type: "image", data, mimeType, name };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Local image paths in a terminal message, as Hermes Agent's gateway finds them: an absolute or ~/
|
|
63
|
+
// path ending in an image extension, quoted when it has spaces, or with the "\ " a Finder drag writes.
|
|
64
|
+
const EXTENSION_TYPES = Object.freeze({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" });
|
|
65
|
+
const QUOTED_PATH = /(["'])((?:~\/|\/)[^"'\n]+?\.(?:png|jpe?g|webp|gif))\1/giu;
|
|
66
|
+
const BARE_PATH = /(?<![\w./:])((?:~\/|\/)(?:\\ |[^\s"'])+?\.(?:png|jpe?g|webp|gif))(?=[\s,.;:!?)]*(?:\s|$))/giu;
|
|
67
|
+
|
|
68
|
+
/** The image files a message names, read from disk; paths that aren't readable images are left as words. */
|
|
69
|
+
export async function imagesFromText(text, { readFile = readFileFromDisk, home = os.homedir() } = {}) {
|
|
70
|
+
const found = [];
|
|
71
|
+
const quoted = String(text ?? "").replace(QUOTED_PATH, (_, _quote, file) => { found.push(file); return " "; });
|
|
72
|
+
for (const match of quoted.matchAll(BARE_PATH)) found.push(match[1].replace(/\\ /gu, " "));
|
|
73
|
+
const images = [];
|
|
74
|
+
for (const written of [...new Set(found)].slice(0, IMAGE_LIMITS.count)) {
|
|
75
|
+
const file = written.startsWith("~/") ? path.join(home, written.slice(2)) : written;
|
|
76
|
+
let bytes;
|
|
77
|
+
try { bytes = await readFile(file); } catch { continue; }
|
|
78
|
+
const mimeType = EXTENSION_TYPES[path.extname(file).slice(1).toLowerCase()];
|
|
79
|
+
const data = Buffer.from(bytes).toString("base64");
|
|
80
|
+
if (data.length > IMAGE_LIMITS.dataChars) {
|
|
81
|
+
throw failure("image_input_too_large", `${path.basename(file)} is too large to send. Try a smaller copy or a screenshot of part of it.`);
|
|
82
|
+
}
|
|
83
|
+
images.push({ data, mimeType, name: path.basename(file) });
|
|
84
|
+
}
|
|
85
|
+
return images;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const modelLabel = (model) => model?.name ?? model?.id ?? "the vision model";
|
|
89
|
+
|
|
90
|
+
/** Native, text through a vision model, or a refusal; the vision model is resolved here. */
|
|
91
|
+
export async function chooseImageRoute({ models, model, visionChoice }) {
|
|
92
|
+
if (visionChoice?.provider && visionChoice?.model) {
|
|
93
|
+
const vision = models.getModel(visionChoice.provider, visionChoice.model);
|
|
94
|
+
if (!vision || !takesImages(vision)) {
|
|
95
|
+
throw failure("vision_model_unavailable", "The vision model chosen in Settings can't see images any more. Choose another under Model > Auxiliary models > Vision.");
|
|
96
|
+
}
|
|
97
|
+
if (!(await models.checkAuth?.(visionChoice.provider))) {
|
|
98
|
+
throw failure("vision_model_signed_out", `${modelLabel(vision)} needs a sign-in before it can see images. Add its key in Settings > Model.`);
|
|
99
|
+
}
|
|
100
|
+
return { mode: "text", vision };
|
|
101
|
+
}
|
|
102
|
+
if (takesImages(model)) return { mode: "native" };
|
|
103
|
+
throw failure("vision_model_missing",
|
|
104
|
+
`${modelLabel(model)} can't see images. Choose a model that can under Settings > Model > Auxiliary models > Vision, or switch to one that sees.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function describe({ models, vision, image, text, signal }) {
|
|
108
|
+
const lightest = clampThinkingLevel(vision, "off");
|
|
109
|
+
const message = await models.completeSimple(vision, {
|
|
110
|
+
systemPrompt: VISION_PROMPT,
|
|
111
|
+
messages: [{ role: "user", timestamp: Date.now(), content: [
|
|
112
|
+
{ type: "text", text: text ? `The listener's message, for context: ${text.slice(0, 1_000)}` : "The listener sent this image without words." },
|
|
113
|
+
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
114
|
+
] }],
|
|
115
|
+
}, { maxTokens: 900, maxRetries: 1, signal, ...(lightest !== "off" ? { reasoning: lightest } : {}) });
|
|
116
|
+
if (!message || message.stopReason === "aborted") throw failure("vision_cancelled", "Stopped describing the image.");
|
|
117
|
+
if (message.stopReason === "error") throw failure("vision_failed", `${modelLabel(vision)} couldn't describe the image. Try again in a moment.`);
|
|
118
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("").replace(/\s+/gu, " ").trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const clip = (value, limit) => (value.length > limit ? `${value.slice(0, limit - 1).trimEnd()}…` : value);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The message as the conversation keeps it, and the images the chat model gets with it (only in
|
|
125
|
+
* native mode). Without images it returns the text unchanged.
|
|
126
|
+
*/
|
|
127
|
+
export async function prepareImageInput({ text, images, models, model, visionChoice, signal }) {
|
|
128
|
+
const attached = normalizeImages(images);
|
|
129
|
+
if (!attached.length) return { text, images: [], mode: null };
|
|
130
|
+
const route = await chooseImageRoute({ models, model, visionChoice });
|
|
131
|
+
const words = String(text ?? "").trim();
|
|
132
|
+
const room = Math.max(400, IMAGE_LIMITS.entryChars - words.length - 40);
|
|
133
|
+
const each = Math.min(IMAGE_LIMITS.descriptionChars, Math.floor(room / attached.length) - 60);
|
|
134
|
+
const notes = [];
|
|
135
|
+
for (const [index, image] of attached.entries()) {
|
|
136
|
+
const label = attached.length > 1 ? `Image ${index + 1} (${image.name})` : `Image (${image.name})`;
|
|
137
|
+
if (route.mode === "native") { notes.push(`[${label} attached]`); continue; }
|
|
138
|
+
const seen = await describe({ models, vision: route.vision, image, text: words, signal });
|
|
139
|
+
notes.push(`[${label}, as ${modelLabel(route.vision)} describes it: ${clip(seen || "nothing it could make out", Math.max(80, each))}]`);
|
|
140
|
+
}
|
|
141
|
+
const kept = [words, notes.join("\n")].filter(Boolean).join("\n\n");
|
|
142
|
+
return {
|
|
143
|
+
text: kept.slice(0, IMAGE_LIMITS.entryChars),
|
|
144
|
+
images: route.mode === "native" ? attached.map(({ type, data, mimeType }) => ({ type, data, mimeType })) : [],
|
|
145
|
+
mode: route.mode,
|
|
146
|
+
...(route.vision ? { vision: { provider: route.vision.provider, model: route.vision.id, name: modelLabel(route.vision) } } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
|
|
6
|
+
|
|
7
|
+
// What each model is for, after Hermes Agent's main model and auxiliary tasks: one chat model answers,
|
|
8
|
+
// and a side task can have a model of its own. A role without a saved choice is "auto": vision uses the
|
|
9
|
+
// chat model when it can see images, and web search takes the first service that is set up.
|
|
10
|
+
// The chat model stays in settings.json, which older Moondogs read with a fixed shape; the other
|
|
11
|
+
// roles live in model-roles.json beside it.
|
|
12
|
+
//
|
|
13
|
+
// kind "model" is a provider and model from the catalog; kind "service" is a web service by id.
|
|
14
|
+
// A "planned" role holds its place in the settings and in this file, but nothing can choose it yet.
|
|
15
|
+
export const MODEL_ROLES = Object.freeze([
|
|
16
|
+
Object.freeze({ id: "chat", kind: "model", state: "enabled", stored: "settings" }),
|
|
17
|
+
Object.freeze({ id: "vision", kind: "model", state: "enabled", input: "image" }),
|
|
18
|
+
Object.freeze({ id: "web_search", kind: "service", state: "enabled" }),
|
|
19
|
+
Object.freeze({ id: "audio", kind: "model", state: "planned", input: "audio" }),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export const WEB_SERVICES = Object.freeze(["deepseek", "brave", "codex"]);
|
|
23
|
+
|
|
24
|
+
const ROLES_VERSION = 1;
|
|
25
|
+
const printable = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{C}\s]/u.test(value);
|
|
26
|
+
|
|
27
|
+
function rolesError(message, cause) {
|
|
28
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
29
|
+
error.name = "MoondogRuntimeSettingsError";
|
|
30
|
+
error.code = "model_roles_invalid";
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const modelRole = (id) => MODEL_ROLES.find((role) => role.id === id) ?? null;
|
|
35
|
+
|
|
36
|
+
/** One role's choice in its saved shape, or null for auto. Throws on a shape the role can't take. */
|
|
37
|
+
export function normalizeRoleChoice(roleId, value) {
|
|
38
|
+
const role = modelRole(roleId);
|
|
39
|
+
if (!role || role.stored) throw rolesError(`Moondog has no model role called ${roleId}.`);
|
|
40
|
+
if (value === null || value === undefined || value === "auto") return null;
|
|
41
|
+
if (role.kind === "service") {
|
|
42
|
+
const service = typeof value?.service === "string" ? value.service.trim().toLowerCase() : "";
|
|
43
|
+
if (!WEB_SERVICES.includes(service)) throw rolesError(`Web search can use ${WEB_SERVICES.join(", ")} or auto.`);
|
|
44
|
+
return { service };
|
|
45
|
+
}
|
|
46
|
+
const provider = typeof value?.provider === "string" ? value.provider.trim().toLowerCase() : "";
|
|
47
|
+
const model = typeof value?.model === "string" ? value.model.trim() : "";
|
|
48
|
+
if (!printable(provider, 80) || !printable(model, 200)) throw rolesError(`The ${roleId} role needs a provider and a model.`);
|
|
49
|
+
return { provider, model, ...(typeof value.effort === "string" && printable(value.effort, 20) ? { effort: value.effort } : {}) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveModelRolesFile(environment = process.env) {
|
|
53
|
+
return path.join(path.dirname(resolveMoondogAuthFile(environment)), "model-roles.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every saved role as it is in the file, including roles a newer Moondog wrote, so a write here
|
|
57
|
+
// never drops them.
|
|
58
|
+
async function readDocument(environment) {
|
|
59
|
+
let source;
|
|
60
|
+
try {
|
|
61
|
+
source = await readFile(resolveModelRolesFile(environment), "utf8");
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === "ENOENT") return {};
|
|
64
|
+
throw rolesError("Moondog could not read its model roles.", error);
|
|
65
|
+
}
|
|
66
|
+
let document;
|
|
67
|
+
try {
|
|
68
|
+
document = JSON.parse(source);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw rolesError("Moondog's model roles are not valid JSON.", error);
|
|
71
|
+
}
|
|
72
|
+
if (!document || typeof document !== "object" || document.version !== ROLES_VERSION ||
|
|
73
|
+
!document.roles || typeof document.roles !== "object" || Array.isArray(document.roles)) {
|
|
74
|
+
throw rolesError("Moondog's model roles use an unsupported shape.");
|
|
75
|
+
}
|
|
76
|
+
return document.roles;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The saved choice for each role this Moondog knows, by role id; a role left on auto is absent. */
|
|
80
|
+
export async function readModelRoles(environment = process.env) {
|
|
81
|
+
const saved = await readDocument(environment);
|
|
82
|
+
const roles = {};
|
|
83
|
+
for (const role of MODEL_ROLES) {
|
|
84
|
+
if (role.stored || role.state !== "enabled" || saved[role.id] === undefined) continue;
|
|
85
|
+
// One bad entry leaves that role on auto instead of failing every role.
|
|
86
|
+
try {
|
|
87
|
+
const choice = normalizeRoleChoice(role.id, saved[role.id]);
|
|
88
|
+
if (choice) roles[role.id] = choice;
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return roles;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One change at a time, so two quick choices never lose either.
|
|
95
|
+
let writing = Promise.resolve();
|
|
96
|
+
/** Saves one role's choice; null puts it back on auto. */
|
|
97
|
+
export function writeModelRole(roleId, value, environment = process.env) {
|
|
98
|
+
const role = modelRole(roleId);
|
|
99
|
+
if (!role || role.stored) return Promise.reject(rolesError(`Moondog has no model role called ${roleId}.`));
|
|
100
|
+
if (role.state !== "enabled") return Promise.reject(rolesError(`The ${roleId} role isn't available yet.`));
|
|
101
|
+
let choice;
|
|
102
|
+
try { choice = normalizeRoleChoice(roleId, value); } catch (error) { return Promise.reject(error); }
|
|
103
|
+
const next = writing.then(async () => {
|
|
104
|
+
const roles = { ...(await readDocument(environment).catch(() => ({}))) };
|
|
105
|
+
if (choice) roles[roleId] = choice;
|
|
106
|
+
else delete roles[roleId];
|
|
107
|
+
const file = resolveModelRolesFile(environment);
|
|
108
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
109
|
+
// Another Moondog may read the roles at any moment, so it never sees half a file.
|
|
110
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
111
|
+
try {
|
|
112
|
+
await writeFile(temporary, `${JSON.stringify({ version: ROLES_VERSION, roles }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
113
|
+
await rename(temporary, file);
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(temporary, { force: true });
|
|
116
|
+
}
|
|
117
|
+
return choice;
|
|
118
|
+
});
|
|
119
|
+
writing = next.catch(() => {});
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { isEndpointProvider, removeEndpoint, saveEndpoint } from "./custom-endpoints.mjs";
|
|
10
10
|
import { LEGACY_MODEL_ALIASES } from "./model-catalog.mjs";
|
|
11
11
|
import { readConversationModels, readModelList, sameModel, writeConversationModel, writeModelList } from "./model-list.mjs";
|
|
12
|
+
import { MODEL_ROLES, modelRole, readModelRoles, writeModelRole } from "./model-roles.mjs";
|
|
12
13
|
import { chatgptProviderId } from "./provider-registry.mjs";
|
|
13
14
|
import { createPersistentCredentialStore } from "./persistent-credential-store.mjs";
|
|
14
15
|
import { getPublicPiModelCatalog } from "./public-model-catalog.mjs";
|
|
@@ -22,6 +23,8 @@ import { clampEffort, readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
|
22
23
|
// key, then Anthropic, Google and xAI. Any other service is reached as an endpoint by address; Pi's
|
|
23
24
|
// other providers show only once a key for one is saved or in the environment.
|
|
24
25
|
const FEATURED = ["openai", "anthropic", "google", "xai"];
|
|
26
|
+
// Whether a catalog model takes an input besides text, such as "image".
|
|
27
|
+
const takes = (model, input) => Boolean(input) && Array.isArray(model?.input) && model.input.includes(input);
|
|
25
28
|
// Moondog's own errors carry fixed text. Pi wraps them, and its own text may not be safe.
|
|
26
29
|
const moondogErrorNames = new Set(["MoondogCredentialStoreError", "MoondogRuntimeSettingsError"]);
|
|
27
30
|
|
|
@@ -183,13 +186,71 @@ export function createModelSettings({
|
|
|
183
186
|
const model = models.getModel(entry.provider, entry.model);
|
|
184
187
|
return { provider: entry.provider, model: entry.model, name: model?.name ?? entry.model,
|
|
185
188
|
providerName: names.get(entry.provider) ?? entry.provider, ready: Boolean(model) && ready.get(entry.provider) === true,
|
|
186
|
-
reasoning: thinks(model) };
|
|
189
|
+
reasoning: thinks(model), vision: takes(model, "image") };
|
|
187
190
|
}),
|
|
188
191
|
default: selection ? { provider: selection.provider, model: selection.model, effort: selection.effort ?? null,
|
|
189
192
|
reasoning: thinks(defaultModel), thinking: selection.effort && defaultModel ? clampEffort(defaultModel, selection.effort) : null } : null,
|
|
190
193
|
};
|
|
191
194
|
},
|
|
192
195
|
|
|
196
|
+
// What each model is for: the chat model from settings.json, then each other role's choice from
|
|
197
|
+
// model-roles.json, null for auto. Vision on auto uses the chat model when it can see images.
|
|
198
|
+
// web is the web research's services(), which knows the web services; Settings passes it in.
|
|
199
|
+
async roles({ web = null } = {}) {
|
|
200
|
+
const selection = await current();
|
|
201
|
+
let saved;
|
|
202
|
+
try {
|
|
203
|
+
saved = await readModelRoles(environment);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw moondogError(error) ?? failure("model_roles_invalid", "Moondog could not read its model roles.");
|
|
206
|
+
}
|
|
207
|
+
const source = await catalog();
|
|
208
|
+
let models;
|
|
209
|
+
try {
|
|
210
|
+
models = withAuth(source);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw moondogError(error) ?? failure("auth_status_failed", "Moondog could not read its saved sign-ins.");
|
|
213
|
+
}
|
|
214
|
+
const names = new Map(source.providers().map(provider => [provider.id, provider.name]));
|
|
215
|
+
const describe = (choice) => {
|
|
216
|
+
const model = models.getModel(choice.provider, choice.model);
|
|
217
|
+
return { provider: choice.provider, model: choice.model, name: model?.name ?? choice.model,
|
|
218
|
+
providerName: names.get(choice.provider) ?? choice.provider, known: Boolean(model), vision: takes(model, "image") };
|
|
219
|
+
};
|
|
220
|
+
const chat = selection ? describe(selection) : null;
|
|
221
|
+
return MODEL_ROLES.map((role) => {
|
|
222
|
+
const base = { id: role.id, kind: role.kind, state: role.state, ...(role.input ? { input: role.input } : {}) };
|
|
223
|
+
if (role.id === "chat") return { ...base, choice: chat };
|
|
224
|
+
if (role.id === "web_search") {
|
|
225
|
+
return { ...base, choice: saved.web_search ?? null,
|
|
226
|
+
...(web ? { services: web.services, search: web.search?.provider ?? null, read: web.read?.provider ?? null } : {}) };
|
|
227
|
+
}
|
|
228
|
+
if (role.state !== "enabled") return { ...base, choice: null };
|
|
229
|
+
// Hermes Agent's auxiliary vision on auto: the main model when it takes images, else none.
|
|
230
|
+
return { ...base, choice: saved[role.id] ? describe(saved[role.id]) : null,
|
|
231
|
+
auto: chat && takes(models.getModel(selection.provider, selection.model), role.input) ? chat : null };
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// Saves one role's choice; null puts it on auto. A model must be in the catalog and take the
|
|
236
|
+
// input its role is for.
|
|
237
|
+
async setRole(roleId, choice) {
|
|
238
|
+
const role = modelRole(String(roleId ?? ""));
|
|
239
|
+
if (!role || role.stored) throw failure("model_role_unknown", "Moondog has no such model role.");
|
|
240
|
+
if (role.state !== "enabled") throw failure("model_role_unavailable", "That model role isn't available yet.");
|
|
241
|
+
if (role.kind === "model" && choice) {
|
|
242
|
+
const provider = String(choice.provider ?? "").trim().toLowerCase();
|
|
243
|
+
const model = (await catalog()).createModels().getModel(provider, String(choice.model ?? "").trim());
|
|
244
|
+
if (!model) throw failure("model_unknown", "Moondog doesn't know that model.");
|
|
245
|
+
if (!takes(model, role.input)) throw failure("model_role_unsuitable", `That model can't take ${role.input} input.`);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return await writeModelRole(role.id, choice ?? null, environment);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
throw moondogError(error) ?? failure("model_roles_invalid", "Moondog could not save its model roles.");
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
|
|
193
254
|
// The model each conversation was given in the window's menu, and a change to one: null gives a
|
|
194
255
|
// conversation back to the default.
|
|
195
256
|
conversationModels: () => readConversationModels(environment),
|
|
@@ -262,7 +323,7 @@ export function createModelSettings({
|
|
|
262
323
|
refresh,
|
|
263
324
|
models: source.models(providerId).filter(({ id }) => !LEGACY_MODEL_ALIASES.includes(id)).map(({ id, name, contextWindow, reasoning, catalogNote }) => {
|
|
264
325
|
const model = full.getModel(providerId, id);
|
|
265
|
-
return { id, name, ...(contextWindow ? { contextWindow } : {}), reasoning,
|
|
326
|
+
return { id, name, ...(contextWindow ? { contextWindow } : {}), reasoning, vision: takes(model, "image"),
|
|
266
327
|
effortLevels: model ? getSupportedThinkingLevels(model) : [],
|
|
267
328
|
...(catalogNote ? { retained: true } : {}) };
|
|
268
329
|
}),
|
|
@@ -109,11 +109,13 @@ export async function writePiRuntimeSelection(
|
|
|
109
109
|
// saved selection, or undefined when none is saved; the surface decides whether to rebuild, with
|
|
110
110
|
// runtimeBehindSelection.
|
|
111
111
|
// The second argument says which file changed: "settings" (the default), "auth" (a sign-in),
|
|
112
|
-
// "endpoints" (an endpoint added, edited or removed)
|
|
112
|
+
// "endpoints" (an endpoint added, edited or removed), "models" (the window's menu list) or "roles"
|
|
113
|
+
// (a vision model or web service chosen in model-roles.json).
|
|
113
114
|
export function watchRuntimeSettings({ environment = process.env, interval = 2000, onChange } = {}) {
|
|
114
115
|
const directory = path.dirname(resolveMoondogAuthFile(environment));
|
|
115
116
|
const files = { settings: resolveMoondogSettingsFile(environment), auth: resolveMoondogAuthFile(environment),
|
|
116
|
-
endpoints: path.join(directory, "endpoints.json"), models: path.join(directory, "models.json")
|
|
117
|
+
endpoints: path.join(directory, "endpoints.json"), models: path.join(directory, "models.json"),
|
|
118
|
+
roles: path.join(directory, "model-roles.json") };
|
|
117
119
|
const listeners = Object.entries(files).map(([changed, file]) => {
|
|
118
120
|
const listener = (now, before) => {
|
|
119
121
|
if (now.mtimeMs === before.mtimeMs) return;
|
|
@@ -564,5 +564,5 @@ function wrapDescription(text, width) {
|
|
|
564
564
|
}
|
|
565
565
|
|
|
566
566
|
export function listeningHelp() {
|
|
567
|
-
return tr("# Your listening room\n\n## 01 / Listen and discover\n\nJust type to talk about music, ask for a playlist, or go exploring.\nTo talk, Moondog needs a model: `/auth` signs you in, then `/model` picks one.\nWhile an answer is coming, each step shows in the conversation, and the footer names the one in progress.\n\n- `/taste` - your profile: open any song or artist to see why it's there\n- `/taste report` - the whole picture on one page\n- `/import` - bring in your history, your library, or a playlist\n- `/lyrics` - the lyrics on the sleeve; `/lyrics sync` finds more\n- `/service` - choose where I play music: Spotify, Apple Music, or NetEase\n- `/spotify` - play, queue, and make playlists in Spotify\n- Ask to move playback onto your iPhone, computer, or another Spotify device by name\n- `/web search <query>` or `/web read <url>` - look things up on public music sites\n\n## 02 / Make it yours\n\n- `/profile` - the same profile as `/taste`\n- `/profile corrections` - everything you've told me\n- `/profile retract <id>` - undo one of those\n- `/profile help` - the full set of profile commands\n- `/remember <text>`, `/memory`, `/forget <id>` - what I remember between conversations\n\n## 03 / Around the room\n\n- `/home` - back to the record sleeve; the conversation stays\n- `/theme paper|charcoal|terminal|auto` - change the look\n- `/art braille|ascii|off` - change the artwork\n- `/motion on|off` - turn the animation on or off\n- `/commands` or Ctrl+P - search every command; your message stays put\n- Page Up and Page Down scroll the conversation. The header and your draft stay put\n- Up and Down in the draft recall what you typed in this conversation\n- `/resume` - pick up a saved conversation (type to filter, Enter opens)\n- `/new` - start fresh; this conversation stays saved\n- `/status`, `/sources`, `/tools`, `/doctor` - see what's connected and working\n- `/reload` - reload model settings and sign-ins\n- `/update [--check] [--channel latest|beta]` - check for a release, or close this room and install it\n- `/help all` - every command, including the ones for the shell\n- `/language` - choose the language Moondog uses\n- `/quit` - leave\n\nTab moves to the menu on the home screen. Esc goes back to typing.\nEnter sends, and Shift+Enter starts a new line.\nTab or Enter accepts a suggestion.\nCtrl+C stops a model or web request, or leaves when nothing is running.\nIn the profile, type to filter, Tab switches lists, and Enter shows what you can do.\nCtrl+R refreshes it and Ctrl+O opens the full report.\nYour profile works without a model.\nPut quotes around paths, titles, and names that have spaces.");
|
|
567
|
+
return tr("# Your listening room\n\n## 01 / Listen and discover\n\nJust type to talk about music, ask for a playlist, or go exploring.\nTo talk, Moondog needs a model: `/auth` signs you in, then `/model` picks one.\nWhile an answer is coming, each step shows in the conversation, and the footer names the one in progress.\n\n- `/taste` - your profile: open any song or artist to see why it's there\n- `/taste report` - the whole picture on one page\n- `/import` - bring in your history, your library, or a playlist\n- `/library` - your saved songs and playlists\n- `/generation on|off` - let Moondog make music on this Mac, or stop\n- `/onair` - go on air, or come off it\n- `/lyrics` - the lyrics on the sleeve; `/lyrics sync` finds more\n- `/service` - choose where I play music: Spotify, Apple Music, or NetEase\n- `/spotify` - play, queue, and make playlists in Spotify\n- Ask to move playback onto your iPhone, computer, or another Spotify device by name\n- `/web search <query>` or `/web read <url>` - look things up on public music sites\n\n## 02 / Make it yours\n\n- `/profile` - the same profile as `/taste`\n- `/profile corrections` - everything you've told me\n- `/profile retract <id>` - undo one of those\n- `/profile help` - the full set of profile commands\n- `/remember <text>`, `/memory`, `/forget <id>` - what I remember between conversations\n\n## 03 / Around the room\n\n- `/home` - back to the record sleeve; the conversation stays\n- `/theme paper|charcoal|terminal|auto` - change the look\n- `/art braille|ascii|off` - change the artwork\n- `/motion on|off` - turn the animation on or off\n- `/commands` or Ctrl+P - search every command; your message stays put\n- Page Up and Page Down scroll the conversation. The header and your draft stay put\n- Up and Down in the draft recall what you typed in this conversation\n- `/resume` - pick up a saved conversation (type to filter, Enter opens)\n- `/new` - start fresh; this conversation stays saved\n- `/rewind` or Alt+R - edit an earlier message in place; the messages after it are replaced\n- `/branch` - go on from here in a new conversation; this one stays saved\n- `/status`, `/sources`, `/tools`, `/doctor` - see what's connected and working\n- `/reload` - reload model settings and sign-ins\n- `/model roles` - see what side tasks use: the model that reads images and the web search service\n- `/effort` - choose how hard the model thinks\n- `/endpoint add` or `/endpoint remove <id>` - add a model service by its address, or remove it\n- `/update [--check] [--channel latest|beta]` - check for a release, or close this room and install it\n- `/help all` - every command, including the ones for the shell\n- `/language` - choose the language Moondog uses\n- `/quit` - leave\n\nTab moves to the menu on the home screen. Esc goes back to typing.\nEnter sends, and Shift+Enter starts a new line.\nTab or Enter accepts a suggestion.\nCtrl+C stops a model or web request, or leaves when nothing is running.\nIn the profile, type to filter, Tab switches lists, and Enter shows what you can do.\nCtrl+R refreshes it and Ctrl+O opens the full report.\nYour profile works without a model.\nPut quotes around paths, titles, and names that have spaces.");
|
|
568
568
|
}
|