@audiofool/moondog 0.2.0-beta.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +1 -1
- package/scripts/moondog.mjs +30 -6
- package/src/core/application-update.mjs +30 -18
- package/src/core/first-run.mjs +53 -0
- package/src/i18n/screen/es.mjs +62 -4
- package/src/i18n/screen/ja.mjs +62 -4
- package/src/i18n/screen/pt.mjs +62 -4
- package/src/i18n/screen/zh.mjs +62 -4
- package/src/integrations/netease/ncm-cli-player.mjs +29 -13
- package/src/memory/reflection-worker.mjs +1 -1
- package/src/runtime/pi/authentication.mjs +31 -10
- package/src/runtime/pi/configured-runtime.mjs +3 -3
- package/src/runtime/pi/custom-endpoints.mjs +229 -0
- package/src/runtime/pi/model-catalog.mjs +7 -3
- package/src/runtime/pi/model-list.mjs +122 -0
- package/src/runtime/pi/model-settings.mjs +131 -20
- package/src/runtime/pi/model-transport.mjs +2 -2
- package/src/runtime/pi/persistent-credential-store.mjs +31 -0
- package/src/runtime/pi/provider-registry.mjs +3 -9
- package/src/runtime/pi/public-model-catalog.mjs +12 -4
- package/src/runtime/pi/runtime-selection.mjs +9 -0
- package/src/runtime/pi/runtime-settings.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +25 -56
- package/src/surfaces/cli/brand-components.mjs +1 -1
- package/src/surfaces/cli/command-completions.mjs +14 -0
- package/src/surfaces/cli/endpoint-command.mjs +106 -0
- package/src/surfaces/cli/first-run-view.mjs +138 -0
- package/src/surfaces/cli/format-output.mjs +1 -1
- package/src/surfaces/cli/tui.mjs +320 -33
|
@@ -6,15 +6,22 @@ import {
|
|
|
6
6
|
createPiAuthentication,
|
|
7
7
|
supportedAuthProviderIds,
|
|
8
8
|
} from "./authentication.mjs";
|
|
9
|
+
import { isEndpointProvider, removeEndpoint, saveEndpoint } from "./custom-endpoints.mjs";
|
|
10
|
+
import { LEGACY_MODEL_ALIASES } from "./model-catalog.mjs";
|
|
11
|
+
import { readConversationModels, readModelList, sameModel, writeConversationModel, writeModelList } from "./model-list.mjs";
|
|
12
|
+
import { chatgptProviderId } from "./provider-registry.mjs";
|
|
9
13
|
import { createPersistentCredentialStore } from "./persistent-credential-store.mjs";
|
|
10
14
|
import { getPublicPiModelCatalog } from "./public-model-catalog.mjs";
|
|
11
|
-
import { readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
15
|
+
import { clampEffort, readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
12
16
|
|
|
13
17
|
// The desktop window picks models and signs in through this service, as the
|
|
14
18
|
// terminal does with /model and /auth. It returns status shapes only: never a
|
|
15
19
|
// credential, a prompt answer or raw provider text.
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
// The providers every listener is offered, in this order: OpenAI by a ChatGPT subscription or an API
|
|
22
|
+
// key, then Anthropic, Google and xAI. Any other service is reached as an endpoint by address; Pi's
|
|
23
|
+
// other providers show only once a key for one is saved or in the environment.
|
|
24
|
+
const FEATURED = ["openai", "anthropic", "google", "xai"];
|
|
18
25
|
// Moondog's own errors carry fixed text. Pi wraps them, and its own text may not be safe.
|
|
19
26
|
const moondogErrorNames = new Set(["MoondogCredentialStoreError", "MoondogRuntimeSettingsError"]);
|
|
20
27
|
|
|
@@ -33,7 +40,7 @@ function moondogError(error) {
|
|
|
33
40
|
const aborted = () => new DOMException("The sign-in was cancelled.", "AbortError");
|
|
34
41
|
|
|
35
42
|
function signInKind(providerId) {
|
|
36
|
-
if (providerId
|
|
43
|
+
if (isEndpointProvider(providerId)) return "endpoint";
|
|
37
44
|
return supportedAuthProviderIds.includes(providerId) ? "api_key" : "environment_only";
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -88,25 +95,23 @@ export function createModelSettings({
|
|
|
88
95
|
}
|
|
89
96
|
};
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const priority = provider => provider.id === currentProvider ? 0 : provider.id === OAUTH_PROVIDER ? 1 : 2;
|
|
97
|
-
return priority(left) - priority(right) || left.name.localeCompare(right.name);
|
|
98
|
-
});
|
|
98
|
+
const listed = (source) => source.providers().filter(provider => provider.modelCount > 0);
|
|
99
|
+
const withAuth = (source) => source.createModels({
|
|
100
|
+
credentials: createPersistentCredentialStore(environment),
|
|
101
|
+
authContext: createPiAuthContext(environment),
|
|
102
|
+
});
|
|
99
103
|
|
|
100
104
|
const rows = async (source, providers) => {
|
|
101
105
|
try {
|
|
102
|
-
const models = source
|
|
103
|
-
credentials: createPersistentCredentialStore(environment),
|
|
104
|
-
authContext: createPiAuthContext(environment),
|
|
105
|
-
});
|
|
106
|
+
const models = withAuth(source);
|
|
106
107
|
return await Promise.all(providers.map(async ({ id, name, modelCount }) => {
|
|
107
108
|
// checkAuth reads only local state. It never refreshes OAuth or calls a provider.
|
|
108
109
|
const auth = await models.checkAuth(id);
|
|
109
|
-
|
|
110
|
+
const endpoint = source.endpoints?.find(entry => entry.id === id);
|
|
111
|
+
// OpenAI takes a ChatGPT sign-in as well as a key; method says which one it has.
|
|
112
|
+
return { id, name, modelCount, signIn: signInKind(id), signedIn: Boolean(auth), source: signInSource(auth),
|
|
113
|
+
...(id === chatgptProviderId ? { chatgpt: true } : {}), ...(auth ? { method: auth.type === "oauth" ? "oauth" : "api_key" } : {}),
|
|
114
|
+
...(endpoint ? { endpoint: { format: endpoint.format, baseUrl: endpoint.baseUrl, keySaved: Boolean(endpoint.key), models: [...endpoint.models] } } : {}) };
|
|
110
115
|
}));
|
|
111
116
|
} catch (error) {
|
|
112
117
|
throw moondogError(error) ?? failure("auth_status_failed", "Moondog could not read its saved sign-ins.");
|
|
@@ -118,6 +123,20 @@ export function createModelSettings({
|
|
|
118
123
|
return (await rows(source, listed(source).filter(provider => provider.id === providerId)))[0];
|
|
119
124
|
};
|
|
120
125
|
|
|
126
|
+
// The models the window's menu offers: the saved list, or the default alone until the listener
|
|
127
|
+
// changes it. The default is always among them.
|
|
128
|
+
const menu = async (selection) => {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readModelList(environment);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw moondogError(error) ?? failure("model_list_invalid", "Moondog could not read its model list.");
|
|
134
|
+
}
|
|
135
|
+
entries ??= [];
|
|
136
|
+
if (selection && !entries.some(entry => sameModel(entry, selection))) entries = [{ provider: selection.provider, model: selection.model }, ...entries];
|
|
137
|
+
return entries;
|
|
138
|
+
};
|
|
139
|
+
|
|
121
140
|
const assertSignInProvider = (providerId) => {
|
|
122
141
|
if (!supportedAuthProviderIds.includes(providerId)) {
|
|
123
142
|
throw failure("auth_provider_unsupported", "Moondog can't save a sign-in for this provider. Set its API key in the environment instead.");
|
|
@@ -127,10 +146,101 @@ export function createModelSettings({
|
|
|
127
146
|
return {
|
|
128
147
|
current,
|
|
129
148
|
|
|
149
|
+
// Settings' providers: the featured ones in their order, then any other provider that has a key or
|
|
150
|
+
// is in use, by name, then the listener's endpoints in the order they were added.
|
|
130
151
|
async providers({ current: currentProvider } = {}) {
|
|
131
152
|
currentProvider ??= (await current().catch(() => null))?.provider;
|
|
132
153
|
const source = await catalog();
|
|
133
|
-
|
|
154
|
+
const all = await rows(source, listed(source));
|
|
155
|
+
const featured = FEATURED.map(id => all.find(provider => provider.id === id)).filter(Boolean);
|
|
156
|
+
const others = all.filter(provider => !FEATURED.includes(provider.id) && provider.signIn !== "endpoint" &&
|
|
157
|
+
(provider.signedIn || provider.id === currentProvider)).sort((left, right) => left.name.localeCompare(right.name));
|
|
158
|
+
const endpoints = (source.endpoints ?? []).map(endpoint => all.find(provider => provider.id === endpoint.id)).filter(Boolean);
|
|
159
|
+
return [...featured, ...others, ...endpoints];
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// The menu's models, each with its provider's name and whether it can answer now, and the default
|
|
163
|
+
// with the thinking level it asks for and the one its model gets.
|
|
164
|
+
async list() {
|
|
165
|
+
const selection = await current();
|
|
166
|
+
const source = await catalog();
|
|
167
|
+
const entries = await menu(selection);
|
|
168
|
+
let models;
|
|
169
|
+
try {
|
|
170
|
+
models = withAuth(source);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw moondogError(error) ?? failure("auth_status_failed", "Moondog could not read its saved sign-ins.");
|
|
173
|
+
}
|
|
174
|
+
const names = new Map(source.providers().map(provider => [provider.id, provider.name]));
|
|
175
|
+
const ready = new Map();
|
|
176
|
+
for (const provider of new Set(entries.map(entry => entry.provider))) {
|
|
177
|
+
ready.set(provider, Boolean(await models.checkAuth(provider).catch(() => undefined)));
|
|
178
|
+
}
|
|
179
|
+
const thinks = model => Boolean(model) && getSupportedThinkingLevels(model).length > 1;
|
|
180
|
+
const defaultModel = selection && models.getModel(selection.provider, selection.model);
|
|
181
|
+
return {
|
|
182
|
+
models: entries.map(entry => {
|
|
183
|
+
const model = models.getModel(entry.provider, entry.model);
|
|
184
|
+
return { provider: entry.provider, model: entry.model, name: model?.name ?? entry.model,
|
|
185
|
+
providerName: names.get(entry.provider) ?? entry.provider, ready: Boolean(model) && ready.get(entry.provider) === true,
|
|
186
|
+
reasoning: thinks(model) };
|
|
187
|
+
}),
|
|
188
|
+
default: selection ? { provider: selection.provider, model: selection.model, effort: selection.effort ?? null,
|
|
189
|
+
reasoning: thinks(defaultModel), thinking: selection.effort && defaultModel ? clampEffort(defaultModel, selection.effort) : null } : null,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// The model each conversation was given in the window's menu, and a change to one: null gives a
|
|
194
|
+
// conversation back to the default.
|
|
195
|
+
conversationModels: () => readConversationModels(environment),
|
|
196
|
+
setConversationModel: (session, selection) => writeConversationModel(String(session), selection ?? null, environment),
|
|
197
|
+
|
|
198
|
+
// Adds a model to the menu or takes it off. The default stays.
|
|
199
|
+
async setListed(provider, model, listed) {
|
|
200
|
+
const selection = await current();
|
|
201
|
+
const entry = { provider: String(provider ?? "").trim().toLowerCase(), model: String(model ?? "").trim() };
|
|
202
|
+
if (!listed && sameModel(entry, selection)) return;
|
|
203
|
+
const entries = await menu(selection);
|
|
204
|
+
const next = listed ? (entries.some(kept => sameModel(kept, entry)) ? entries : [...entries, entry])
|
|
205
|
+
: entries.filter(kept => !sameModel(kept, entry));
|
|
206
|
+
try {
|
|
207
|
+
await writeModelList(next, environment);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw moondogError(error) ?? failure("model_list_invalid", "Moondog could not save its model list.");
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// A new endpoint whose model IDs were typed goes straight into the menu with them; listed ones
|
|
214
|
+
// wait to be chosen, since an endpoint such as OpenRouter lists hundreds.
|
|
215
|
+
async saveEndpoint(input) {
|
|
216
|
+
let saved;
|
|
217
|
+
try {
|
|
218
|
+
saved = await saveEndpoint(input, environment);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw moondogError(error) ?? failure("endpoint_save_failed", "Moondog could not save that endpoint.");
|
|
221
|
+
}
|
|
222
|
+
if (!input?.id && saved.typedModels) {
|
|
223
|
+
const entries = await menu(await current());
|
|
224
|
+
const added = saved.endpoint.models.map(model => ({ provider: saved.endpoint.id, model }));
|
|
225
|
+
await writeModelList([...entries, ...added.filter(entry => !entries.some(kept => sameModel(kept, entry)))].slice(0, 40), environment);
|
|
226
|
+
}
|
|
227
|
+
await (await catalog()).readEndpoints?.();
|
|
228
|
+
return saved.endpoint;
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Removes an endpoint, its key and its models from the menu. A default on it stays saved, and asks
|
|
232
|
+
// for another model until one is chosen.
|
|
233
|
+
async removeEndpoint(id) {
|
|
234
|
+
try {
|
|
235
|
+
await removeEndpoint(id, environment);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw moondogError(error) ?? failure("endpoint_remove_failed", "Moondog could not remove that endpoint.");
|
|
238
|
+
}
|
|
239
|
+
const selection = await current();
|
|
240
|
+
const entries = await menu(null);
|
|
241
|
+
const kept = entries.filter(entry => entry.provider !== id || sameModel(entry, selection));
|
|
242
|
+
if (kept.length !== entries.length) await writeModelList(kept, environment);
|
|
243
|
+
await (await catalog()).readEndpoints?.();
|
|
134
244
|
},
|
|
135
245
|
|
|
136
246
|
async models(providerId, { force = false, signal } = {}) {
|
|
@@ -150,7 +260,7 @@ export function createModelSettings({
|
|
|
150
260
|
return {
|
|
151
261
|
provider: providerId,
|
|
152
262
|
refresh,
|
|
153
|
-
models: source.models(providerId).map(({ id, name, contextWindow, reasoning, catalogNote }) => {
|
|
263
|
+
models: source.models(providerId).filter(({ id }) => !LEGACY_MODEL_ALIASES.includes(id)).map(({ id, name, contextWindow, reasoning, catalogNote }) => {
|
|
154
264
|
const model = full.getModel(providerId, id);
|
|
155
265
|
return { id, name, ...(contextWindow ? { contextWindow } : {}), reasoning,
|
|
156
266
|
effortLevels: model ? getSupportedThinkingLevels(model) : [],
|
|
@@ -159,7 +269,8 @@ export function createModelSettings({
|
|
|
159
269
|
};
|
|
160
270
|
},
|
|
161
271
|
|
|
162
|
-
|
|
272
|
+
// method "oauth" signs in to OpenAI with ChatGPT; anything else asks for a key.
|
|
273
|
+
async signIn(providerId, interaction, { method } = {}) {
|
|
163
274
|
assertSignInProvider(providerId);
|
|
164
275
|
if (typeof interaction?.prompt !== "function" || typeof interaction.notify !== "function") {
|
|
165
276
|
throw failure("auth_interaction_invalid", "Sign-in needs a way to ask and to show progress.");
|
|
@@ -180,7 +291,7 @@ export function createModelSettings({
|
|
|
180
291
|
if (!shape) return;
|
|
181
292
|
try { interaction.notify(shape); } catch { /* A closed window cannot stop the sign-in. */ }
|
|
182
293
|
},
|
|
183
|
-
});
|
|
294
|
+
}, { method: method === "oauth" ? "oauth" : "api_key" });
|
|
184
295
|
} catch (error) {
|
|
185
296
|
const safe = wrapLoginError(signal?.aborted ? aborted() : moondogError(error) ?? error, providerId);
|
|
186
297
|
throw failure(safe.code, safe.message);
|
|
@@ -25,7 +25,7 @@ export function safeProviderErrorMessage(value, provider) {
|
|
|
25
25
|
const message = typeof value === "string" && value.trim()
|
|
26
26
|
? value.trim() : "The model provider request failed.";
|
|
27
27
|
if (credentialBearingErrorPattern.test(message)) {
|
|
28
|
-
return `${provider === "openai
|
|
28
|
+
return `${provider === "openai" ? "OpenAI" : provider} authentication failed. Run moondog auth login ${provider}${provider === "openai" ? " --chatgpt, or with an API key," : ""} and try again.`;
|
|
29
29
|
}
|
|
30
30
|
return message;
|
|
31
31
|
}
|
|
@@ -66,7 +66,7 @@ export function isModelConnectionFailure(value) {
|
|
|
66
66
|
|
|
67
67
|
export function createModelConnectionError({ provider, cause, toolsExecuted = false }) {
|
|
68
68
|
const label = ({
|
|
69
|
-
|
|
69
|
+
openai: "OpenAI", anthropic: "Anthropic",
|
|
70
70
|
deepseek: "DeepSeek", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI China",
|
|
71
71
|
xai: "xAI", zai: "Z.AI",
|
|
72
72
|
})[provider] ?? (/^[a-z0-9][a-z0-9._-]{0,63}$/iu.test(provider ?? "") ? provider : "The model provider");
|
|
@@ -21,6 +21,7 @@ const LOCK_TIMEOUT_MS = 30_000;
|
|
|
21
21
|
// API keys for services other than models, such as Brave Search for web lookups.
|
|
22
22
|
export const serviceApiKeyIds = Object.freeze(["brave"]);
|
|
23
23
|
const apiKeyProviderIds = Object.freeze([...apiKeyPiProviderIds, ...serviceApiKeyIds]);
|
|
24
|
+
// openai-codex is no longer offered, but a store that still holds its sign-in keeps loading.
|
|
24
25
|
const SUPPORTED_CREDENTIAL_PROVIDERS = new Set([
|
|
25
26
|
"openai-codex",
|
|
26
27
|
"spotify",
|
|
@@ -105,6 +106,33 @@ function validateOpenAICodexCredential(value) {
|
|
|
105
106
|
return structuredClone(value);
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// Sign in with ChatGPT, through Pi's OpenAI provider: a token for api.openai.com and its refresh.
|
|
110
|
+
function validateOpenAIChatGPTCredential(value) {
|
|
111
|
+
assertPlainRecord(value, "openai credential");
|
|
112
|
+
assertExactKeys(
|
|
113
|
+
value,
|
|
114
|
+
["access", "clientId", "expires", "refresh", "type", ...(value.scopes === undefined ? [] : ["scopes"])],
|
|
115
|
+
"openai credential shape",
|
|
116
|
+
);
|
|
117
|
+
assertSecretString(value.access, "OAuth access token");
|
|
118
|
+
assertSecretString(value.refresh, "OAuth refresh token");
|
|
119
|
+
assertSecretString(value.clientId, "OAuth client identifier");
|
|
120
|
+
if (!Number.isFinite(value.expires) || value.expires <= 0) {
|
|
121
|
+
throw safeError(
|
|
122
|
+
"credential_store_corrupt",
|
|
123
|
+
"Moondog credential storage contains an invalid OAuth expiry.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (value.scopes !== undefined && (!Array.isArray(value.scopes) || value.scopes.length > 50 ||
|
|
127
|
+
value.scopes.some((scope) => typeof scope !== "string" || !/^[\x21-\x7e]{1,200}$/u.test(scope)))) {
|
|
128
|
+
throw safeError(
|
|
129
|
+
"credential_store_corrupt",
|
|
130
|
+
"Moondog credential storage contains invalid OAuth scopes.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return structuredClone(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
function validateSpotifyCredential(value) {
|
|
109
137
|
assertPlainRecord(value, "spotify credential");
|
|
110
138
|
assertExactKeys(
|
|
@@ -173,6 +201,9 @@ function validateCredential(providerId, value) {
|
|
|
173
201
|
if (providerId === "spotify") {
|
|
174
202
|
return validateSpotifyCredential(value);
|
|
175
203
|
}
|
|
204
|
+
if (providerId === "openai" && value?.type === "oauth") {
|
|
205
|
+
return validateOpenAIChatGPTCredential(value);
|
|
206
|
+
}
|
|
176
207
|
if (apiKeyProviderIds.includes(providerId)) {
|
|
177
208
|
assertPlainRecord(value, `${providerId} credential`);
|
|
178
209
|
assertExactKeys(value, ["type", "key"], `${providerId} credential shape`);
|
|
@@ -35,12 +35,6 @@ const providerLoaders = {
|
|
|
35
35
|
);
|
|
36
36
|
return openaiProvider();
|
|
37
37
|
},
|
|
38
|
-
"openai-codex": async () => {
|
|
39
|
-
const { openaiCodexProvider } = await import(
|
|
40
|
-
"@earendil-works/pi-ai/providers/openai-codex"
|
|
41
|
-
);
|
|
42
|
-
return openaiCodexProvider();
|
|
43
|
-
},
|
|
44
38
|
openrouter: async () => {
|
|
45
39
|
const { openrouterProvider } = await import(
|
|
46
40
|
"@earendil-works/pi-ai/providers/openrouter"
|
|
@@ -63,9 +57,9 @@ export const supportedPiProviderIds = Object.freeze(
|
|
|
63
57
|
Object.keys(providerLoaders),
|
|
64
58
|
);
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
// Every provider takes a key; OpenAI also takes Sign in with ChatGPT.
|
|
61
|
+
export const apiKeyPiProviderIds = supportedPiProviderIds;
|
|
62
|
+
export const chatgptProviderId = "openai";
|
|
69
63
|
|
|
70
64
|
export async function loadPiProvider(providerId) {
|
|
71
65
|
if (!Object.hasOwn(providerLoaders, providerId)) return undefined;
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { addEndpoints, readEndpoints } from "./custom-endpoints.mjs";
|
|
5
6
|
import { createPiModels, listPiModels, listPiProviders } from "./model-catalog.mjs";
|
|
6
7
|
import { acquirePrivateFileLock, resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
|
|
7
8
|
import { supportedPiProviderIds } from "./provider-registry.mjs";
|
|
@@ -159,15 +160,21 @@ async function writeCache(root, file, document, signal) {
|
|
|
159
160
|
export class PublicPiModelCatalog {
|
|
160
161
|
constructor({ environment = process.env, fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = 4000 } = {}) {
|
|
161
162
|
this.root = path.join(path.dirname(resolveMoondogAuthFile(environment)), "model-catalog");
|
|
163
|
+
this.environment = environment;
|
|
162
164
|
this.fetch = fetchImpl; this.now = now; this.timeoutMs = timeoutMs;
|
|
163
|
-
this.baseline = createPiModels(); this.records = new Map(); this.pending = new Set();
|
|
165
|
+
this.baseline = createPiModels(); this.records = new Map(); this.pending = new Set(); this.endpoints = [];
|
|
164
166
|
this.snapshot = this.baseline;
|
|
165
167
|
this.ready = this.restore();
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
file(id) { return path.join(this.root, `${id}.json`); }
|
|
169
171
|
publish() { this.snapshot = this.createModels(); }
|
|
170
|
-
|
|
172
|
+
// The listener's own endpoints join Pi's providers; their models are theirs, not from pi.dev.
|
|
173
|
+
createModels(options) { return addEndpoints(createPiModels({ ...options, catalogModels: new Map([...this.records].map(([id, record]) => [id, record.models])) }), this.endpoints); }
|
|
174
|
+
async readEndpoints() {
|
|
175
|
+
try { this.endpoints = await readEndpoints(this.environment); } catch { this.endpoints = []; }
|
|
176
|
+
this.publish();
|
|
177
|
+
}
|
|
171
178
|
providers() { return listPiProviders(this.snapshot); }
|
|
172
179
|
models(id) {
|
|
173
180
|
const record = this.records.get(id);
|
|
@@ -277,12 +284,13 @@ export async function getPublicPiModelCatalog(environment = process.env) {
|
|
|
277
284
|
const key = path.dirname(resolveMoondogAuthFile(environment));
|
|
278
285
|
if (!catalogs.has(key)) {
|
|
279
286
|
const catalog = new PublicPiModelCatalog({ environment });
|
|
280
|
-
catalogs.set(key, catalog); await catalog.ready; return catalog;
|
|
287
|
+
catalogs.set(key, catalog); await catalog.ready; await catalog.readEndpoints(); return catalog;
|
|
281
288
|
}
|
|
282
289
|
const catalog = catalogs.get(key);
|
|
283
290
|
await catalog.ready;
|
|
284
|
-
// Another running app can persist a new selection
|
|
291
|
+
// Another running app can persist a new selection, its catalog entry or an endpoint.
|
|
285
292
|
// Reload public cache only; do not fetch or touch the active model object.
|
|
286
293
|
await catalog.restore();
|
|
294
|
+
await catalog.readEndpoints();
|
|
287
295
|
return catalog;
|
|
288
296
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Whether an agent built earlier is behind the saved choice: another model or effort, or an agent
|
|
2
|
+
// that could not answer then, which a sign-in since may let answer. Token refreshes rewrite
|
|
3
|
+
// auth.json often, so a working agent on the saved model is kept. Kept apart from the settings
|
|
4
|
+
// file, so the window code the project page runs in the browser can use it.
|
|
5
|
+
export function runtimeBehindSelection(status, selection) {
|
|
6
|
+
if (status?.state !== "configured") return true;
|
|
7
|
+
return status.provider !== selection?.provider || status.model !== selection?.model ||
|
|
8
|
+
(status.effort ?? undefined) !== selection?.effort;
|
|
9
|
+
}
|
|
@@ -1,11 +1,27 @@
|
|
|
1
|
+
import { unwatchFile, watchFile } from "node:fs";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
5
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
6
|
+
|
|
4
7
|
import { resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
|
|
5
8
|
|
|
6
9
|
const SETTINGS_VERSION = 1;
|
|
7
10
|
// Pi's thinking levels. Each model supports a subset; the runtime clamps to it.
|
|
8
11
|
export const EFFORT_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
12
|
+
// The scale the window and /effort offer for every model that thinks.
|
|
13
|
+
export const EFFORT_SCALE = Object.freeze(["off", "low", "medium", "high", "xhigh", "max"]);
|
|
14
|
+
|
|
15
|
+
// A level the model lacks steps down to the nearest one it has, so a choice never costs more than it
|
|
16
|
+
// said, but never down to no thinking; with nothing below, it takes the lightest level the model has.
|
|
17
|
+
export function clampEffort(model, level) {
|
|
18
|
+
const supported = getSupportedThinkingLevels(model);
|
|
19
|
+
if (supported.includes(level)) return level;
|
|
20
|
+
const at = EFFORT_LEVELS.indexOf(level);
|
|
21
|
+
for (let index = at - 1; index > 0; index -= 1) if (supported.includes(EFFORT_LEVELS[index])) return EFFORT_LEVELS[index];
|
|
22
|
+
for (let index = Math.max(at + 1, 1); index < EFFORT_LEVELS.length; index += 1) if (supported.includes(EFFORT_LEVELS[index])) return EFFORT_LEVELS[index];
|
|
23
|
+
return supported[0] ?? "off";
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
function settingsError(message, cause) {
|
|
11
27
|
const error = new Error(message, cause ? { cause } : undefined);
|
|
@@ -87,3 +103,24 @@ export async function writePiRuntimeSelection(
|
|
|
87
103
|
);
|
|
88
104
|
return normalized;
|
|
89
105
|
}
|
|
106
|
+
|
|
107
|
+
// The terminal and the window each build their agent from settings.json and auth.json. A model
|
|
108
|
+
// chosen, or a sign-in made, on one side reaches the other through this watch. onChange hears the
|
|
109
|
+
// saved selection, or undefined when none is saved; the surface decides whether to rebuild, with
|
|
110
|
+
// runtimeBehindSelection.
|
|
111
|
+
// The second argument says which file changed: "settings" (the default), "auth" (a sign-in),
|
|
112
|
+
// "endpoints" (an endpoint added, edited or removed) or "models" (the window's menu list).
|
|
113
|
+
export function watchRuntimeSettings({ environment = process.env, interval = 2000, onChange } = {}) {
|
|
114
|
+
const directory = path.dirname(resolveMoondogAuthFile(environment));
|
|
115
|
+
const files = { settings: resolveMoondogSettingsFile(environment), auth: resolveMoondogAuthFile(environment),
|
|
116
|
+
endpoints: path.join(directory, "endpoints.json"), models: path.join(directory, "models.json") };
|
|
117
|
+
const listeners = Object.entries(files).map(([changed, file]) => {
|
|
118
|
+
const listener = (now, before) => {
|
|
119
|
+
if (now.mtimeMs === before.mtimeMs) return;
|
|
120
|
+
void readPiRuntimeSelection(environment).then((selection) => onChange?.(selection, changed), () => {});
|
|
121
|
+
};
|
|
122
|
+
watchFile(file, { interval, persistent: false }, listener);
|
|
123
|
+
return [file, listener];
|
|
124
|
+
});
|
|
125
|
+
return () => { for (const [file, listener] of listeners) unwatchFile(file, listener); };
|
|
126
|
+
}
|
|
@@ -8,22 +8,22 @@ import {
|
|
|
8
8
|
} from "../../runtime/pi/authentication.mjs";
|
|
9
9
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
10
10
|
|
|
11
|
-
const DEFAULT_AUTH_PROVIDER = "openai
|
|
11
|
+
const DEFAULT_AUTH_PROVIDER = "openai";
|
|
12
12
|
|
|
13
13
|
export function authHelpText() {
|
|
14
14
|
return `# Moondog authentication
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
17
|
|
|
18
|
-
- \`moondog auth login openai
|
|
19
|
-
- \`moondog auth login openai-codex --device-code\` - use headless device login
|
|
18
|
+
- \`moondog auth login openai --chatgpt\` - sign in with your ChatGPT subscription in the browser
|
|
20
19
|
- \`moondog auth login <provider>\` - save an API key using hidden terminal input
|
|
21
20
|
- \`moondog auth status [provider] [--json]\` - inspect saved or environment credentials
|
|
22
21
|
- \`moondog auth logout <provider> [--json]\` - remove that provider's saved credential
|
|
23
22
|
|
|
24
|
-
API key providers: ${supportedAuthProviderIds.filter((id) =>
|
|
23
|
+
API key providers: ${supportedAuthProviderIds.filter((id) => !serviceKeyProviders.some((service) => service.id === id)).join(", ")}.
|
|
25
24
|
Web search keys: ${serviceKeyProviders.map((service) => `${service.id} for ${service.name} (or ${service.variable})`).join(", ")}; a deepseek key also searches and reads pages.
|
|
26
|
-
Use
|
|
25
|
+
Use openai for GPT, anthropic for Claude, google for Gemini, xai for Grok, zai for GLM, and moonshotai or moonshotai-cn for Kimi.
|
|
26
|
+
OpenAI takes either a ChatGPT sign-in or an API key; the newer one replaces the other.
|
|
27
27
|
Moondog keeps credentials in its own private local store.
|
|
28
28
|
API keys are entered interactively, never as command arguments.
|
|
29
29
|
It does not read, modify, or log out the Codex CLI or ChatGPT desktop session.`;
|
|
@@ -39,19 +39,11 @@ function parseAuthArguments(args) {
|
|
|
39
39
|
action: "help",
|
|
40
40
|
provider: undefined,
|
|
41
41
|
extra: [],
|
|
42
|
-
|
|
42
|
+
chatgpt: false,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const deviceCode = deviceCodeIndex !== -1;
|
|
48
|
-
const browser = browserIndex !== -1;
|
|
49
|
-
if (deviceCode) values.splice(deviceCodeIndex, 1);
|
|
50
|
-
const adjustedBrowserIndex = values.indexOf("--browser");
|
|
51
|
-
if (adjustedBrowserIndex !== -1) values.splice(adjustedBrowserIndex, 1);
|
|
52
|
-
if (deviceCode && browser) {
|
|
53
|
-
throw new Error("Choose either --browser or --device-code, not both.");
|
|
54
|
-
}
|
|
45
|
+
const chatgptIndex = values.indexOf("--chatgpt");
|
|
46
|
+
if (chatgptIndex !== -1) values.splice(chatgptIndex, 1);
|
|
55
47
|
const unknownOption = values.find((value) => value.startsWith("-"));
|
|
56
48
|
if (unknownOption) {
|
|
57
49
|
throw new Error(
|
|
@@ -62,7 +54,7 @@ function parseAuthArguments(args) {
|
|
|
62
54
|
action: values[0],
|
|
63
55
|
provider: values[1],
|
|
64
56
|
extra: values.slice(2),
|
|
65
|
-
|
|
57
|
+
chatgpt: chatgptIndex !== -1,
|
|
66
58
|
};
|
|
67
59
|
}
|
|
68
60
|
|
|
@@ -80,7 +72,8 @@ function formatStatus(value) {
|
|
|
80
72
|
return `${value.provider}: not configured`;
|
|
81
73
|
}
|
|
82
74
|
|
|
83
|
-
|
|
75
|
+
// A readline output that can go quiet, so a key typed into it is never echoed.
|
|
76
|
+
export function createReadlineOutput(output) {
|
|
84
77
|
let muted = false;
|
|
85
78
|
const stream = new Writable({
|
|
86
79
|
write(chunk, encoding, callback) {
|
|
@@ -105,7 +98,6 @@ function createReadlineOutput(output) {
|
|
|
105
98
|
function createAuthInteraction({
|
|
106
99
|
input,
|
|
107
100
|
output,
|
|
108
|
-
loginMethod,
|
|
109
101
|
signal,
|
|
110
102
|
interactive,
|
|
111
103
|
apiKeyLogin,
|
|
@@ -126,15 +118,9 @@ function createAuthInteraction({
|
|
|
126
118
|
signal,
|
|
127
119
|
async prompt(prompt) {
|
|
128
120
|
if (prompt.type === "select") {
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
throw new Error(
|
|
122
|
+
"The installed Pi provider asked for a login choice Moondog doesn't offer.",
|
|
131
123
|
);
|
|
132
|
-
if (!selected) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
"The installed Pi provider does not support the requested login method.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
return selected.id;
|
|
138
124
|
}
|
|
139
125
|
if (prompt.type === "secret" && !interactive) {
|
|
140
126
|
throw new Error(
|
|
@@ -175,12 +161,6 @@ function createAuthInteraction({
|
|
|
175
161
|
writeLine(output, event.url);
|
|
176
162
|
if (event.instructions) writeLine(output, event.instructions);
|
|
177
163
|
break;
|
|
178
|
-
case "device_code":
|
|
179
|
-
writeLine(output);
|
|
180
|
-
writeLine(output, "Open this URL to sign in with ChatGPT:");
|
|
181
|
-
writeLine(output, event.verificationUri);
|
|
182
|
-
writeLine(output, `Device code: ${event.userCode}`);
|
|
183
|
-
break;
|
|
184
164
|
case "info":
|
|
185
165
|
writeLine(output, event.message);
|
|
186
166
|
for (const link of event.links ?? []) {
|
|
@@ -202,7 +182,7 @@ function createAuthInteraction({
|
|
|
202
182
|
// Pi's login errors can carry provider response bodies. Every surface that signs
|
|
203
183
|
// in shows only these fixed messages and codes.
|
|
204
184
|
export function wrapLoginError(error, provider) {
|
|
205
|
-
const name = provider === DEFAULT_AUTH_PROVIDER ? "OpenAI
|
|
185
|
+
const name = provider === DEFAULT_AUTH_PROVIDER ? "OpenAI" : provider;
|
|
206
186
|
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
|
207
187
|
const cancelled = new Error(`${name} login was cancelled.`);
|
|
208
188
|
cancelled.code = "auth_login_cancelled";
|
|
@@ -248,11 +228,8 @@ export async function runAuthCommand({
|
|
|
248
228
|
if (!["login", "status", "logout"].includes(options.action)) {
|
|
249
229
|
throw new Error("Unknown auth command. Use login, status, logout, or help.");
|
|
250
230
|
}
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
(options.loginMethod !== "browser" || args.includes("--browser"))
|
|
254
|
-
) {
|
|
255
|
-
throw new Error("Login method options are valid only for auth login.");
|
|
231
|
+
if (options.action !== "login" && options.chatgpt) {
|
|
232
|
+
throw new Error("--chatgpt is valid only for auth login.");
|
|
256
233
|
}
|
|
257
234
|
|
|
258
235
|
const provider = options.provider ??
|
|
@@ -265,15 +242,10 @@ export async function runAuthCommand({
|
|
|
265
242
|
`Unsupported auth provider. Choose ${supportedAuthProviderIds.join(", ")}.`,
|
|
266
243
|
);
|
|
267
244
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
apiKeyLogin &&
|
|
271
|
-
(args.includes("--device-code") || args.includes("--browser"))
|
|
272
|
-
) {
|
|
273
|
-
throw new Error(
|
|
274
|
-
"Browser and device-code options apply only to openai-codex OAuth login.",
|
|
275
|
-
);
|
|
245
|
+
if (options.chatgpt && provider !== DEFAULT_AUTH_PROVIDER) {
|
|
246
|
+
throw new Error("Only openai signs in with ChatGPT; other providers take an API key.");
|
|
276
247
|
}
|
|
248
|
+
const apiKeyLogin = !options.chatgpt;
|
|
277
249
|
|
|
278
250
|
if (options.action === "status") {
|
|
279
251
|
const authentication = await createAuthentication({ environment });
|
|
@@ -299,9 +271,7 @@ export async function runAuthCommand({
|
|
|
299
271
|
writeLine(output, formatStatus(status));
|
|
300
272
|
writeLine(
|
|
301
273
|
output,
|
|
302
|
-
|
|
303
|
-
? "Moondog's saved API key was removed. Environment credentials remain available until unset."
|
|
304
|
-
: "Moondog's local credential was removed. Codex CLI and ChatGPT desktop login were not changed.",
|
|
274
|
+
"Moondog's saved credential was removed. Environment credentials remain available until unset; the Codex CLI and ChatGPT desktop sign-ins were not changed.",
|
|
305
275
|
);
|
|
306
276
|
}
|
|
307
277
|
return;
|
|
@@ -315,9 +285,9 @@ export async function runAuthCommand({
|
|
|
315
285
|
"API key login requires an interactive terminal with hidden input. For headless use, set the provider's API key environment variable.",
|
|
316
286
|
);
|
|
317
287
|
}
|
|
318
|
-
if (!apiKeyLogin &&
|
|
288
|
+
if (!apiKeyLogin && !interactive) {
|
|
319
289
|
throw new Error(
|
|
320
|
-
"
|
|
290
|
+
"ChatGPT sign-in requires an interactive terminal. For headless use, set OPENAI_API_KEY instead.",
|
|
321
291
|
);
|
|
322
292
|
}
|
|
323
293
|
|
|
@@ -328,7 +298,6 @@ export async function runAuthCommand({
|
|
|
328
298
|
const login = createAuthInteraction({
|
|
329
299
|
input,
|
|
330
300
|
output: progressOutput,
|
|
331
|
-
loginMethod: options.loginMethod,
|
|
332
301
|
signal: controller.signal,
|
|
333
302
|
interactive,
|
|
334
303
|
apiKeyLogin,
|
|
@@ -336,7 +305,7 @@ export async function runAuthCommand({
|
|
|
336
305
|
try {
|
|
337
306
|
let status;
|
|
338
307
|
try {
|
|
339
|
-
status = await authentication.login(provider, login.interaction);
|
|
308
|
+
status = await authentication.login(provider, login.interaction, { method: apiKeyLogin ? "api_key" : "oauth" });
|
|
340
309
|
} catch (error) {
|
|
341
310
|
throw wrapLoginError(error, provider);
|
|
342
311
|
}
|
|
@@ -352,8 +321,8 @@ export async function runAuthCommand({
|
|
|
352
321
|
: "Next, return to Moondog and run /model.");
|
|
353
322
|
if (!apiKeyLogin) {
|
|
354
323
|
writeLine(output, "For a non-interactive launch, use environment overrides:");
|
|
355
|
-
writeLine(output, " export MOONDOG_PROVIDER=openai
|
|
356
|
-
writeLine(output, " export MOONDOG_MODEL=gpt-
|
|
324
|
+
writeLine(output, " export MOONDOG_PROVIDER=openai");
|
|
325
|
+
writeLine(output, " export MOONDOG_MODEL=gpt-6.1-sol");
|
|
357
326
|
writeLine(output, " moondog");
|
|
358
327
|
}
|
|
359
328
|
} finally {
|
|
@@ -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- `/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- `/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.");
|
|
568
568
|
}
|