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.
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AEGIS thin client — zero-dependency transport to aegiscloud.org.
4
+ *
5
+ * This file is the ONLY public surface that talks to the AEGIS backend. It
6
+ * forwards requests and normalises responses; it contains NO engine, brain,
7
+ * orchestration, routing, or tier logic. All of that lives in the private
8
+ * ae-guix product and behind aegiscloud.org.
9
+ *
10
+ * Runs unchanged under three hosts:
11
+ * - mcp/server.js (Claude Code MCP plugin) — CommonJS require
12
+ * - desktop/ (thin Electron shell) — CommonJS require
13
+ * (vendored byte-identical copy at desktop/vendor/aegis.js)
14
+ * - aegis-online (browser SPA, vendored copy) — <script> tag →
15
+ * window.AegisClient
16
+ *
17
+ * Usage (Node):
18
+ * const { createClient } = require('./client/aegis.js');
19
+ * const aegis = createClient(); // reads AEGIS_API_KEY from env
20
+ * const aegis = createClient({ apiBase, apiKey, memoryToken });
21
+ *
22
+ * Usage (browser):
23
+ * <script src="/static/vendor/aegis.js"></script>
24
+ * const aegis = window.AegisClient.createClient({ apiKey });
25
+ *
26
+ * BYOK note: byokChatCompletion() takes the provider key per request (or from
27
+ * opts.providerKey) and sends it as X-Provider-Key — it is relayed to the
28
+ * provider for that request only and never stored server-side.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Environment-agnostic preamble. This file must parse in Node AND in a plain
35
+ // browser <script> tag, so no bare require/process/window/module references
36
+ // may appear at load time.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Read an env var, treating unexpanded "${VAR}" templates as absent. */
40
+ function envVar(name) {
41
+ if (typeof process === 'undefined' || !process.env) return '';
42
+ const v = process.env[name];
43
+ return v && !/^\$\{[A-Z_]+\}$/.test(v) ? v : '';
44
+ }
45
+
46
+ /**
47
+ * UUID v4 that works everywhere: Web Crypto first (browsers, Node ≥ 19),
48
+ * then Node's CJS crypto module (Node < 19), then a Math.random fallback for
49
+ * sandboxed contexts (e.g. a VM or an opaque browser context) that expose no
50
+ * crypto API at all. Used by hosts for session/conversation ids only.
51
+ */
52
+ function randomUUID() {
53
+ const root =
54
+ (typeof globalThis !== 'undefined' && globalThis) ||
55
+ (typeof self !== 'undefined' && self) ||
56
+ null;
57
+ const c = root && root.crypto;
58
+ if (c && typeof c.randomUUID === 'function') {
59
+ try {
60
+ return c.randomUUID();
61
+ } catch (_) {
62
+ /* fall through */
63
+ }
64
+ }
65
+ if (c && typeof c.getRandomValues === 'function') {
66
+ try {
67
+ const b = new Uint8Array(16);
68
+ c.getRandomValues(b);
69
+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
70
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10
71
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
72
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
73
+ } catch (_) {
74
+ /* fall through */
75
+ }
76
+ }
77
+ if (typeof require === 'function') {
78
+ try {
79
+ const nodeCrypto = require('crypto');
80
+ if (nodeCrypto && typeof nodeCrypto.randomUUID === 'function') {
81
+ return nodeCrypto.randomUUID();
82
+ }
83
+ } catch (_) {
84
+ /* not Node — keep going */
85
+ }
86
+ }
87
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
88
+ const r = (Math.random() * 16) | 0;
89
+ const v = ch === 'x' ? r : (r & 0x3) | 0x8;
90
+ return v.toString(16);
91
+ });
92
+ }
93
+
94
+ const DEFAULT_API_BASE = 'https://aegiscloud.org';
95
+ const CLIENT_VERSION = '3.2.0';
96
+
97
+ /**
98
+ * .mcp.json / Electron pass config as "${VAR}" template refs. When the var is
99
+ * unset, hosts have been observed to leave the template literally unexpanded
100
+ * (e.g. "${AEGIS_API_BASE}") instead of omitting it — which defeats a plain
101
+ * `|| default` fallback since the literal string is truthy. Treat anything
102
+ * shaped like an unexpanded template as absent.
103
+ */
104
+ function createClient(opts = {}) {
105
+ const apiBase = (
106
+ opts.apiBase ||
107
+ envVar('AEGIS_API_BASE') ||
108
+ DEFAULT_API_BASE
109
+ ).replace(/\/+$/, '');
110
+ let apiKey = opts.apiKey !== undefined ? opts.apiKey : envVar('AEGIS_API_KEY');
111
+ // Optional: a memory token supplied directly, bypassing the api_key exchange.
112
+ const memoryToken =
113
+ opts.memoryToken !== undefined ? opts.memoryToken : envVar('AEGIS_MEMORY_TOKEN');
114
+ const clientVersion = opts.clientVersion || CLIENT_VERSION;
115
+
116
+ let memoryTokenCache = null;
117
+
118
+ // -------------------------------------------------------------------------
119
+ // HTTP helpers
120
+ // -------------------------------------------------------------------------
121
+
122
+ /** Base headers every request carries (version gate + key when present). */
123
+ function authHeaders(extra) {
124
+ const headers = {
125
+ 'Content-Type': 'application/json',
126
+ 'X-AEGIS-Version': clientVersion,
127
+ ...extra,
128
+ };
129
+ // Keys are optional per-client (a BYOK-only browser page has none); when
130
+ // absent, omit the auth headers entirely so no empty values are sent.
131
+ if (apiKey) {
132
+ headers['X-API-Key'] = apiKey;
133
+ headers.Authorization = `Bearer ${apiKey}`;
134
+ }
135
+ return headers;
136
+ }
137
+
138
+ async function apiPost(path, body, headers) {
139
+ const res = await fetch(`${apiBase}${path}`, {
140
+ method: 'POST',
141
+ headers: headers || authHeaders(),
142
+ body: JSON.stringify(body || {}),
143
+ });
144
+ return parseResponse(res);
145
+ }
146
+
147
+ async function apiGet(path, headers) {
148
+ const res = await fetch(`${apiBase}${path}`, {
149
+ method: 'GET',
150
+ headers: headers || authHeaders(),
151
+ });
152
+ return parseResponse(res);
153
+ }
154
+
155
+ async function parseResponse(res) {
156
+ const text = await res.text();
157
+ let data;
158
+ try {
159
+ data = text ? JSON.parse(text) : {};
160
+ } catch {
161
+ data = { raw: text };
162
+ }
163
+ if (!res.ok) {
164
+ const msg =
165
+ (data && (data.error?.message || data.error || data.message)) ||
166
+ `HTTP ${res.status}`;
167
+ const err = new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
168
+ err.status = res.status;
169
+ err.data = data;
170
+ throw err;
171
+ }
172
+ return data;
173
+ }
174
+
175
+ /** Extract a human message from a structured backend error body. */
176
+ function errorMessageOf(data) {
177
+ if (!data) return '';
178
+ const e = data.error;
179
+ if (typeof e === 'string') return e;
180
+ if (e && typeof e.message === 'string') return e.message;
181
+ if (e && typeof e.error === 'string') return e.error;
182
+ if (typeof data.message === 'string') return data.message;
183
+ if (typeof data.error_message === 'string') return data.error_message;
184
+ return '';
185
+ }
186
+
187
+ /** Throw an Error carrying status/data from a 2xx body that still has an
188
+ * error field (seen on streaming endpoints that answer JSON mid-stream). */
189
+ function throwErrorFrom(data, status) {
190
+ const msg = errorMessageOf(data);
191
+ if (!msg) return;
192
+ const err = new Error(msg);
193
+ err.status = status || 500;
194
+ err.data = data;
195
+ throw err;
196
+ }
197
+
198
+ // Memory endpoints authenticate with a memory_token, not the API key.
199
+ async function getMemoryToken() {
200
+ if (memoryToken) return memoryToken;
201
+ if (memoryTokenCache) return memoryTokenCache;
202
+ const info = await apiPost('/api/verify-api-key', { api_key: apiKey });
203
+ if (!info || !info.memory_token) {
204
+ throw new Error(
205
+ 'This AEGIS account has no memory token. Enable cloud memory at https://aegiscloud.org/subscribe.'
206
+ );
207
+ }
208
+ memoryTokenCache = info.memory_token;
209
+ return memoryTokenCache;
210
+ }
211
+
212
+ function memoryHeaders(token) {
213
+ return {
214
+ 'Content-Type': 'application/json',
215
+ 'X-AEGIS-Version': clientVersion,
216
+ Authorization: `Bearer ${token}`,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Replace the API key at runtime (desktop in-app key entry). Clears the
222
+ * cached memory token so the next memory/account call re-exchanges with the
223
+ * new key. Returns the trimmed raw key — main-process only; hosts must mask
224
+ * it before returning anything to a renderer.
225
+ */
226
+ function setApiKey(key) {
227
+ apiKey = typeof key === 'string' ? key.trim() : '';
228
+ memoryTokenCache = null;
229
+ return apiKey;
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // High-level API (returns raw backend data; hosts decide how to format it)
234
+ // -------------------------------------------------------------------------
235
+
236
+ async function verifyApiKey() {
237
+ return apiPost('/api/verify-api-key', { api_key: apiKey });
238
+ }
239
+
240
+ /** Build the OpenAI-style messages array from either a full history or the
241
+ * single-shot { system, prompt } shorthand.
242
+ *
243
+ * The history is no longer returned verbatim when present: doing so dropped
244
+ * `system` entirely, so the agent loop's ported persona disappeared on this
245
+ * transport alone. The system turn is prepended unless the caller already
246
+ * supplied one, and a non-empty `prompt` is appended as a final user turn
247
+ * (skipped when the history already ends with that same turn). */
248
+ function buildMessages(messages, system, prompt) {
249
+ const history = Array.isArray(messages) ? messages.filter(Boolean) : [];
250
+ const out = [];
251
+ if (system && !history.some((m) => m && m.role === 'system')) {
252
+ out.push({ role: 'system', content: system });
253
+ }
254
+ out.push(...history);
255
+ if (prompt != null && prompt !== '') {
256
+ const last = out[out.length - 1];
257
+ if (!(last && last.role === 'user' && last.content === prompt)) {
258
+ out.push({ role: 'user', content: prompt });
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * Chat completion against the AEGIS pool (API key auth). Non-streaming by
266
+ * default (structured JSON error bodies — this is what the MCP host relies
267
+ * on). When `stream: true` AND `onStream` is a function, the request is sent
268
+ * with `stream: true` and each SSE delta is delivered as `onStream({ delta })`.
269
+ * The resolved value is normalised to the same shape as the non-streaming
270
+ * response either way, so hosts can reconcile final text after the last chunk.
271
+ *
272
+ * Additive options shared with byokChatCompletion():
273
+ * - `messages` full OpenAI-format history (supersedes prompt/system)
274
+ * - `extra` extra body fields merged verbatim (e.g. { aegis_memory,
275
+ * session } for the online host's synced-memory writeback)
276
+ */
277
+ async function chatCompletion({
278
+ prompt,
279
+ system,
280
+ messages,
281
+ model,
282
+ mode,
283
+ maxTokens,
284
+ stream = false,
285
+ onStream,
286
+ signal,
287
+ extra,
288
+ } = {}) {
289
+ // Model-first: an explicit `model` pins that provider id verbatim; with no
290
+ // model the server picks its default (no client-invented tier id). `mode`
291
+ // is a legacy server-side shorthand — forwarded verbatim only when the
292
+ // caller supplies it, never defaulted, never built into a model id.
293
+ const body = {
294
+ messages: buildMessages(messages, system, prompt),
295
+ max_tokens: maxTokens || 4096,
296
+ ...(extra || {}),
297
+ };
298
+ if (model) body.model = model;
299
+ else if (mode) body.mode = mode;
300
+ if (!stream || typeof onStream !== 'function') {
301
+ return apiPost('/api/v1/chat/completions', { ...body, stream: false });
302
+ }
303
+ return postStream('/api/v1/chat/completions', body, authHeaders(), onStream, signal);
304
+ }
305
+
306
+ /**
307
+ * BYOK chat completion: relay a provider request through
308
+ * /api/v1/byok/chat/completions using a per-request X-Provider-Key. The
309
+ * provider key never touches AEGIS storage — it is forwarded straight to the
310
+ * provider for this request only. Mirrors chatCompletion()'s streaming /
311
+ * fallback semantics exactly.
312
+ */
313
+ async function byokChatCompletion({
314
+ provider = 'openai',
315
+ model,
316
+ messages,
317
+ prompt,
318
+ system,
319
+ maxTokens,
320
+ stream = false,
321
+ onStream,
322
+ signal,
323
+ providerKey,
324
+ } = {}) {
325
+ const key = providerKey !== undefined ? providerKey : opts.providerKey;
326
+ const body = {
327
+ provider,
328
+ messages: buildMessages(messages, system, prompt),
329
+ max_tokens: maxTokens || 4096,
330
+ };
331
+ if (model) body.model = model;
332
+ const headers = {
333
+ 'Content-Type': 'application/json',
334
+ 'X-AEGIS-Version': clientVersion,
335
+ 'X-Provider-Key': key,
336
+ };
337
+ if (!stream || typeof onStream !== 'function') {
338
+ return apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers);
339
+ }
340
+ return postStream('/api/v1/byok/chat/completions', body, headers, onStream, signal);
341
+ }
342
+
343
+ /** Extract the assistant text from a full (non-streamed) completion JSON. */
344
+ function textOf(data) {
345
+ const choice = data && data.choices && data.choices[0];
346
+ const content = choice && (choice.message && choice.message.content);
347
+ return typeof content === 'string' ? content : '';
348
+ }
349
+
350
+ /**
351
+ * POST body with `stream: true` and forward SSE deltas to onStream.
352
+ * Shared by the AEGIS pool and the BYOK relay so every host parses exactly
353
+ * one wire format.
354
+ *
355
+ * Handles two servers gracefully:
356
+ * - a real SSE endpoint -> incremental deltas, normalised final result
357
+ * - an endpoint that ignores `stream` and replies with plain JSON (or
358
+ * rejects `stream: true` outright) -> one-shot fallback: deliver the full
359
+ * text as a single delta and resolve the parsed JSON, unchanged.
360
+ * This keeps streaming purely additive for hosts that opt in.
361
+ */
362
+ async function postStream(path, body, headers, onStream, signal) {
363
+ let res;
364
+ try {
365
+ res = await fetch(`${apiBase}${path}`, {
366
+ method: 'POST',
367
+ headers,
368
+ body: JSON.stringify({ ...body, stream: true }),
369
+ signal,
370
+ });
371
+ } catch (err) {
372
+ throw err; // network-level failure; nothing to fall back to
373
+ }
374
+
375
+ if (!res.ok) {
376
+ // The endpoint may not accept `stream: true`. Retry once without it so
377
+ // the caller gets the normal structured JSON (result or error).
378
+ const data = await apiPost(path, { ...body, stream: false }, headers);
379
+ const fullText = textOf(data);
380
+ if (fullText) onStream({ delta: fullText });
381
+ return data;
382
+ }
383
+
384
+ const contentType = res.headers.get('content-type') || '';
385
+ if (!contentType.includes('text/event-stream')) {
386
+ // Server ignored the stream flag and answered with plain JSON.
387
+ const data = await parseResponse(res);
388
+ const fullText = textOf(data);
389
+ if (fullText) {
390
+ onStream({ delta: fullText });
391
+ } else {
392
+ throwErrorFrom(data, res.status);
393
+ }
394
+ return data;
395
+ }
396
+
397
+ // Real SSE: parse `data:` lines incrementally, OpenAI-chunk shape.
398
+ const reader = res.body.getReader();
399
+ const decoder = new TextDecoder();
400
+ let buffer = '';
401
+ let fullText = '';
402
+ let resultModel = body.model;
403
+ let usage = null;
404
+ let sseError = '';
405
+ // Tool-call fragments, keyed by the provider's index. The pool forwards
406
+ // the provider's own `delta.tool_calls` chunks verbatim when the caller
407
+ // sent `tools`, so the shared client has to reassemble them the same way
408
+ // the non-streaming branch does — otherwise a tool-calling turn would
409
+ // resolve with text only and the caller could never see the calls.
410
+ const toolCallsByIndex = new Map();
411
+ let finishReason = null;
412
+
413
+ // Idle watchdog: if the server holds the connection open without ever
414
+ // sending another byte (a stuck upstream call, a proxy that swallows the
415
+ // close), reader.read() below waits forever and the whole desktop host
416
+ // hangs with no way out but force-quit. Cap the gap between chunks —
417
+ // not the whole response — so a slow-but-alive generation is untouched.
418
+ const SSE_IDLE_TIMEOUT_MS = 60_000;
419
+ async function readWithIdleTimeout() {
420
+ let timer;
421
+ const timeout = new Promise((_, reject) => {
422
+ timer = setTimeout(() => {
423
+ reject(new Error(`stream stalled - no data for ${SSE_IDLE_TIMEOUT_MS / 1000}s`));
424
+ }, SSE_IDLE_TIMEOUT_MS);
425
+ });
426
+ try {
427
+ return await Promise.race([reader.read(), timeout]);
428
+ } finally {
429
+ clearTimeout(timer);
430
+ }
431
+ }
432
+
433
+ for (;;) {
434
+ let done, value;
435
+ try {
436
+ ({ done, value } = await readWithIdleTimeout());
437
+ } catch (err) {
438
+ reader.cancel().catch(() => {});
439
+ throw err;
440
+ }
441
+ if (done) break;
442
+ buffer += decoder.decode(value, { stream: true });
443
+
444
+ const lines = buffer.split('\n');
445
+ buffer = lines.pop(); // keep the last partial line for the next read
446
+
447
+ for (const rawLine of lines) {
448
+ const line = rawLine.trim();
449
+ if (!line.startsWith('data:')) continue;
450
+ const payload = line.slice(5).trim();
451
+ if (!payload || payload === '[DONE]') continue;
452
+ let json;
453
+ try {
454
+ json = JSON.parse(payload);
455
+ } catch {
456
+ continue; // partial/keepalive line — ignore
457
+ }
458
+ if (json.error) {
459
+ if (!sseError) {
460
+ const e = json.error;
461
+ sseError =
462
+ typeof e === 'string'
463
+ ? e
464
+ : errorMessageOf({ error: e }) || 'stream error';
465
+ }
466
+ continue;
467
+ }
468
+ if (json.model) resultModel = json.model;
469
+ if (json.usage) usage = json.usage;
470
+ const choice = json.choices && json.choices[0];
471
+ if (choice && choice.finish_reason) finishReason = choice.finish_reason;
472
+ const delta =
473
+ (choice &&
474
+ ((choice.delta && choice.delta.content) ||
475
+ (choice.message && choice.message.content))) ||
476
+ '';
477
+ if (delta) {
478
+ fullText += delta;
479
+ onStream({ delta });
480
+ }
481
+ const fragments =
482
+ (choice && choice.delta && choice.delta.tool_calls) ||
483
+ (choice && choice.message && choice.message.tool_calls);
484
+ if (Array.isArray(fragments)) {
485
+ for (const tc of fragments) {
486
+ const idx = tc.index == null ? 0 : tc.index;
487
+ const cur = toolCallsByIndex.get(idx) || {
488
+ id: '',
489
+ type: 'function',
490
+ function: { name: '', arguments: '' },
491
+ };
492
+ if (tc.id) cur.id = tc.id;
493
+ if (tc.type) cur.type = tc.type;
494
+ const fn = tc.function || {};
495
+ if (fn.name) cur.function.name = fn.name;
496
+ if (typeof fn.arguments === 'string') cur.function.arguments += fn.arguments;
497
+ toolCallsByIndex.set(idx, cur);
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ if (!fullText && sseError) {
504
+ const err = new Error(sseError);
505
+ err.status = res.status;
506
+ throw err;
507
+ }
508
+
509
+ // An id-less fragment stream still yields usable calls, but the caller
510
+ // needs *an* id to pair results back, so synthesize one.
511
+ const toolCalls = [...toolCallsByIndex.entries()]
512
+ .sort((a, b) => a[0] - b[0])
513
+ .map(([idx, c]) => ({ ...c, id: c.id || `call_${idx}` }));
514
+
515
+ const message = { content: fullText };
516
+ if (toolCalls.length) message.tool_calls = toolCalls;
517
+ const result = {
518
+ model: resultModel,
519
+ choices: [
520
+ { message, ...(finishReason ? { finish_reason: finishReason } : {}) },
521
+ ],
522
+ };
523
+ if (usage) result.usage = usage;
524
+ return result;
525
+ }
526
+
527
+ async function listModels() {
528
+ return apiGet('/api/v1/models');
529
+ }
530
+
531
+ async function tokenBankBalance() {
532
+ return apiGet('/api/token-bank/balance');
533
+ }
534
+
535
+ /** Start a token-bank top-up; resolves to { url } for the payment page. */
536
+ async function tokenBankTopup(amountEur) {
537
+ return apiPost('/api/token-bank/topup', { amount_eur: amountEur });
538
+ }
539
+
540
+ async function byokStatus() {
541
+ return apiGet('/api/user/api-keys');
542
+ }
543
+
544
+ async function byokSet(provider, providerApiKey) {
545
+ return apiPost('/api/user/api-keys', {
546
+ provider,
547
+ api_key: providerApiKey || '',
548
+ });
549
+ }
550
+
551
+ async function memorySearch(query, limit) {
552
+ const token = await getMemoryToken();
553
+ return apiPost(
554
+ '/api/memory/search',
555
+ { query: query || '', limit: limit || 5 },
556
+ memoryHeaders(token)
557
+ );
558
+ }
559
+
560
+ async function memorySave(entry) {
561
+ const token = await getMemoryToken();
562
+ return apiPost('/api/memory/save', { entry }, memoryHeaders(token));
563
+ }
564
+
565
+ async function memoryList(limit) {
566
+ const token = await getMemoryToken();
567
+ // /api/memory/list is session-cookie (dashboard) auth, unusable here.
568
+ // The bearer-authenticated way to get recent entries is search with an
569
+ // empty query, which the backend returns most-recent-first.
570
+ return apiPost(
571
+ '/api/memory/search',
572
+ { query: '', limit: limit || 10 },
573
+ memoryHeaders(token)
574
+ );
575
+ }
576
+
577
+ /** Verify a bearer token (memory or API) against the backend. */
578
+ async function verifyToken(token) {
579
+ return apiPost(
580
+ '/api/verify-token',
581
+ { token: token || '' },
582
+ {
583
+ 'Content-Type': 'application/json',
584
+ 'X-AEGIS-Version': clientVersion,
585
+ Authorization: `Bearer ${token}`,
586
+ }
587
+ );
588
+ }
589
+
590
+ /** Activate cloud memory for the authenticated account. */
591
+ async function memoryActivate(token) {
592
+ const t = token || (await getMemoryToken());
593
+ return apiPost('/api/memory/activate', {}, memoryHeaders(t));
594
+ }
595
+
596
+ /** Pull memory entries updated since a timestamp (epoch ms). */
597
+ async function memoryPull(since) {
598
+ const token = await getMemoryToken();
599
+ return apiPost(
600
+ '/api/memory/pull',
601
+ { since: since || 0 },
602
+ memoryHeaders(token)
603
+ );
604
+ }
605
+
606
+ /** Save a batch of memory entries in one request. */
607
+ async function memorySaveBatch(entries) {
608
+ const token = await getMemoryToken();
609
+ return apiPost(
610
+ '/api/memory/save',
611
+ { entries: entries || [] },
612
+ memoryHeaders(token)
613
+ );
614
+ }
615
+
616
+ /**
617
+ * Push one local conversation transcript to aegis1's conversation-sync
618
+ * surface (P4.5), authenticated the same way as the memory endpoints
619
+ * (memory_token, not the API key — see getMemoryToken()). The response is
620
+ * returned verbatim; callers read `session_id`/`sessions` off it.
621
+ */
622
+ async function conversationSyncPush(transcript) {
623
+ const token = await getMemoryToken();
624
+ return apiPost(
625
+ '/api/conversations/sync',
626
+ {
627
+ session_id: transcript && transcript.session_id,
628
+ title: (transcript && transcript.title) || '',
629
+ messages: (transcript && transcript.messages) || [],
630
+ source: (transcript && transcript.source) || 'aegis-desktop',
631
+ },
632
+ memoryHeaders(token)
633
+ );
634
+ }
635
+
636
+ /** Pull the account's remote conversation sessions (no transcript to push —
637
+ * same endpoint, list-only request). */
638
+ async function conversationSyncPull() {
639
+ const token = await getMemoryToken();
640
+ return apiPost('/api/conversations/sync', {}, memoryHeaders(token));
641
+ }
642
+
643
+ /** Import a conversation transcript for later memory/training use. */
644
+ async function importConversation({ messages, url, title, source } = {}) {
645
+ return apiPost('/api/import', {
646
+ messages: messages || [],
647
+ url: url || '',
648
+ title: title || '',
649
+ source: source || 'aegiscode-plugin',
650
+ });
651
+ }
652
+
653
+ return {
654
+ apiBase,
655
+ get apiKey() {
656
+ return apiKey;
657
+ },
658
+ setApiKey,
659
+ get clientVersion() {
660
+ return clientVersion;
661
+ },
662
+ verifyApiKey,
663
+ chatCompletion,
664
+ byokChatCompletion,
665
+ listModels,
666
+ tokenBankBalance,
667
+ tokenBankTopup,
668
+ byokStatus,
669
+ byokSet,
670
+ getMemoryToken,
671
+ memorySearch,
672
+ memorySave,
673
+ memoryList,
674
+ verifyToken,
675
+ memoryActivate,
676
+ memoryPull,
677
+ memorySaveBatch,
678
+ conversationSyncPush,
679
+ conversationSyncPull,
680
+ importConversation,
681
+ randomUUID,
682
+ };
683
+ }
684
+
685
+ const api = { createClient, envVar, randomUUID, DEFAULT_API_BASE, CLIENT_VERSION };
686
+
687
+ // Node / Electron (CommonJS): the MCP plugin and desktop shell require() this.
688
+ if (typeof module !== 'undefined' && module.exports) {
689
+ module.exports = api;
690
+ }
691
+ // Browser <script>: expose a single namespaced global for the aegis-online SPA.
692
+ if (typeof window !== 'undefined') {
693
+ window.AegisClient = api;
694
+ }