aegiscode 6.3.1 → 6.4.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.
package/src/commands.js CHANGED
@@ -46,6 +46,7 @@ const { execSync } = require('node:child_process');
46
46
  const { span } = require('./screen.js');
47
47
  const { C, BOLD, BOLD_OFF } = require('./theme.js');
48
48
  const panels = require('./panels.js');
49
+ const overlays = require('./overlays.js');
49
50
  const {
50
51
  updateConfig, loadConfig, loadPermissions, savePermissions, addPermissionRule,
51
52
  DEFAULT_PERMISSIONS, permissionsPath, configPath,
@@ -57,6 +58,9 @@ const { summarizeTranscript, recapLine } = require('./summarize.js');
57
58
  const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
58
59
  const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
59
60
  const { detectDevCommand, runDevServer } = require('./devrun.js');
61
+ const credentials = require('./credentials.js');
62
+ const cloudsync = require('./cloudsync.js');
63
+ const { maskKey } = require('./format.js');
60
64
  const { openUrl, URLS } = require('./system.js');
61
65
  const { sniffProject, buildAegisMd } = require('./init.js');
62
66
  const {
@@ -93,7 +97,13 @@ const CATEGORIES = [
93
97
 
94
98
  const categoryLabel = (id) => (CATEGORIES.find((cat) => cat.id === id) || {}).label || id;
95
99
 
96
- const EFFORT_LEVELS = ['low', 'medium', 'high'];
100
+ // One table, shared with the picker that draws it. The two used to be separate
101
+ // lists — this one a string array, chatflow.js's own copy of the same three
102
+ // strings, and overlays.js's label/note rows — so adding a level meant editing
103
+ // three places, and the index a picker row reported was mapped back to a value
104
+ // through whichever list happened to be in scope. EFFORT_VALUES is the picker's
105
+ // own order, `null` first for "auto".
106
+ const EFFORT_LEVELS = overlays.EFFORT_VALUES;
97
107
 
98
108
  // ── Small handler helpers ─────────────────────────────────────────────────────
99
109
 
@@ -120,8 +130,117 @@ async function loadModels(c) {
120
130
  * The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
121
131
  * as the reference hides its ÆGIS routes when the backend is absent. `hidden`
122
132
  * is a function so visibility follows the environment at call time.
133
+ *
134
+ * It reads the *resolved* credential, not the environment: a key saved by
135
+ * `aegiscode login` lives in credentials.json, and checking only
136
+ * `process.env.AEGIS_API_KEY` here hid every AEGIS route from a user who had
137
+ * just successfully set one.
138
+ */
139
+ const cloudReady = () => credentials.hasApiKey();
140
+
141
+ // ── account key + cloud sync handlers ────────────────────────────────────────
142
+
143
+ /** The account-key panel body, shared by /key, /login and /cloud. */
144
+ function keyPanelLines(c, title = 'AEGIS account key') {
145
+ const st = c.keyStatus();
146
+ const lines = [
147
+ [span(C.gold + BOLD, title), span(BOLD_OFF, '')],
148
+ [span(C.gray, '─'.repeat(34))],
149
+ [
150
+ span(st.configured ? C.green : C.coral, ` ${st.configured ? '✔' : '⚠'}`),
151
+ span(C.white, ' key: '),
152
+ span(st.configured ? C.white : C.gray, st.configured ? maskKey(st.key) : 'not set'),
153
+ span(C.gray, ` (${credentials.sourceLabel(st.source)})`),
154
+ ],
155
+ [span(C.gray, ` stored in: ${st.path}${st.fileMode ? ` ${st.fileMode}` : ''}`)],
156
+ ];
157
+ if (st.verifiedAt) lines.push([span(C.gray, ` verified: ${st.verifiedAt}`)]);
158
+ if (st.account && (st.account.plan || st.account.email)) {
159
+ lines.push([
160
+ span(C.gray, ' account: '),
161
+ span(C.white, [st.account.plan, st.account.email].filter(Boolean).join(' · ')),
162
+ ]);
163
+ }
164
+ lines.push([
165
+ span(C.gray, ' memory: '),
166
+ span(st.memoryToken ? C.green : C.gray, st.memoryToken ? 'token held (cloud sync ready)' : 'no token yet'),
167
+ ]);
168
+ if (!st.configured) {
169
+ lines.push([span(C.gray, '')]);
170
+ lines.push([span(C.white, ' Set one with /key <api_key>, or press Enter on /key to paste it.')]);
171
+ lines.push([span(C.gray, ' Free keys: https://aegiscloud.org')]);
172
+ }
173
+ if (st.legacyPlaintext) {
174
+ lines.push([span(C.gray, '')]);
175
+ lines.push([
176
+ span(C.coral, ' ⚠ a plaintext copy of the key is still in config.json (another AEGIS CLI wrote it).'),
177
+ ]);
178
+ lines.push([span(C.gray, ' Re-save with /key <api_key> and delete that block when convenient.')]);
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ /**
184
+ * Take a key from the argument or an echo-off prompt, save it, and report what
185
+ * the account said. Shared by `/key`, `/login` and `/cloud key` so all three
186
+ * behave identically — the whole point is that there is one way to set a key.
123
187
  */
124
- const cloudReady = () => !!process.env.AEGIS_API_KEY;
188
+ async function applyAccountKey(c, raw, { label = 'AEGIS API key' } = {}) {
189
+ let key = String(raw || '').trim();
190
+ if (!key) {
191
+ key = await c.readSecret(`${label} (kept off screen, Enter to cancel): `);
192
+ }
193
+ if (!key) {
194
+ note(c, 'no key given — /key <api_key> takes it inline, or press Enter on a bare /key to paste it');
195
+ return { ok: false };
196
+ }
197
+ const res = await c.setApiKey(key);
198
+ if (!res.ok) {
199
+ note(c, res.message || 'that key could not be saved');
200
+ return res;
201
+ }
202
+ if (res.error) {
203
+ note(c, `saved, but the account check failed: ${res.error.message}`);
204
+ note(c, 'the key is stored — /key status shows it; re-run /key <api_key> if it was mistyped');
205
+ return res;
206
+ }
207
+ const acct = (res.account && (res.account.email || res.account.plan)) || null;
208
+ done(c, `AEGIS key saved to ${res.path}${acct ? ` — signed in as ${acct}` : ' and verified'}`);
209
+ // The catalog is key-gated, so a freshly authenticated session should offer
210
+ // /model's picker without the user having to know to re-run it.
211
+ try {
212
+ await c.loadModels({ force: true });
213
+ const n = (c.state().models || []).length;
214
+ if (n) note(c, `${n} pinnable model${n === 1 ? '' : 's'} available — /model to pin one`);
215
+ } catch {}
216
+ return res;
217
+ }
218
+
219
+ /** Push + pull once, and report the counts (or the quota refusal). */
220
+ async function runCloudSync(c, o = {}) {
221
+ if (!c.client.apiKey) {
222
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
223
+ return null;
224
+ }
225
+ const res = await c.withWorking(() => cloudsync.syncNow(c.client, o));
226
+ const pushed = (res.push && res.push.pushed) || [];
227
+ const failed = [...((res.push && res.push.failed) || []), ...((res.pull && res.pull.failed) || [])];
228
+ const pulled = (res.pull && res.pull.imported) || 0;
229
+ if (pushed.length) {
230
+ done(c, `pushed ${pushed.length} session${pushed.length === 1 ? '' : 's'} (${pushed[0].messages} messages in the first)`);
231
+ } else if (res.push && res.push.skipped && res.push.skipped.length) {
232
+ note(c, `${res.push.skipped.length} session(s) had nothing to send`);
233
+ } else {
234
+ note(c, 'nothing to push — every local session is already in the cloud');
235
+ }
236
+ if (pulled) done(c, `pulled ${pulled} record${pulled === 1 ? '' : 's'} into the local store — /resume lists them`);
237
+ else if (res.pull && res.pull.ok) note(c, `cloud has ${res.pull.remote || 0} session(s); nothing newer locally`);
238
+ for (const f of failed) {
239
+ note(c, `${f.kind === 'quota' ? 'quota' : 'failed'}: ${f.message}`);
240
+ if (f.hint) note(c, f.hint);
241
+ }
242
+ return res;
243
+ }
125
244
 
126
245
  /** Read + merge hook config from the standard settings files. */
127
246
  function readHooks() {
@@ -348,12 +467,12 @@ const COMMANDS = [
348
467
  },
349
468
  },
350
469
  {
351
- name: 'effort', args: ['level'], hint: '[low|medium|high]', category: 'model',
352
- desc: 'Set effort level for model usage',
470
+ name: 'effort', args: ['level'], hint: '[auto|low|medium|high]', category: 'model',
471
+ desc: 'Set the token budget the pool sizes each turn from',
353
472
  handler: async (c, args) => {
354
473
  const level = (args.level || '').toLowerCase();
355
- if (level && !EFFORT_LEVELS.includes(level)) {
356
- note(c, `Unknown effort level "${args.level}". Use ${EFFORT_LEVELS.join(', ')}.`);
474
+ if (level && level !== 'auto' && !EFFORT_LEVELS.includes(level)) {
475
+ note(c, `Unknown effort level "${args.level}". Use auto, ${EFFORT_LEVELS.filter(Boolean).join(', ')}.`);
357
476
  c.render();
358
477
  return true;
359
478
  }
@@ -361,9 +480,14 @@ const COMMANDS = [
361
480
  c.openOverlay({ type: 'effort', sel: Math.max(0, EFFORT_LEVELS.indexOf(c.ctx.effort)) });
362
481
  return true;
363
482
  }
364
- c.ctx.effort = level;
365
- c.saveConfig({ effort: level });
366
- note(c, `Effort level: ${level}`);
483
+ // 'auto' is the absence of a pin, not a fourth rung: it is stored as null
484
+ // so nothing is sent and the server sizes the turn from the ask.
485
+ const pinned = level === 'auto' ? null : level;
486
+ c.ctx.effort = pinned;
487
+ c.saveConfig({ effort: pinned });
488
+ note(c, pinned
489
+ ? `Effort level: ${pinned} — the pool sizes this turn's token budget from it`
490
+ : 'Effort level: auto — the pool sizes each turn from the ask');
367
491
  c.render();
368
492
  return true;
369
493
  },
@@ -455,15 +579,32 @@ const COMMANDS = [
455
579
  },
456
580
  },
457
581
  {
458
- name: 'login', hint: '', category: 'auth',
459
- desc: 'Sign in to Claude Code',
460
- unavailable: 'sign-in uses Claude Code auth, which this client does not have — it authenticates with your AEGIS key',
461
- alt: '/byok-set',
582
+ name: 'login', aliases: ['signin'], args: ['value'], hint: '[<api_key>]', category: 'auth',
583
+ desc: 'Save your AEGIS API key',
584
+ handler: async (c, args) => {
585
+ // This used to be `unavailable` with `/byok-set` as its alternative —
586
+ // which was worse than useless: /byok-set stores a *provider* key
587
+ // server-side, so a user following that advice pasted their AEGIS key
588
+ // into a BYOK provider slot. Signing in to AEGIS Cloud *is* handing over
589
+ // an API key, and this is now where you give it one.
590
+ const arg = String(args.value || args._rest || '').trim();
591
+ await applyAccountKey(c, arg, { label: 'AEGIS API key' });
592
+ c.render();
593
+ return true;
594
+ },
462
595
  },
463
596
  {
464
- name: 'logout', hint: '', category: 'auth',
465
- desc: 'Sign out',
466
- unavailable: 'there is no Claude Code sign-in here to end — aegiscode holds no session token of its own',
597
+ name: 'logout', args: [], hint: '', category: 'auth',
598
+ desc: 'Remove the saved AEGIS API key',
599
+ handler: async (c) => {
600
+ const res = c.forgetApiKey();
601
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
602
+ if (credentials.legacyKeyOnDisk()) {
603
+ note(c, `a copy is still in ${configPath()} (another AEGIS CLI wrote it) — delete its "aegiscloud" block too`);
604
+ }
605
+ c.render();
606
+ return true;
607
+ },
467
608
  },
468
609
  {
469
610
  name: 'model', aliases: ['m'], args: ['sub'], hint: '[id|-]', category: 'model',
@@ -697,8 +838,19 @@ const COMMANDS = [
697
838
  let git = false;
698
839
  try { execSync('git rev-parse --git-dir', { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'ignore'], timeout: 4000 }); git = true; } catch {}
699
840
  ok('git repo', git, git ? 'inside a work tree' : 'not a git repository');
700
- const key = process.env.AEGIS_API_KEY || '';
701
- ok('AEGIS_API_KEY', !!key, key ? 'set' : 'not set — export one (https://aegiscloud.org)');
841
+ const ks = credentials.keyStatus();
842
+ ok(
843
+ 'AEGIS key',
844
+ ks.configured,
845
+ ks.configured
846
+ ? `${maskKey(ks.key)} via ${credentials.sourceLabel(ks.source)}`
847
+ : `not set — ${credentials.HOW_TO_SET} (free at https://aegiscloud.org)`
848
+ );
849
+ ok('credentials', true, `${ks.path}${ks.fileMode ? ` (${ks.fileMode})` : ''}`);
850
+ ok('cloud memory', ks.memoryToken, ks.memoryToken ? 'token held' : 'no memory token — /cloud activate');
851
+ if (ks.legacyPlaintext) {
852
+ ok('plaintext key', false, `a copy also sits in ${configPath()} (another AEGIS CLI) — /key re-saves it to ${ks.path}`);
853
+ }
702
854
  ok('config path', true, configPath());
703
855
  const lines = [[span(C.gold + BOLD, 'Doctor'), span(BOLD_OFF, '')], [span(C.gray, '─'.repeat(30))]];
704
856
  for (const [good, label, detail] of checks) {
@@ -1193,17 +1345,145 @@ const COMMANDS = [
1193
1345
  },
1194
1346
  },
1195
1347
  {
1196
- name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate]', category: 'support',
1197
- desc: 'Show ÆGIS cloud sync status',
1348
+ name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate|sync [on|off|now]]', category: 'support',
1349
+ desc: 'Show and control ÆGIS cloud sync',
1198
1350
  handler: async (c, args) => {
1199
1351
  const sub = (args.sub || '').toLowerCase();
1200
- if (['key', 'activate', 'deactivate'].includes(sub)) {
1201
- note(c, 'ÆGIS cloud key/sync is managed by the aegis CLI: run `aegis login` (free) — aegiscode does not store cloud keys.');
1352
+ const rest = (args.value || args._rest || '').trim();
1353
+
1354
+ if (sub === 'key') {
1355
+ await applyAccountKey(c, rest);
1202
1356
  c.render();
1203
1357
  return true;
1204
1358
  }
1205
- const st = c.state();
1206
- panel(c, panels.buildAegisStatus({ ...st, cloud: { key: st.online, sync: st.online } }, c.ctx));
1359
+
1360
+ if (sub === 'activate') {
1361
+ if (!c.client.apiKey) {
1362
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
1363
+ c.render();
1364
+ return true;
1365
+ }
1366
+ try {
1367
+ const res = await c.withWorking(() => c.client.memoryActivate());
1368
+ const token = await c.client.getMemoryToken();
1369
+ if (token) credentials.saveMemoryToken(token, { memorySubscribed: true });
1370
+ done(c, res && res.ok === false ? 'the server refused activation' : 'cloud memory activated for this account');
1371
+ if (res && res.token_limit) note(c, `quota: ${res.tokens_used || 0} / ${res.token_limit} synced tokens`);
1372
+ } catch (e) {
1373
+ note(c, `activation failed: ${e.message}`);
1374
+ }
1375
+ c.render();
1376
+ return true;
1377
+ }
1378
+
1379
+ if (sub === 'deactivate') {
1380
+ // There is no /api/memory/deactivate on the server, so this clears the
1381
+ // credential this host holds rather than pretending to unenrol the
1382
+ // account. Saying which one it did is the difference between a control
1383
+ // and a lie.
1384
+ credentials.clearMemoryToken();
1385
+ c.client.setApiKey(c.client.apiKey);
1386
+ note(c, 'cloud memory token cleared locally — sync stops until /cloud activate re-issues one');
1387
+ note(c, 'the account itself is still subscribed; manage the subscription at https://aegiscloud.org/subscribe');
1388
+ c.render();
1389
+ return true;
1390
+ }
1391
+
1392
+ if (sub === 'sync' || sub === 'push' || sub === 'pull') {
1393
+ const mode = sub === 'sync' ? (rest || 'status').toLowerCase() : sub;
1394
+ if (mode === 'on' || mode === 'off') {
1395
+ const on = mode === 'on';
1396
+ c.setCloudSync(on);
1397
+ note(c, on ? 'cloud sync on — /sync now pushes after each turn' : 'cloud sync off — nothing is sent until /sync now');
1398
+ c.render();
1399
+ return true;
1400
+ }
1401
+ if (mode === 'now') {
1402
+ await runCloudSync(c);
1403
+ c.render();
1404
+ return true;
1405
+ }
1406
+ const st = cloudsync.status();
1407
+ const rows = [
1408
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1409
+ [span(C.gray, '─'.repeat(34))],
1410
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`), span(C.gray, c.cloudSyncEnabled() ? ' (auto-push each turn)' : ' (manual: /sync now)')],
1411
+ [span(C.gray, ` local sessions: ${st.local} · pending push: ${st.pending} · in sync: ${st.synced}`)],
1412
+ [span(C.gray, ` last push: ${st.lastPushAt ? new Date(st.lastPushAt).toLocaleString() : 'never'} · last pull: ${st.lastPullAt ? new Date(st.lastPullAt).toLocaleString() : 'never'}`)],
1413
+ [span(C.gray, ` state: ${st.path}`)],
1414
+ ];
1415
+ for (const e of st.errors.slice(0, 3)) {
1416
+ rows.push([span(C.coral, ` ⚠ ${e.error}`)]);
1417
+ }
1418
+ panel(c, rows);
1419
+ c.render();
1420
+ return true;
1421
+ }
1422
+
1423
+ // Default: the whole picture — key plus sync state.
1424
+ const st = cloudsync.status();
1425
+ panel(c, [
1426
+ ...keyPanelLines(c, 'AEGIS cloud'),
1427
+ [span(C.gray, '')],
1428
+ [span(C.white + BOLD, ' Sync'), span(BOLD_OFF, '')],
1429
+ [span(C.gray, ` ${c.cloudSyncEnabled() ? 'on' : 'off'} · ${st.pending} pending · ${st.synced} in sync · ${st.importedRemote} pulled from cloud`)],
1430
+ [span(C.gray, ' /cloud sync on | now | off')],
1431
+ ]);
1432
+ c.render();
1433
+ return true;
1434
+ },
1435
+ },
1436
+ {
1437
+ name: 'key', aliases: ['apikey', 'api-key'], args: ['value'], hint: '[<api_key>|status|clear]', category: 'auth',
1438
+ desc: 'Save or show the AEGIS account key',
1439
+ handler: async (c, args) => {
1440
+ const arg = String(args.value || args._rest || '').trim();
1441
+ const lower = arg.toLowerCase();
1442
+ if (lower === 'status') {
1443
+ panel(c, keyPanelLines(c));
1444
+ c.render();
1445
+ return true;
1446
+ }
1447
+ if (lower === 'clear' || lower === 'remove' || lower === 'rm') {
1448
+ const res = c.forgetApiKey();
1449
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
1450
+ if (credentials.legacyKeyOnDisk()) {
1451
+ note(c, `a copy is still in ${configPath()} (written by another AEGIS CLI) — delete the "aegiscloud" block to finish the job`);
1452
+ }
1453
+ c.render();
1454
+ return true;
1455
+ }
1456
+ await applyAccountKey(c, arg);
1457
+ c.render();
1458
+ return true;
1459
+ },
1460
+ },
1461
+ {
1462
+ name: 'sync', aliases: ['cloudsync', 'cloud-sync'], args: ['mode'], hint: '[now|on|off|status]', category: 'data',
1463
+ desc: 'Sync conversations with AEGIS Cloud',
1464
+ hidden: (c) => !cloudReady(),
1465
+ handler: async (c, args) => {
1466
+ const mode = (args.mode || '').toLowerCase();
1467
+ if (mode === 'on' || mode === 'off') {
1468
+ c.setCloudSync(mode === 'on');
1469
+ note(c, mode === 'on' ? 'cloud sync on' : 'cloud sync off — /sync now still runs a manual pass');
1470
+ c.render();
1471
+ return true;
1472
+ }
1473
+ if (mode === 'status') {
1474
+ const st = cloudsync.status();
1475
+ panel(c, [
1476
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1477
+ [span(C.gray, '─'.repeat(34))],
1478
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`)],
1479
+ [span(C.gray, ` local ${st.local} · pending ${st.pending} · in sync ${st.synced}`)],
1480
+ ]);
1481
+ c.render();
1482
+ return true;
1483
+ }
1484
+ // Bare /sync and /sync now both do the thing: this is the command a user
1485
+ // reaches for when they want their sessions in the cloud right now.
1486
+ await runCloudSync(c);
1207
1487
  c.render();
1208
1488
  return true;
1209
1489
  },
package/src/config.js CHANGED
@@ -59,7 +59,12 @@ const DEFAULT_CONFIG = {
59
59
  // aegiscode- name so /model add/remove/switch stay compatible both ways.
60
60
  models: null,
61
61
  currentModelId: null,
62
- effort: 'high',
62
+ // `null` = no effort pinned, i.e. "auto": each turn is sized by the server
63
+ // from the ask itself (aegis1 services/pool_brain.py estimate_effort). The
64
+ // old 'high' was a *pin* on the top rung of the budget ladder — the most the
65
+ // server can grant — so the CLI's default turn was the most expensive one it
66
+ // could make, and /effort could only ever move it down.
67
+ effort: null,
63
68
  vim: false,
64
69
  lastCwd: '',
65
70
  };