aegis-desktop 0.8.28 → 0.8.31

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/README.md CHANGED
@@ -61,13 +61,19 @@ with context intact:
61
61
  | **Bring your own key** | your provider key, relayed by AEGIS — see below | main process | yes — flat AEGIS handling fee |
62
62
  | **Local** | a model on your own machine or network — your Ollama daemon at `localhost:11434` by default | n/a — no key | no — free, no AEGIS account needed |
63
63
 
64
- The Cloud class offers **no pinnable model** in this app: its dropdown keeps the
65
- one *server default (auto)* entry, and the aegiscloud ids (`nexus-brain` and the
66
- `-smart`/`-neo` spellings the server sends as aliases of it, plus the
67
- per-provider ids it lists beside them) are omitted from the list. The lane is
68
- unchanged by that — the account key, the pooled turn, cloud memory sync and the
69
- BYOK handling fee billed to this account all still work; there is simply nothing
70
- to pin. Per-provider models are reachable through **Bring your own key** below.
64
+ The Cloud class offers **everything the server advertises**: the pooled brain
65
+ first, as one row (`Nexus` — the `nexus-brain`/`aegis-brain` and the
66
+ `-smart`/`-neo` spellings the server sends as aliases of it are folded into that
67
+ one row, because they all name the same route), then every per-provider id the
68
+ same catalogue lists, so `deepseek`, `anthropic`, `groq`, `openai` … can be
69
+ pinned directly. Both hosts build that list from one shared rule
70
+ (`client/brain-catalog.js`, `offerableCatalog`), so the GUI and the terminal
71
+ cannot show one account two different lists. A pinned per-provider id is still a
72
+ **pooled** turn: your account key pays the pool's margin, and the plan and
73
+ balance gates on `/api/v1/chat/completions` apply to it exactly as they do to
74
+ the brain row. The same models are also reachable through **Bring your own
75
+ key** below — your provider key, and a flat handling fee instead of the pool's
76
+ margin.
71
77
 
72
78
  Get a free AEGIS key at **https://aegiscloud.org** for the Cloud and BYOK
73
79
  classes. The generic OpenAI-/Anthropic-compatible direct-dial lanes that used
@@ -108,9 +114,26 @@ traffic — BYOK stays the cheaper lane, it just is not the free one.
108
114
  handling fee has to be billed somewhere. With no AEGIS key connected the class
109
115
  shows every model but says exactly that, rather than failing opaquely.
110
116
 
111
- Note that a **BYOK turn is single-shot** — the relay takes no `tools` parameter,
112
- so the agentic tool loop below is off for these models. Use the **Aegis Cloud**
113
- class when you want file and shell access.
117
+ **And the relay decides whether your bank has to be funded.** The route
118
+ publishes its own policy on `GET /api/v1/byok/providers` (`fee.gate_mode`,
119
+ `fee.require_account`, `fee.require_balance`) and the app reads it there rather
120
+ than assuming one: in the `balance` mode the live deployment runs, a BYOK turn
121
+ needs an account **with a balance**, and a zero bank answers `402 Insufficient
122
+ balance. Top up to continue using BYOK.` — your provider key is still valid, the
123
+ AEGIS handling fee is what ran out (see the top-up surface). In the softer
124
+ `owner` mode a named account is enough and an unfunded fee is recorded as owed.
125
+ An anonymous caller (no AEGIS key at all) is refused in every enforcing mode,
126
+ which is the released clients' own gate too — this class does not serve a turn
127
+ nobody can be billed for.
128
+
129
+ **The agentic tool loop works here too.** The relay's BYOK route accepts the
130
+ OpenAI-shaped `tools`/`tool_choice` and translates them to each provider's own
131
+ wire format (Anthropic-backed callers get real tool calls), so file, shell and
132
+ search access below are available on this lane — the engine sends the same tool
133
+ schema it sends on the pooled lane. What the class does *not* get is the cloud
134
+ drain in the **autonomous queue**, which is `Aegis Cloud`-only (see
135
+ `desktop/lib/local/autonomous.js`) — that is the fan-out Pro buys, not the tool
136
+ loop.
114
137
 
115
138
  ## Screenshots
