aegis-desktop 0.7.4 → 0.7.6

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.
@@ -8,7 +8,7 @@
8
8
  * `sessions.json`, while the terminal host kept `~/.aegiscode/history.jsonl`
9
9
  * and the MCP plugin could see neither — three hosts, one account, three
10
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,
11
+ * `client/session-store.js`, which is the tree every host already bundles,
12
12
  * and every call here forwards to it.
13
13
  *
14
14
  * Two things that matter are deliberately unchanged:
package/main.js CHANGED
@@ -39,7 +39,7 @@ const { createClient } = sharedClient;
39
39
  // The shared credential store (`client/credentials.js`) — the same 0600 file
40
40
  // the terminal host writes with `aegiscode login` and the MCP plugin reads. The
41
41
  // app resolves its own account key through it, so signing in once serves all
42
- // three hosts; see resolveStartupKey() and persistApiKey() below.
42
+ // four hosts; see resolveStartupKey() and persistApiKey() below.
43
43
  let credentials;
44
44
  try {
45
45
  credentials = require('../client/credentials.js');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.7.4",
4
+ "version": "0.7.6",
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
@@ -274,12 +274,36 @@ function foldRoll(sessionId, usage, opts = {}) {
274
274
  * Paint the topbar meter. Hidden while a session has accounted for nothing —
275
275
  * an unused thread must not display a `0 tok` it never measured — and shown
276
276
  * the moment a turn is folded in.
277
+ *
278
+ * `live`, when given, previews the in-flight turn on top of the session's
279
+ * already-folded total — text estimated the same way `foldRoll` estimates a
280
+ * turn the wire never reported on — WITHOUT folding it: the preview roll
281
+ * returned by `rollTurn` here is thrown away every frame and `rollsBySession`
282
+ * is never written to, so the real `foldRoll` at completion still starts from
283
+ * the untouched persisted total and cannot double-count this turn. (`turns`/
284
+ * `calls` are left at their default +1 rather than forced to 0 — `fmtRoll`
285
+ * treats a roll with both at 0 as "nothing counted yet" and blanks the line,
286
+ * which hid the preview entirely.) Before this, the meter held the previous
287
+ * turn's total frozen for the whole reply and only jumped at the end, which
288
+ * read as "the counter is dead while the AI works".
277
289
  */
278
- function renderRollMeter(sessionId) {
290
+ function renderRollMeter(sessionId, live) {
279
291
  const el = els.sessionMeter;
280
292
  if (!el) return;
281
- const roll = rollsBySession.get(sessionId || '');
282
- const line = roll && currentSessionId === sessionId ? fmtRoll(roll) : '';
293
+ if (currentSessionId !== sessionId) { el.hidden = true; return; }
294
+ let roll = rollsBySession.get(sessionId || '');
295
+ if (live) {
296
+ roll = rollTurn(roll || emptyRoll(), undefined, {
297
+ prompt: live.prompt,
298
+ reply: live.reply,
299
+ // The thinking trace is billed output too — see `estimatedBuckets`. Left
300
+ // out, this preview measured only the visible answer, so on a reasoning
301
+ // model the meter sat on one number for the whole (longest, priciest)
302
+ // phase of the turn and looked dead while the AI was demonstrably working.
303
+ reasoning: live.reasoning,
304
+ });
305
+ }
306
+ const line = roll ? fmtRoll(roll) : '';
283
307
  el.textContent = line;
284
308
  el.hidden = !line;
285
309
  if (line) el.title = 'This session, counted the way the CLI counts it — every turn rolled into one running total';
@@ -312,6 +336,7 @@ function ledgerFields(usage, model, turn, text) {
312
336
  calls: text && text.calls,
313
337
  prompt: text && text.prompt,
314
338
  reply: text && text.reply,
339
+ reasoning: text && text.reasoning,
315
340
  });
316
341
  const fields = row ? Object.assign({}, row) : {};
317
342
  if (model) fields.model = model;
@@ -3146,6 +3171,10 @@ async function send() {
3146
3171
  }
3147
3172
  const bodyEl = pendingEl.querySelector('.body');
3148
3173
  if (bodyEl && bodyEl.textContent !== streamedText) bodyEl.textContent = streamedText;
3174
+ // Live estimate so the topbar meter keeps moving while the reply streams
3175
+ // in, instead of sitting frozen on the previous turn's total until this
3176
+ // one resolves — see renderRollMeter's `live` param.
3177
+ renderRollMeter(sessionId, { prompt, reply: streamedText, reasoning: reasoningText });
3149
3178
  stickToBottom();
3150
3179
  });
3151
3180
 
@@ -3225,7 +3254,7 @@ async function send() {
3225
3254
  // count is never read as a reported one. Printing nothing here while the
3226
3255
  // session total moved was the other half of "the counter looks dead".
3227
3256
  else {
3228
- const est = estimatedBuckets(prompt, text);
3257
+ const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
3229
3258
  if (est) bits.push(`~${est.input + est.output} tokens`);
3230
3259
  }
3231
3260
  if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
@@ -3244,6 +3273,10 @@ async function send() {
3244
3273
  // CLI's `appendHistory` rule and the reason the total moves every turn.
3245
3274
  prompt,
3246
3275
  reply: text,
3276
+ // The thinking trace is billed output and is not part of `text`, so it is
3277
+ // counted here too — guarded, because the same string must never be
3278
+ // estimated twice if a turn ever collapses the two into one.
3279
+ reasoning: text === reasoningText ? '' : reasoningText,
3247
3280
  });
3248
3281
  const rollLine = fmtRoll(roll);
3249
3282
  if (rollLine) bits.push(`session: ${rollLine}`);
@@ -3259,6 +3292,10 @@ async function send() {
3259
3292
  ...ledgerFields(data && data.usage, model, turn, {
3260
3293
  prompt,
3261
3294
  reply: text,
3295
+ // The thinking trace is billed output and is not part of `text`; it
3296
+ // has to persist too, or reopening the window rebuilds a roll short
3297
+ // by the longest part of the turn.
3298
+ reasoning: text === reasoningText ? '' : reasoningText,
3262
3299
  calls: data && data.calls,
3263
3300
  }),
3264
3301
  });
