dsh-cursor-dev 0.0.0-stage → 0.11.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.
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Settings the user changes from the panel rather than from YAML.
3
+ *
4
+ * Two things a coding agent's operator keeps wanting to change without editing
5
+ * a profile file and reloading: **which model the child runs on**, and **what
6
+ * language the panel speaks**. Both live in one small JSON file in the plugin's
7
+ * data directory, next to the conversation index, because the profile config
8
+ * belongs to the profile: asking a user to hand-edit `cordis.patch.yml` and
9
+ * restart the app to switch from Composer to Grok is exactly the kind of
10
+ * friction that made the stock ACP provider unusable.
11
+ *
12
+ * The same file caches the model list the panel shows, so the picker opens
13
+ * instantly instead of waiting on the CLI every time. There are two name spaces
14
+ * for a model and only one of them can be switched to, so the cache records
15
+ * which source answered: the ACP session roster (`default[]`,
16
+ * `composer-2.5[fast=true]` — the ids `session/set_config_option` accepts) is
17
+ * preferred, and the CLI's own `--list-models` listing is only a fallback for a
18
+ * build that cannot be probed. `source` in the cache and in every `modelsFor`
19
+ * result says which one the panel is looking at, because an alias list and a
20
+ * roster look nothing alike and silently mixing them is what broke switching.
21
+ *
22
+ * @module dsh-cursor-dev/settings
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { runOnce } from './cli.js';
27
+ import { normalizeModelId } from './models.js';
28
+
29
+ /** File name inside the plugin data directory. */
30
+ const FILE = 'settings.json';
31
+
32
+ /** How long a model list stays fresh. */
33
+ export const MODEL_CACHE_MS = 10 * 60 * 1000;
34
+
35
+ /** Largest settings body accepted from the panel. */
36
+ const MAX_BODY_BYTES = 32 * 1024;
37
+
38
+ /** Locales the panel ships copy for. */
39
+ const LANGUAGES = new Set(['zh', 'en']);
40
+
41
+ /** Model list sources the cache can have come from. */
42
+ const MODEL_SOURCES = new Set(['acp', 'cli']);
43
+
44
+ /** Modes a Cursor session can run in (`agent` is the default). */
45
+ const MODES = new Set(['agent', 'plan', 'ask']);
46
+
47
+ /** Absolute path of the settings file. */
48
+ export function settingsPath(dataDir) {
49
+ return join(dataDir, FILE);
50
+ }
51
+
52
+ /** The settings a fresh install runs with. */
53
+ export function emptySettings() {
54
+ return {
55
+ version: 1,
56
+ model: '',
57
+ modelPolicy: 'per-call',
58
+ mode: '',
59
+ language: '',
60
+ // Empty means "ask the machine", which on Windows finds the registry
61
+ // proxy. That default is the whole point: the child CLI does not read
62
+ // the registry itself, so without this it lists only the models the
63
+ // direct route publishes.
64
+ proxy: '',
65
+ // Models the operator switched off in the picker. A DENY list, not an
66
+ // allow list, so a catalogue that gains entries keeps showing them: an
67
+ // empty list means "everything the CLI knows about" and a fresh install
68
+ // (or one upgrading from before this setting) hides nothing.
69
+ disabledModels: [],
70
+ updatedAt: 0,
71
+ modelsFetchedAt: 0,
72
+ models: [],
73
+ modelsSource: '',
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Coerce anything read off disk (or sent by a client) into the settings shape.
79
+ *
80
+ * Every field is optional and every wrong type falls back to "not set", because
81
+ * this file is edited by hand as often as by the panel and a typo must not take
82
+ * the dashboard down.
83
+ *
84
+ * @param raw - parsed JSON or a patch.
85
+ */
86
+ export function normalizeSettings(raw) {
87
+ const value = raw !== null && typeof raw === 'object' ? raw : {};
88
+ const models = Array.isArray(value.models) ? value.models.filter(isModelEntry) : [];
89
+ return {
90
+ version: 1,
91
+ model: stringOrUndefined(value.model) ?? '',
92
+ modelPolicy: value.modelPolicy === 'panel' ? 'panel' : 'per-call',
93
+ mode: MODES.has(value.mode) ? value.mode : '',
94
+ language: LANGUAGES.has(value.language) ? value.language : '',
95
+ proxy: proxySetting(value.proxy),
96
+ disabledModels: modelIdList(value.disabledModels),
97
+ updatedAt: numberOrUndefined(value.updatedAt) ?? 0,
98
+ modelsFetchedAt: numberOrUndefined(value.modelsFetchedAt) ?? 0,
99
+ models,
100
+ // A cache written before sources were recorded says nothing about where
101
+ // its ids came from; `''` means exactly that, not "no list".
102
+ modelsSource: MODEL_SOURCES.has(value.modelsSource) ? value.modelsSource : '',
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Read the settings file.
108
+ *
109
+ * A corrupt or unreadable file yields the defaults rather than throwing: the
110
+ * panel's job is to show state, and a settings read happens on the way to every
111
+ * page load.
112
+ *
113
+ * @param dataDir - plugin data directory.
114
+ */
115
+ export function readSettings(dataDir) {
116
+ try {
117
+ const path = settingsPath(dataDir);
118
+ if (!existsSync(path))
119
+ return emptySettings();
120
+ return normalizeSettings(JSON.parse(readFileSync(path, 'utf8')));
121
+ }
122
+ catch {
123
+ return emptySettings();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Merge a patch into the settings file and return the stored result.
129
+ *
130
+ * Written through a temporary file and a rename so a reader (the panel polls
131
+ * this) can never observe a half-written file.
132
+ *
133
+ * @param dataDir - plugin data directory.
134
+ * @param patch - fields to change; `undefined` values are ignored.
135
+ */
136
+ export function writeSettings(dataDir, patch) {
137
+ const current = readSettings(dataDir);
138
+ const clean = {};
139
+ for (const [key, value] of Object.entries(patch ?? {})) {
140
+ if (value !== undefined)
141
+ clean[key] = value;
142
+ }
143
+ const next = normalizeSettings({ ...current, ...clean, updatedAt: Date.now() });
144
+ mkdirSync(dataDir, { recursive: true });
145
+ const path = settingsPath(dataDir);
146
+ const temporary = `${path}.tmp`;
147
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
148
+ renameSync(temporary, path);
149
+ return next;
150
+ }
151
+
152
+ /**
153
+ * Which model a run uses when the tool call does not name one.
154
+ *
155
+ * The order is deliberate: the panel's choice beats the profile's config,
156
+ * because the profile is the machine's default and the panel is the operator's
157
+ * current intent. The result is normalized into the name space
158
+ * `session/set_config_option` speaks, so a panel choice of `auto` reaches a run
159
+ * as `default[]` — the id the ACP roster publishes — instead of as an alias the
160
+ * CLI rejects with `-32602 Invalid params: Invalid model value: auto`.
161
+ *
162
+ * An empty result still means "let the CLI decide": nothing was configured at
163
+ * all, which is not the same statement as "the operator picked Auto".
164
+ *
165
+ * @param settings - result of `readSettings`.
166
+ * @param configModel - `model` from the plugin config.
167
+ */
168
+ export function effectiveModel(settings, configModel) {
169
+ const chosen = modelChoice(settings?.model) ?? modelChoice(configModel);
170
+ return chosen === undefined ? '' : normalizeModelId(chosen);
171
+ }
172
+
173
+ /**
174
+ * Resolve one run's model. A panel lock is an operator choice that applies to
175
+ * every call; otherwise an explicit call retains the existing override behavior.
176
+ * Auto is an explicit choice (`default[]`), distinct from an unset CLI default.
177
+ */
178
+ export function resolveModelSelection(settings, configModel, requestedModel) {
179
+ const requested = modelChoice(requestedModel);
180
+ const panel = modelChoice(settings?.model);
181
+ const configured = modelChoice(configModel);
182
+ const baseline = panel ?? configured;
183
+ const locked = settings?.modelPolicy === 'panel' && baseline !== undefined;
184
+ const selected = locked ? baseline : (requested ?? baseline);
185
+ return {
186
+ model: selected === undefined ? '' : normalizeModelId(selected),
187
+ source: locked ? (panel !== undefined ? 'panel-lock' : 'config-lock')
188
+ : requested !== undefined ? 'call'
189
+ : panel !== undefined ? 'panel' : configured !== undefined ? 'config' : 'cli',
190
+ locked,
191
+ ...(requested === undefined ? {} : { requestedModel: requested }),
192
+ };
193
+ }
194
+
195
+ function modelChoice(value) {
196
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
197
+ }
198
+
199
+ /**
200
+ * Parse `agent --list-models`.
201
+ *
202
+ * The CLI prints one `id - Display name` per line, marks the active one, and
203
+ * follows the list with a tip that is not a model. Display names carry
204
+ * zero-width characters (the upstream list is copy-pasted from a table), so
205
+ * they are stripped here rather than showing up in the picker.
206
+ *
207
+ * @param stdout - raw command output.
208
+ */
209
+ export function parseModelList(stdout) {
210
+ const models = [];
211
+ const seen = new Set();
212
+ for (const rawLine of String(stdout ?? '').split(/\r?\n/)) {
213
+ const line = stripInvisible(rawLine).trim();
214
+ if (line === '' || line.startsWith('Tip:') || line.startsWith('Available models'))
215
+ continue;
216
+ const split = line.indexOf(' - ');
217
+ if (split <= 0)
218
+ continue;
219
+ const id = line.slice(0, split).trim();
220
+ if (!/^[a-z0-9][a-z0-9._\-[\]=,]*$/i.test(id) || seen.has(id))
221
+ continue;
222
+ let name = line.slice(split + 3).trim();
223
+ let current = false;
224
+ let isDefault = false;
225
+ const marked = /\(([^()]*)\)\s*$/.exec(name);
226
+ if (marked !== null && /\b(current|default)\b/.test(marked[1])) {
227
+ current = /\bcurrent\b/.test(marked[1]);
228
+ isDefault = /\bdefault\b/.test(marked[1]);
229
+ name = name.slice(0, marked.index).trim();
230
+ }
231
+ seen.add(id);
232
+ models.push({ id, name: name === '' ? id : name, current, isDefault });
233
+ }
234
+ return models;
235
+ }
236
+
237
+ /**
238
+ * Ask the CLI which models the account can use.
239
+ *
240
+ * @param deps - `spawn`, resolved agent, cwd, env.
241
+ */
242
+ export async function listModels(deps) {
243
+ const result = await runOnce({ ...deps, tail: ['--list-models'] });
244
+ const models = parseModelList(result.stdout);
245
+ if (models.length === 0) {
246
+ const detail = `${result.stderr}\n${result.stdout}`.trim().split(/\r?\n/).filter(Boolean).slice(-2).join(' ');
247
+ throw new Error(detail === '' ? 'the CLI listed no models' : detail);
248
+ }
249
+ return models;
250
+ }
251
+
252
+ /**
253
+ * Read the model list the panel should show.
254
+ *
255
+ * The ACP session roster is preferred because it is the ONLY list
256
+ * `session/set_config_option` accepts; the CLI's `--list-models` listing is a
257
+ * fallback for the case where a probe cannot answer (no CLI, a signed-out
258
+ * account, a build without `configOptions`). The result always names the source
259
+ * that answered, so the panel can say what the ids it renders actually are
260
+ * instead of implying two name spaces are one.
261
+ *
262
+ * @param dataDir - plugin data directory.
263
+ * @param deps - spawn/agent dependencies, `probeModelRoster` (optional), `force`.
264
+ * @returns `{ok, list, source, fetchedAt, cached, error?}` where `source` is
265
+ * `'acp'`, `'cli'`, `'cache'` (an old record that never said) or `'none'`.
266
+ */
267
+ export async function modelsFor(dataDir, deps) {
268
+ const settings = readSettings(dataDir);
269
+ const age = Date.now() - settings.modelsFetchedAt;
270
+ if (deps.force !== true && settings.models.length > 0 && (settings.modelsFetchedAt > 0 && age < MODEL_CACHE_MS))
271
+ return { ok: true, list: settings.models, source: settings.modelsSource ?? 'cache', fetchedAt: settings.modelsFetchedAt, cached: true };
272
+
273
+ const probed = await probeRoster(deps);
274
+ // Only a probe that really produced a roster may answer for the panel. A
275
+ // probe that failed (or answered with an empty list) must fall through to
276
+ // the listing — otherwise a broken probe would look exactly like an account
277
+ // that offers no models, and the picker would come up empty.
278
+ if (probed?.list !== undefined) {
279
+ // Keep the CLI listing beside the roster: a build whose roster omits the
280
+ // model the operator currently has stored would otherwise show an empty
281
+ // picker where it can still offer something they can fall back to.
282
+ const list = mergeListing(probed.list, settings.models);
283
+ const stored = writeSettings(dataDir, { models: list, modelsFetchedAt: Date.now(), modelsSource: 'acp' });
284
+ return { ok: true, list: stored.models, source: 'acp', fetchedAt: stored.modelsFetchedAt, cached: false };
285
+ }
286
+
287
+ try {
288
+ const list = await listModels(deps);
289
+ const stored = writeSettings(dataDir, { models: list, modelsFetchedAt: Date.now(), modelsSource: 'cli' });
290
+ // The listing answered, but if the PREFERRED source (the ACP roster) had
291
+ // been skipped, say so: the panel can then explain why it is showing
292
+ // aliases instead of session ids, which are not the same thing.
293
+ return {
294
+ ok: true,
295
+ list: stored.models,
296
+ source: 'cli',
297
+ fetchedAt: stored.modelsFetchedAt,
298
+ cached: false,
299
+ ...(probed?.error === undefined ? {} : { probeError: probed.error }),
300
+ };
301
+ }
302
+ catch (error) {
303
+ // A stale list still beats an empty picker: report the failure beside it,
304
+ // together with the probe's reason (which is the one that matters when
305
+ // the roster was the point of asking).
306
+ return {
307
+ ok: false,
308
+ list: settings.models,
309
+ source: settings.modelsSource ?? 'none',
310
+ fetchedAt: settings.modelsFetchedAt,
311
+ cached: true,
312
+ error: errorText(error),
313
+ ...(probed === undefined ? {} : { probeError: probed.error }),
314
+ };
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Ask the pool for the ACP roster, if a probe was wired in.
320
+ *
321
+ * @param deps - dependencies, optionally carrying `probeModelRoster`.
322
+ * @returns `{list}` on success, `{error}` on a failed probe, or `undefined`
323
+ * when no probe is available at all (which is not a failure — it just means
324
+ * the CLI listing is the only source this deployment has).
325
+ */
326
+ async function probeRoster(deps) {
327
+ if (typeof deps?.probeModelRoster !== 'function')
328
+ return undefined;
329
+ try {
330
+ const result = await deps.probeModelRoster();
331
+ const list = result !== null && typeof result === 'object' ? result.list : undefined;
332
+ // A probe that answers "not ok" or with an empty list is a FAILED probe,
333
+ // not a successful empty answer: it must fall through to the listing,
334
+ // never be merged as if it were a roster.
335
+ if (result?.ok === true && Array.isArray(list) && list.length > 0)
336
+ return { list };
337
+ return { error: result?.error ?? 'the model probe answered nothing' };
338
+ }
339
+ catch (error) {
340
+ return { error: errorText(error) };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Merge a preferred list with entries only the other source knows.
346
+ *
347
+ * The preferred (`source`) list keeps its order and its `current`/`isDefault`
348
+ * facts; an extra entry is added only when neither list already names its model.
349
+ * Entries are matched on their model id, which is what a switch is asked for, so
350
+ * a roster id that differs from an alias of the same model is kept once per id.
351
+ *
352
+ * @param preferred - entries from the source that answered first.
353
+ * @param extra - entries from the cache, used to fill gaps.
354
+ */
355
+ function mergeListing(preferred, extra) {
356
+ // Each entry carries where it came from: the roster is the list Cursor
357
+ // itself answered with, and anything only the cache knows about is an id
358
+ // this host cannot necessarily switch to. The browser groups by that.
359
+ const list = (Array.isArray(preferred) ? preferred : []).map(entry => ({ ...entry, source: 'acp' }));
360
+ const seen = new Set(list.map(entry => entry.id));
361
+ // `extra` is the cache, and a cache is allowed to be missing or corrupt;
362
+ // the preferred list is the one that must survive.
363
+ for (const entry of Array.isArray(extra) ? extra : []) {
364
+ if (seen.has(entry.id))
365
+ continue;
366
+ seen.add(entry.id);
367
+ list.push({ ...entry, source: 'cache' });
368
+ }
369
+ return list;
370
+ }
371
+
372
+ /**
373
+ * Register the settings API the panel reads and writes.
374
+ *
375
+ * @param ctx - Cordis context.
376
+ * @param deps - data dir, spawn/agent dependencies, config model.
377
+ */
378
+ export function registerSettingsRoutes(ctx, deps) {
379
+ const registrations = [];
380
+ const server = ctx.get?.('webServer');
381
+ if (server === undefined)
382
+ return undefined;
383
+
384
+ const payload = async (force, justRefreshed = false) => {
385
+ const settings = readSettings(deps.dataDir);
386
+ const models = await modelsFor(deps.dataDir, { ...deps, force });
387
+ // A refresh asked for by this very request is not answered from the
388
+ // cache it just filled: the panel says "just refreshed" only when the
389
+ // list was really fetched for it.
390
+ if (justRefreshed === true && models.ok === true)
391
+ models.cached = false;
392
+ return {
393
+ ok: true,
394
+ settings: {
395
+ model: settings.model,
396
+ modelPolicy: settings.modelPolicy,
397
+ mode: settings.mode,
398
+ language: settings.language,
399
+ proxy: settings.proxy,
400
+ disabledModels: settings.disabledModels,
401
+ updatedAt: settings.updatedAt,
402
+ },
403
+ // What the `proxy` setting resolves to right now, so the panel can
404
+ // show the route a child would actually take instead of just echoing
405
+ // the box the operator typed in.
406
+ proxy: {
407
+ configured: settings.proxy,
408
+ route: typeof deps.proxyRoute === 'function' ? deps.proxyRoute() : '',
409
+ error: typeof deps.proxyError === 'function' ? deps.proxyError() : '',
410
+ },
411
+ effectiveModel: effectiveModel(settings, deps.configModel),
412
+ configModel: deps.configModel ?? '',
413
+ agent: deps.resolved === undefined ? null : { command: deps.resolved.command, source: deps.resolved.source },
414
+ models: { ...models, source: models.source ?? 'none' },
415
+ };
416
+ };
417
+
418
+ const settingsRoute = {
419
+ kind: 'exact',
420
+ path: '/plugins/dsh-cursor-dev/api/settings',
421
+ handler: async (req, res) => {
422
+ const method = String(req?.method ?? 'GET').toUpperCase();
423
+ const url = new URL(req?.url ?? '/', 'http://localhost');
424
+ if (method === 'GET') {
425
+ sendJson(res, 200, await payload(url.searchParams.get('refreshModels') === '1'));
426
+ return;
427
+ }
428
+ if (method !== 'POST') {
429
+ sendJson(res, 405, { ok: false, error: `method ${method} is not supported` });
430
+ return;
431
+ }
432
+ let body;
433
+ try {
434
+ body = await readJsonBody(req);
435
+ }
436
+ catch (error) {
437
+ sendJson(res, 400, { ok: false, error: errorText(error) });
438
+ return;
439
+ }
440
+ let refreshed = false;
441
+ if (body !== null && typeof body === 'object' && typeof body.refreshModels === 'boolean' && body.refreshModels) {
442
+ await modelsFor(deps.dataDir, { ...deps, force: true });
443
+ refreshed = true;
444
+ }
445
+ const patch = {};
446
+ if (typeof body?.model === 'string')
447
+ patch.model = body.model.trim();
448
+ if (body?.modelPolicy === 'panel' || body?.modelPolicy === 'per-call')
449
+ patch.modelPolicy = body.modelPolicy;
450
+ if (typeof body?.language === 'string' && LANGUAGES.has(body.language))
451
+ patch.language = body.language;
452
+ if (typeof body?.mode === 'string' && (body.mode === '' || MODES.has(body.mode)))
453
+ patch.mode = body.mode;
454
+ if (typeof body?.proxy === 'string')
455
+ patch.proxy = body.proxy;
456
+ if (Array.isArray(body?.disabledModels))
457
+ patch.disabledModels = body.disabledModels;
458
+ if (Object.keys(patch).length > 0)
459
+ writeSettings(deps.dataDir, patch);
460
+ // Re-resolve before answering: the payload reports the route a child
461
+ // would take, and it has to describe the proxy that was just saved,
462
+ // not the one that was in force a moment ago.
463
+ if (patch.proxy !== undefined && typeof deps.refreshProxy === 'function')
464
+ await deps.refreshProxy();
465
+ sendJson(res, 200, await payload(false, refreshed));
466
+ },
467
+ };
468
+
469
+ const guard = (path, handler) => async (req, res) => {
470
+ try {
471
+ await handler(req, res);
472
+ }
473
+ catch (error) {
474
+ ctx.logger?.warn?.(`dsh-cursor-dev: ${path} failed: ${errorText(error)}`);
475
+ try {
476
+ if (res.headersSent === true)
477
+ res.end();
478
+ else
479
+ sendJson(res, 500, { ok: false, error: errorText(error) });
480
+ }
481
+ catch {
482
+ // The socket is already gone; nothing left to answer.
483
+ }
484
+ }
485
+ };
486
+
487
+ for (const route of [settingsRoute]) {
488
+ try {
489
+ registrations.push(server.register({
490
+ ...route,
491
+ // This route is writable (it changes the child's model, the mode
492
+ // and the panel language) and it is served outside the host's own
493
+ // `/api` carrier, so the host's Host/Origin fence never sees it.
494
+ // Without this gate any page on any origin could POST here and
495
+ // rewrite the operator's settings.
496
+ handler: guard(route.path, (req, res) => {
497
+ if (deps.fence !== undefined && deps.fence(req, res) !== true)
498
+ return undefined;
499
+ return route.handler(req, res);
500
+ }),
501
+ }));
502
+ }
503
+ catch (error) {
504
+ ctx.logger?.warn?.(`dsh-cursor-dev: settings route ${route.path} unavailable: ${errorText(error)}`);
505
+ }
506
+ }
507
+
508
+ return () => {
509
+ for (const dispose of registrations) {
510
+ try {
511
+ dispose();
512
+ }
513
+ catch {
514
+ // Unloading is best-effort.
515
+ }
516
+ }
517
+ };
518
+ }
519
+
520
+ /**
521
+ * Read a JSON request body with a size cap.
522
+ *
523
+ * @param req - Node request.
524
+ */
525
+ function readJsonBody(req) {
526
+ return new Promise((resolve, reject) => {
527
+ let size = 0;
528
+ const chunks = [];
529
+ req.on('data', (chunk) => {
530
+ size += chunk.length;
531
+ if (size > MAX_BODY_BYTES) {
532
+ reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`));
533
+ req.destroy?.();
534
+ return;
535
+ }
536
+ chunks.push(chunk);
537
+ });
538
+ req.on('error', reject);
539
+ req.on('end', () => {
540
+ const text = Buffer.concat(chunks).toString('utf8').trim();
541
+ if (text === '') {
542
+ resolve({});
543
+ return;
544
+ }
545
+ try {
546
+ resolve(JSON.parse(text));
547
+ }
548
+ catch (error) {
549
+ reject(new Error(`body is not JSON: ${errorText(error)}`));
550
+ }
551
+ });
552
+ });
553
+ }
554
+
555
+ /** Write a JSON response. */
556
+ function sendJson(res, status, body) {
557
+ res.writeHead(status, {
558
+ 'content-type': 'application/json; charset=utf-8',
559
+ 'cache-control': 'no-store',
560
+ });
561
+ res.end(JSON.stringify(body, null, 2));
562
+ }
563
+
564
+ /** Strip zero-width characters a copy-pasted CLI table carries. */
565
+ function stripInvisible(value) {
566
+ return String(value).replace(/[\u200b-\u200f\ufeff]/g, '');
567
+ }
568
+
569
+ /** Coerce a string, else undefined. */
570
+ function stringOrUndefined(value) {
571
+ return typeof value === 'string' && value !== '' ? value : undefined;
572
+ }
573
+
574
+ /** Longest proxy value accepted; anything past this is a paste accident. */
575
+ const MAX_PROXY_CHARS = 200;
576
+
577
+ /**
578
+ * Coerce the `proxy` setting.
579
+ *
580
+ * `''` means auto-detect and is the default; `none` means never proxy; anything
581
+ * else is an endpoint. Only the shape of the value is checked here — whether it
582
+ * parses is `resolveChildProxy`'s job, and it can report that failure without
583
+ * the settings file having to be valid first. An over-long value is dropped
584
+ * rather than truncated, because half a URL is worse than none.
585
+ */
586
+ function proxySetting(value) {
587
+ if (typeof value !== 'string') return '';
588
+ const text = value.trim();
589
+ return text.length > MAX_PROXY_CHARS ? '' : text;
590
+ }
591
+
592
+ /** Longest model id accepted in the picker's deny list; ids are short by nature. */
593
+ const MAX_MODEL_ID_CHARS = 200;
594
+
595
+ /**
596
+ * Coerce the picker's deny list into a set of distinct, plausible model ids.
597
+ *
598
+ * Anything that is not a non-empty bounded string is dropped rather than
599
+ * repaired: a malformed entry can only ever mean "hide a model", and guessing
600
+ * which model was meant would hide the wrong one.
601
+ */
602
+ function modelIdList(value) {
603
+ if (!Array.isArray(value)) return [];
604
+ const seen = new Set();
605
+ for (const entry of value) {
606
+ const raw = stringOrUndefined(entry);
607
+ if (raw === undefined || raw.length > MAX_MODEL_ID_CHARS) continue;
608
+ // Auto aliases must collapse: the picker switches on `default[]`, and a
609
+ // deny list that stored `auto` beside it would leave one of them on.
610
+ const id = normalizeModelId(raw);
611
+ if (id === '') continue;
612
+ seen.add(id);
613
+ }
614
+ return [...seen];
615
+ }
616
+
617
+ /** Coerce a finite number, else undefined. */
618
+ function numberOrUndefined(value) {
619
+ if (typeof value === 'number')
620
+ return Number.isFinite(value) ? value : undefined;
621
+ if (typeof value === 'string' && value !== '') {
622
+ const parsed = Number(value);
623
+ return Number.isFinite(parsed) ? parsed : undefined;
624
+ }
625
+ return undefined;
626
+ }
627
+
628
+ /** Whether one cached model entry is usable. */
629
+ function isModelEntry(value) {
630
+ return value !== null
631
+ && typeof value === 'object'
632
+ && typeof value.id === 'string'
633
+ && value.id !== ''
634
+ && typeof value.name === 'string';
635
+ }
636
+
637
+ /** Render an unknown thrown value as text. */
638
+ function errorText(error) {
639
+ if (error instanceof Error)
640
+ return error.message;
641
+ return String(error);
642
+ }