aegis-desktop 0.4.4 → 0.5.0

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.
@@ -1142,6 +1142,17 @@ body.memory-open {
1142
1142
  display: none;
1143
1143
  }
1144
1144
 
1145
+ /* Same trap, one row up: `.max-tokens-row` is display: flex, so the `hidden`
1146
+ attribute did nothing on `#max-tokens-row`/`#effort-row` — which is how the
1147
+ budget-control swap (effort for the pooled class, a token ceiling for the
1148
+ others) would have shown BOTH controls at once, the exact state it exists to
1149
+ prevent. `display: none` is also what lets a `<label>` be hidden, since
1150
+ labels are inline by default and carry no author display rule. */
1151
+ .max-tokens-row[hidden],
1152
+ label[hidden] {
1153
+ display: none;
1154
+ }
1155
+
1145
1156
  .autonomous-controls {
1146
1157
  display: flex;
1147
1158
  align-items: center;
package/vendor/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 || 4096,
318
+ ...(maxTokens ? { max_tokens: maxTokens } : {}),
304
319
  ...(extra || {}),
305
320
  };
306
321
  if (model) body.model = model;
@@ -0,0 +1,386 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * credentials.js — the ONE AEGIS account credential store, shared by every
5
+ * host in this repo (terminal `aegiscode`, the MCP plugin, the desktop app).
6
+ *
7
+ * Why it lives in `client/`: this directory is the only tree all three hosts
8
+ * already bundle. The MCP plugin ships `mcp/` + `client/` and nothing else, so
9
+ * a reader placed here needs no cross-package dependency — which is what the
10
+ * alternative (the MCP host requiring `cli/src/credentials.js`) would have
11
+ * forced, and why that host used to read `AEGIS_API_KEY` from the environment
12
+ * alone while the CLI could save a key to disk. One login, one file, three
13
+ * hosts: that is the point of this module.
14
+ *
15
+ * Zero dependencies, no host imports. The data dir is resolved here
16
+ * (`aegisHome()`) rather than imported from the CLI's config.js, so this file
17
+ * runs from a bare `node client/credentials.js` inside the plugin tree.
18
+ *
19
+ * Resolution order, first hit wins:
20
+ *
21
+ * 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
22
+ * working and nothing written here can shadow them.
23
+ * 2. `credentials.json` in the data dir, mode 0600. The writable store.
24
+ * 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
25
+ * earlier AEGIS CLI left in the same data dir. Read, never written, and
26
+ * never deleted: it is another product's file and the user's key is in
27
+ * it. When one is found it is *also* copied into credentials.json so the
28
+ * next run reads the 0600 copy, and the status line says the plaintext
29
+ * copy is still there rather than silently leaving it.
30
+ *
31
+ * The key is never printed in full by anything in this repo; callers mask it.
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const os = require('node:os');
36
+ const path = require('node:path');
37
+
38
+ const KEY_ENV = 'AEGIS_API_KEY';
39
+ /** Optional override for the memory token (cloud sync's own credential). */
40
+ const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
41
+ const HOME_ENV = 'AEGISCODE_HOME';
42
+ const CREDENTIALS_FILE = 'credentials.json';
43
+ const CONFIG_FILE = 'config.json';
44
+ const FILE_MODE = 0o600;
45
+ const DIR_MODE = 0o700;
46
+
47
+ /**
48
+ * The per-user AEGIS data directory: `$AEGISCODE_HOME` or `~/.aegiscode`.
49
+ *
50
+ * Every host resolves its data dir through this one function, so the CLI, the
51
+ * MCP plugin and the desktop app agree on where memory and credentials live
52
+ * instead of each keeping a private store the others cannot see. (The desktop
53
+ * still keeps its *settings* — the safeStorage-encrypted key, window state —
54
+ * in Electron's userData; only the shared memory + credential files live here.)
55
+ */
56
+ function aegisHome(env) {
57
+ const e = env || process.env;
58
+ const override = e && e[HOME_ENV];
59
+ if (override && String(override).trim()) return path.resolve(String(override).trim());
60
+ return path.join(os.homedir(), '.aegiscode');
61
+ }
62
+
63
+ function credentialsPath(dir) {
64
+ return path.join(dir || aegisHome(), CREDENTIALS_FILE);
65
+ }
66
+
67
+ function configPath(dir) {
68
+ return path.join(dir || aegisHome(), CONFIG_FILE);
69
+ }
70
+
71
+ /**
72
+ * Accept the key in the shapes a user actually pastes it.
73
+ *
74
+ * Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
75
+ * are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
76
+ * prompt that stores the literal string produces an auth failure the user
77
+ * cannot see the cause of, because the key *looks* right in the status line.
78
+ */
79
+ function normalizeApiKey(raw) {
80
+ let s = String(raw == null ? '' : raw).trim();
81
+ if (!s) return '';
82
+ s = s.replace(/^export\s+/i, '').trim();
83
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
84
+ if (assignment) s = assignment[1].trim();
85
+ s = s.replace(/^["']|["']$/g, '').trim();
86
+ s = s.replace(/^Bearer\s+/i, '').trim();
87
+ return s;
88
+ }
89
+
90
+ /**
91
+ * Shape check only — no network. Catches the two mistakes that are structural
92
+ * (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
93
+ * refuse before spending a verification round trip.
94
+ */
95
+ function validateApiKey(raw) {
96
+ const key = normalizeApiKey(raw);
97
+ if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
98
+ if (/\s/.test(key)) {
99
+ return {
100
+ ok: false,
101
+ key,
102
+ reason: 'whitespace',
103
+ message: 'that looks like more than one word — paste just the key',
104
+ };
105
+ }
106
+ if (key.length < 16) {
107
+ return {
108
+ ok: false,
109
+ key,
110
+ reason: 'too short',
111
+ message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
112
+ };
113
+ }
114
+ return { ok: true, key, reason: null, message: null };
115
+ }
116
+
117
+ /** The stored credential object, or {} — never throws on a missing/corrupt file. */
118
+ function readCredentials(dir) {
119
+ try {
120
+ const parsed = JSON.parse(fs.readFileSync(credentialsPath(dir), 'utf8'));
121
+ if (parsed && typeof parsed === 'object') return parsed;
122
+ } catch {}
123
+ return {};
124
+ }
125
+
126
+ /**
127
+ * Merge a patch into the store, creating it 0600 (and tightening an existing
128
+ * file that is wider — a key file that a previous run left 0644 is exactly the
129
+ * leak this file exists to avoid).
130
+ */
131
+ function writeCredentials(patch, dir) {
132
+ const target = credentialsPath(dir);
133
+ const next = { ...readCredentials(dir), ...patch, version: 1 };
134
+ try {
135
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: DIR_MODE });
136
+ const tmp = target + '.tmp';
137
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
138
+ fs.renameSync(tmp, target);
139
+ try {
140
+ fs.chmodSync(target, FILE_MODE);
141
+ } catch {}
142
+ } catch (e) {
143
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
144
+ return { ok: false, error: e, credentials: next };
145
+ }
146
+ return { ok: true, credentials: next };
147
+ }
148
+
149
+ /**
150
+ * The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
151
+ * `memory` blocks are not ours). Read-only.
152
+ */
153
+ function readLegacyConfig(dir) {
154
+ try {
155
+ const parsed = JSON.parse(fs.readFileSync(configPath(dir), 'utf8'));
156
+ if (!parsed || typeof parsed !== 'object') return {};
157
+ const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
158
+ const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
159
+ return {
160
+ apiKey: normalizeApiKey(cloud.api_key),
161
+ memoryToken: String(memory.token || '').trim(),
162
+ syncConversations: cloud.syncConversations === true ? true : undefined,
163
+ lastVerified: cloud.lastVerified || null,
164
+ memorySubscribed: memory.subscribed === true ? true : undefined,
165
+ };
166
+ } catch {
167
+ return {};
168
+ }
169
+ }
170
+
171
+ /** True when config.json still carries a plaintext copy of the account key. */
172
+ function legacyKeyOnDisk(dir) {
173
+ return !!readLegacyConfig(dir).apiKey;
174
+ }
175
+
176
+ /**
177
+ * Copy a legacy key/token into the 0600 store, once, without touching the file
178
+ * it came from. Returns which fields were adopted so the caller can say so.
179
+ */
180
+ function adoptLegacy(dir) {
181
+ const legacy = readLegacyConfig(dir);
182
+ const creds = readCredentials(dir);
183
+ const patch = {};
184
+ if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
185
+ if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
186
+ if (!Object.keys(patch).length) return { adopted: [] };
187
+ const written = writeCredentials({ ...patch, adoptedFrom: configPath(dir) }, dir);
188
+ if (!written.ok) return { adopted: [] };
189
+ return { adopted: Object.keys(patch) };
190
+ }
191
+
192
+ /**
193
+ * The key to use, and where it came from.
194
+ *
195
+ * @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
196
+ */
197
+ function resolveApiKey(o = {}) {
198
+ const env = o.env || process.env;
199
+ const dir = o.dir;
200
+ const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
201
+ if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
202
+
203
+ const creds = readCredentials(dir);
204
+ const stored = normalizeApiKey(creds.aegisApiKey);
205
+ if (stored) return { key: stored, source: 'credentials', from: credentialsPath(dir) };
206
+
207
+ const legacy = readLegacyConfig(dir).apiKey;
208
+ if (legacy) return { key: legacy, source: 'config', from: configPath(dir) };
209
+
210
+ return { key: '', source: 'none', from: '' };
211
+ }
212
+
213
+ /** Whether any credential is available (env, store or legacy config). */
214
+ function hasApiKey(o = {}) {
215
+ return !!resolveApiKey(o).key;
216
+ }
217
+
218
+ /**
219
+ * Persist a key.
220
+ *
221
+ * @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
222
+ */
223
+ function saveApiKey(raw, dir) {
224
+ const v = validateApiKey(raw);
225
+ if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(dir), message: v.message };
226
+ const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() }, dir);
227
+ if (!written.ok) {
228
+ return {
229
+ ok: false,
230
+ key: v.key,
231
+ path: credentialsPath(dir),
232
+ error: written.error,
233
+ message: `could not write ${credentialsPath(dir)}`,
234
+ };
235
+ }
236
+ return { ok: true, key: v.key, path: credentialsPath(dir) };
237
+ }
238
+
239
+ /**
240
+ * Remove the stored key (and nothing else — the memory token stays, since a
241
+ * key rotation should not silently unsubscribe cloud memory). config.json is
242
+ * never touched: another product writes it.
243
+ */
244
+ function clearApiKey(dir) {
245
+ const had = !!normalizeApiKey(readCredentials(dir).aegisApiKey);
246
+ const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' }, dir);
247
+ return { cleared: had, ok: written.ok, path: credentialsPath(dir) };
248
+ }
249
+
250
+ /** The memory token cloud sync authenticates with, from store or legacy. */
251
+ function resolveMemoryToken(o = {}) {
252
+ const env = o.env || process.env;
253
+ const dir = o.dir;
254
+ const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
255
+ if (fromEnv) return { token: fromEnv, source: 'env' };
256
+ const creds = readCredentials(dir);
257
+ const stored = String(creds.memoryToken || '').trim();
258
+ if (stored) return { token: stored, source: 'credentials' };
259
+ const legacy = readLegacyConfig(dir).memoryToken;
260
+ if (legacy) return { token: legacy, source: 'config' };
261
+ return { token: '', source: 'none' };
262
+ }
263
+
264
+ function saveMemoryToken(token, extra = {}, dir) {
265
+ return writeCredentials({ memoryToken: String(token || '').trim(), ...extra }, dir);
266
+ }
267
+
268
+ function clearMemoryToken(dir) {
269
+ return writeCredentials({ memoryToken: '', memorySubscribed: false }, dir);
270
+ }
271
+
272
+ /**
273
+ * Everything a status screen or a `doctor` line needs about the credential,
274
+ * without ever exposing the key itself.
275
+ */
276
+ function keyStatus(o = {}) {
277
+ const dir = o.dir;
278
+ const { key, source, from } = resolveApiKey(o);
279
+ const creds = readCredentials(dir);
280
+ let mode = null;
281
+ try {
282
+ mode = fs.statSync(credentialsPath(dir)).mode & 0o777;
283
+ } catch {}
284
+ return {
285
+ configured: !!key,
286
+ key,
287
+ source,
288
+ from,
289
+ path: credentialsPath(dir),
290
+ fileMode: mode == null ? null : '0' + mode.toString(8),
291
+ stored: !!normalizeApiKey(creds.aegisApiKey),
292
+ legacyPlaintext: legacyKeyOnDisk(dir),
293
+ verifiedAt: creds.verifiedAt || null,
294
+ account: creds.account || null,
295
+ memoryToken: !!resolveMemoryToken(o).token,
296
+ memorySource: resolveMemoryToken(o).source,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Client options for `createClient`: the resolved key plus the stored memory
302
+ * token, so a restart does not re-exchange for a token it already has.
303
+ */
304
+ function clientOptions(o = {}) {
305
+ const { key } = resolveApiKey(o);
306
+ const { token } = resolveMemoryToken(o);
307
+ const opts = {};
308
+ if (key) opts.apiKey = key;
309
+ if (token) opts.memoryToken = token;
310
+ return opts;
311
+ }
312
+
313
+ /** Human label for a resolution source, for status lines. */
314
+ const SOURCE_LABEL = {
315
+ env: `${KEY_ENV}`,
316
+ credentials: 'saved key file',
317
+ config: 'config.json (aegis CLI)',
318
+ none: 'not set',
319
+ };
320
+
321
+ function sourceLabel(source) {
322
+ return SOURCE_LABEL[source] || SOURCE_LABEL.none;
323
+ }
324
+
325
+ /**
326
+ * Prefer the most recently saved key between a host's own copy and this file.
327
+ *
328
+ * The desktop keeps its key encrypted via Electron safeStorage, which is a
329
+ * better place for it than a 0600 file — but only one of the two can be in
330
+ * force, and picking wrong is silent: the user rotates their key with
331
+ * `aegiscode login`, the app keeps presenting an old string it saved last week,
332
+ * and every call 401s with a key that looks right in Settings.
333
+ *
334
+ * Both sides stamp `savedAt`, so the rule is simply "the newest write wins",
335
+ * with the host's own copy breaking a tie (it is encrypted). A missing stamp on
336
+ * either side loses to a present one; equal keys always resolve to the host's.
337
+ *
338
+ * @returns {{key:string, source:'host'|'shared'|'none', sharedAt:number, hostAt:number}}
339
+ */
340
+ function preferNewest(storedKey, storedAt, o = {}) {
341
+ const ownKey = normalizeApiKey(storedKey);
342
+ const ownAt = Date.parse(storedAt || '') || 0;
343
+ const shared = readCredentials(o.dir);
344
+ const sharedKey = normalizeApiKey(shared.aegisApiKey);
345
+ const sharedAt = Date.parse(shared.savedAt || '') || 0;
346
+
347
+ if (!sharedKey) return { key: ownKey, source: ownKey ? 'host' : 'none', sharedAt, hostAt: ownAt };
348
+ if (!ownKey) return { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt };
349
+ if (ownKey === sharedKey) return { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
350
+ return sharedAt > ownAt
351
+ ? { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt }
352
+ : { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
353
+ }
354
+
355
+ /** One line telling the user how to supply a key, used by every error path. */
356
+ const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
357
+
358
+ module.exports = {
359
+ KEY_ENV,
360
+ MEMORY_ENV,
361
+ HOME_ENV,
362
+ CREDENTIALS_FILE,
363
+ CONFIG_FILE,
364
+ aegisHome,
365
+ credentialsPath,
366
+ configPath,
367
+ normalizeApiKey,
368
+ validateApiKey,
369
+ readCredentials,
370
+ writeCredentials,
371
+ readLegacyConfig,
372
+ legacyKeyOnDisk,
373
+ adoptLegacy,
374
+ resolveApiKey,
375
+ hasApiKey,
376
+ saveApiKey,
377
+ clearApiKey,
378
+ resolveMemoryToken,
379
+ saveMemoryToken,
380
+ clearMemoryToken,
381
+ keyStatus,
382
+ clientOptions,
383
+ preferNewest,
384
+ sourceLabel,
385
+ HOW_TO_SET,
386
+ };