116
139
 
@@ -97,48 +97,100 @@ function writtenPath(tool) {
97
97
  const DEFAULT_MODEL = 'nexus-brain';
98
98
 
99
99
  /**
100
- * THE QUEUE RUNS ON AEGIS CLOUD, AND NOTHING ELSE.
100
+ * THE QUEUE RUNS ON WHATEVER CLASS THE TASK NAMES — aegis, byok, or local —
101
+ * the same three classes engine.js's interactive turns serve (engine.js's
102
+ * exported `CLASSES`). This module does not `require('./engine.js')` to read
103
+ * that list itself (same reason queue.js stays engine-free): duplicating the
104
+ * three names here keeps both modules testable with no Electron, no network,
105
+ * and no model.
101
106
  *
102
- * A queued task is billed to the AEGIS pool and every turn it makes goes out
103
- * with `class: 'aegis'` (see runTask below) — the pool is the only backend this
104
- * worker can reach. So a model id here has to be one the *pool* serves. A
105
- * direct-provider id is not a cheaper option the queue could fall back to; it
106
- * is a request the pool cannot honour, or worse, a per-provider spelling
107
- * (`anthropic`, `groq`, …) that quietly pins one upstream instead of letting
108
- * the pool auto-route across whichever providers hold a live key.
107
+ * This used to be Aegis Cloud only, because runTask sent every turn out with
108
+ * a hardcoded `class: 'aegis'` — a byok or local model id would have gone out
109
+ * under the wrong transport and failed at the server, or worse, silently
110
+ * picked up the wrong provider's key. Now `class` travels with the task
111
+ * (`item.class`, defaulted to 'aegis' for every task that predates this field
112
+ * — the CLI, a hand-written queue file, the old desktop card), and runTask
113
+ * forwards it verbatim instead of overwriting it.
109
114
  *
110
- * The accept-list is therefore the pooled-brain tier family — the one entry
111
- * engine.js's offerableCatalog offers for the Aegis Cloud class, plus the
112
- * `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
113
- * mirrors selectBrainEntry() there rather than re-deriving "anything starting
114
- * with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
115
- * accepting a made-up id means a queued task that fails at the server after
116
- * being picked up, or runs on a tier nobody chose.
115
+ * Each class validates differently, because each has a different notion of
116
+ * "no model stated is fine":
117
+ * - 'aegis' has a real default (the pooled brain), so a blank model is
118
+ * accepted and resolveModel fills it in. The accept-list is the
119
+ * pooled-brain tier family — the one entry engine.js's offerableCatalog
120
+ * offers for the Aegis Cloud class, plus the `-smart`/`-neo` tier
121
+ * spellings the server still serves as aliases of it (mirrors
122
+ * selectBrainEntry() there rather than re-deriving "anything starting
123
+ * with nexus-": `nexus-fast` is not a tier the catalog has ever served).
124
+ * - 'byok' and 'local' have no such default: a blank byok model cannot be
125
+ * "whichever provider the pool picks" (there is no pool), and a blank
126
+ * local model cannot be "whichever model is pulled" when the answer
127
+ * depends on what the user's own daemon happens to have. Both REQUIRE an
128
+ * explicit model, refused by name instead of silently resolving to
129
+ * something nobody chose.
117
130
  */
131
+ const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
132
+
118
133
  const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
119
134
  const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
120
135
 
121
- /** True when `id` names an AEGIS Cloud pooled-brain tier (the queue's only models). */
136
+ /** True when `id` names an AEGIS Cloud pooled-brain tier. */
122
137
  function isAegisModel(id) {
123
138
  return AEGIS_MODEL_RE.test(String(id == null ? '' : id).trim());
124
139
  }
125
140
 
126
141
  /**
127
- * Why a STATED model id cannot be queued, or '' when it can. Blank is not a
128
- * refusal — "no pick" is the default model, which resolveModel supplies.
142
+ * Normalize a task's stated class to one this module (and engine.js) knows.
143
+ * Blank or absent — every task queued before this field existed — defaults to
144
+ * 'aegis', the only class the queue ever ran before today.
145
+ */
146
+ function normalizeClass(cls) {
147
+ const stated = String(cls == null ? '' : cls).trim().toLowerCase();
148
+ return stated || 'aegis';
149
+ }
150
+
151
+ /**
152
+ * Whether `id` is shaped "<provider>:<model>" — the only form engine.js's
153
+ * byok class accepts (splitByokModel). Not re-deriving the live provider list
154
+ * here (that is a catalog call, `listModels('byok')`, this module has no
155
+ * business caching) — just enough of a shape check that a malformed id is
156
+ * refused at the door instead of failing splitByokModel at the server with no
157
+ * context.
158
+ */
159
+ function looksLikeByokModel(id) {
160
+ const s = String(id == null ? '' : id).trim();
161
+ const i = s.indexOf(':');
162
+ return i > 0 && i < s.length - 1;
163
+ }
164
+
165
+ /**
166
+ * Why a STATED (model, class) pair cannot be queued, or '' when it can.
129
167
  *
130
- * The message names the pool, because the failure it prevents ("queued on
131
- * claude-sonnet-4, ran on — or was billed to — something else") is invisible
132
- * otherwise: `class: 'aegis'` would be sent with an id the server does not
133
- * serve, and the task would come back as an opaque error minutes later.
168
+ * `cls` defaults to 'aegis' when omitted, so every existing caller of
169
+ * `modelRefusal(id)` keeps its old behaviour verbatim.
134
170
  */
135
- function modelRefusal(id) {
171
+ function modelRefusal(id, cls) {
136
172
  const stated = String(id == null ? '' : id).trim();
137
- if (!stated || isAegisModel(stated)) return '';
138
- return (
139
- `the autonomous queue runs Aegis Cloud models only (${DEFAULT_MODEL}, ` +
140
- `${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
141
- );
173
+ const normalized = normalizeClass(cls);
174
+ if (!MODEL_CLASSES.includes(normalized)) {
175
+ return `"${cls}" is not a model class the queue serves (${MODEL_CLASSES.join('/')})`;
176
+ }
177
+ if (normalized === 'aegis') {
178
+ if (!stated || isAegisModel(stated)) return '';
179
+ return (
180
+ `the autonomous queue's Aegis Cloud class runs pooled-brain models only (${DEFAULT_MODEL}, ` +
181
+ `${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
182
+ );
183
+ }
184
+ if (normalized === 'byok') {
185
+ if (!stated) return 'a byok task needs a model, as "<provider>:<model>" (pick one from /models)';
186
+ if (!looksLikeByokModel(stated)) {
187
+ return `"${stated}" is not a byok model id — it must be "<provider>:<model>" (pick one from /models)`;
188
+ }
189
+ return '';
190
+ }
191
+ // normalized === 'local'
192
+ if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
193
+ return '';
142
194
  }
143
195
 
144
196
  /** Phrase match for "work autonomously" in a prompt or a queued task. */
@@ -239,10 +291,14 @@ function withRoundHorizon(rounds, env, fn) {
239
291
  * task over it would break drains for a reason that is not the task's
240
292
  * fault; a fallback to the pooled brain keeps the drain honest and on-cloud.
241
293
  */
242
- function resolveModel({ model, env } = {}) {
243
- const e = env || process.env;
294
+ function resolveModel({ model, env, cls } = {}) {
244
295
  const picked = String(model || '').trim();
296
+ // byok/local have no cloud default to fall back to — modelRefusal already
297
+ // guaranteed a non-empty, shaped model at queue time, so the task's own pick
298
+ // is the only answer for either class.
299
+ if (normalizeClass(cls) !== 'aegis') return picked;
245
300
  if (picked) return picked;
301
+ const e = env || process.env;
246
302
  const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
247
303
  return isAegisModel(fromEnv) ? fromEnv : DEFAULT_MODEL;
248
304
  }
@@ -255,7 +311,8 @@ function resolveModel({ model, env } = {}) {
255
311
  * it: the queue falls back to the pool, and the one place that says so is this
256
312
  * string, which runTask emits as a note and the desktop card shows.
257
313
  */
258
- function ignoredEnvModel({ model, env } = {}) {
314
+ function ignoredEnvModel({ model, env, cls } = {}) {
315
+ if (normalizeClass(cls) !== 'aegis') return ''; // AEGIS_AUTONOMOUS_MODEL is an Aegis-only pin
259
316
  if (String(model || '').trim()) return ''; // the task's own pick is what counts
260
317
  const e = env || process.env;
261
318
  const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
@@ -462,16 +519,23 @@ function createQueueWorker({
462
519
  // pool does not serve: billed work if it happens to be a per-provider
463
520
  // spelling, an opaque server error otherwise. Failing here names the model
464
521
  // and the allowed ones, and costs nothing.
465
- const refusal = modelRefusal(item.model);
522
+ const cls = normalizeClass(item.class);
523
+ const refusal = modelRefusal(item.model, cls);
466
524
  if (refusal) {
467
- const failed = { ok: false, error: refusal, model: item.model, ms: 0 };
525
+ const failed = { ok: false, error: refusal, model: item.model, class: cls, ms: 0 };
468
526
  emit({ type: 'finish', taskId: item.id, ok: false, result: failed });
469
527
  return failed;
470
528
  }
471
- const model = resolveModel({ model: item.model, env });
472
- const ignored = ignoredEnvModel({ model: item.model, env });
529
+ const model = resolveModel({ model: item.model, env, cls });
530
+ const ignored = ignoredEnvModel({ model: item.model, env, cls });
473
531
  if (ignored) emit({ type: 'note', taskId: item.id, note: ignored });
474
- const fanoutAsked = resolveFanout(item, env);
532
+ // The pooled-brain fan-out is an Aegis Cloud mechanism (aegis1
533
+ // services/pool_brain.py) — engine.js itself only honours `autonomous`
534
+ // under `cls === 'aegis'` (engine.js:1524), so a byok/local task asking for
535
+ // it is forced to a plain single pass HERE rather than relying on that
536
+ // silent no-op, which would otherwise size the task's effort/round
537
+ // reporting as if a fan-out it never got had been granted.
538
+ const fanoutAsked = cls === 'aegis' && resolveFanout(item, env);
475
539
  // The clamp. `canFanOut()` is the entitlement answer (true everywhere
476
540
  // except a non-Pro desktop session), and it can only ever take a fan-out
477
541
  // AWAY: a task the session is not entitled to fan out runs as one plain
@@ -526,14 +590,14 @@ function createQueueWorker({
526
590
  const wantCommit = commit === undefined ? Boolean(item.commit) : Boolean(commit);
527
591
  const before = wantCommit ? safe(() => git.gitStatusSnapshot(cwd), null) : null;
528
592
 
529
- emit({ type: 'start', taskId: item.id, model, cwd, rounds, fanout, effort });
593
+ emit({ type: 'start', taskId: item.id, model, class: cls, cwd, rounds, fanout, effort });
530
594
  const started = now();
531
595
  let result;
532
596
  try {
533
597
  const res = await withRoundHorizon(rounds, env, () =>
534
598
  engine.chat(
535
599
  {
536
- class: 'aegis',
600
+ class: cls,
537
601
  model,
538
602
  prompt: taskPrompt(item, { carry, rounds }),
539
603
  // The pooled-brain fan-out ("fan out this turn" in the GUI chat
@@ -831,8 +895,11 @@ module.exports = {
831
895
  DEFAULT_MODEL,
832
896
  DEFAULT_ROUNDS,
833
897
  MAX_ROUND_STOPS,
898
+ MODEL_CLASSES,
834
899
  AEGIS_MODEL_IDS,
835
900
  isAegisModel,
901
+ normalizeClass,
902
+ looksLikeByokModel,
836
903
  modelRefusal,
837
904
  WRITE_TOOLS,
838
905
  writtenPath,
@@ -40,17 +40,27 @@ function messageTokens(message) {
40
40
  * @param {string} [opts.system='']
41
41
  * @param {number} [opts.budgetTokens=8192] total tokens allowed on the wire
42
42
  * @param {number} [opts.reserveTokens=0] tokens reserved for the reply
43
- * @returns {{messages:Array, system:string, dropped:number, estimatedTokens:number}}
43
+ * @param {number} [opts.fixedTokens=0] wire cost that is NOT a message and
44
+ * cannot be dropped (the tool schemas), counted before any turn is kept
45
+ * @returns {{messages:Array, system:string, dropped:number, estimatedTokens:number, fixedTokens:number}}
44
46
  */
45
47
  function trimContext({
46
48
  messages = [],
47
49
  system = '',
48
50
  budgetTokens = 8192,
49
51
  reserveTokens = 0,
52
+ fixedTokens = 0,
50
53
  } = {}) {
51
54
  const limit = Math.max(0, budgetTokens - reserveTokens);
52
55
  const kept = [];
53
- let used = 0;
56
+ // Tool schemas go on the wire every round and cannot be trimmed away, so they
57
+ // are charged against the budget FIRST. Without this the trimmer believed a
58
+ // 4096-token window had 4096 tokens for history when the schemas had already
59
+ // spent ~1971 of them (measured: 10 schemas, 7882 chars) — and the daemon,
60
+ // run with `--context-shift`, discarded the SYSTEM row to make room instead
61
+ // of erroring. Counting them here is what keeps the system row alive.
62
+ const fixed = Math.max(0, Number(fixedTokens) || 0);
63
+ let used = fixed;
54
64
 
55
65
  if (system) used += MESSAGE_OVERHEAD + estimateTokens(system);
56
66
 
@@ -69,6 +79,7 @@ function trimContext({
69
79
  system,
70
80
  dropped: messages.length - kept.length,
71
81
  estimatedTokens: used,
82
+ fixedTokens: fixed,
72
83
  };
73
84
  }
74
85
 
@@ -968,6 +968,19 @@ function createLocalEngine({
968
968
  // reverse its verdict. An approval that could waive Λ2 would be the exact
969
969
  // capture K3 describes. To change what is permitted, the covenant text
970
970
  // itself has to be re-sealed — which no runtime input can do.
971
+ //
972
+ // DO NOT MOVE THIS BELOW A SHORT-CIRCUIT. It sits above every `return` of
973
+ // the confirmation gate on purpose (ON8/D8 rule 2): `gatedExecuteTool`
974
+ // short-circuits on `!confirmModeEnabled()`, on a remembered session
975
+ // "allow", and on a `/permissions` skip — and since ON8 the confirm-mode
976
+ // preference is the SHARED store's, i.e. the same value the terminal face
977
+ // reads, and `--dangerously-skip-permissions` can arrive in that store as
978
+ // well. A refactor that hoists this below any of those three would make
979
+ // the covenant waivable by a setting a user can write, which is the one
980
+ // thing D8 rule 2 forbids outright. The frozen classes
981
+ // (`write`/`shell`/`network`/`offDevice`, always `'ask'`; see
982
+ // lib/avatar/level.js `LEVEL_INDEPENDENT_APPROVALS`) have the same floor
983
+ // and are asserted beside this one by one/test/permissions.test.mjs.
971
984
  const cv = covenant.preActionAssert({ tool: name, args: args || {} });
972
985
  if (!cv.allowed) {
973
986
  // Non-silent (never a quiet no-op) and bound for Λ4 accountability.
@@ -2114,6 +2127,18 @@ function createLocalEngine({
2114
2127
  return { ok: Boolean(controller) };
2115
2128
  }
2116
2129
 
2130
+ /**
2131
+ * Unload every local model this engine warmed, across every daemon it
2132
+ * talked to (local.js `unloadAll`). The desktop host calls this on its quit
2133
+ * path so closing the app also closes the model — the local lane bills
2134
+ * nothing precisely because it is YOUR hardware, and hardware left running
2135
+ * a model nobody is using anymore after the app exits is the one cost that
2136
+ * lane was supposed to never have.
2137
+ */
2138
+ function unloadLocalModels() {
2139
+ return localT.unloadAll();
2140
+ }
2141
+
2117
2142
  return {
2118
2143
  CLASSES,
2119
2144
  listClasses,
@@ -2123,6 +2148,7 @@ function createLocalEngine({
2123
2148
  respondApproval,
2124
2149
  clearSessionApprovals,
2125
2150
  settings,
2151
+ unloadLocalModels,
2126
2152
  };
2127
2153
  }
2128
2154