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.
- package/lib/local/engine.js +12 -7
- package/lib/settings.js +15 -0
- package/lib/sync/sessions.js +70 -193
- package/main.js +50 -8
- package/package.json +1 -1
- package/renderer/app.js +70 -10
- package/renderer/index.html +20 -9
- package/renderer/style.css +11 -0
- package/vendor/aegis.js +16 -1
- package/vendor/credentials.js +386 -0
- package/vendor/session-store.js +418 -0
package/lib/local/engine.js
CHANGED
|
@@ -584,13 +584,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
584
584
|
// model as the fan-out, one sample instead of four — otherwise
|
|
585
585
|
// dropping the fan-out would have quietly changed the model too.
|
|
586
586
|
...(brainFlag === false ? { mode: 'brain' } : {}),
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
587
|
+
// `effort` is the budget rung, and it is sent whenever the caller has
|
|
588
|
+
// one — not only alongside a fan-out. The fan-out is triggered by the
|
|
589
|
+
// model id (this class sends ``nexus-brain``), so a caller that never
|
|
590
|
+
// ticked "work autonomously" still ran a pooled call and had no way to
|
|
591
|
+
// say how big it should be: the server fell through to its own default
|
|
592
|
+
// rung. That is how a one-word prompt came to reserve the top of the
|
|
593
|
+
// ladder. A single-pass pass carries it too — inert on that path, but
|
|
594
|
+
// it keeps the request honest about what it asked for.
|
|
595
|
+
...(opts.effort ? { effort: opts.effort } : {}),
|
|
596
|
+
// Only meaningful with a running fan-out — aegis1 services/pool_brain.py
|
|
597
|
+
// parse_brain_request reads `workers` straight off the body and clamps
|
|
598
|
+
// it itself (MAX_WORKERS), so no client-side validation here.
|
|
594
599
|
...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
|
|
595
600
|
// The pool forwards `tools` to the provider and returns tool_calls
|
|
596
601
|
// (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
|
package/lib/settings.js
CHANGED
|
@@ -186,11 +186,25 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
186
186
|
return decrypt(cfg.key);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* When this host last wrote its own AEGIS key (ISO string, or null).
|
|
191
|
+
*
|
|
192
|
+
* The shared credential file records the same thing, and startup compares the
|
|
193
|
+
* two so the most recently saved key wins. Without a stamp the app cannot tell
|
|
194
|
+
* "I saved this last week" from "the user ran `aegiscode login` a minute ago
|
|
195
|
+
* with a rotated key", and would silently keep presenting the stale one.
|
|
196
|
+
*/
|
|
197
|
+
function aegisKeySavedAt() {
|
|
198
|
+
const cfg = load()[AEGIS_KEY_NAMESPACE] || {};
|
|
199
|
+
return cfg.savedAt || null;
|
|
200
|
+
}
|
|
201
|
+
|
|
189
202
|
function setAegisKey(key) {
|
|
190
203
|
const data = load();
|
|
191
204
|
data[AEGIS_KEY_NAMESPACE] = {
|
|
192
205
|
...(data[AEGIS_KEY_NAMESPACE] || {}),
|
|
193
206
|
key: key ? encrypt(key) : null,
|
|
207
|
+
savedAt: key ? new Date().toISOString() : null,
|
|
194
208
|
};
|
|
195
209
|
save(data);
|
|
196
210
|
return aegisKey();
|
|
@@ -278,6 +292,7 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
278
292
|
maskKey,
|
|
279
293
|
aegisKey,
|
|
280
294
|
aegisRawKey,
|
|
295
|
+
aegisKeySavedAt,
|
|
281
296
|
setAegisKey,
|
|
282
297
|
migrateLegacyAegisKey,
|
|
283
298
|
quickLauncherConfig,
|
package/lib/sync/sessions.js
CHANGED
|
@@ -1,219 +1,93 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* sessions.js —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* sessions.js — the desktop's session store, now a thin binding over the
|
|
5
|
+
* SHARED store in `client/session-store.js`.
|
|
6
|
+
*
|
|
7
|
+
* This file used to be the implementation. It was the desktop's private
|
|
8
|
+
* `sessions.json`, while the terminal host kept `~/.aegiscode/history.jsonl`
|
|
9
|
+
* and the MCP plugin could see neither — three hosts, one account, three
|
|
10
|
+
* disjoint views of the conversation. The implementation now lives in
|
|
11
|
+
* `client/session-store.js`, which is the tree all three hosts already bundle,
|
|
12
|
+
* and every call here forwards to it.
|
|
13
|
+
*
|
|
14
|
+
* Two things that matter are deliberately unchanged:
|
|
15
|
+
*
|
|
16
|
+
* * the public API and its argument order (`(dir, …)` first) — `main.js`, the
|
|
17
|
+
* sync dispatch and `test/sync-sessions.test.mjs` call it by those names;
|
|
18
|
+
* * the record shape — `pending`, `seq`, `updatedAt`, `remoteId`,
|
|
19
|
+
* `lastSyncedAt`, `__seq` all keep their meaning, because cloud sync and
|
|
20
|
+
* the renderer's session list read them.
|
|
21
|
+
*
|
|
22
|
+
* The only real change is *where* `dir` points: main.js now passes the shared
|
|
23
|
+
* data dir (see `resolveStoreDir`), so a session typed in the terminal appears
|
|
24
|
+
* in the desktop's list without a network round trip.
|
|
25
|
+
*
|
|
26
|
+
* Resolution mirrors main.js's own pattern for the shared client: `client/` in
|
|
27
|
+
* a source checkout, `vendor/` in the packaged app (electron-builder cannot
|
|
28
|
+
* reach outside the app dir, so predist.mjs stages the file).
|
|
8
29
|
*/
|
|
9
30
|
|
|
10
|
-
const
|
|
11
|
-
const path = require('node:path');
|
|
12
|
-
|
|
13
|
-
function sessionsFile(dir) {
|
|
14
|
-
return path.join(dir, 'sessions.json');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function load(dir) {
|
|
31
|
+
const shared = (() => {
|
|
18
32
|
try {
|
|
19
|
-
return
|
|
33
|
+
return require('../../../client/session-store.js');
|
|
20
34
|
} catch {
|
|
21
|
-
return
|
|
35
|
+
return require('../../vendor/session-store.js');
|
|
22
36
|
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Monotonic write counter stored in the file's `__seq` field. Every mutation
|
|
27
|
-
* bumps it, so sessions written in the same millisecond still order
|
|
28
|
-
* deterministically (listSessions sorts by updatedAt, then by seq).
|
|
29
|
-
*/
|
|
30
|
-
function nextSeq(sessions) {
|
|
31
|
-
const seq = (typeof sessions.__seq === 'number' ? sessions.__seq : 0) + 1;
|
|
32
|
-
sessions.__seq = seq;
|
|
33
|
-
return seq;
|
|
34
|
-
}
|
|
37
|
+
})();
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
* parsed; epoch-ms numbers pass through; anything else becomes 0.
|
|
42
|
-
*/
|
|
43
|
-
function toEpochMs(value) {
|
|
44
|
-
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
45
|
-
if (typeof value === 'string' && value.trim()) {
|
|
46
|
-
const ms = Date.parse(value);
|
|
47
|
-
if (Number.isFinite(ms)) return ms;
|
|
39
|
+
const credentials = (() => {
|
|
40
|
+
try {
|
|
41
|
+
return require('../../../client/credentials.js');
|
|
42
|
+
} catch {
|
|
43
|
+
return require('../../vendor/credentials.js');
|
|
48
44
|
}
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function atomicWrite(file, data) {
|
|
53
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
55
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
56
|
-
fs.renameSync(tmp, file);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function save(dir, sessions) {
|
|
60
|
-
atomicWrite(sessionsFile(dir), sessions);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Create or merge a full session record. Any local write (`upsertSession` /
|
|
65
|
-
* `appendMessage`) marks the session `pending: true` — it has local content
|
|
66
|
-
* the cloud hasn't seen yet. Only `markSynced()` clears the flag, so a push
|
|
67
|
-
* that fails (offline/no key) never silently drops the session from the
|
|
68
|
-
* retry queue.
|
|
69
|
-
*/
|
|
70
|
-
function upsertSession(dir, session) {
|
|
71
|
-
const id = session && session.id;
|
|
72
|
-
if (!id) throw new Error('session.id is required');
|
|
73
|
-
const sessions = load(dir);
|
|
74
|
-
const prev = sessions[id] || { messages: [] };
|
|
75
|
-
sessions[id] = { ...prev, ...session, id, pending: true };
|
|
76
|
-
if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
|
|
77
|
-
sessions[id].seq = nextSeq(sessions);
|
|
78
|
-
save(dir, sessions);
|
|
79
|
-
return sessions[id];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Append one message to a session (crash-safe). */
|
|
83
|
-
function appendMessage(dir, sessionId, message) {
|
|
84
|
-
if (!sessionId) throw new Error('sessionId is required');
|
|
85
|
-
const sessions = load(dir);
|
|
86
|
-
const session = sessions[sessionId] || { id: sessionId, messages: [] };
|
|
87
|
-
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
88
|
-
session.messages = messages.concat(message);
|
|
89
|
-
session.updatedAt = Date.now();
|
|
90
|
-
session.pending = true;
|
|
91
|
-
session.seq = nextSeq(sessions);
|
|
92
|
-
sessions[sessionId] = session;
|
|
93
|
-
save(dir, sessions);
|
|
94
|
-
return session;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function listSessions(dir) {
|
|
98
|
-
const sessions = load(dir);
|
|
99
|
-
return Object.values(sessions)
|
|
100
|
-
.filter((s) => s && s.id)
|
|
101
|
-
.sort(
|
|
102
|
-
(a, b) =>
|
|
103
|
-
(b.updatedAt || 0) - (a.updatedAt || 0) ||
|
|
104
|
-
(b.seq || 0) - (a.seq || 0)
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getSession(dir, id) {
|
|
109
|
-
return load(dir)[id] || null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function deleteSession(dir, id) {
|
|
113
|
-
const sessions = load(dir);
|
|
114
|
-
delete sessions[id];
|
|
115
|
-
save(dir, sessions);
|
|
116
|
-
return { ok: true };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Clear the pending flag after a successful cloud push. `remote.remoteId`,
|
|
120
|
-
* when the server assigns its own conversation id, is stashed alongside. */
|
|
121
|
-
function markSynced(dir, id, remote) {
|
|
122
|
-
const sessions = load(dir);
|
|
123
|
-
const session = sessions[id];
|
|
124
|
-
if (!session) return null;
|
|
125
|
-
session.pending = false;
|
|
126
|
-
session.lastSyncedAt = Date.now();
|
|
127
|
-
if (remote && remote.remoteId) session.remoteId = remote.remoteId;
|
|
128
|
-
session.seq = nextSeq(sessions);
|
|
129
|
-
sessions[id] = session;
|
|
130
|
-
save(dir, sessions);
|
|
131
|
-
return session;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Force a session back into the retry queue (e.g. a push that partially failed). */
|
|
135
|
-
function markPending(dir, id) {
|
|
136
|
-
const sessions = load(dir);
|
|
137
|
-
const session = sessions[id];
|
|
138
|
-
if (!session) return null;
|
|
139
|
-
session.pending = true;
|
|
140
|
-
session.seq = nextSeq(sessions);
|
|
141
|
-
sessions[id] = session;
|
|
142
|
-
save(dir, sessions);
|
|
143
|
-
return session;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Sessions with local content the cloud hasn't confirmed yet (including
|
|
147
|
-
* sessions predating this field, which default to pending). */
|
|
148
|
-
function listPending(dir) {
|
|
149
|
-
return listSessions(dir).filter((s) => s.pending !== false);
|
|
150
|
-
}
|
|
45
|
+
})();
|
|
151
46
|
|
|
152
47
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* remote copy on the next push instead.
|
|
48
|
+
* The directory the shared store lives in: `$AEGISCODE_HOME` or `~/.aegiscode`.
|
|
49
|
+
* `legacyDir` (Electron's userData) is only used to adopt a pre-unification
|
|
50
|
+
* store, never to read sessions from afterwards.
|
|
157
51
|
*/
|
|
158
|
-
function
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
for (const remote of list) {
|
|
163
|
-
const id = remote && (remote.session_id || remote.id);
|
|
164
|
-
if (!id) continue;
|
|
165
|
-
const local = sessions[id];
|
|
166
|
-
const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
|
|
167
|
-
if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
sessions[id] = {
|
|
171
|
-
id,
|
|
172
|
-
title: remote.title || (local && local.title) || '',
|
|
173
|
-
messages: Array.isArray(remote.messages) ? remote.messages : [],
|
|
174
|
-
updatedAt: remoteUpdatedAt || Date.now(),
|
|
175
|
-
pending: false,
|
|
176
|
-
lastSyncedAt: Date.now(),
|
|
177
|
-
remoteId: remote.session_id || remote.id,
|
|
178
|
-
};
|
|
179
|
-
sessions[id].seq = nextSeq(sessions);
|
|
180
|
-
merged += 1;
|
|
181
|
-
}
|
|
182
|
-
if (merged) save(dir, sessions);
|
|
183
|
-
return merged;
|
|
52
|
+
function resolveStoreDir(legacyDir) {
|
|
53
|
+
const dir = credentials.aegisHome();
|
|
54
|
+
if (legacyDir && legacyDir !== dir) adopt(dir, legacyDir);
|
|
55
|
+
return dir;
|
|
184
56
|
}
|
|
185
57
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
58
|
+
const {
|
|
59
|
+
storeFile,
|
|
60
|
+
load,
|
|
61
|
+
save,
|
|
62
|
+
upsertSession,
|
|
63
|
+
appendMessage,
|
|
64
|
+
recordExchange,
|
|
65
|
+
listSessions,
|
|
66
|
+
getSession,
|
|
67
|
+
deleteSession,
|
|
68
|
+
markSynced,
|
|
69
|
+
markPending,
|
|
70
|
+
listPending,
|
|
71
|
+
mergeRemoteSessions,
|
|
72
|
+
listSummaries,
|
|
73
|
+
readTranscript,
|
|
74
|
+
adopt,
|
|
75
|
+
toMarkdown,
|
|
76
|
+
toJson,
|
|
77
|
+
} = shared;
|
|
205
78
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
return JSON.stringify(session, null, 2);
|
|
209
|
-
}
|
|
79
|
+
/** Kept under its old name — main.js and the tests call `sessionsFile`. */
|
|
80
|
+
const sessionsFile = storeFile;
|
|
210
81
|
|
|
211
82
|
module.exports = {
|
|
212
83
|
sessionsFile,
|
|
84
|
+
storeFile,
|
|
85
|
+
resolveStoreDir,
|
|
213
86
|
load,
|
|
214
87
|
save,
|
|
215
88
|
upsertSession,
|
|
216
89
|
appendMessage,
|
|
90
|
+
recordExchange,
|
|
217
91
|
listSessions,
|
|
218
92
|
getSession,
|
|
219
93
|
deleteSession,
|
|
@@ -221,6 +95,9 @@ module.exports = {
|
|
|
221
95
|
markPending,
|
|
222
96
|
listPending,
|
|
223
97
|
mergeRemoteSessions,
|
|
98
|
+
listSummaries,
|
|
99
|
+
readTranscript,
|
|
100
|
+
adopt,
|
|
224
101
|
toMarkdown,
|
|
225
102
|
toJson,
|
|
226
103
|
};
|
package/main.js
CHANGED
|
@@ -36,6 +36,17 @@ try {
|
|
|
36
36
|
}
|
|
37
37
|
const { createClient } = sharedClient;
|
|
38
38
|
|
|
39
|
+
// The shared credential store (`client/credentials.js`) — the same 0600 file
|
|
40
|
+
// the terminal host writes with `aegiscode login` and the MCP plugin reads. The
|
|
41
|
+
// app resolves its own account key through it, so signing in once serves all
|
|
42
|
+
// three hosts; see resolveStartupKey() and persistApiKey() below.
|
|
43
|
+
let credentials;
|
|
44
|
+
try {
|
|
45
|
+
credentials = require('../client/credentials.js');
|
|
46
|
+
} catch {
|
|
47
|
+
credentials = require('./vendor/credentials.js');
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
let electron = null;
|
|
40
51
|
try {
|
|
41
52
|
// In plain Node (CI smoke test, `node --check`) this either throws
|
|
@@ -666,14 +677,24 @@ function resolveUserDataDir(app) {
|
|
|
666
677
|
* Wire the real LocalEngine registry: settings store + ollama/providers
|
|
667
678
|
* transports + the shared cloud client. Transport-only — no brain logic.
|
|
668
679
|
*/
|
|
669
|
-
function createEngine(aegis, { app, safeStorage, dir: dirOverride } = {}) {
|
|
670
|
-
|
|
671
|
-
|
|
680
|
+
function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir: sessionsOverride } = {}) {
|
|
681
|
+
// Settings (the safeStorage-encrypted key, window state, provider entries)
|
|
682
|
+
// stay in Electron's userData: they are this host's own, and the key is only
|
|
683
|
+
// readable by this process.
|
|
684
|
+
const settingsDir = dirOverride || resolveUserDataDir(app);
|
|
685
|
+
const settings = createSettingsStore({ dir: settingsDir, safeStorage });
|
|
672
686
|
// Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
|
|
673
687
|
// it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
|
|
674
688
|
settings.migrateLegacyAegisKey();
|
|
675
689
|
const engine = createLocalEngine({ aegis, settings, ollama, providers });
|
|
676
|
-
|
|
690
|
+
// Sessions/memory live in the SHARED data dir ($AEGISCODE_HOME or
|
|
691
|
+
// ~/.aegiscode) — the same file the terminal host and the MCP plugin read —
|
|
692
|
+
// so a thread started in either shows up in the other without cloud sync and
|
|
693
|
+
// without a key. resolveStoreDir() also adopts a pre-unification
|
|
694
|
+
// userData/sessions.json, once, when the shared store is still empty, so an
|
|
695
|
+
// upgrade does not look like every conversation was deleted.
|
|
696
|
+
const sessionsDir = sessionsOverride || sessionStore.resolveStoreDir(settingsDir);
|
|
697
|
+
return { engine, sessionsDir, settingsDir, settings };
|
|
677
698
|
}
|
|
678
699
|
|
|
679
700
|
/**
|
|
@@ -1233,7 +1254,7 @@ function bootstrap() {
|
|
|
1233
1254
|
else pendingDeepLink = parsed;
|
|
1234
1255
|
});
|
|
1235
1256
|
|
|
1236
|
-
const aegis = createClient();
|
|
1257
|
+
const aegis = createClient(credentials.clientOptions());
|
|
1237
1258
|
const dataDir = resolveUserDataDir(app);
|
|
1238
1259
|
|
|
1239
1260
|
// One settings store backs both the provider settings surface and the AEGIS
|
|
@@ -1248,7 +1269,17 @@ function bootstrap() {
|
|
|
1248
1269
|
// Persist the in-app AEGIS key in its own reserved namespace, encrypted —
|
|
1249
1270
|
// never as a provider named 'aegis' (that coupling let the Settings pane's
|
|
1250
1271
|
// "Remove" delete the AEGIS key; defect #1).
|
|
1251
|
-
|
|
1272
|
+
//
|
|
1273
|
+
// Mirrored into the shared 0600 store as well, so one sign-in covers this app,
|
|
1274
|
+
// `aegiscode` and the MCP plugin instead of each host needing its own. The
|
|
1275
|
+
// app's copy stays the encrypted one; resolveStartupKey() compares their
|
|
1276
|
+
// `savedAt` stamps so whichever host wrote last is the key in force.
|
|
1277
|
+
const persistApiKey = (key) => {
|
|
1278
|
+
const result = settings.setAegisKey(key);
|
|
1279
|
+
if (key) credentials.saveApiKey(key);
|
|
1280
|
+
else credentials.clearApiKey();
|
|
1281
|
+
return result;
|
|
1282
|
+
};
|
|
1252
1283
|
|
|
1253
1284
|
// Native "reply ready" notification: main.js is exactly where every
|
|
1254
1285
|
// chatCompletion/model:chat call already resolves (withReplyNotify above),
|
|
@@ -1565,8 +1596,19 @@ function bootstrap() {
|
|
|
1565
1596
|
// Hydrate the client from the persisted key BEFORE the renderer issues any
|
|
1566
1597
|
// status/model call, so class/model dropdowns populate immediately when a
|
|
1567
1598
|
// key was saved in-app (safeStorage is usable only after app ready).
|
|
1568
|
-
|
|
1569
|
-
|
|
1599
|
+
//
|
|
1600
|
+
// `$AEGIS_API_KEY` still outranks both stores: createClient() already read
|
|
1601
|
+
// it, and nothing here may shadow an explicit export.
|
|
1602
|
+
const envKey = credentials.normalizeApiKey(process.env[credentials.KEY_ENV]);
|
|
1603
|
+
if (!envKey) {
|
|
1604
|
+
const chosen = credentials.preferNewest(settings.aegisRawKey(), settings.aegisKeySavedAt());
|
|
1605
|
+
if (chosen.key) {
|
|
1606
|
+
aegis.setApiKey(chosen.key);
|
|
1607
|
+
// The shared store is the newer write: bring this host's encrypted copy
|
|
1608
|
+
// up to date so a CLI rotation does not have to be redone in Settings.
|
|
1609
|
+
if (chosen.source === 'shared') settings.setAegisKey(chosen.key);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1570
1612
|
// Apply whatever was last saved (or the default): ship the shortcut only
|
|
1571
1613
|
// when app.isPackaged || the settings flag is on — see
|
|
1572
1614
|
// shouldEnableGlobalShortcut — so a plain `electron .` dev run never
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/renderer/app.js
CHANGED
|
@@ -69,7 +69,10 @@ const ELEMENT_IDS = {
|
|
|
69
69
|
modelPreset: 'model-preset',
|
|
70
70
|
modelInput: 'model-input',
|
|
71
71
|
maxTokens: 'max-tokens',
|
|
72
|
+
maxTokensLabel: 'max-tokens-label',
|
|
73
|
+
maxTokensRow: 'max-tokens-row',
|
|
72
74
|
maxTokensAdaptive: 'max-tokens-adaptive',
|
|
75
|
+
effortRow: 'effort-row',
|
|
73
76
|
autonomousToggle: 'autonomous-toggle',
|
|
74
77
|
autonomousToggleWrap: 'autonomous-toggle-wrap',
|
|
75
78
|
autonomousControls: 'autonomous-controls',
|
|
@@ -351,6 +354,35 @@ function updateAutonomousControlsVisibility() {
|
|
|
351
354
|
els.autonomousControls.hidden = !(wrapVisible && autonomousEnabled());
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Which budget control applies to the selected class.
|
|
359
|
+
*
|
|
360
|
+
* Aegis Cloud is sized server-side from `effort`; the other three classes take
|
|
361
|
+
* a per-call token ceiling from the dropdown. Showing both at once is what made
|
|
362
|
+
* the token cap untrustworthy on the pooled class — the dropdown was displayed,
|
|
363
|
+
* read on every send, and then raised by the server's effort ladder, so the
|
|
364
|
+
* number beside it was never the budget the call ran on. Exactly one control is
|
|
365
|
+
* on offer now, and it is the one the request actually travels with.
|
|
366
|
+
*/
|
|
367
|
+
function updateBudgetControls(cls) {
|
|
368
|
+
const pooled = cls === AUTONOMOUS_CLASS;
|
|
369
|
+
if (els.effortRow) els.effortRow.hidden = !pooled;
|
|
370
|
+
if (els.maxTokensRow) els.maxTokensRow.hidden = pooled;
|
|
371
|
+
if (els.maxTokensLabel) els.maxTokensLabel.hidden = pooled;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The effort to send, or undefined for the classes the server does not size
|
|
376
|
+
* from it. `auto` means "let the server infer it from the ask" — the same thing
|
|
377
|
+
* the server already does for a request that names no effort, and a deliberate
|
|
378
|
+
* choice rather than the old silent fall-through to the top rung.
|
|
379
|
+
*/
|
|
380
|
+
function effortFor(cls) {
|
|
381
|
+
if (cls !== AUTONOMOUS_CLASS) return undefined;
|
|
382
|
+
const value = els.autonomousEffort && els.autonomousEffort.value;
|
|
383
|
+
return value && value !== 'auto' ? value : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
354
386
|
// ---------------------------------------------------------------- UI helpers
|
|
355
387
|
|
|
356
388
|
function setConn(ok, text) {
|
|
@@ -1769,6 +1801,7 @@ async function loadModels(cls) {
|
|
|
1769
1801
|
els.autonomousToggleWrap.hidden = cls !== AUTONOMOUS_CLASS;
|
|
1770
1802
|
}
|
|
1771
1803
|
updateAutonomousControlsVisibility();
|
|
1804
|
+
updateBudgetControls(cls);
|
|
1772
1805
|
|
|
1773
1806
|
const custom = CUSTOM_CLASSES.has(cls);
|
|
1774
1807
|
els.modelSelect.hidden = custom;
|
|
@@ -2274,12 +2307,21 @@ async function send() {
|
|
|
2274
2307
|
addMessage('user', prompt);
|
|
2275
2308
|
|
|
2276
2309
|
const ceiling = applyMaxTokensClamp(model);
|
|
2277
|
-
|
|
2310
|
+
// Aegis Cloud takes no token cap from here at all: the server sizes the call
|
|
2311
|
+
// from `effort`, and a number in this position is a per-pass ceiling *over*
|
|
2312
|
+
// that ladder (aegis1 services/pool_brain.py pass_budgets). Sending the
|
|
2313
|
+
// dropdown's value anyway is what made "Max tokens: 4k" beside a turn a
|
|
2314
|
+
// figure the turn never ran on. The row is hidden for this class as well, so
|
|
2315
|
+
// the two controls can never disagree.
|
|
2316
|
+
const maxTokens = cls === AUTONOMOUS_CLASS
|
|
2317
|
+
? undefined
|
|
2318
|
+
: maxTokensAdaptive() ? ceiling : parseInt(els.maxTokens.value, 10) || 4096;
|
|
2278
2319
|
const autonomous = cls === AUTONOMOUS_CLASS && autonomousEnabled();
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
|
|
2320
|
+
// Sent for the pooled class whether or not the fan-out is ticked: the fan-out
|
|
2321
|
+
// is enabled by the model id this class sends, so a turn that never entered
|
|
2322
|
+
// autonomous mode still ran pooled and had no way to say how big it should
|
|
2323
|
+
// be. `undefined` = "auto" = the server infers it from the ask.
|
|
2324
|
+
const effort = effortFor(cls);
|
|
2283
2325
|
const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
|
|
2284
2326
|
// Reuse the open thread's session id (minted once, on its first message)
|
|
2285
2327
|
// instead of a fresh one per send — a new id every turn is what made both
|
|
@@ -2378,7 +2420,11 @@ async function send() {
|
|
|
2378
2420
|
if (data && data.model) bits.push(`model: ${data.model}`);
|
|
2379
2421
|
else if (model) bits.push(`model: ${model}`);
|
|
2380
2422
|
bits.push(classLabel(cls));
|
|
2381
|
-
if (autonomous) bits.push(`autonomous (${effort}, ${workers ||
|
|
2423
|
+
if (autonomous) bits.push(`autonomous (${effort || 'auto'}, ${workers || 'auto'}w)`);
|
|
2424
|
+
// The budget rung is worth showing even without the fan-out: on the pooled
|
|
2425
|
+
// class it is what sized the call, and a user cannot tell a 16k turn from a
|
|
2426
|
+
// 64k one by looking at the answer.
|
|
2427
|
+
else if (effort) bits.push(`effort: ${effort}`);
|
|
2382
2428
|
const turnTokens = usageTokens(data && data.usage);
|
|
2383
2429
|
if (turnTokens != null) bits.push(`tokens: ${turnTokens}`);
|
|
2384
2430
|
addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
|
|
@@ -2459,14 +2505,23 @@ async function init() {
|
|
|
2459
2505
|
updateAutonomousControlsVisibility();
|
|
2460
2506
|
});
|
|
2461
2507
|
|
|
2462
|
-
//
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2508
|
+
// Budget control for the pooled class: which rung of the server's effort
|
|
2509
|
+
// ladder sizes the call, or "auto" to let the server infer it from the ask
|
|
2510
|
+
// (aegis1 services/pool_brain.py parse_brain_request). Opt-in per machine,
|
|
2511
|
+
// remembered across restarts; a stored value from a build whose list was
|
|
2512
|
+
// short (low/medium/high, no "auto") falls through to the markup's default
|
|
2513
|
+
// rather than assigning an option that no longer exists.
|
|
2465
2514
|
const savedEffort = localStorage.getItem(AUTONOMOUS_EFFORT_KEY);
|
|
2466
|
-
if (savedEffort
|
|
2515
|
+
if (savedEffort && Array.from(els.autonomousEffort.options).some((o) => o.value === savedEffort)) {
|
|
2516
|
+
els.autonomousEffort.value = savedEffort;
|
|
2517
|
+
}
|
|
2467
2518
|
els.autonomousEffort.addEventListener('change', () => {
|
|
2468
2519
|
localStorage.setItem(AUTONOMOUS_EFFORT_KEY, els.autonomousEffort.value);
|
|
2469
2520
|
});
|
|
2521
|
+
// Worker count for the fan-out. Left empty by default on purpose: an empty
|
|
2522
|
+
// field is what tells the server to size the fan-out from the ask
|
|
2523
|
+
// (parse_brain_request's auto path) instead of the old client-side default of
|
|
2524
|
+
// 3, which made every turn a 4-pass fan-out.
|
|
2470
2525
|
const savedWorkers = localStorage.getItem(AUTONOMOUS_WORKERS_KEY);
|
|
2471
2526
|
if (savedWorkers) els.autonomousWorkers.value = savedWorkers;
|
|
2472
2527
|
els.autonomousWorkers.addEventListener('change', () => {
|
|
@@ -2484,6 +2539,11 @@ async function init() {
|
|
|
2484
2539
|
|
|
2485
2540
|
els.classSelect.addEventListener('change', () => {
|
|
2486
2541
|
localStorage.setItem(CLASS_KEY, els.classSelect.value);
|
|
2542
|
+
// Apply the budget-control choice immediately rather than waiting for the
|
|
2543
|
+
// model list: loadModels() is async, and until it resolves the previous
|
|
2544
|
+
// class's control would still be on screen — showing a token cap on a
|
|
2545
|
+
// pooled turn, or hiding the effort rung it runs on.
|
|
2546
|
+
updateBudgetControls(els.classSelect.value);
|
|
2487
2547
|
loadModels(els.classSelect.value);
|
|
2488
2548
|
});
|
|
2489
2549
|
|
package/renderer/index.html
CHANGED
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
hidden
|
|
77
77
|
/>
|
|
78
78
|
|
|
79
|
-
<label for="max-tokens">Max tokens</label>
|
|
80
|
-
<div class="max-tokens-row">
|
|
79
|
+
<label for="max-tokens" id="max-tokens-label">Max tokens</label>
|
|
80
|
+
<div class="max-tokens-row" id="max-tokens-row">
|
|
81
81
|
<select id="max-tokens">
|
|
82
82
|
<option value="1024">1k</option>
|
|
83
83
|
<option value="4096" selected>4k</option>
|
|
@@ -95,6 +95,23 @@
|
|
|
95
95
|
</label>
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
|
+
<!-- The pooled AEGIS Cloud class sizes a request's token budget
|
|
99
|
+
server-side from this ladder (effort -> low/medium/high ->
|
|
100
|
+
16384/32768/65536 total across the fan-out), so the max-tokens row
|
|
101
|
+
above is hidden for that class instead of being shown and silently
|
|
102
|
+
overridden: the server treats a body max_tokens as a ceiling, and
|
|
103
|
+
an effort rung as the budget. Shown for Ollama and custom
|
|
104
|
+
endpoints, where a per-call ceiling is the only control there is. -->
|
|
105
|
+
<div class="max-tokens-row" id="effort-row" hidden>
|
|
106
|
+
<label for="autonomous-effort">Effort</label>
|
|
107
|
+
<select id="autonomous-effort">
|
|
108
|
+
<option value="low">low</option>
|
|
109
|
+
<option value="medium">medium</option>
|
|
110
|
+
<option value="high">high</option>
|
|
111
|
+
<option value="auto" selected>auto</option>
|
|
112
|
+
</select>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
98
115
|
<label
|
|
99
116
|
class="flow-toggle"
|
|
100
117
|
for="autonomous-toggle"
|
|
@@ -106,14 +123,8 @@
|
|
|
106
123
|
<span>work autonomously</span>
|
|
107
124
|
</label>
|
|
108
125
|
<div id="autonomous-controls" class="autonomous-controls" hidden>
|
|
109
|
-
<label for="autonomous-effort">Effort</label>
|
|
110
|
-
<select id="autonomous-effort">
|
|
111
|
-
<option value="low">low</option>
|
|
112
|
-
<option value="medium">medium</option>
|
|
113
|
-
<option value="high" selected>high</option>
|
|
114
|
-
</select>
|
|
115
126
|
<label for="autonomous-workers">Workers</label>
|
|
116
|
-
<input type="number" id="autonomous-workers" min="1" max="8" step="1"
|
|
127
|
+
<input type="number" id="autonomous-workers" min="1" max="8" step="1" />
|
|
117
128
|
</div>
|
|
118
129
|
<p class="hint" id="model-hint"></p>
|
|
119
130
|
</section>
|