aegis-desktop 0.3.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/bin/aegis.js +30 -0
- package/build/icon.png +0 -0
- package/lib/local/agents.js +102 -0
- package/lib/local/context.js +81 -0
- package/lib/local/engine.js +460 -0
- package/lib/local/ollama.js +77 -0
- package/lib/local/prompt.js +91 -0
- package/lib/local/providers.js +536 -0
- package/lib/local/shell.js +208 -0
- package/lib/local/tools.js +638 -0
- package/lib/settings.js +225 -0
- package/lib/sync/memory-queue.js +57 -0
- package/lib/sync/sessions.js +199 -0
- package/main.js +715 -0
- package/package.json +46 -0
- package/preload.js +168 -0
- package/renderer/app.js +1990 -0
- package/renderer/index.html +289 -0
- package/renderer/max-tokens.js +18 -0
- package/renderer/style.css +1454 -0
- package/vendor/aegis.js +694 -0
- package/vendor/foreign-memory.js +666 -0
package/main.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* AEGIS Desktop — thin Electron main shell (host 2).
|
|
4
|
+
*
|
|
5
|
+
* This process owns the ONLY privileged object in the app: an instance of the
|
|
6
|
+
* shared thin client (../client/aegis.js), which holds the AEGIS_API_KEY read
|
|
7
|
+
* from the environment. The renderer never sees the key; it talks exclusively
|
|
8
|
+
* over a whitelisted, context-isolated IPC surface (see preload.js).
|
|
9
|
+
*
|
|
10
|
+
* Hard boundary: this file is a window + IPC shell. It contains no engine,
|
|
11
|
+
* orchestration, routing, tier, or memory logic — that all lives behind
|
|
12
|
+
* aegiscloud.org. If it grows beyond a thin shell, it is wrong.
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* - bootstrap() Electron-only: window + IPC registration + lifecycle
|
|
16
|
+
* - createIpcDispatch() pure: method-name -> client call (unit-testable
|
|
17
|
+
* in plain Node, no Electron binary needed)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
// Dev / CI / smoke-test layout: desktop/ lives inside the repo, so the shared
|
|
25
|
+
// client resolves via ../client/aegis.js. In the packaged app the client is
|
|
26
|
+
// copied to desktop/vendor/aegis.js by the predist step (electron-builder
|
|
27
|
+
// cannot reach outside the app dir), so fall back to that copy. Both files
|
|
28
|
+
// are the same thin transport — never brain logic.
|
|
29
|
+
let sharedClient;
|
|
30
|
+
try {
|
|
31
|
+
sharedClient = require('../client/aegis.js');
|
|
32
|
+
} catch {
|
|
33
|
+
sharedClient = require('./vendor/aegis.js');
|
|
34
|
+
}
|
|
35
|
+
const { createClient } = sharedClient;
|
|
36
|
+
|
|
37
|
+
let electron = null;
|
|
38
|
+
try {
|
|
39
|
+
// In plain Node (CI smoke test, `node --check`) this either throws
|
|
40
|
+
// (module absent) or resolves to the electron binary path string with no
|
|
41
|
+
// `.app` — either way bootstrap() is skipped and the pure exports survive.
|
|
42
|
+
electron = require('electron');
|
|
43
|
+
} catch {
|
|
44
|
+
/* not running under Electron */
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// LocalEngine wiring (plan P1 §5.2): the model:/sync: surfaces are additive,
|
|
48
|
+
// backed by transport-only modules in desktop/lib/. No brain logic enters here.
|
|
49
|
+
const os = require('node:os');
|
|
50
|
+
const { createLocalEngine } = require('./lib/local/engine.js');
|
|
51
|
+
const { createSettingsStore, isReservedNamespace } = require('./lib/settings.js');
|
|
52
|
+
const ollama = require('./lib/local/ollama.js');
|
|
53
|
+
const providers = require('./lib/local/providers.js');
|
|
54
|
+
const sessionStore = require('./lib/sync/sessions.js');
|
|
55
|
+
const memoryQueue = require('./lib/sync/memory-queue.js');
|
|
56
|
+
// Foreign-memory scanner (shared with client/foreign-memory.js). Same
|
|
57
|
+
// resolution rule as the transport above: the canonical file inside the repo,
|
|
58
|
+
// the predist-staged copy in a packaged app. Never forked logic — so it needs
|
|
59
|
+
// no wrapper module of its own.
|
|
60
|
+
let foreignMemory;
|
|
61
|
+
try {
|
|
62
|
+
foreignMemory = require('../client/foreign-memory.js');
|
|
63
|
+
} catch {
|
|
64
|
+
foreignMemory = require('./vendor/foreign-memory.js');
|
|
65
|
+
}
|
|
66
|
+
// Builtin tool executor for the agent loop (client half of aegiscodex-dev's
|
|
67
|
+
// tool calling). MAIN-process only: it is reachable from the renderer solely
|
|
68
|
+
// through the whitelisted `tools:` IPC surface registered below — see the
|
|
69
|
+
// sandbox note on registerToolsIpc().
|
|
70
|
+
const localTools = require('./lib/local/tools.js');
|
|
71
|
+
|
|
72
|
+
const IPC_PREFIX = 'aegis:';
|
|
73
|
+
const MODEL_PREFIX = 'model:';
|
|
74
|
+
const SYNC_PREFIX = 'sync:';
|
|
75
|
+
const TOOLS_PREFIX = 'tools:';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
|
|
79
|
+
* The invoke of aegis:chatCompletion still resolves once with the normalised
|
|
80
|
+
* final result; live chunks travel on this single dedicated channel.
|
|
81
|
+
*/
|
|
82
|
+
const CHAT_DELTA_CHANNEL = `${IPC_PREFIX}chatDelta`;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Address one SSE delta to the stream that produced it (D2.2 multi-stream).
|
|
86
|
+
*
|
|
87
|
+
* The renderer's chat flow runs a *set* of concurrent streams — the primary
|
|
88
|
+
* answer plus the horizontal discovery lane — so every chunk must say which
|
|
89
|
+
* sessionId it belongs to. `id` is added last so the routing tag stays
|
|
90
|
+
* authoritative, while `delta` (and any future field the client sends) is
|
|
91
|
+
* copied through untouched: existing single-stream callers that never set a
|
|
92
|
+
* sessionId simply receive `id: undefined`, exactly the shape they see today.
|
|
93
|
+
*/
|
|
94
|
+
function taggedChunk(chunk, sessionId) {
|
|
95
|
+
const base = chunk && typeof chunk === 'object' ? chunk : { delta: chunk };
|
|
96
|
+
return { ...base, id: sessionId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const APP_VERSION = require('./package.json').version;
|
|
100
|
+
|
|
101
|
+
/** Never ship a full key to the renderer — only a masked preview. */
|
|
102
|
+
function maskKey(key) {
|
|
103
|
+
if (!key) return null;
|
|
104
|
+
if (key.length <= 10) return 'configured';
|
|
105
|
+
return `${key.slice(0, 9)}\u2026${key.slice(-4)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Classify a memory-endpoint failure: is this the free-plan cap, or is it just
|
|
110
|
+
* offline? aegis1 answers HTTP 402 `free_session_limit_reached` on every
|
|
111
|
+
* metered memory endpoint (app.py:9229) and the shared client attaches
|
|
112
|
+
* `err.status` / `err.data` (vendor/aegis.js parseResponse) — but
|
|
113
|
+
* `ipcRenderer.invoke` only carries the *message string* across the process
|
|
114
|
+
* boundary. A thrown cap therefore reaches the renderer as the bare text
|
|
115
|
+
* "free_session_limit_reached" with `status`/`data` stripped, which is why the
|
|
116
|
+
* upgrade UI in fetchMemory() never fired. So: detect it here, in main, where
|
|
117
|
+
* the fields still exist, and hand the renderer a plain resolved payload.
|
|
118
|
+
*
|
|
119
|
+
* Returns null for anything that is not a cap (offline, no key, 500, …).
|
|
120
|
+
*/
|
|
121
|
+
function upgradeInfo(err) {
|
|
122
|
+
const status = err && err.status;
|
|
123
|
+
const data = (err && err.data) || {};
|
|
124
|
+
const code = typeof data.error === 'string' ? data.error : '';
|
|
125
|
+
if (status !== 402 && code !== 'free_session_limit_reached') return null;
|
|
126
|
+
return {
|
|
127
|
+
url: data.upgradeUrl || 'https://aegiscloud.org/subscribe',
|
|
128
|
+
used: data.sessionsUsed != null ? data.sessionsUsed : null,
|
|
129
|
+
limit: data.freeSessionLimit != null ? data.freeSessionLimit : null,
|
|
130
|
+
code: code || 'free_session_limit_reached',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Message text for a failure, without assuming the Error shape. */
|
|
135
|
+
function errorText(err) {
|
|
136
|
+
return err && err.message ? err.message : String(err);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `aegis:memorySave` with the offline-first fallback (plan P3 §7): try the
|
|
141
|
+
* cloud save first; if it fails (no key, offline, transient error) queue the
|
|
142
|
+
* entry in <dir>/memory-queue.json instead of throwing, so the renderer's
|
|
143
|
+
* "remember" affordance never surfaces an error for the no-key case. `dir` is
|
|
144
|
+
* optional — callers that omit it (e.g. the desktop-shell smoke test) simply
|
|
145
|
+
* get the un-queued rejection back, unchanged from before this existed.
|
|
146
|
+
*
|
|
147
|
+
* The cap is the one failure that is deliberately NOT queued: see upgradeInfo.
|
|
148
|
+
* A 402 resolves `{ ok:false, upgrade }` — never thrown, because the fields
|
|
149
|
+
* would not survive the IPC trip — and the entry is left un-stored so the
|
|
150
|
+
* renderer can keep it in the box and point at the subscribe page.
|
|
151
|
+
*/
|
|
152
|
+
async function saveMemoryWithQueue(aegis, dir, entry) {
|
|
153
|
+
try {
|
|
154
|
+
return await aegis.memorySave(entry);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const upgrade = upgradeInfo(err);
|
|
157
|
+
if (upgrade) {
|
|
158
|
+
return { ok: false, queued: 0, saved: 0, upgrade, reason: errorText(err) };
|
|
159
|
+
}
|
|
160
|
+
if (!dir || !entry) throw err;
|
|
161
|
+
memoryQueue.enqueue(dir, entry);
|
|
162
|
+
return { ok: true, queued: true, reason: errorText(err) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read side of the same normalisation. `memorySearch` / `memoryList` run
|
|
168
|
+
* `_memory_sync_access` server-side, so they 402 on exactly the same cap;
|
|
169
|
+
* resolving `{ entries: [], upgrade }` keeps one detection path for all four
|
|
170
|
+
* memory calls instead of four renderer-side branches that can't see `err.status`.
|
|
171
|
+
*/
|
|
172
|
+
async function normalizeMemoryRead(promise) {
|
|
173
|
+
try {
|
|
174
|
+
return await promise;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const upgrade = upgradeInfo(err);
|
|
177
|
+
if (!upgrade) throw err;
|
|
178
|
+
return { entries: [], upgrade };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `aegis:memoryImport` — scan this machine for other AI tools' memory and,
|
|
184
|
+
* when confirmed, push it into AEGIS cloud memory.
|
|
185
|
+
*
|
|
186
|
+
* Read-only against the foreign stores (client/foreign-memory.js never writes
|
|
187
|
+
* to them). Two-phase by design: a dry run reports counts so the user can see
|
|
188
|
+
* what would land before anything leaves the machine. When a confirmed save
|
|
189
|
+
* can't reach the cloud (no key / offline), entries fall back to the same
|
|
190
|
+
* local memory-queue the single-entry save path uses, so the import is not
|
|
191
|
+
* lost — it flushes on the next "Sync now".
|
|
192
|
+
*
|
|
193
|
+
* The renderer gets counts and a summary string, never the entry bodies:
|
|
194
|
+
* 1000 entries x 2 kB over IPC is pure waste, and the bodies are already
|
|
195
|
+
* either in the cloud or in the queue by the time this resolves.
|
|
196
|
+
*/
|
|
197
|
+
async function importForeignMemory(aegis, dir, payload) {
|
|
198
|
+
const opts = payload || {};
|
|
199
|
+
const report = foreignMemory.scan({
|
|
200
|
+
sources: opts.sources,
|
|
201
|
+
limit: opts.limit || 1000,
|
|
202
|
+
});
|
|
203
|
+
const summary = foreignMemory.describe(report);
|
|
204
|
+
const sources = report.sources.map((s) => ({
|
|
205
|
+
id: s.id,
|
|
206
|
+
label: s.label,
|
|
207
|
+
verified: s.verified,
|
|
208
|
+
present: s.present,
|
|
209
|
+
count: s.count,
|
|
210
|
+
skipped: s.skipped,
|
|
211
|
+
files: s.files,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
const base = { summary, totals: report.totals, sources };
|
|
215
|
+
|
|
216
|
+
if (!opts.confirm || !report.entries.length) {
|
|
217
|
+
return { ...base, ok: true, dryRun: !opts.confirm, saved: 0, queued: 0 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let saved = 0;
|
|
221
|
+
let queued = 0;
|
|
222
|
+
const errors = [];
|
|
223
|
+
let upgrade = null;
|
|
224
|
+
for (const batch of foreignMemory.chunk(report.entries, 200)) {
|
|
225
|
+
try {
|
|
226
|
+
const data = await aegis.memorySaveBatch(batch);
|
|
227
|
+
saved += (data && data.saved) || batch.length;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// The cap is not an offline failure: don't queue the batch. The entries
|
|
230
|
+
// are still in the foreign stores, so a later scan re-finds them — but a
|
|
231
|
+
// queued copy would flush-fail forever and, worse, made a mid-import 402
|
|
232
|
+
// report as a silent `queued` count. Report the upgrade instead.
|
|
233
|
+
upgrade = upgradeInfo(err);
|
|
234
|
+
if (!upgrade && dir) {
|
|
235
|
+
// Offline-first, exactly like saveMemoryWithQueue(): keep the entries
|
|
236
|
+
// rather than dropping them. Anything already saved stays saved.
|
|
237
|
+
for (const entry of batch) {
|
|
238
|
+
memoryQueue.enqueue(dir, entry);
|
|
239
|
+
queued += 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
errors.push(errorText(err));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
...base,
|
|
249
|
+
ok: errors.length === 0,
|
|
250
|
+
dryRun: false,
|
|
251
|
+
saved,
|
|
252
|
+
queued,
|
|
253
|
+
upgrade,
|
|
254
|
+
reason: errors[0] || null,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Pure mapping: IPC payload -> shared-client call. No Electron types here, so
|
|
260
|
+
* tests can drive it with a stub client and a fake ipcMain.
|
|
261
|
+
*/
|
|
262
|
+
function createIpcDispatch(aegis, dir, persistApiKey) {
|
|
263
|
+
const dispatch = {
|
|
264
|
+
status: () => ({
|
|
265
|
+
appVersion: APP_VERSION,
|
|
266
|
+
clientVersion: aegis.clientVersion,
|
|
267
|
+
apiBase: aegis.apiBase,
|
|
268
|
+
keyConfigured: Boolean(aegis.apiKey),
|
|
269
|
+
keyMask: maskKey(aegis.apiKey),
|
|
270
|
+
}),
|
|
271
|
+
|
|
272
|
+
// In-app API key entry (plan rebuild): replace the live client key and
|
|
273
|
+
// persist it encrypted at rest via the settings store. The renderer only
|
|
274
|
+
// ever sees the masked preview back — never the raw key.
|
|
275
|
+
setApiKey: (payload) => {
|
|
276
|
+
const key = payload && payload.key;
|
|
277
|
+
aegis.setApiKey(key || '');
|
|
278
|
+
if (persistApiKey) persistApiKey(aegis.apiKey);
|
|
279
|
+
return { keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
verifyApiKey: () => aegis.verifyApiKey(),
|
|
283
|
+
tokenBankBalance: () => aegis.tokenBankBalance(),
|
|
284
|
+
|
|
285
|
+
listModels: () => aegis.listModels(),
|
|
286
|
+
|
|
287
|
+
chatCompletion: (payload) =>
|
|
288
|
+
aegis.chatCompletion({
|
|
289
|
+
prompt: payload && payload.prompt,
|
|
290
|
+
system: payload && payload.system,
|
|
291
|
+
model: payload && payload.model,
|
|
292
|
+
mode: payload && payload.mode,
|
|
293
|
+
maxTokens: payload && payload.maxTokens,
|
|
294
|
+
// stream/onStream are forwarded when present; registerIpc() injects
|
|
295
|
+
// the IPC chunk forwarder for the desktop host (see below).
|
|
296
|
+
stream: payload && payload.stream,
|
|
297
|
+
onStream: payload && payload.onStream,
|
|
298
|
+
}),
|
|
299
|
+
|
|
300
|
+
byokStatus: () => aegis.byokStatus(),
|
|
301
|
+
byokSet: (payload) =>
|
|
302
|
+
aegis.byokSet(
|
|
303
|
+
payload && payload.provider,
|
|
304
|
+
payload && payload.apiKey
|
|
305
|
+
),
|
|
306
|
+
|
|
307
|
+
memorySearch: (payload) =>
|
|
308
|
+
normalizeMemoryRead(
|
|
309
|
+
aegis.memorySearch(payload && payload.query, payload && payload.limit)
|
|
310
|
+
),
|
|
311
|
+
memorySave: (payload) =>
|
|
312
|
+
saveMemoryWithQueue(aegis, dir, payload && payload.entry),
|
|
313
|
+
memoryList: (payload) =>
|
|
314
|
+
normalizeMemoryRead(aegis.memoryList(payload && payload.limit)),
|
|
315
|
+
|
|
316
|
+
verifyToken: (payload) =>
|
|
317
|
+
aegis.verifyToken(payload && payload.token),
|
|
318
|
+
memoryActivate: (payload) =>
|
|
319
|
+
aegis.memoryActivate(payload && payload.token),
|
|
320
|
+
memoryPull: (payload) =>
|
|
321
|
+
aegis.memoryPull(payload && payload.since),
|
|
322
|
+
memorySaveBatch: (payload) =>
|
|
323
|
+
aegis.memorySaveBatch(payload && payload.entries),
|
|
324
|
+
memoryImport: (payload) =>
|
|
325
|
+
importForeignMemory(aegis, dir, payload),
|
|
326
|
+
importConversation: (payload) =>
|
|
327
|
+
aegis.importConversation(payload || {}),
|
|
328
|
+
};
|
|
329
|
+
return dispatch;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Register every dispatch method as `aegis:<name>` on ipcMain. `dir` (the
|
|
333
|
+
* user-data dir) is optional and threaded through only for the memorySave
|
|
334
|
+
* offline-queue fallback — see saveMemoryWithQueue(). */
|
|
335
|
+
function registerIpc(ipcMain, aegis, dir, persistApiKey) {
|
|
336
|
+
const dispatch = createIpcDispatch(aegis, dir, persistApiKey);
|
|
337
|
+
for (const [name, handler] of Object.entries(dispatch)) {
|
|
338
|
+
if (name === 'chatCompletion') {
|
|
339
|
+
// Streaming render (D2.1): when the renderer asks for stream, SSE deltas
|
|
340
|
+
// are pushed over CHAT_DELTA_CHANNEL as they arrive while the invoke
|
|
341
|
+
// promise still resolves once with the normalised final result. When the
|
|
342
|
+
// payload does not request stream, behaviour is the plain non-streaming
|
|
343
|
+
// dispatch (what the headless shell test drives directly).
|
|
344
|
+
ipcMain.handle(`${IPC_PREFIX}${name}`, (event, payload) => {
|
|
345
|
+
const opts = { ...(payload || {}) };
|
|
346
|
+
if (!opts.stream) return handler(opts);
|
|
347
|
+
const sender = event && event.sender;
|
|
348
|
+
const forward = (chunk) => {
|
|
349
|
+
if (
|
|
350
|
+
sender &&
|
|
351
|
+
typeof sender.send === 'function' &&
|
|
352
|
+
!sender.isDestroyed()
|
|
353
|
+
) {
|
|
354
|
+
// Address every delta with the request's sessionId (D2.2): the
|
|
355
|
+
// chat flow can run more than one stream at a time (the primary
|
|
356
|
+
// answer plus the horizontal discovery lane), so an unaddressed
|
|
357
|
+
// broadcast would interleave both replies into one bubble. The
|
|
358
|
+
// renderer's preload filters on `id`; chunks stay shape-compatible
|
|
359
|
+
// ({ delta }) for callers that never set a sessionId.
|
|
360
|
+
sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
return handler({ ...opts, stream: true, onStream: forward });
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
|
|
368
|
+
}
|
|
369
|
+
return dispatch;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the per-user data directory for settings + sessions. Electron gives
|
|
374
|
+
* us app.getPath('userData'); outside Electron (headless smoke tests) fall back
|
|
375
|
+
* to ~/.aegiscode so the pure functions can still be exercised.
|
|
376
|
+
*/
|
|
377
|
+
function resolveUserDataDir(app) {
|
|
378
|
+
if (app && typeof app.getPath === 'function') {
|
|
379
|
+
try {
|
|
380
|
+
return app.getPath('userData');
|
|
381
|
+
} catch {
|
|
382
|
+
/* fall through */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return path.join(os.homedir(), '.aegiscode');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Wire the real LocalEngine registry: settings store + ollama/providers
|
|
390
|
+
* transports + the shared cloud client. Transport-only — no brain logic.
|
|
391
|
+
*/
|
|
392
|
+
function createEngine(aegis, { app, safeStorage, dir: dirOverride } = {}) {
|
|
393
|
+
const dir = dirOverride || resolveUserDataDir(app);
|
|
394
|
+
const settings = createSettingsStore({ dir, safeStorage });
|
|
395
|
+
// Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
|
|
396
|
+
// it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
|
|
397
|
+
settings.migrateLegacyAegisKey();
|
|
398
|
+
const engine = createLocalEngine({ aegis, settings, ollama, providers });
|
|
399
|
+
return { engine, sessionsDir: dir, settings };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
|
|
404
|
+
*/
|
|
405
|
+
function createModelDispatch(engine) {
|
|
406
|
+
return {
|
|
407
|
+
listClasses: () => engine.listClasses(),
|
|
408
|
+
listModels: (payload) => engine.listModels(payload && payload.class),
|
|
409
|
+
chat: (payload) => engine.chat(payload, payload && payload.onStream),
|
|
410
|
+
// The AEGIS key lives in a reserved namespace the store refuses to expose
|
|
411
|
+
// or delete through this surface; the filter is belt-and-braces so a
|
|
412
|
+
// mis-wired store can never hand the renderer a removable AEGIS entry.
|
|
413
|
+
'settings.get': () =>
|
|
414
|
+
engine.settings.list().filter((s) => !(s && isReservedNamespace(s.provider))),
|
|
415
|
+
'settings.set': (payload) =>
|
|
416
|
+
engine.settings.set(payload && payload.provider, {
|
|
417
|
+
baseURL: payload && payload.baseURL,
|
|
418
|
+
key: payload && payload.key,
|
|
419
|
+
}),
|
|
420
|
+
'settings.remove': (payload) =>
|
|
421
|
+
engine.settings.remove(payload && payload.provider),
|
|
422
|
+
cancel: (payload) => engine.cancel(payload && payload.sessionId),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Pure mapping: sync:<name> -> sessions store call (unit-testable in Node).
|
|
428
|
+
* `push`/`pull` are the P3 cloud-sync surface (plan §7/§8.5): they reuse the
|
|
429
|
+
* shared client's memory-token auth (`aegis.conversationSyncPush/Pull`) — no
|
|
430
|
+
* new credential flow. Offline-first: with no AEGIS key configured, both
|
|
431
|
+
* resolve `{ ok: false, reason }` without ever throwing, and every local
|
|
432
|
+
* flow (save/append/list) keeps working untouched.
|
|
433
|
+
*/
|
|
434
|
+
function createSyncDispatch(sessions, dir, aegis) {
|
|
435
|
+
let lastSyncAt = null;
|
|
436
|
+
|
|
437
|
+
function hasCloud() {
|
|
438
|
+
return Boolean(aegis && aegis.apiKey);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Retry every locally queued memory-save (plan P3 §7 "queue locally and
|
|
442
|
+
* sync later") now that the cloud is reachable. Entries that still fail
|
|
443
|
+
* (e.g. one bad entry among several) stay queued for the next attempt. */
|
|
444
|
+
async function flushMemoryQueue() {
|
|
445
|
+
const queued = memoryQueue.listQueued(dir);
|
|
446
|
+
if (!queued.length) return { flushed: 0, remaining: 0, upgrade: null };
|
|
447
|
+
const remaining = [];
|
|
448
|
+
let flushed = 0;
|
|
449
|
+
let upgrade = null;
|
|
450
|
+
for (let i = 0; i < queued.length; i += 1) {
|
|
451
|
+
try {
|
|
452
|
+
await aegis.memorySave(queued[i]);
|
|
453
|
+
flushed += 1;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
const capped = upgradeInfo(err);
|
|
456
|
+
if (capped) {
|
|
457
|
+
// A 402 is not "try again later": every remaining entry would fail
|
|
458
|
+
// the same way, the queue would never drain, and the paywall would
|
|
459
|
+
// stay invisible behind a "synced" toast. Stop, keep the rest
|
|
460
|
+
// queued for after the upgrade, and surface the cap to the renderer.
|
|
461
|
+
upgrade = capped;
|
|
462
|
+
remaining.push(...queued.slice(i));
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
// Any other failure (offline / transient) keeps the entry queued.
|
|
466
|
+
remaining.push(queued[i]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
memoryQueue.save(dir, remaining);
|
|
470
|
+
return { flushed, remaining: remaining.length, upgrade };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function push() {
|
|
474
|
+
const pending = sessions.listPending(dir);
|
|
475
|
+
if (!hasCloud()) {
|
|
476
|
+
return { ok: false, queued: pending.length, reason: 'no AEGIS key configured' };
|
|
477
|
+
}
|
|
478
|
+
const memoryFlush = await flushMemoryQueue();
|
|
479
|
+
if (!pending.length) {
|
|
480
|
+
return {
|
|
481
|
+
ok: true,
|
|
482
|
+
queued: 0,
|
|
483
|
+
pushed: 0,
|
|
484
|
+
memoryFlushed: memoryFlush.flushed,
|
|
485
|
+
upgrade: memoryFlush.upgrade,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let pushed = 0;
|
|
490
|
+
let lastError = null;
|
|
491
|
+
for (const session of pending) {
|
|
492
|
+
try {
|
|
493
|
+
const result = await aegis.conversationSyncPush({
|
|
494
|
+
session_id: session.id,
|
|
495
|
+
title: session.title,
|
|
496
|
+
messages: session.messages,
|
|
497
|
+
});
|
|
498
|
+
const remoteId = result && (result.session_id || result.id);
|
|
499
|
+
sessions.markSynced(dir, session.id, { remoteId });
|
|
500
|
+
pushed += 1;
|
|
501
|
+
} catch (err) {
|
|
502
|
+
lastError = err;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const queued = sessions.listPending(dir).length;
|
|
506
|
+
if (pushed) lastSyncAt = Date.now();
|
|
507
|
+
if (pushed === 0 && lastError) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
queued,
|
|
511
|
+
reason: lastError.message || 'push failed',
|
|
512
|
+
memoryFlushed: memoryFlush.flushed,
|
|
513
|
+
upgrade: memoryFlush.upgrade,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: true,
|
|
518
|
+
queued,
|
|
519
|
+
pushed,
|
|
520
|
+
memoryFlushed: memoryFlush.flushed,
|
|
521
|
+
upgrade: memoryFlush.upgrade,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function pull() {
|
|
526
|
+
if (!hasCloud()) {
|
|
527
|
+
return { ok: false, merged: 0, reason: 'no AEGIS key configured' };
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const data = await aegis.conversationSyncPull();
|
|
531
|
+
const merged = sessions.mergeRemoteSessions(dir, (data && data.sessions) || []);
|
|
532
|
+
lastSyncAt = Date.now();
|
|
533
|
+
return { ok: true, merged };
|
|
534
|
+
} catch (err) {
|
|
535
|
+
return { ok: false, merged: 0, reason: err && err.message ? err.message : String(err) };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
listSessions: () => ({ sessions: sessions.listSessions(dir) }),
|
|
541
|
+
open: (payload) => sessions.getSession(dir, payload && payload.sessionId),
|
|
542
|
+
save: (payload) => sessions.upsertSession(dir, payload || {}),
|
|
543
|
+
append: (payload) =>
|
|
544
|
+
sessions.appendMessage(
|
|
545
|
+
dir,
|
|
546
|
+
payload && payload.sessionId,
|
|
547
|
+
payload && payload.message
|
|
548
|
+
),
|
|
549
|
+
delete: (payload) =>
|
|
550
|
+
sessions.deleteSession(dir, payload && payload.sessionId),
|
|
551
|
+
push,
|
|
552
|
+
pull,
|
|
553
|
+
status: () => ({
|
|
554
|
+
count: sessions.listSessions(dir).length,
|
|
555
|
+
pending: sessions.listPending(dir).length,
|
|
556
|
+
cloud: hasCloud(),
|
|
557
|
+
lastSyncAt,
|
|
558
|
+
}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Register model:<name> and sync:<name> on ipcMain. `model:chat` is always
|
|
564
|
+
* streaming: deltas are pushed over CHAT_DELTA_CHANNEL exactly like the
|
|
565
|
+
* existing `aegis:chatCompletion` special case. `aegis` (the shared client)
|
|
566
|
+
* is optional — headless/offline callers omit it and every sync method falls
|
|
567
|
+
* back to its no-cloud branch.
|
|
568
|
+
*
|
|
569
|
+
* `model:listModels` and `sync:status` also fire a background retry push of
|
|
570
|
+
* any pending sessions (plan §7 "retry on a heartbeat"): fire-and-forget,
|
|
571
|
+
* never awaited, never throws — it just gives queued sessions another
|
|
572
|
+
* chance to sync without a dedicated poller.
|
|
573
|
+
*/
|
|
574
|
+
function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
|
|
575
|
+
const modelDispatch = createModelDispatch(engine);
|
|
576
|
+
const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
|
|
577
|
+
|
|
578
|
+
function heartbeatRetry() {
|
|
579
|
+
syncDispatch.push().catch(() => {});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (const [name, handler] of Object.entries(modelDispatch)) {
|
|
583
|
+
if (name === 'chat') {
|
|
584
|
+
ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
|
|
585
|
+
const opts = { ...(payload || {}) };
|
|
586
|
+
const sender = event && event.sender;
|
|
587
|
+
const forward = (chunk) => {
|
|
588
|
+
if (
|
|
589
|
+
sender &&
|
|
590
|
+
typeof sender.send === 'function' &&
|
|
591
|
+
!sender.isDestroyed()
|
|
592
|
+
) {
|
|
593
|
+
// Two streams can be live at once (main answer + discovery lane),
|
|
594
|
+
// so each delta carries the sessionId it belongs to. See the
|
|
595
|
+
// matching note on the aegis:chatCompletion forwarder.
|
|
596
|
+
sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
return handler({ ...opts, onStream: forward });
|
|
600
|
+
});
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (name === 'listModels') {
|
|
604
|
+
ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => {
|
|
605
|
+
heartbeatRetry();
|
|
606
|
+
return handler(payload);
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => handler(payload));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const [name, handler] of Object.entries(syncDispatch)) {
|
|
614
|
+
if (name === 'status') {
|
|
615
|
+
ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => {
|
|
616
|
+
heartbeatRetry();
|
|
617
|
+
return handler(payload);
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => handler(payload));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return { modelDispatch, syncDispatch };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// Electron-only bootstrap
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
function bootstrap() {
|
|
632
|
+
const { app, BrowserWindow, ipcMain, safeStorage } = electron;
|
|
633
|
+
|
|
634
|
+
app.setName('AEGIS Desktop');
|
|
635
|
+
|
|
636
|
+
const aegis = createClient();
|
|
637
|
+
const dataDir = resolveUserDataDir(app);
|
|
638
|
+
|
|
639
|
+
// One settings store backs both the provider settings surface and the AEGIS
|
|
640
|
+
// API key entry. It lives only in the main process and encrypts keys at rest
|
|
641
|
+
// via Electron safeStorage (best-effort base64 when unavailable).
|
|
642
|
+
const { engine, sessionsDir, settings } = createEngine(aegis, {
|
|
643
|
+
app,
|
|
644
|
+
safeStorage,
|
|
645
|
+
dir: dataDir,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Persist the in-app AEGIS key in its own reserved namespace, encrypted —
|
|
649
|
+
// never as a provider named 'aegis' (that coupling let the Settings pane's
|
|
650
|
+
// "Remove" delete the AEGIS key; defect #1).
|
|
651
|
+
const persistApiKey = (key) => settings.setAegisKey(key);
|
|
652
|
+
|
|
653
|
+
registerIpc(ipcMain, aegis, dataDir, persistApiKey);
|
|
654
|
+
registerModelIpc(ipcMain, engine, sessionsDir, aegis);
|
|
655
|
+
|
|
656
|
+
function createWindow() {
|
|
657
|
+
const win = new BrowserWindow({
|
|
658
|
+
width: 1080,
|
|
659
|
+
height: 720,
|
|
660
|
+
minWidth: 720,
|
|
661
|
+
minHeight: 480,
|
|
662
|
+
backgroundColor: '#0d1117',
|
|
663
|
+
title: 'AEGIS Desktop',
|
|
664
|
+
webPreferences: {
|
|
665
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
666
|
+
contextIsolation: true,
|
|
667
|
+
nodeIntegration: false,
|
|
668
|
+
sandbox: true,
|
|
669
|
+
spellcheck: true,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
win.setMenuBarVisibility(false);
|
|
674
|
+
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|
675
|
+
return win;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
app.whenReady().then(() => {
|
|
679
|
+
// Hydrate the client from the persisted key BEFORE the renderer issues any
|
|
680
|
+
// status/model call, so class/model dropdowns populate immediately when a
|
|
681
|
+
// key was saved in-app (safeStorage is usable only after app ready).
|
|
682
|
+
const persistedKey = settings.aegisRawKey();
|
|
683
|
+
if (persistedKey) aegis.setApiKey(persistedKey);
|
|
684
|
+
createWindow();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
app.on('window-all-closed', () => {
|
|
688
|
+
if (process.platform !== 'darwin') app.quit();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
app.on('activate', () => {
|
|
692
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (electron && electron.app) {
|
|
697
|
+
bootstrap();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
module.exports = {
|
|
701
|
+
createIpcDispatch,
|
|
702
|
+
registerIpc,
|
|
703
|
+
IPC_PREFIX,
|
|
704
|
+
MODEL_PREFIX,
|
|
705
|
+
SYNC_PREFIX,
|
|
706
|
+
CHAT_DELTA_CHANNEL,
|
|
707
|
+
taggedChunk,
|
|
708
|
+
maskKey,
|
|
709
|
+
createModelDispatch,
|
|
710
|
+
createSyncDispatch,
|
|
711
|
+
registerModelIpc,
|
|
712
|
+
createEngine,
|
|
713
|
+
resolveUserDataDir,
|
|
714
|
+
importForeignMemory,
|
|
715
|
+
};
|