aegiscode 6.3.0 → 6.3.2
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/package.json +1 -1
- package/src/app.js +96 -2
- package/src/chatflow.js +7 -4
- package/src/commands.js +46 -9
- package/src/config.js +17 -2
- package/src/models.js +123 -0
- package/src/overlays.js +18 -5
- package/src/panels.js +3 -1
- package/vendor/client/aegis.js +16 -1
- package/vendor/desktop/lib/local/engine.js +23 -7
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.3.
|
|
4
|
+
"version": "6.3.2",
|
|
5
5
|
"description": "aegiscode — the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/src/app.js
CHANGED
|
@@ -27,6 +27,7 @@ const { GLYPH, VERBS, themeOf, RESET, THEME_TABLE } = require('./theme.js');
|
|
|
27
27
|
const { LiveRegion, termWidth, w } = require('./screen.js');
|
|
28
28
|
const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
|
|
29
29
|
const { updateConfig, loadPermissions, loadConfig, configExists } = require('./config.js');
|
|
30
|
+
const { normalizeModelCatalog, pickerEntries, catalogIds } = require('./models.js');
|
|
30
31
|
const { appendHistory, readSessionTranscript, readOwnSessions } = require('./history.js');
|
|
31
32
|
const { snapshotCheckpoint } = require('./checkpoint.js');
|
|
32
33
|
const screens = require('./screens.js');
|
|
@@ -86,7 +87,11 @@ function createApp(options = {}) {
|
|
|
86
87
|
const commandCtx = {
|
|
87
88
|
light: opts.light,
|
|
88
89
|
model: opts.model,
|
|
89
|
-
|
|
90
|
+
// `null` = "auto": send no effort, so the server sizes the turn from the
|
|
91
|
+
// ask. This is the *budget* control on the pooled class (aegis1 sizes the
|
|
92
|
+
// token ladder from it), which is why it is not defaulted to a rung here —
|
|
93
|
+
// pinning one would make every turn cost what that rung grants.
|
|
94
|
+
effort: null,
|
|
90
95
|
thinking: false,
|
|
91
96
|
themeIndex: opts.light ? 2 : 1,
|
|
92
97
|
vim: false,
|
|
@@ -146,6 +151,71 @@ function createApp(options = {}) {
|
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
154
|
+
// ── the AEGIS Cloud model catalog ──────────────────────────────────────────
|
|
155
|
+
//
|
|
156
|
+
// `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
|
|
157
|
+
// from the server, and `state().models` is where it looks. That path had no
|
|
158
|
+
// source at all: `commands.js` calls `c.loadModels()` inside a `try {} catch
|
|
159
|
+
// {}`, `c.loadModels` was never defined on either command context, and
|
|
160
|
+
// `buildState()` hardcoded `models: []` — so the TypeError was swallowed and
|
|
161
|
+
// both `/model` and the picker reported "no models advertised" on a perfectly
|
|
162
|
+
// healthy account, forever. The ids themselves are the server's (see
|
|
163
|
+
// models.js), fetched here and cached, because the pool adds and retires
|
|
164
|
+
// providers without a client release.
|
|
165
|
+
const MODEL_CACHE_MS = 5 * 60_000;
|
|
166
|
+
const modelCache = { at: 0, models: [] };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch (or return the cached) model catalog. Rejects when the account cannot
|
|
170
|
+
* read it at all — no key, offline, or a server error — which callers treat as
|
|
171
|
+
* "nothing to offer" rather than retrying per keystroke.
|
|
172
|
+
* @returns {Promise<Array<{id:string,label:string,note:string}>>}
|
|
173
|
+
*/
|
|
174
|
+
async function loadModels({ force = false } = {}) {
|
|
175
|
+
const fresh = modelCache.models.length && Date.now() - modelCache.at < MODEL_CACHE_MS;
|
|
176
|
+
if (!force && fresh) return modelCache.models;
|
|
177
|
+
const data = await client.listModels();
|
|
178
|
+
modelCache.models = normalizeModelCatalog(data && data.models);
|
|
179
|
+
modelCache.at = Date.now();
|
|
180
|
+
return modelCache.models;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A pinned model id the server does not advertise is a *silent* fallback: the
|
|
185
|
+
* pool answers from its own default with no error, so the pin looks honoured
|
|
186
|
+
* while the reply came from another model — and the cost is attributed to the
|
|
187
|
+
* model that was pinned. Two shipped defaults did exactly that: `sonnet`
|
|
188
|
+
* (written into config.json by onboarding, merged in from DEFAULT_CONFIG, and
|
|
189
|
+
* never advertised by AEGIS Cloud) and any `provider/model` spelling a user
|
|
190
|
+
* typed by hand. Clears such a pin once, says so, and leaves the server's own
|
|
191
|
+
* default in its place.
|
|
192
|
+
*
|
|
193
|
+
* Best-effort and offline-safe: a catalog that cannot be read clears nothing.
|
|
194
|
+
*/
|
|
195
|
+
async function validatePinnedModel() {
|
|
196
|
+
const pinned = commandCtx.model;
|
|
197
|
+
if (!pinned) return null;
|
|
198
|
+
let models;
|
|
199
|
+
try {
|
|
200
|
+
models = await loadModels();
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (!models.length) return null;
|
|
205
|
+
if (catalogIds(models).has(String(pinned).toLowerCase())) return null;
|
|
206
|
+
commandCtx.model = null;
|
|
207
|
+
updateConfig({ model: null, currentModelId: null });
|
|
208
|
+
emit(
|
|
209
|
+
render.renderNotice(
|
|
210
|
+
ctx(),
|
|
211
|
+
'warn',
|
|
212
|
+
`pinned model "${pinned}" is not advertised by AEGIS Cloud — pin cleared, ` +
|
|
213
|
+
'the pool will choose; /models lists what you can pin.'
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
return pinned;
|
|
217
|
+
}
|
|
218
|
+
|
|
149
219
|
function bannerLines() {
|
|
150
220
|
return render.renderBanner(ctx(), {
|
|
151
221
|
width: width(),
|
|
@@ -305,6 +375,14 @@ function createApp(options = {}) {
|
|
|
305
375
|
model: commandCtx.model || undefined,
|
|
306
376
|
system: opts.system,
|
|
307
377
|
maxTokens: opts.maxTokens,
|
|
378
|
+
// `effort` is the pooled budget control, and it travels on every turn
|
|
379
|
+
// — this host only ever runs the 'aegis' class, whose calls are sized
|
|
380
|
+
// server-side from it (aegis1 services/pool_brain.py). It used to be
|
|
381
|
+
// held in the session state and rendered in the status line without
|
|
382
|
+
// ever reaching the wire, so /effort changed the display and nothing
|
|
383
|
+
// about what the turn cost. `null` (auto) is omitted so the server
|
|
384
|
+
// infers the rung from the ask.
|
|
385
|
+
effort: commandCtx.effort || undefined,
|
|
308
386
|
// false asks the engine for the buffered (non-stream) wire form, so
|
|
309
387
|
// `--no-stream` and piped runs get a single body rather than SSE.
|
|
310
388
|
stream: commandCtx.stream !== false,
|
|
@@ -517,7 +595,9 @@ function createApp(options = {}) {
|
|
|
517
595
|
plan: session.plan || null,
|
|
518
596
|
account: session.account || null,
|
|
519
597
|
permissions: { mode: rules.defaultMode, rules },
|
|
520
|
-
|
|
598
|
+
// The selectable (pickable) catalog, not the raw payload: alias tiers are
|
|
599
|
+
// dropped, live ids only (see models.js pickerEntries).
|
|
600
|
+
models: pickerEntries(modelCache.models),
|
|
521
601
|
commands: visibleCommands(),
|
|
522
602
|
transcript: transcript.slice(),
|
|
523
603
|
sessions: [],
|
|
@@ -633,6 +713,10 @@ function createApp(options = {}) {
|
|
|
633
713
|
runPrompt: (text) => runPrompt(text),
|
|
634
714
|
ask: (text) => ask(text),
|
|
635
715
|
runTool: (name, args) => runTool(name, args),
|
|
716
|
+
// The AEGIS catalog fetch `/model` and alt+p expect (see loadModels).
|
|
717
|
+
// Wired on the app's context so the chatflow's `Object.assign`-based
|
|
718
|
+
// context inherits it too — one definition, both hosts.
|
|
719
|
+
loadModels: (o) => loadModels(o),
|
|
636
720
|
refreshSpend: () => refreshSpend(),
|
|
637
721
|
state: () => buildState(),
|
|
638
722
|
setInput: () => {},
|
|
@@ -916,6 +1000,7 @@ function createApp(options = {}) {
|
|
|
916
1000
|
TOOLS,
|
|
917
1001
|
ask: (prompt, o) => ask(prompt, o),
|
|
918
1002
|
makeCommandContext: () => makeCommandContext(),
|
|
1003
|
+
loadModels: (o) => loadModels(o),
|
|
919
1004
|
buildState: () => buildState(),
|
|
920
1005
|
dispatchLine: (line, c) => handleLine(line, c),
|
|
921
1006
|
refreshSpend: () => refreshSpend(),
|
|
@@ -1000,6 +1085,8 @@ function createApp(options = {}) {
|
|
|
1000
1085
|
return;
|
|
1001
1086
|
}
|
|
1002
1087
|
if (!opts.model && cfg.model) commandCtx.model = cfg.model;
|
|
1088
|
+
// `null` in the config is "auto" and is deliberately not assigned over the
|
|
1089
|
+
// default — there is nothing to restore, because nothing is pinned.
|
|
1003
1090
|
if (cfg.effort) commandCtx.effort = cfg.effort;
|
|
1004
1091
|
if (typeof cfg.vim === 'boolean') commandCtx.vim = cfg.vim;
|
|
1005
1092
|
if (cfg.lastRecap) commandCtx.lastRecap = cfg.lastRecap;
|
|
@@ -1037,6 +1124,11 @@ function createApp(options = {}) {
|
|
|
1037
1124
|
save: (patch) => updateConfig(patch),
|
|
1038
1125
|
});
|
|
1039
1126
|
if (!onboard.ok) return 0;
|
|
1127
|
+
// A stored pin the platform does not advertise routes elsewhere in
|
|
1128
|
+
// silence (see validatePinnedModel) — checked once, here, where the user
|
|
1129
|
+
// can act on it. Not on `-p`: a network round-trip ahead of the first
|
|
1130
|
+
// token would be a startup cost bought for a warning no script watches.
|
|
1131
|
+
await validatePinnedModel();
|
|
1040
1132
|
// `--continue` must load the last session *before* the loop starts, and
|
|
1041
1133
|
// it has to be read here rather than captured at construction: the
|
|
1042
1134
|
// history file is written by the loop itself.
|
|
@@ -1071,6 +1163,8 @@ function createApp(options = {}) {
|
|
|
1071
1163
|
refreshSpend,
|
|
1072
1164
|
bannerLines,
|
|
1073
1165
|
makeHost,
|
|
1166
|
+
loadModels,
|
|
1167
|
+
validatePinnedModel,
|
|
1074
1168
|
recordTurn,
|
|
1075
1169
|
persistTurn,
|
|
1076
1170
|
restorePrefs,
|
package/src/chatflow.js
CHANGED
|
@@ -164,7 +164,7 @@ const separatorLine = (cols, ctx) => [span(themeOf(ctx).dim, '─'.repeat(cols))
|
|
|
164
164
|
/** The idle bottom-left line: which effort level the next turn runs at. */
|
|
165
165
|
function effortLine(ctx, cols) {
|
|
166
166
|
const t = themeOf(ctx);
|
|
167
|
-
const txt = `● ${ctx.effort || '
|
|
167
|
+
const txt = `● ${ctx.effort || 'auto'} · /effort`;
|
|
168
168
|
const pad = ' '.repeat(Math.max(0, cols - [...txt].length - 4));
|
|
169
169
|
return [span(t.gray, pad + txt)];
|
|
170
170
|
}
|
|
@@ -1111,12 +1111,15 @@ async function runSession(host) {
|
|
|
1111
1111
|
return;
|
|
1112
1112
|
}
|
|
1113
1113
|
if (type === 'effort') {
|
|
1114
|
-
|
|
1115
|
-
|
|
1114
|
+
// The picker's own order, not a second copy of the level names: this
|
|
1115
|
+
// used to re-declare ['low','medium','high'] here while the picker drew
|
|
1116
|
+
// its own table, so the row a user picked and the value it stored were
|
|
1117
|
+
// resolved through different lists.
|
|
1118
|
+
const chosen = overlays.EFFORT_VALUES[overlay.sel || 0];
|
|
1116
1119
|
overlay = null;
|
|
1117
1120
|
ctx.effort = chosen;
|
|
1118
1121
|
host.updateConfig({ effort: chosen });
|
|
1119
|
-
note(`effort: ${chosen}`);
|
|
1122
|
+
note(`effort: ${chosen || 'auto'}`);
|
|
1120
1123
|
render();
|
|
1121
1124
|
return;
|
|
1122
1125
|
}
|
package/src/commands.js
CHANGED
|
@@ -46,6 +46,7 @@ const { execSync } = require('node:child_process');
|
|
|
46
46
|
const { span } = require('./screen.js');
|
|
47
47
|
const { C, BOLD, BOLD_OFF } = require('./theme.js');
|
|
48
48
|
const panels = require('./panels.js');
|
|
49
|
+
const overlays = require('./overlays.js');
|
|
49
50
|
const {
|
|
50
51
|
updateConfig, loadConfig, loadPermissions, savePermissions, addPermissionRule,
|
|
51
52
|
DEFAULT_PERMISSIONS, permissionsPath, configPath,
|
|
@@ -93,7 +94,13 @@ const CATEGORIES = [
|
|
|
93
94
|
|
|
94
95
|
const categoryLabel = (id) => (CATEGORIES.find((cat) => cat.id === id) || {}).label || id;
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
// One table, shared with the picker that draws it. The two used to be separate
|
|
98
|
+
// lists — this one a string array, chatflow.js's own copy of the same three
|
|
99
|
+
// strings, and overlays.js's label/note rows — so adding a level meant editing
|
|
100
|
+
// three places, and the index a picker row reported was mapped back to a value
|
|
101
|
+
// through whichever list happened to be in scope. EFFORT_VALUES is the picker's
|
|
102
|
+
// own order, `null` first for "auto".
|
|
103
|
+
const EFFORT_LEVELS = overlays.EFFORT_VALUES;
|
|
97
104
|
|
|
98
105
|
// ── Small handler helpers ─────────────────────────────────────────────────────
|
|
99
106
|
|
|
@@ -348,12 +355,12 @@ const COMMANDS = [
|
|
|
348
355
|
},
|
|
349
356
|
},
|
|
350
357
|
{
|
|
351
|
-
name: 'effort', args: ['level'], hint: '[low|medium|high]', category: 'model',
|
|
352
|
-
desc: 'Set
|
|
358
|
+
name: 'effort', args: ['level'], hint: '[auto|low|medium|high]', category: 'model',
|
|
359
|
+
desc: 'Set the token budget the pool sizes each turn from',
|
|
353
360
|
handler: async (c, args) => {
|
|
354
361
|
const level = (args.level || '').toLowerCase();
|
|
355
|
-
if (level && !EFFORT_LEVELS.includes(level)) {
|
|
356
|
-
note(c, `Unknown effort level "${args.level}". Use ${EFFORT_LEVELS.join(', ')}.`);
|
|
362
|
+
if (level && level !== 'auto' && !EFFORT_LEVELS.includes(level)) {
|
|
363
|
+
note(c, `Unknown effort level "${args.level}". Use auto, ${EFFORT_LEVELS.filter(Boolean).join(', ')}.`);
|
|
357
364
|
c.render();
|
|
358
365
|
return true;
|
|
359
366
|
}
|
|
@@ -361,9 +368,14 @@ const COMMANDS = [
|
|
|
361
368
|
c.openOverlay({ type: 'effort', sel: Math.max(0, EFFORT_LEVELS.indexOf(c.ctx.effort)) });
|
|
362
369
|
return true;
|
|
363
370
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
371
|
+
// 'auto' is the absence of a pin, not a fourth rung: it is stored as null
|
|
372
|
+
// so nothing is sent and the server sizes the turn from the ask.
|
|
373
|
+
const pinned = level === 'auto' ? null : level;
|
|
374
|
+
c.ctx.effort = pinned;
|
|
375
|
+
c.saveConfig({ effort: pinned });
|
|
376
|
+
note(c, pinned
|
|
377
|
+
? `Effort level: ${pinned} — the pool sizes this turn's token budget from it`
|
|
378
|
+
: 'Effort level: auto — the pool sizes each turn from the ask');
|
|
367
379
|
c.render();
|
|
368
380
|
return true;
|
|
369
381
|
},
|
|
@@ -478,10 +490,25 @@ const COMMANDS = [
|
|
|
478
490
|
await loadModels(c);
|
|
479
491
|
const models = c.state().models || [];
|
|
480
492
|
if (!models.length) {
|
|
481
|
-
|
|
493
|
+
// Say why, and what unblocks it: an unreachable catalog is almost
|
|
494
|
+
// always a missing key or no network, and "no models advertised"
|
|
495
|
+
// read as "the platform has none" rather than "this client could not
|
|
496
|
+
// ask".
|
|
497
|
+
note(c, c.state().online
|
|
498
|
+
? 'Could not read the model catalog (offline, or the server refused it) — /models retries.'
|
|
499
|
+
: // The key travels in the environment only (client/aegis.js reads
|
|
500
|
+
// AEGIS_API_KEY); /login is an unavailable command here, so
|
|
501
|
+
// pointing at it would send the user to a refusal.
|
|
502
|
+
'No API key set, so the model catalog cannot be read — export AEGIS_API_KEY (free at https://aegiscloud.org), then retry /model.');
|
|
482
503
|
c.render();
|
|
483
504
|
return true;
|
|
484
505
|
}
|
|
506
|
+
// A pin that is not in the catalog is not honoured — the pool answers
|
|
507
|
+
// from its own default with no error. Say so where the pin is visible
|
|
508
|
+
// rather than letting the reply look like the pinned model.
|
|
509
|
+
if (c.ctx.model && !models.some((m) => m.id === c.ctx.model)) {
|
|
510
|
+
note(c, `pinned model "${c.ctx.model}" is not in the catalog — the pool will answer with its own default; pick one below.`);
|
|
511
|
+
}
|
|
485
512
|
// The overlay's own copy promises /model add|remove (overlays.js, a
|
|
486
513
|
// separate workstream); this build refuses both, so say here how a
|
|
487
514
|
// model is actually selected.
|
|
@@ -516,6 +543,16 @@ const COMMANDS = [
|
|
|
516
543
|
// dir, so the write stays inside that dir.
|
|
517
544
|
c.saveConfig({ model: id, currentModelId: id });
|
|
518
545
|
note(c, `Pinned model: ${id}`);
|
|
546
|
+
// The server accepts an id it does not advertise and answers from its own
|
|
547
|
+
// default — no error, a different model, and the spend attributed to the
|
|
548
|
+
// id that was pinned. Warn (never refuse: the catalog is cached, and
|
|
549
|
+
// refusing would break a pin made against a server that is briefly
|
|
550
|
+
// unreachable) so the mismatch is visible at the moment it is created.
|
|
551
|
+
await loadModels(c);
|
|
552
|
+
const models = c.state().models || [];
|
|
553
|
+
if (models.length && !models.some((m) => m.id === id)) {
|
|
554
|
+
note(c, `"${id}" is not in the AEGIS Cloud catalog — the pool will answer with its own default. /models lists the real ids.`);
|
|
555
|
+
}
|
|
519
556
|
c.render();
|
|
520
557
|
return true;
|
|
521
558
|
},
|
package/src/config.js
CHANGED
|
@@ -43,13 +43,28 @@ function permissionsPath() {
|
|
|
43
43
|
|
|
44
44
|
const DEFAULT_CONFIG = {
|
|
45
45
|
themeIndex: 1, // Dark mode
|
|
46
|
-
model
|
|
46
|
+
// No pinned model. This host runs on AEGIS Cloud, whose pinnable ids are the
|
|
47
|
+
// server's (`/models`) — a client-side default here would have to name one,
|
|
48
|
+
// and the one it named (`sonnet`) is not advertised by the platform at all:
|
|
49
|
+
// the pool accepts an unknown id and answers from its own default with no
|
|
50
|
+
// error, so the pin looked honoured while the reply came from another model.
|
|
51
|
+
// Worse, onboarding persists this object on first run (`updateConfig` merges
|
|
52
|
+
// DEFAULT_CONFIG under the patch), so the phantom pin was written to disk for
|
|
53
|
+
// every user who ever completed the trust check. `null` = no pin; the server
|
|
54
|
+
// chooses, and app.js's validatePinnedModel() clears a stored id the catalog
|
|
55
|
+
// does not advertise.
|
|
56
|
+
model: null,
|
|
47
57
|
// Phase 6: the full model table (seeded from src/models.js MODELS on first
|
|
48
58
|
// read by pickerModels()). 'currentModelId' mirrors `model` under the
|
|
49
59
|
// aegiscode- name so /model add/remove/switch stay compatible both ways.
|
|
50
60
|
models: null,
|
|
51
61
|
currentModelId: null,
|
|
52
|
-
effort:
|
|
62
|
+
// `null` = no effort pinned, i.e. "auto": each turn is sized by the server
|
|
63
|
+
// from the ask itself (aegis1 services/pool_brain.py estimate_effort). The
|
|
64
|
+
// old 'high' was a *pin* on the top rung of the budget ladder — the most the
|
|
65
|
+
// server can grant — so the CLI's default turn was the most expensive one it
|
|
66
|
+
// could make, and /effort could only ever move it down.
|
|
67
|
+
effort: null,
|
|
53
68
|
vim: false,
|
|
54
69
|
lastCwd: '',
|
|
55
70
|
};
|
package/src/models.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AEGIS Cloud model catalog shaping — the one place `/model`, the alt+p picker
|
|
5
|
+
* and `/models` agree on what a pinnable model is.
|
|
6
|
+
*
|
|
7
|
+
* The ids a user may pin are the *server's*, never ours: the pool adds, renames
|
|
8
|
+
* and retires providers without a client release, so the catalog is fetched
|
|
9
|
+
* (`client.listModels()` → GET /api/v1/models on aegiscloud.org) and this module
|
|
10
|
+
* only normalises the payload. It deliberately invents no id, and has no
|
|
11
|
+
* fallback list: an offline client offers nothing rather than a model name that
|
|
12
|
+
* would silently route somewhere else.
|
|
13
|
+
*
|
|
14
|
+
* The platform lists per-provider ids (`deepseek`, `anthropic`, `groq`, …) plus
|
|
15
|
+
* the pooled-brain tiers. The desktop host collapsed all of that to a single
|
|
16
|
+
* "Nexus" entry because its dropdown *is* the model choice and surfacing
|
|
17
|
+
* per-provider routing invites pinning a provider that happens to be dead right
|
|
18
|
+
* now (see desktop/lib/local/engine.js selectBrainEntry). This host keeps every
|
|
19
|
+
* distinct id — a CLI user pinning `deepseek` explicitly is a legitimate
|
|
20
|
+
* request — but drops the pure-alias duplicates the server itself marks
|
|
21
|
+
* `hidden: true, alias_of: "<id>"` (`aegis-brain`, `nexus-brain-smart`, …),
|
|
22
|
+
* which are the same model advertised under another spelling.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A pinned id of `null` means "no pin — let the pool choose" (the server's own default). */
|
|
26
|
+
const NO_PIN = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Labels for catalog ids the platform advertises without capabilities or a
|
|
30
|
+
* label of their own — `openai-gpt4o-mini` reads like a typo next to `openai`,
|
|
31
|
+
* and the brain tier is the one id whose *cost shape* a user needs before
|
|
32
|
+
* pinning it: verified live 2026-09-14, `model: "nexus-brain"` streams
|
|
33
|
+
* `pool-brain: 3 workers · effort=high · tier=brain · passes=4`, i.e. four
|
|
34
|
+
* billed provider calls per turn, where a provider id is one.
|
|
35
|
+
*/
|
|
36
|
+
const ID_NOTES = Object.freeze({
|
|
37
|
+
'openai-gpt4o-mini': 'OpenAI gpt-4o-mini, pooled',
|
|
38
|
+
'anthropic-haiku': 'Anthropic Haiku, pooled',
|
|
39
|
+
'nexus-brain': 'pooled brain · 3 workers + synthesis',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** One raw entry (string id or object) → a catalog entry, or null when unusable. */
|
|
43
|
+
function normalizeModel(entry) {
|
|
44
|
+
const m = typeof entry === 'string' ? { id: entry } : entry;
|
|
45
|
+
if (!m || typeof m !== 'object') return null;
|
|
46
|
+
const id = typeof m.id === 'string' ? m.id.trim() : '';
|
|
47
|
+
if (!id) return null;
|
|
48
|
+
const aliasOf =
|
|
49
|
+
typeof m.alias_of === 'string' && m.alias_of.trim() ? m.alias_of.trim() : null;
|
|
50
|
+
const capabilities = Array.isArray(m.capabilities)
|
|
51
|
+
? m.capabilities.filter((c) => typeof c === 'string' && c)
|
|
52
|
+
: [];
|
|
53
|
+
const label =
|
|
54
|
+
(typeof m.label === 'string' && m.label.trim()) ||
|
|
55
|
+
(typeof m.name === 'string' && m.name.trim()) ||
|
|
56
|
+
id;
|
|
57
|
+
// The picker's right-hand column: what this entry *is*. An alias says so —
|
|
58
|
+
// otherwise a user reads five brain tiers and assumes five different models.
|
|
59
|
+
const note = aliasOf
|
|
60
|
+
? `alias of ${aliasOf}`
|
|
61
|
+
: ID_NOTES[id] || capabilities.join(', ');
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
label,
|
|
65
|
+
note,
|
|
66
|
+
hidden: m.hidden === true,
|
|
67
|
+
aliasOf,
|
|
68
|
+
capabilities,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalise a raw `/api/v1/models` payload (`{models: [...]}` or the array). */
|
|
73
|
+
function normalizeModelCatalog(raw) {
|
|
74
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw && raw.models) ? raw.models : [];
|
|
75
|
+
const out = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const entry of list) {
|
|
78
|
+
const m = normalizeModel(entry);
|
|
79
|
+
if (!m || seen.has(m.id)) continue;
|
|
80
|
+
seen.add(m.id);
|
|
81
|
+
out.push(m);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The entries the picker and `/models` offer: every distinct advertised model,
|
|
88
|
+
* minus the alias duplicates — unless the catalog is *only* aliases, in which
|
|
89
|
+
* case the aliases are all the server advertises and are offered rather than
|
|
90
|
+
* leaving the user with an empty list.
|
|
91
|
+
*/
|
|
92
|
+
function pickerEntries(catalog) {
|
|
93
|
+
const all = Array.isArray(catalog) ? catalog.filter(Boolean) : [];
|
|
94
|
+
const distinct = all.filter((m) => !m.aliasOf);
|
|
95
|
+
return distinct.length ? distinct : all;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The catalog entry for `id`, or null — the "is this a real id?" question. */
|
|
99
|
+
function findModel(catalog, id) {
|
|
100
|
+
const want = typeof id === 'string' ? id.trim() : '';
|
|
101
|
+
if (!want) return null;
|
|
102
|
+
return (Array.isArray(catalog) ? catalog : []).find((m) => m && m.id === want) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The set of ids the server accepts, lower-cased: a catalog id and a pinned id
|
|
107
|
+
* are the same thing only when they match byte-for-byte, but `/model Nexus-Brain`
|
|
108
|
+
* is a typo a user will make and the server's routing is case-insensitive
|
|
109
|
+
* enough that warning about it would be noise.
|
|
110
|
+
*/
|
|
111
|
+
function catalogIds(catalog) {
|
|
112
|
+
return new Set((Array.isArray(catalog) ? catalog : []).map((m) => String((m && m.id) || '').toLowerCase()).filter(Boolean));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
NO_PIN,
|
|
117
|
+
ID_NOTES,
|
|
118
|
+
normalizeModel,
|
|
119
|
+
normalizeModelCatalog,
|
|
120
|
+
pickerEntries,
|
|
121
|
+
findModel,
|
|
122
|
+
catalogIds,
|
|
123
|
+
};
|
package/src/overlays.js
CHANGED
|
@@ -177,27 +177,38 @@ function renderModelPicker(models, sel = 0, width = 80, height = 24, current = n
|
|
|
177
177
|
// ── Effort picker overlay (/effort) ──────────────────────────────────────────
|
|
178
178
|
|
|
179
179
|
const EFFORT_LEVELS = [
|
|
180
|
-
{ label: '
|
|
180
|
+
{ label: 'Auto', value: null, note: 'Sized per turn from the ask (default)' },
|
|
181
|
+
{ label: 'Low', note: 'Fastest, cheapest budget' },
|
|
181
182
|
{ label: 'Medium', note: 'Balanced' },
|
|
182
|
-
{ label: 'High', note: 'Highest
|
|
183
|
+
{ label: 'High', note: 'Highest budget, slowest' },
|
|
183
184
|
];
|
|
184
185
|
|
|
186
|
+
// The picker's order reduced to the values a selection means, so the row a user
|
|
187
|
+
// picks and the value the session stores can never come from two different
|
|
188
|
+
// lists (commands.js validates against this, chatflow.js resolves the overlay
|
|
189
|
+
// through it). `null` is "auto" — no rung pinned.
|
|
190
|
+
const EFFORT_VALUES = EFFORT_LEVELS.map((lv) => (lv.value === undefined ? lv.label.toLowerCase() : lv.value));
|
|
191
|
+
|
|
185
192
|
/**
|
|
186
193
|
* @param {number} sel selected index
|
|
187
194
|
* @param {number} width
|
|
188
|
-
* @param {string|null} current the currently-selected effort level
|
|
195
|
+
* @param {string|null} current the currently-selected effort level (null = auto)
|
|
189
196
|
* @param {Array} [levels] override the level table
|
|
190
197
|
*/
|
|
191
198
|
function renderEffortPicker(sel = 0, width = 80, current = null, levels = EFFORT_LEVELS) {
|
|
192
199
|
const lines = [];
|
|
193
200
|
lines.push(blank(width));
|
|
194
201
|
lines.push([span('', ' '), span(C.white + BOLD, 'Select effort'), span(BOLD_OFF, '')]);
|
|
195
|
-
lines.push([span('', ' '), span(C.gray, '
|
|
202
|
+
lines.push([span('', ' '), span(C.gray, 'Sets the token budget the pool sizes each turn from.')]);
|
|
196
203
|
lines.push(blank(width));
|
|
197
204
|
for (let i = 0; i < levels.length; i++) {
|
|
198
205
|
const lv = levels[i];
|
|
199
206
|
const active = i === sel;
|
|
200
|
-
|
|
207
|
+
// `current == null` is auto, which is the first row's own value — matching
|
|
208
|
+
// on the label alone would never mark the default as the current choice.
|
|
209
|
+
const cur = current == null
|
|
210
|
+
? lv.value === null
|
|
211
|
+
: String(current).toLowerCase() === String(lv.label).toLowerCase();
|
|
201
212
|
const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
|
|
202
213
|
const nameSpan = active ? span(C.lavender, lv.label) : span(C.white, lv.label);
|
|
203
214
|
const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
|
|
@@ -283,4 +294,6 @@ module.exports = {
|
|
|
283
294
|
renderModelPicker,
|
|
284
295
|
renderEffortPicker,
|
|
285
296
|
renderResumeList,
|
|
297
|
+
EFFORT_LEVELS,
|
|
298
|
+
EFFORT_VALUES,
|
|
286
299
|
};
|
package/src/panels.js
CHANGED
|
@@ -202,7 +202,9 @@ function buildStatus(state, ctx = {}) {
|
|
|
202
202
|
add('Working directory', s.cwd);
|
|
203
203
|
add('Home', s.home);
|
|
204
204
|
add('Model', s.model);
|
|
205
|
-
|
|
205
|
+
// `null` = auto: the pool sizes each turn from the ask, so the row says so
|
|
206
|
+
// rather than vanishing (add() skips empty values).
|
|
207
|
+
add('Effort', s.effort || 'auto');
|
|
206
208
|
if (s.thinking != null) add('Thinking', s.thinking ? 'on' : 'off');
|
|
207
209
|
if (s.stream != null) add('Streaming', s.stream === false ? 'off' : 'on');
|
|
208
210
|
if (s.vim != null) add('Vim keymap', s.vim ? 'on' : 'off');
|
package/vendor/client/aegis.js
CHANGED
|
@@ -278,6 +278,13 @@ function createClient(opts = {}) {
|
|
|
278
278
|
* so a reasoning-only stream still counts as unanswered.
|
|
279
279
|
* - `idleTimeoutMs` override the stalled-stream watchdog for this call
|
|
280
280
|
* (a worker fan-out is legitimately silent between passes)
|
|
281
|
+
* - `maxTokens` output ceiling. Omitted from the body when absent so the
|
|
282
|
+
* server's own budget applies — on a pooled request that is
|
|
283
|
+
* the `effort` ladder, and a number sent from here caps it.
|
|
284
|
+
* - `effort` / `workers` pooled-brain budget rung and fan-out size.
|
|
285
|
+
* Forwarded only inside `extra`, as the server reads them off
|
|
286
|
+
* the body (aegis1 services/pool_brain.py parse_brain_request)
|
|
287
|
+
* and sizes the budget from them.
|
|
281
288
|
*/
|
|
282
289
|
async function chatCompletion({
|
|
283
290
|
prompt,
|
|
@@ -298,9 +305,17 @@ function createClient(opts = {}) {
|
|
|
298
305
|
// model the server picks its default (no client-invented tier id). `mode`
|
|
299
306
|
// is a legacy server-side shorthand — forwarded verbatim only when the
|
|
300
307
|
// caller supplies it, never defaulted, never built into a model id.
|
|
308
|
+
//
|
|
309
|
+
// `max_tokens` is omitted entirely when the caller states none, rather than
|
|
310
|
+
// defaulted here. The server has its own budget ladder (`mode`/`effort` on
|
|
311
|
+
// the pooled path) and treats a body max_tokens as a *ceiling* over it, so
|
|
312
|
+
// an invented 4096 was not a harmless default: it capped every pass of a
|
|
313
|
+
// pooled-brain fan-out at 4096 and overrode the ladder the caller's effort
|
|
314
|
+
// selected. Omitting it is the documented way to say "server default" (see
|
|
315
|
+
// mcp/tools.js's max_tokens description).
|
|
301
316
|
const body = {
|
|
302
317
|
messages: buildMessages(messages, system, prompt),
|
|
303
|
-
max_tokens: maxTokens
|
|
318
|
+
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
|
304
319
|
...(extra || {}),
|
|
305
320
|
};
|
|
306
321
|
if (model) body.model = model;
|
|
@@ -473,6 +473,17 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
473
473
|
|
|
474
474
|
async function listModels(cls) {
|
|
475
475
|
if (cls === 'aegis') {
|
|
476
|
+
// The catalog is behind the account's key: GET /api/v1/models answers
|
|
477
|
+
// `401 {"error":{"message":"No API key"}}` without one (verified against
|
|
478
|
+
// aegiscloud.org). Aegis Cloud is the *default* class, so firing the call
|
|
479
|
+
// anyway painted a raw "listModels failed: … 401" over the default model
|
|
480
|
+
// picker for every user who had just installed the app and not yet
|
|
481
|
+
// pasted a key — the one state where the UI must say what unblocks it and
|
|
482
|
+
// not what went wrong. Report the missing key as a state (`needsKey`) and
|
|
483
|
+
// let the renderer invite the user to connect; nothing else about the
|
|
484
|
+
// class changes, and the model dropdown keeps its "server default (auto)"
|
|
485
|
+
// entry so the class is usable the moment a key lands.
|
|
486
|
+
if (!aegis.apiKey) return { class: cls, models: [], needsKey: true };
|
|
476
487
|
const data = await aegis.listModels();
|
|
477
488
|
return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
|
|
478
489
|
}
|
|
@@ -573,13 +584,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
573
584
|
// model as the fan-out, one sample instead of four — otherwise
|
|
574
585
|
// dropping the fan-out would have quietly changed the model too.
|
|
575
586
|
...(brainFlag === false ? { mode: 'brain' } : {}),
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
587
|
+
// `effort` is the budget rung, and it is sent whenever the caller has
|
|
588
|
+
// one — not only alongside a fan-out. The fan-out is triggered by the
|
|
589
|
+
// model id (this class sends ``nexus-brain``), so a caller that never
|
|
590
|
+
// ticked "work autonomously" still ran a pooled call and had no way to
|
|
591
|
+
// say how big it should be: the server fell through to its own default
|
|
592
|
+
// rung. That is how a one-word prompt came to reserve the top of the
|
|
593
|
+
// ladder. A single-pass pass carries it too — inert on that path, but
|
|
594
|
+
// it keeps the request honest about what it asked for.
|
|
595
|
+
...(opts.effort ? { effort: opts.effort } : {}),
|
|
596
|
+
// Only meaningful with a running fan-out — aegis1 services/pool_brain.py
|
|
597
|
+
// parse_brain_request reads `workers` straight off the body and clamps
|
|
598
|
+
// it itself (MAX_WORKERS), so no client-side validation here.
|
|
583
599
|
...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
|
|
584
600
|
// The pool forwards `tools` to the provider and returns tool_calls
|
|
585
601
|
// (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
|