@@ -3293,11 +3330,16 @@ async function send() {
3293
3330
  // on this path the figure is the text estimate, marked `est` — and
3294
3331
  // never a fabricated zero.
3295
3332
  const turn = turnAccounting(undefined, model, {});
3296
- const roll = foldRoll(sessionId, undefined, { model, prompt, reply: text });
3333
+ const roll = foldRoll(sessionId, undefined, {
3334
+ model,
3335
+ prompt,
3336
+ reply: text,
3337
+ reasoning: text === reasoningText ? '' : reasoningText,
3338
+ });
3297
3339
  const stopBits = ['stopped by you'];
3298
3340
  if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
3299
3341
  else {
3300
- const est = estimatedBuckets(prompt, text);
3342
+ const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
3301
3343
  if (est) stopBits.push(`~${est.input + est.output} tokens`);
3302
3344
  }
3303
3345
  const stopRollLine = fmtRoll(roll);
@@ -3307,7 +3349,11 @@ async function send() {
3307
3349
  await sync.append(sessionId, {
3308
3350
  role: 'assistant',
3309
3351
  content: text,
3310
- ...ledgerFields(undefined, model, turn, { prompt, reply: text }),
3352
+ ...ledgerFields(undefined, model, turn, {
3353
+ prompt,
3354
+ reply: text,
3355
+ reasoning: text === reasoningText ? '' : reasoningText,
3356
+ }),
3311
3357
  });
3312
3358
  } catch {
3313
3359
  /* persistence is non-fatal */
package/renderer/usage.js CHANGED
@@ -99,17 +99,29 @@ function estimateTokens(text) {
99
99
  * genuinely reported nothing (no usage, no prompt, no reply) still lands in
100
100
  * `unknown` instead of being handed a fabricated zero.
101
101
  *
102
+ * `reasoning` is the extended-thinking trace, and it is OUTPUT: a reasoning
103
+ * model bills its chain of thought as output tokens, which is why the wire's
104
+ * own `output_tokens` already includes it. The desktop renders that trace in
105
+ * its own element (app.js `reasoningText`) rather than in the reply body, so
106
+ * the two streams had to be added back together here — an estimate measured off
107
+ * the visible answer alone sat still for the entire think phase, which on a
108
+ * reasoning model is both the longest and the most expensive part of the turn.
109
+ *
102
110
  * @param {string} [prompt]
103
111
  * @param {string} [reply]
112
+ * @param {string} [reasoning]
104
113
  * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number}|null}
105
114
  */
106
- function estimatedBuckets(prompt, reply) {
115
+ function estimatedBuckets(prompt, reply, reasoning) {
107
116
  const hasPrompt = typeof prompt === 'string' && prompt.length > 0;
108
- const hasReply = typeof reply === 'string' && reply.length > 0;
117
+ const out =
118
+ (typeof reply === 'string' ? reply : '') +
119
+ (typeof reasoning === 'string' ? reasoning : '');
120
+ const hasReply = out.length > 0;
109
121
  if (!hasPrompt && !hasReply) return null;
110
122
  return {
111
123
  input: estimateTokens(prompt),
112
- output: estimateTokens(reply),
124
+ output: estimateTokens(out),
113
125
  cacheRead: 0,
114
126
  cacheWrite: 0,
115
127
  real: false,
@@ -250,6 +262,8 @@ function turnAccounting(usage, model, opts = {}) {
250
262
  * back is that RUNNING TOTAL. The status bar prints `state.tokens`
251
263
  * (renderStatus), and `ctrl+t` prints the tallies in one line
252
264
  * (`tokenSummary`: `12,400 tok (10,100 in / 2,300 out) · 4 calls · €0.03`).
265
+ * The tallies are reproduced; that one abbreviation is not — `fmtRoll` explains
266
+ * why it drops the parenthetical rather than carrying the CLI's line verbatim.
253
267
  *
254
268
  * The desktop counted per turn only. Every meta row was a fresh count that
255
269
  * reset at the next call, so "what has this session spent" was answerable only
@@ -337,7 +351,7 @@ function rollTurn(roll, usage, opts = {}) {
337
351
  // estimate here is what makes the live roll and the rebuilt roll the same
338
352
  // number, and what stops the total standing still on exactly the turns the
339
353
  // pool declined to report on — the symptom this fallback exists to remove.
340
- const est = estimatedBuckets(opts.prompt, opts.reply);
354
+ const est = estimatedBuckets(opts.prompt, opts.reply, opts.reasoning);
341
355
  if (!est) {
342
356
  // Nothing reported AND no text to estimate from. The single case that
343
357
  // stays uncounted: `unknown` names the gap, where a fabricated zero would
@@ -439,7 +453,10 @@ function ledgerRow(usage, turn, opts = {}) {
439
453
  if (calls !== undefined) row.calls = calls;
440
454
  return row;
441
455
  }
442
- const est = estimatedBuckets(opts.prompt, opts.reply);
456
+ // `reasoning` is part of the billed output, so it is estimated with the
457
+ // reply — a stored row for a thinking-heavy turn must not persist a count
458
+ // that excludes the longest thing the model wrote.
459
+ const est = estimatedBuckets(opts.prompt, opts.reply, opts.reasoning);
443
460
  if (!est) return null;
444
461
  const row = { tokens: est };
445
462
  if (calls !== undefined) row.calls = calls;
@@ -471,28 +488,30 @@ function fmtTokens(n) {
471
488
  * an untouched session adds no noise to a turn's meta line.
472
489
  *
473
490
  * @param {object} [roll]
474
- * @returns {string} e.g. `12,400 tok (10,100 in / 2,300 out) · 4 calls · $0.0310`
491
+ * @returns {string} e.g. `12,400 tok · 4 calls · $0.0310` — the CLI's fields in
492
+ * the CLI's order, minus the input/output parenthetical (see below).
475
493
  */
476
494
  function fmtRoll(roll) {
477
495
  const r = roll || emptyRoll();
478
496
  if (!r.turns && !r.calls) return '';
479
- // The first two fields are `tokenSummary` (cli/src/app.js:1413) verbatim,
480
- // separator and all: `12,400 tok (10,100 in / 2,300 out) · 4 calls`. There the
481
- // parenthetical is unconditional and the call count is singular at one; both
482
- // are kept, because a line that is only *sometimes* shaped like the CLI's is a
483
- // lookalike rather than the same quantity. The call count is also the number
484
- // that reveals a fan-out, which is why it is not hidden at 1.
497
+ // The fields are `tokenSummary`'s (cli/src/app.js:1413) in its order — tokens,
498
+ // calls, money — with one dropped: the `(10,100 in / 2,300 out)` split. `in`
499
+ // and `out` are a terminal status-line shorthand, legible to someone already
500
+ // reading that status line and to nobody else, which is what an unlabelled
501
+ // abbreviation in a GUI topbar turns into. The total is the figure a reader
502
+ // wants; `r.input`/`r.output` are still folded onto the roll (see `rollTurn`)
503
+ // for any surface that wants to show the split with real labels. The call
504
+ // count stays unconditional, because it is the number that reveals a
505
+ // fan-out, and it is not hidden at 1.
485
506
  const bits = [];
486
507
  // The token half only when something was actually counted. `rollTurn` counts
487
508
  // turns and calls BEFORE it looks at the token count, so a dispatch that
488
509
  // reported nothing still reaches here — and printing its empty tally would
489
- // put `0 tok (0 in / 0 out)` on the topbar, a figure the meter never took,
490
- // which reads as a counter that does not move. The turn count is still
491
- // stated, because that much is true.
510
+ // put `0 tok` on the topbar, a figure the meter never took, which reads as a
511
+ // counter that does not move. The turn count is still stated, because that
512
+ // much is true.
492
513
  if (r.tokens > 0) {
493
- bits.push(
494
- `${fmtTokens(r.tokens)} tok (${fmtTokens(r.input)} in / ${fmtTokens(r.output)} out)`
495
- );
514
+ bits.push(`${fmtTokens(r.tokens)} tok`);
496
515
  }
497
516
  bits.push(`${r.calls} call${r.calls === 1 ? '' : 's'}`);
498
517
  // Money is where this line departs from `tokenSummary`, deliberately: that
package/vendor/aegis.js CHANGED
@@ -7,10 +7,13 @@
7
7
  * orchestration, routing, or tier logic. All of that lives in the private
8
8
  * ae-guix product and behind aegiscloud.org.
9
9
  *
10
- * Runs unchanged under three hosts:
10
+ * Runs unchanged under four hosts:
11
11
  * - mcp/server.js (Claude Code MCP plugin) — CommonJS require
12
12
  * - desktop/ (thin Electron shell) — CommonJS require
13
13
  * (vendored byte-identical copy at desktop/vendor/aegis.js)
14
+ * - cli/ (aegiscode terminal host) — CommonJS require
15
+ * (vendored byte-identical copy at
16
+ * cli/vendor/client/aegis.js)
14
17
  * - aegis-online (browser SPA, vendored copy) — <script> tag →
15
18
  * window.AegisClient
16
19
  *
@@ -348,6 +351,12 @@ function createClient(opts = {}) {
348
351
  * provider key never touches AEGIS storage — it is forwarded straight to the
349
352
  * provider for this request only. Mirrors chatCompletion()'s streaming /
350
353
  * fallback semantics exactly.
354
+ *
355
+ * The caller's *AEGIS* key is sent alongside the provider key as X-AEGIS-Key.
356
+ * Two credentials, two headers, on purpose: the provider key authenticates the
357
+ * upstream call, the AEGIS key says whose bank pays the handling fee. With no
358
+ * AEGIS key configured the header is omitted and the call stays anonymous —
359
+ * allowed, just unbilled (see _byok_identify_user on the server).
351
360
  */
352
361
  async function byokChatCompletion({
353
362
  provider = 'openai',
@@ -373,6 +382,10 @@ function createClient(opts = {}) {
373
382
  'X-AEGIS-Version': clientVersion,
374
383
  'X-Provider-Key': key,
375
384
  };
385
+ // Identify the payer. Absent key => anonymous call, which the server
386
+ // accepts but cannot bill; sending it is what turns a BYOK turn into
387
+ // billable traffic instead of a free ride.
388
+ if (apiKey) headers['X-AEGIS-Key'] = apiKey;
376
389
  if (!stream || typeof onStream !== 'function') {
377
390
  return apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers);
378
391
  }
@@ -683,6 +696,15 @@ function createClient(opts = {}) {
683
696
  return apiGet('/api/user/api-keys');
684
697
  }
685
698
 
699
+ /** The server's BYOK provider catalog: which providers accept a key, which
700
+ * models each unlocks, where to get the key, and the prefix a valid key
701
+ * starts with. Deliberately NOT key-gated — "which key do I go get, and
702
+ * what will it unlock?" is the question asked *before* an AEGIS key exists.
703
+ * An AEGIS key, if present, only adds the `configured` column. */
704
+ async function byokProviders() {
705
+ return apiGet('/api/v1/byok/providers');
706
+ }
707
+
686
708
  async function byokSet(provider, providerApiKey) {
687
709
  return apiPost('/api/user/api-keys', {
688
710
  provider,
@@ -809,6 +831,7 @@ function createClient(opts = {}) {
809
831
  tokenBankTopup,
810
832
  billingCheckout,
811
833
  byokStatus,
834
+ byokProviders,
812
835
  byokSet,
813
836
  getMemoryToken,
814
837
  memorySearch,
@@ -4,13 +4,13 @@
4
4
  * credentials.js — the ONE AEGIS account credential store, shared by every
5
5
  * host in this repo (terminal `aegiscode`, the MCP plugin, the desktop app).
6
6
  *
7
- * Why it lives in `client/`: this directory is the only tree all three hosts
8
- * already bundle. The MCP plugin ships `mcp/` + `client/` and nothing else, so
7
+ * Why it lives in `client/`: this directory is the only tree every host
8
+ * already bundles. The MCP plugin ships `mcp/` + `client/` and nothing else, so
9
9
  * a reader placed here needs no cross-package dependency — which is what the
10
10
  * alternative (the MCP host requiring `cli/src/credentials.js`) would have
11
11
  * forced, and why that host used to read `AEGIS_API_KEY` from the environment
12
- * alone while the CLI could save a key to disk. One login, one file, three
13
- * hosts: that is the point of this module.
12
+ * alone while the CLI could save a key to disk. One login, one file, all
13
+ * four hosts: that is the point of this module.
14
14
  *
15
15
  * Zero dependencies, no host imports. The data dir is resolved here
16
16
  * (`aegisHome()`) rather than imported from the CLI's config.js, so this file