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/vendor/aegis.js
ADDED
|
@@ -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
|
+
}
|