@audiofool/moondog 0.3.0-beta.10 → 0.3.0-beta.11
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 +29 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +10 -4
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +10 -2
- package/src/i18n/screen/es.mjs +2 -0
- package/src/i18n/screen/ja.mjs +2 -0
- package/src/i18n/screen/pt.mjs +2 -0
- package/src/i18n/screen/zh.mjs +2 -0
- package/src/integrations/playlist-links.mjs +8 -2
- package/src/integrations/public-playlists.mjs +29 -4
- 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 +35 -6
- 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/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +15 -1
|
@@ -844,6 +844,7 @@ export class ListeningHistoryStore {
|
|
|
844
844
|
#coverageSources = null;
|
|
845
845
|
#evidence = null;
|
|
846
846
|
#ledgerChanges = 0;
|
|
847
|
+
#listeningDays = null;
|
|
847
848
|
|
|
848
849
|
constructor(database) {
|
|
849
850
|
this.#database = database;
|
|
@@ -2058,6 +2059,59 @@ export class ListeningHistoryStore {
|
|
|
2058
2059
|
...(started.length ? { moondog_started_plays: started.length } : {}), top_artists: artists });
|
|
2059
2060
|
}
|
|
2060
2061
|
|
|
2062
|
+
// The listener's own plays a local day at a time, for the window's taste record: every day's plays, the
|
|
2063
|
+
// same for the most played artists, and the plays Moondog started, kept apart as recentAttention keeps
|
|
2064
|
+
// them. Counts and artist names only. Kept until the stored plays change, since a long history is many rows.
|
|
2065
|
+
listeningDays({ subjectId, artists = 8 } = {}) {
|
|
2066
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2067
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
2068
|
+
if (!Number.isSafeInteger(artists) || artists < 0 || artists > 20) throw new TypeError("Artist count is invalid");
|
|
2069
|
+
const subject = subjectId.toLowerCase();
|
|
2070
|
+
const version = `${this.listeningDataVersion()}:${subject}:${artists}`;
|
|
2071
|
+
if (this.#listeningDays?.version === version) return structuredClone(this.#listeningDays.value);
|
|
2072
|
+
const effective = `e.subject_id = ?
|
|
2073
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
|
|
2074
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
|
|
2075
|
+
const suggestions = this.#suggestionsForMatching();
|
|
2076
|
+
const since = suggestions.length ? new Date(Date.parse(suggestions[0].requested_at) - SUGGESTION_WINDOW.beforeMs).toISOString() : null;
|
|
2077
|
+
const started = !since ? [] : markMoondogStartedPlays(this.#database.prepare(`SELECT
|
|
2078
|
+
e.listening_event_id, e.record_json AS event_json, t.record_json AS track_json FROM listening_events e
|
|
2079
|
+
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
2080
|
+
WHERE ${effective} AND e.occurred_at >= ?`)
|
|
2081
|
+
.all(subject, since).map((row) => ({ id: row.listening_event_id, event: JSON.parse(row.event_json), track: JSON.parse(row.track_json) })),
|
|
2082
|
+
suggestions).rows.filter((row) => row.moondog_suggestion_id).map((row) => row.id);
|
|
2083
|
+
const own = `${effective} AND e.listening_event_id NOT IN (SELECT value FROM json_each(?))`;
|
|
2084
|
+
const left = JSON.stringify(started);
|
|
2085
|
+
const pairs = (rows) => rows.map((row) => [row.day, row.plays]);
|
|
2086
|
+
const days = this.#database.prepare(`SELECT date(e.occurred_at, 'localtime') AS day, COUNT(*) AS plays
|
|
2087
|
+
FROM listening_events e WHERE ${own} GROUP BY day ORDER BY day`).all(subject, left);
|
|
2088
|
+
let value = null;
|
|
2089
|
+
if (days.length) {
|
|
2090
|
+
const credited = `FROM listening_events e
|
|
2091
|
+
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
2092
|
+
JOIN json_each(t.record_json, '$.artist_credits') a
|
|
2093
|
+
WHERE ${own} AND json_extract(a.value, '$.name') IS NOT NULL`;
|
|
2094
|
+
const top = artists === 0 ? [] : this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays
|
|
2095
|
+
${credited} GROUP BY name ORDER BY plays DESC, name LIMIT ?`).all(subject, left, artists);
|
|
2096
|
+
const byArtist = new Map(top.map((row) => [row.name, []]));
|
|
2097
|
+
if (top.length) {
|
|
2098
|
+
for (const row of this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name,
|
|
2099
|
+
date(e.occurred_at, 'localtime') AS day, COUNT(*) AS plays
|
|
2100
|
+
${credited} AND json_extract(a.value, '$.name') IN (SELECT value FROM json_each(?))
|
|
2101
|
+
GROUP BY name, day ORDER BY day`).iterate(subject, left, JSON.stringify(top.map((artist) => artist.name)))) {
|
|
2102
|
+
byArtist.get(row.name)?.push([row.day, row.plays]);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
const moondog = started.length ? this.#database.prepare(`SELECT date(occurred_at, 'localtime') AS day, COUNT(*) AS plays
|
|
2106
|
+
FROM listening_events WHERE listening_event_id IN (SELECT value FROM json_each(?)) GROUP BY day ORDER BY day`).all(left) : [];
|
|
2107
|
+
value = { first_day: days[0].day, last_day: days.at(-1).day, days: pairs(days),
|
|
2108
|
+
artists: top.map((artist) => ({ name: artist.name, plays: artist.plays, days: byArtist.get(artist.name) })),
|
|
2109
|
+
moondog_days: pairs(moondog) };
|
|
2110
|
+
}
|
|
2111
|
+
this.#listeningDays = { version, value };
|
|
2112
|
+
return structuredClone(value);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2061
2115
|
// Which spans of time the stored plays cover, and where plays may be missing.
|
|
2062
2116
|
listeningCoverage({ subjectId } = {}) {
|
|
2063
2117
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
@@ -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"
|
|
@@ -7671,8 +7672,11 @@ export class PiAgentRuntime {
|
|
|
7671
7672
|
};
|
|
7672
7673
|
|
|
7673
7674
|
constructor({ application, models, model, provider, modelId, effort, effortLevels, thinkingLevel, modelFetch, modelRetryDelay, toolTrace = null,
|
|
7674
|
-
nameConversations = false, clock = () => new Date() }) {
|
|
7675
|
+
nameConversations = false, clock = () => new Date(), visionChoice = null }) {
|
|
7675
7676
|
this.application = application;
|
|
7677
|
+
// The vision model chosen in Settings, read at each message with images, so a change there
|
|
7678
|
+
// needs no new agent; null leaves images to the chat model.
|
|
7679
|
+
this.visionChoice = typeof visionChoice === "function" ? visionChoice : null;
|
|
7676
7680
|
// The command passes a local trace of tool calls; tests and other hosts leave it out.
|
|
7677
7681
|
this.toolTrace = toolTrace;
|
|
7678
7682
|
this.clock = clock;
|
|
@@ -8129,6 +8133,28 @@ export class PiAgentRuntime {
|
|
|
8129
8133
|
throw Object.assign(new Error("Moondog is answering in this conversation in another window or terminal."), { code: "conversation_busy" });
|
|
8130
8134
|
}
|
|
8131
8135
|
if ((this.application?.latestConversationEntryId?.() ?? null) !== this.syncedEntryId) this.restoreSession();
|
|
8136
|
+
// Attached images become text the conversation keeps, and, for a chat model that sees, the
|
|
8137
|
+
// images themselves. A vision model's description can take a few seconds, so the prompt counts
|
|
8138
|
+
// as running from here; Ctrl+C stops it.
|
|
8139
|
+
let nativeImages = [];
|
|
8140
|
+
if (callbacks.images?.length) {
|
|
8141
|
+
this.promptInFlight = true;
|
|
8142
|
+
this.imageAbort = new AbortController();
|
|
8143
|
+
try {
|
|
8144
|
+
const visionChoice = await Promise.resolve(this.visionChoice?.()).catch(() => null);
|
|
8145
|
+
callbacks.onImagesStart?.({ count: callbacks.images.length, vision: Boolean(visionChoice) });
|
|
8146
|
+
const prepared = await prepareImageInput({ text, images: callbacks.images, models: this.models, model: this.model,
|
|
8147
|
+
visionChoice, signal: this.imageAbort.signal });
|
|
8148
|
+
text = prepared.text;
|
|
8149
|
+
nativeImages = prepared.images;
|
|
8150
|
+
callbacks.onImagesReady?.({ mode: prepared.mode, text, ...(prepared.vision ? { vision: prepared.vision } : {}) });
|
|
8151
|
+
} catch (error) {
|
|
8152
|
+
this.promptInFlight = false;
|
|
8153
|
+
throw error;
|
|
8154
|
+
} finally {
|
|
8155
|
+
this.imageAbort = null;
|
|
8156
|
+
}
|
|
8157
|
+
}
|
|
8132
8158
|
// What the listener sees of the reply is saved as it grows, so the other one shows it too.
|
|
8133
8159
|
const host = callbacks;
|
|
8134
8160
|
let shownText = "";
|
|
@@ -8544,7 +8570,8 @@ export class PiAgentRuntime {
|
|
|
8544
8570
|
}
|
|
8545
8571
|
});
|
|
8546
8572
|
|
|
8547
|
-
await this.agent.prompt(text);
|
|
8573
|
+
if (nativeImages.length) await this.agent.prompt(text, nativeImages);
|
|
8574
|
+
else await this.agent.prompt(text);
|
|
8548
8575
|
// One bounded continuation keeps an explicit queue request from stopping
|
|
8549
8576
|
// at a plan/empty adjacency result or a redundant confirmation. It cannot
|
|
8550
8577
|
// grant new authority, retry a write, or outlive cancellation.
|
|
@@ -8797,6 +8824,8 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
|
|
|
8797
8824
|
}
|
|
8798
8825
|
|
|
8799
8826
|
abort() {
|
|
8827
|
+
// A vision model may still be describing the message's images.
|
|
8828
|
+
this.imageAbort?.abort();
|
|
8800
8829
|
if (this.activePromptState) {
|
|
8801
8830
|
this.activePromptState.abortRequested = true;
|
|
8802
8831
|
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;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { MODEL_ROLES, WEB_SERVICES } from "../../runtime/pi/model-roles.mjs";
|
|
2
|
+
|
|
3
|
+
// /model roles, /model vision and /model web: the terminal's side of Settings' Auxiliary models.
|
|
4
|
+
// The chat model stays /model <provider> <model>.
|
|
5
|
+
export const MODEL_ROLE_WORDS = Object.freeze({ roles: null, vision: "vision", web: "web_search", audio: "audio" });
|
|
6
|
+
|
|
7
|
+
const titles = { chat: "Chat", vision: "Vision", web_search: "Web search", audio: "Audio" };
|
|
8
|
+
const usage = "Usage: /model roles, /model vision auto|<provider> <model>, or /model web auto|deepseek|brave|codex.";
|
|
9
|
+
|
|
10
|
+
const modelName = (choice) => `${choice.name ?? choice.model} (${choice.providerName ?? choice.provider})`;
|
|
11
|
+
|
|
12
|
+
function describe(role) {
|
|
13
|
+
if (role.state !== "enabled") return "not available yet; its place is kept for a later Moondog";
|
|
14
|
+
if (role.id === "chat") return role.choice ? modelName(role.choice) : "none chosen; /model picks one";
|
|
15
|
+
if (role.kind === "service") {
|
|
16
|
+
const service = (id) => role.services?.find((entry) => entry.id === id || entry.provider === id);
|
|
17
|
+
if (role.choice) {
|
|
18
|
+
const chosen = service(role.choice.service);
|
|
19
|
+
return `${chosen?.name ?? role.choice.service}${chosen && !chosen.ready ? " (not set up; falls back to the next service)" : ""}`;
|
|
20
|
+
}
|
|
21
|
+
const used = service(role.search);
|
|
22
|
+
return used?.ready ? `auto, now ${used.name}` : "auto, but no service is set up; /web status says how";
|
|
23
|
+
}
|
|
24
|
+
if (role.choice) return modelName(role.choice);
|
|
25
|
+
return role.auto ? `auto, now ${modelName(role.auto)}` : "auto, but the chat model can't see images";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatModelRoles(roles) {
|
|
29
|
+
const lines = roles.map((role) => `- ${titles[role.id] ?? role.id}: ${describe(role)}`);
|
|
30
|
+
return `# Chat and auxiliary models\n\n${lines.join("\n")}\n\n` +
|
|
31
|
+
"`/model vision <provider> <model>` gives images a model of their own, and `/model vision auto` gives them back to the chat model when it can see.\n" +
|
|
32
|
+
"`/model web deepseek|brave|codex` picks the web search service, and `/model web auto` takes the first one set up.";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runModelRolesCommand({ args = [], modelSettings, webResearch } = {}) {
|
|
36
|
+
if (!modelSettings?.roles) throw new Error("Model roles aren't available when Moondog is started this way.");
|
|
37
|
+
const [word, ...rest] = args;
|
|
38
|
+
const roleId = MODEL_ROLE_WORDS[String(word ?? "").toLowerCase()];
|
|
39
|
+
const show = async () => formatModelRoles(await modelSettings.roles({ web: await webResearch?.services?.().catch(() => null) ?? null }));
|
|
40
|
+
if (roleId === null) {
|
|
41
|
+
if (rest.length) throw new Error(usage);
|
|
42
|
+
return show();
|
|
43
|
+
}
|
|
44
|
+
if (roleId === undefined) throw new Error(usage);
|
|
45
|
+
const role = MODEL_ROLES.find((entry) => entry.id === roleId);
|
|
46
|
+
if (role.state !== "enabled") return `${titles[roleId]} isn't available yet. Its place is kept, so a later Moondog can add it.`;
|
|
47
|
+
if (!rest.length) return show();
|
|
48
|
+
let choice;
|
|
49
|
+
if (rest.length === 1 && rest[0].toLowerCase() === "auto") choice = null;
|
|
50
|
+
else if (role.kind === "service") {
|
|
51
|
+
if (rest.length !== 1 || !WEB_SERVICES.includes(rest[0].toLowerCase())) throw new Error(usage);
|
|
52
|
+
choice = { service: rest[0].toLowerCase() };
|
|
53
|
+
} else {
|
|
54
|
+
if (rest.length !== 2) throw new Error(usage);
|
|
55
|
+
choice = { provider: rest[0], model: rest[1] };
|
|
56
|
+
}
|
|
57
|
+
await modelSettings.setRole(roleId, choice);
|
|
58
|
+
if (roleId === "web_search") await webResearch?.refresh?.();
|
|
59
|
+
return show();
|
|
60
|
+
}
|