@audiofool/moondog 0.2.0-beta.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +5 -4
  2. package/assets/brand/moondog-logo.svg +1 -0
  3. package/package.json +2 -2
  4. package/scripts/moondog.mjs +30 -6
  5. package/src/core/application-update.mjs +5 -3
  6. package/src/core/first-run.mjs +53 -0
  7. package/src/i18n/screen/es.mjs +62 -4
  8. package/src/i18n/screen/ja.mjs +62 -4
  9. package/src/i18n/screen/pt.mjs +62 -4
  10. package/src/i18n/screen/zh.mjs +62 -4
  11. package/src/integrations/netease/ncm-cli-player.mjs +29 -13
  12. package/src/memory/reflection-worker.mjs +1 -1
  13. package/src/runtime/pi/authentication.mjs +31 -10
  14. package/src/runtime/pi/configured-runtime.mjs +3 -3
  15. package/src/runtime/pi/custom-endpoints.mjs +229 -0
  16. package/src/runtime/pi/model-catalog.mjs +7 -3
  17. package/src/runtime/pi/model-list.mjs +122 -0
  18. package/src/runtime/pi/model-settings.mjs +131 -20
  19. package/src/runtime/pi/model-transport.mjs +2 -2
  20. package/src/runtime/pi/persistent-credential-store.mjs +31 -0
  21. package/src/runtime/pi/provider-registry.mjs +3 -9
  22. package/src/runtime/pi/public-model-catalog.mjs +12 -4
  23. package/src/runtime/pi/runtime-selection.mjs +9 -0
  24. package/src/runtime/pi/runtime-settings.mjs +37 -0
  25. package/src/surfaces/brand/logo.mjs +237 -0
  26. package/src/surfaces/cli/auth-command.mjs +25 -56
  27. package/src/surfaces/cli/brand-components.mjs +1 -1
  28. package/src/surfaces/cli/command-completions.mjs +14 -0
  29. package/src/surfaces/cli/endpoint-command.mjs +106 -0
  30. package/src/surfaces/cli/first-run-view.mjs +138 -0
  31. package/src/surfaces/cli/format-output.mjs +1 -1
  32. package/src/surfaces/cli/terminal-art.mjs +24 -66
  33. package/src/surfaces/cli/tui.mjs +320 -33
  34. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
@@ -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
- const OAUTH_PROVIDER = "openai-codex";
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 === OAUTH_PROVIDER) return "oauth";
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
- // The same list as the terminal's /model picker: providers with models, the
92
- // current one first, then ChatGPT sign-in, then by name.
93
- const listed = (source, currentProvider) => source.providers()
94
- .filter(provider => provider.modelCount > 0)
95
- .sort((left, right) => {
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.createModels({
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
- return { id, name, modelCount, signIn: signInKind(id), signedIn: Boolean(auth), source: signInSource(auth) };
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
- return rows(source, listed(source, currentProvider));
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
- async signIn(providerId, interaction) {
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-codex" ? "OpenAI Codex" : provider} authentication failed. Run moondog auth login ${provider} and try again.`;
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
- "openai-codex": "OpenAI Codex", openai: "OpenAI", anthropic: "Anthropic",
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
- export const apiKeyPiProviderIds = Object.freeze(
67
- supportedPiProviderIds.filter((providerId) => providerId !== "openai-codex"),
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
- createModels(options) { return createPiModels({ ...options, catalogModels: new Map([...this.records].map(([id, record]) => [id, record.models])) }); }
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 and its catalog entry.
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
+ }