aegiscode 6.3.0 → 6.3.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/package.json +1 -1
- package/src/app.js +81 -1
- package/src/commands.js +26 -1
- package/src/config.js +11 -1
- package/src/models.js +123 -0
- package/vendor/desktop/lib/local/engine.js +11 -0
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.1",
|
|
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');
|
|
@@ -146,6 +147,71 @@ function createApp(options = {}) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// ── the AEGIS Cloud model catalog ──────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
|
|
153
|
+
// from the server, and `state().models` is where it looks. That path had no
|
|
154
|
+
// source at all: `commands.js` calls `c.loadModels()` inside a `try {} catch
|
|
155
|
+
// {}`, `c.loadModels` was never defined on either command context, and
|
|
156
|
+
// `buildState()` hardcoded `models: []` — so the TypeError was swallowed and
|
|
157
|
+
// both `/model` and the picker reported "no models advertised" on a perfectly
|
|
158
|
+
// healthy account, forever. The ids themselves are the server's (see
|
|
159
|
+
// models.js), fetched here and cached, because the pool adds and retires
|
|
160
|
+
// providers without a client release.
|
|
161
|
+
const MODEL_CACHE_MS = 5 * 60_000;
|
|
162
|
+
const modelCache = { at: 0, models: [] };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fetch (or return the cached) model catalog. Rejects when the account cannot
|
|
166
|
+
* read it at all — no key, offline, or a server error — which callers treat as
|
|
167
|
+
* "nothing to offer" rather than retrying per keystroke.
|
|
168
|
+
* @returns {Promise<Array<{id:string,label:string,note:string}>>}
|
|
169
|
+
*/
|
|
170
|
+
async function loadModels({ force = false } = {}) {
|
|
171
|
+
const fresh = modelCache.models.length && Date.now() - modelCache.at < MODEL_CACHE_MS;
|
|
172
|
+
if (!force && fresh) return modelCache.models;
|
|
173
|
+
const data = await client.listModels();
|
|
174
|
+
modelCache.models = normalizeModelCatalog(data && data.models);
|
|
175
|
+
modelCache.at = Date.now();
|
|
176
|
+
return modelCache.models;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A pinned model id the server does not advertise is a *silent* fallback: the
|
|
181
|
+
* pool answers from its own default with no error, so the pin looks honoured
|
|
182
|
+
* while the reply came from another model — and the cost is attributed to the
|
|
183
|
+
* model that was pinned. Two shipped defaults did exactly that: `sonnet`
|
|
184
|
+
* (written into config.json by onboarding, merged in from DEFAULT_CONFIG, and
|
|
185
|
+
* never advertised by AEGIS Cloud) and any `provider/model` spelling a user
|
|
186
|
+
* typed by hand. Clears such a pin once, says so, and leaves the server's own
|
|
187
|
+
* default in its place.
|
|
188
|
+
*
|
|
189
|
+
* Best-effort and offline-safe: a catalog that cannot be read clears nothing.
|
|
190
|
+
*/
|
|
191
|
+
async function validatePinnedModel() {
|
|
192
|
+
const pinned = commandCtx.model;
|
|
193
|
+
if (!pinned) return null;
|
|
194
|
+
let models;
|
|
195
|
+
try {
|
|
196
|
+
models = await loadModels();
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (!models.length) return null;
|
|
201
|
+
if (catalogIds(models).has(String(pinned).toLowerCase())) return null;
|
|
202
|
+
commandCtx.model = null;
|
|
203
|
+
updateConfig({ model: null, currentModelId: null });
|
|
204
|
+
emit(
|
|
205
|
+
render.renderNotice(
|
|
206
|
+
ctx(),
|
|
207
|
+
'warn',
|
|
208
|
+
`pinned model "${pinned}" is not advertised by AEGIS Cloud — pin cleared, ` +
|
|
209
|
+
'the pool will choose; /models lists what you can pin.'
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
return pinned;
|
|
213
|
+
}
|
|
214
|
+
|
|
149
215
|
function bannerLines() {
|
|
150
216
|
return render.renderBanner(ctx(), {
|
|
151
217
|
width: width(),
|
|
@@ -517,7 +583,9 @@ function createApp(options = {}) {
|
|
|
517
583
|
plan: session.plan || null,
|
|
518
584
|
account: session.account || null,
|
|
519
585
|
permissions: { mode: rules.defaultMode, rules },
|
|
520
|
-
|
|
586
|
+
// The selectable (pickable) catalog, not the raw payload: alias tiers are
|
|
587
|
+
// dropped, live ids only (see models.js pickerEntries).
|
|
588
|
+
models: pickerEntries(modelCache.models),
|
|
521
589
|
commands: visibleCommands(),
|
|
522
590
|
transcript: transcript.slice(),
|
|
523
591
|
sessions: [],
|
|
@@ -633,6 +701,10 @@ function createApp(options = {}) {
|
|
|
633
701
|
runPrompt: (text) => runPrompt(text),
|
|
634
702
|
ask: (text) => ask(text),
|
|
635
703
|
runTool: (name, args) => runTool(name, args),
|
|
704
|
+
// The AEGIS catalog fetch `/model` and alt+p expect (see loadModels).
|
|
705
|
+
// Wired on the app's context so the chatflow's `Object.assign`-based
|
|
706
|
+
// context inherits it too — one definition, both hosts.
|
|
707
|
+
loadModels: (o) => loadModels(o),
|
|
636
708
|
refreshSpend: () => refreshSpend(),
|
|
637
709
|
state: () => buildState(),
|
|
638
710
|
setInput: () => {},
|
|
@@ -916,6 +988,7 @@ function createApp(options = {}) {
|
|
|
916
988
|
TOOLS,
|
|
917
989
|
ask: (prompt, o) => ask(prompt, o),
|
|
918
990
|
makeCommandContext: () => makeCommandContext(),
|
|
991
|
+
loadModels: (o) => loadModels(o),
|
|
919
992
|
buildState: () => buildState(),
|
|
920
993
|
dispatchLine: (line, c) => handleLine(line, c),
|
|
921
994
|
refreshSpend: () => refreshSpend(),
|
|
@@ -1037,6 +1110,11 @@ function createApp(options = {}) {
|
|
|
1037
1110
|
save: (patch) => updateConfig(patch),
|
|
1038
1111
|
});
|
|
1039
1112
|
if (!onboard.ok) return 0;
|
|
1113
|
+
// A stored pin the platform does not advertise routes elsewhere in
|
|
1114
|
+
// silence (see validatePinnedModel) — checked once, here, where the user
|
|
1115
|
+
// can act on it. Not on `-p`: a network round-trip ahead of the first
|
|
1116
|
+
// token would be a startup cost bought for a warning no script watches.
|
|
1117
|
+
await validatePinnedModel();
|
|
1040
1118
|
// `--continue` must load the last session *before* the loop starts, and
|
|
1041
1119
|
// it has to be read here rather than captured at construction: the
|
|
1042
1120
|
// history file is written by the loop itself.
|
|
@@ -1071,6 +1149,8 @@ function createApp(options = {}) {
|
|
|
1071
1149
|
refreshSpend,
|
|
1072
1150
|
bannerLines,
|
|
1073
1151
|
makeHost,
|
|
1152
|
+
loadModels,
|
|
1153
|
+
validatePinnedModel,
|
|
1074
1154
|
recordTurn,
|
|
1075
1155
|
persistTurn,
|
|
1076
1156
|
restorePrefs,
|
package/src/commands.js
CHANGED
|
@@ -478,10 +478,25 @@ const COMMANDS = [
|
|
|
478
478
|
await loadModels(c);
|
|
479
479
|
const models = c.state().models || [];
|
|
480
480
|
if (!models.length) {
|
|
481
|
-
|
|
481
|
+
// Say why, and what unblocks it: an unreachable catalog is almost
|
|
482
|
+
// always a missing key or no network, and "no models advertised"
|
|
483
|
+
// read as "the platform has none" rather than "this client could not
|
|
484
|
+
// ask".
|
|
485
|
+
note(c, c.state().online
|
|
486
|
+
? 'Could not read the model catalog (offline, or the server refused it) — /models retries.'
|
|
487
|
+
: // The key travels in the environment only (client/aegis.js reads
|
|
488
|
+
// AEGIS_API_KEY); /login is an unavailable command here, so
|
|
489
|
+
// pointing at it would send the user to a refusal.
|
|
490
|
+
'No API key set, so the model catalog cannot be read — export AEGIS_API_KEY (free at https://aegiscloud.org), then retry /model.');
|
|
482
491
|
c.render();
|
|
483
492
|
return true;
|
|
484
493
|
}
|
|
494
|
+
// A pin that is not in the catalog is not honoured — the pool answers
|
|
495
|
+
// from its own default with no error. Say so where the pin is visible
|
|
496
|
+
// rather than letting the reply look like the pinned model.
|
|
497
|
+
if (c.ctx.model && !models.some((m) => m.id === c.ctx.model)) {
|
|
498
|
+
note(c, `pinned model "${c.ctx.model}" is not in the catalog — the pool will answer with its own default; pick one below.`);
|
|
499
|
+
}
|
|
485
500
|
// The overlay's own copy promises /model add|remove (overlays.js, a
|
|
486
501
|
// separate workstream); this build refuses both, so say here how a
|
|
487
502
|
// model is actually selected.
|
|
@@ -516,6 +531,16 @@ const COMMANDS = [
|
|
|
516
531
|
// dir, so the write stays inside that dir.
|
|
517
532
|
c.saveConfig({ model: id, currentModelId: id });
|
|
518
533
|
note(c, `Pinned model: ${id}`);
|
|
534
|
+
// The server accepts an id it does not advertise and answers from its own
|
|
535
|
+
// default — no error, a different model, and the spend attributed to the
|
|
536
|
+
// id that was pinned. Warn (never refuse: the catalog is cached, and
|
|
537
|
+
// refusing would break a pin made against a server that is briefly
|
|
538
|
+
// unreachable) so the mismatch is visible at the moment it is created.
|
|
539
|
+
await loadModels(c);
|
|
540
|
+
const models = c.state().models || [];
|
|
541
|
+
if (models.length && !models.some((m) => m.id === id)) {
|
|
542
|
+
note(c, `"${id}" is not in the AEGIS Cloud catalog — the pool will answer with its own default. /models lists the real ids.`);
|
|
543
|
+
}
|
|
519
544
|
c.render();
|
|
520
545
|
return true;
|
|
521
546
|
},
|
package/src/config.js
CHANGED
|
@@ -43,7 +43,17 @@ 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.
|
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
|
+
};
|
|
@@ -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
|
}
|