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.
- package/LICENSE +21 -0
- package/README.md +263 -2
- package/cordis.patch.yml +12 -0
- package/docs/INSTALL.md +219 -0
- package/lib/changes.js +192 -0
- package/lib/cli.js +237 -0
- package/lib/client.js +626 -0
- package/lib/command.js +271 -0
- package/lib/conversations.js +101 -0
- package/lib/cursor-session.js +871 -0
- package/lib/fence.js +123 -0
- package/lib/index.js +477 -0
- package/lib/journal.js +276 -0
- package/lib/mirror.js +1293 -0
- package/lib/models.js +314 -0
- package/lib/native-provider.js +224 -0
- package/lib/prompt.js +59 -0
- package/lib/proxy.js +414 -0
- package/lib/resolve-agent.js +251 -0
- package/lib/session-pool.js +927 -0
- package/lib/settings.js +642 -0
- package/lib/tool.js +747 -0
- package/lib/usage.js +2262 -0
- package/package.json +60 -4
- package/screenshots/panel-zh-settings.png +0 -0
- package/screenshots/panel-zh.png +0 -0
package/lib/settings.js
ADDED
|
@@ -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
|
+
}
|