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 +33 -10
- package/lib/local/autonomous.js +105 -38
- package/lib/local/context.js +13 -2
- package/lib/local/engine.js +26 -0
- package/lib/local/local.js +1410 -46
- package/lib/local/queue.js +20 -16
- package/lib/settings.js +189 -28
- package/lib/sync/persist-gate.js +35 -10
- package/main.js +218 -22
- package/package.json +2 -1
- package/preload.js +7 -2
- package/renderer/app.js +169 -10
- package/renderer/index.html +18 -0
- package/vendor/platform.js +7 -0
- package/vendor/prefs.js +348 -0
- package/vendor/topup.js +294 -0
- package/vendor/update.js +141 -3
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 **
|
|
65
|
-
one
|
|
66
|
-
`-smart`/`-neo` spellings the server sends as aliases of it
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
package/lib/local/autonomous.js
CHANGED
|
@@ -97,48 +97,100 @@ function writtenPath(tool) {
|
|
|
97
97
|
const DEFAULT_MODEL = 'nexus-brain';
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* THE QUEUE RUNS ON
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
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
|
-
*
|
|
128
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
`
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|
package/lib/local/context.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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
|
|
package/lib/local/engine.js
CHANGED
|
@@ -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
|
|