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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.3.0",
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
- models: [],
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
- note(c, 'No pinnable models advertised /models lists what the server advertises.');
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: 'sonnet',
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
  }