aegis-desktop 0.7.3 → 0.7.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.7.3",
4
+ "version": "0.7.5",
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,31 @@ 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
+ });
300
+ }
301
+ const line = roll ? fmtRoll(roll) : '';
283
302
  el.textContent = line;
284
303
  el.hidden = !line;
285
304
  if (line) el.title = 'This session, counted the way the CLI counts it — every turn rolled into one running total';
@@ -296,14 +315,24 @@ function renderRollMeter(sessionId) {
296
315
  * and `costUsd` into the very same file — came back with its full total. That
297
316
  * asymmetry is the accounting difference, not the rendering of it.
298
317
  *
299
- * Nothing is written for a turn that reported no usage: a fabricated
318
+ * One authority writes the row: `ledgerRow` in usage.js, which mirrors the
319
+ * CLI's `appendHistory` shape exactly. A turn the wire did not report on is
320
+ * STILL written — as the CLI writes it, an estimate from the turn's own text
321
+ * marked `real: false` — because that is what keeps the live roll and the
322
+ * rebuilt roll the same number. Only a dispatch with neither reported usage
323
+ * nor any text to estimate from writes nothing: a fabricated
300
324
  * `{input: 0, output: 0}` row would read as a measured zero forever after,
301
325
  * which is the one lie the token meter was built to avoid.
302
326
  */
303
- function ledgerFields(usage, model, turn) {
304
- const fields = {};
305
- if (turn && turn.tokens != null) fields.tokens = usageBuckets(usage);
306
- if (turn && turn.cost != null && turn.real) fields.costUsd = turn.cost;
327
+ function ledgerFields(usage, model, turn, text) {
328
+ const row = ledgerRow(usage, turn, {
329
+ model,
330
+ costUsd: turn && turn.real && typeof turn.cost === 'number' ? turn.cost : undefined,
331
+ calls: text && text.calls,
332
+ prompt: text && text.prompt,
333
+ reply: text && text.reply,
334
+ });
335
+ const fields = row ? Object.assign({}, row) : {};
307
336
  if (model) fields.model = model;
308
337
  return fields;
309
338
  }
@@ -2101,6 +2130,11 @@ async function spawnPath(card, spec) {
2101
2130
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
2102
2131
  calls: data && data.calls,
2103
2132
  turns: 0,
2133
+ // The dispatch's own prompt and stream, for the same reason as the turn
2134
+ // site: a path that reports no usage is estimated from its own text and
2135
+ // counted, instead of leaving the lane's calls out of the session total.
2136
+ prompt: `Original request:\n${spec.prompt}\n\n${spec.path.hint}`,
2137
+ reply: text,
2104
2138
  });
2105
2139
  const rollLine = fmtRoll(roll);
2106
2140
  if (rollLine) bits.push(`session: ${rollLine}`);
@@ -3131,6 +3165,10 @@ async function send() {
3131
3165
  }
3132
3166
  const bodyEl = pendingEl.querySelector('.body');
3133
3167
  if (bodyEl && bodyEl.textContent !== streamedText) bodyEl.textContent = streamedText;
3168
+ // Live estimate so the topbar meter keeps moving while the reply streams
3169
+ // in, instead of sitting frozen on the previous turn's total until this
3170
+ // one resolves — see renderRollMeter's `live` param.
3171
+ renderRollMeter(sessionId, { prompt, reply: streamedText });
3134
3172
  stickToBottom();
3135
3173
  });
3136
3174
 
@@ -3205,6 +3243,14 @@ async function send() {
3205
3243
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
3206
3244
  });
3207
3245
  if (turn.tokens != null) bits.push(`tokens: ${turn.tokens}`);
3246
+ // A turn the wire did not report on still gets a figure — the same text
3247
+ // estimate that goes into the session total, marked `~` so an inferred
3248
+ // count is never read as a reported one. Printing nothing here while the
3249
+ // session total moved was the other half of "the counter looks dead".
3250
+ else {
3251
+ const est = estimatedBuckets(prompt, text);
3252
+ if (est) bits.push(`~${est.input + est.output} tokens`);
3253
+ }
3208
3254
  if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
3209
3255
  // …AND the running session total beside it, which is the number the CLI
3210
3256
  // prints. The per-turn figure answers "what did that call cost"; only the
@@ -3216,6 +3262,11 @@ async function send() {
3216
3262
  model,
3217
3263
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
3218
3264
  calls: data && data.calls,
3265
+ // The turn's own text, used ONLY when the wire reported no usage — so
3266
+ // such a turn is estimated and counted rather than dropped. This is the
3267
+ // CLI's `appendHistory` rule and the reason the total moves every turn.
3268
+ prompt,
3269
+ reply: text,
3219
3270
  });
3220
3271
  const rollLine = fmtRoll(roll);
3221
3272
  if (rollLine) bits.push(`session: ${rollLine}`);
@@ -3228,7 +3279,11 @@ async function send() {
3228
3279
  // The turn's ledger fields, so this window's spend survives the window
3229
3280
  // — see ledgerFields. This is what makes the rolling meter the same
3230
3281
  // quantity after a reopen as it was before one.
3231
- ...ledgerFields(data && data.usage, model, turn),
3282
+ ...ledgerFields(data && data.usage, model, turn, {
3283
+ prompt,
3284
+ reply: text,
3285
+ calls: data && data.calls,
3286
+ }),
3232
3287
  });
3233
3288
  await sync.save({ id: sessionId, title: prompt.slice(0, 60) });
3234
3289
  } catch {
@@ -3252,9 +3307,31 @@ async function send() {
3252
3307
  if (isCancellation(err, { userStopped })) {
3253
3308
  const text = streamedText || reasoningText || '(stopped before any output)';
3254
3309
  threadMessages.push({ role: 'assistant', content: text });
3255
- addMessage('assistant', text, 'stopped by you', sessionId, toolLog);
3310
+ // A stopped turn is a real exchange and the CLI records one: its
3311
+ // `appendHistory` writes a `status: 'stopped'` entry for every stopped
3312
+ // turn, and `aggregateSessionUsage` sums it like any other. The desktop
3313
+ // wrote `{role, content}` and folded nothing, so an Escape mid-answer
3314
+ // left the session total standing still on a turn the provider had
3315
+ // already billed. Folded here like any other turn; with no wire usage
3316
+ // on this path the figure is the text estimate, marked `est` — and
3317
+ // never a fabricated zero.
3318
+ const turn = turnAccounting(undefined, model, {});
3319
+ const roll = foldRoll(sessionId, undefined, { model, prompt, reply: text });
3320
+ const stopBits = ['stopped by you'];
3321
+ if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
3322
+ else {
3323
+ const est = estimatedBuckets(prompt, text);
3324
+ if (est) stopBits.push(`~${est.input + est.output} tokens`);
3325
+ }
3326
+ const stopRollLine = fmtRoll(roll);
3327
+ if (stopRollLine) stopBits.push(`session: ${stopRollLine}`);
3328
+ addMessage('assistant', text, stopBits.join(' · '), sessionId, toolLog);
3256
3329
  try {
3257
- await sync.append(sessionId, { role: 'assistant', content: text });
3330
+ await sync.append(sessionId, {
3331
+ role: 'assistant',
3332
+ content: text,
3333
+ ...ledgerFields(undefined, model, turn, { prompt, reply: text }),
3334
+ });
3258
3335
  } catch {
3259
3336
  /* persistence is non-fatal */
3260
3337
  }
package/renderer/usage.js CHANGED
@@ -66,6 +66,56 @@ function usageTokens(usage) {
66
66
  return (input || 0) + (output || 0);
67
67
  }
68
68
 
69
+ /**
70
+ * Rough token count for text the wire never measured — a direct port of the
71
+ * CLI's own estimator (`aegiscodex-dev/src/tokens.js estimateTokens`), and the
72
+ * reason its session total is MONOTONIC.
73
+ *
74
+ * This is the whole difference being fixed. The CLI's `appendHistory`
75
+ * (aegiscodex-dev/src/history.js) writes a `tokens` object for EVERY finished
76
+ * exchange: `{input, output, cacheRead, cacheWrite, real: true}` when the wire
77
+ * reported usage, and `{input: estimateTokens(prompt), output:
78
+ * estimateTokens(reply), real: false}` when it did not. `real` is a FLAG, not a
79
+ * gate — `aggregateSessionUsage` adds `t.input || 0` for every row regardless,
80
+ * so a turn without usage still moves the total and only marks it as partly
81
+ * estimated.
82
+ *
83
+ * The desktop rolled only reported usage and dropped everything else, so its
84
+ * total was flat across any turn the pool did not report on — the meter looked
85
+ * dead while the conversation was being billed. ~4 characters per token, the
86
+ * CLI's heuristic, kept identical so the two surfaces estimate the same turn
87
+ * the same way. Empty text is 0, never the `Math.max(1, …)` floor.
88
+ */
89
+ function estimateTokens(text) {
90
+ if (!text) return 0;
91
+ return Math.max(1, Math.ceil([...String(text)].length / 4));
92
+ }
93
+
94
+ /**
95
+ * The bucket shape an exchange gets when the wire reported no usage: the CLI's
96
+ * `{input: estimateTokens(prompt), output: estimateTokens(reply)}`.
97
+ *
98
+ * `null` when there is no text at all to estimate from, so a dispatch that
99
+ * genuinely reported nothing (no usage, no prompt, no reply) still lands in
100
+ * `unknown` instead of being handed a fabricated zero.
101
+ *
102
+ * @param {string} [prompt]
103
+ * @param {string} [reply]
104
+ * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number}|null}
105
+ */
106
+ function estimatedBuckets(prompt, reply) {
107
+ const hasPrompt = typeof prompt === 'string' && prompt.length > 0;
108
+ const hasReply = typeof reply === 'string' && reply.length > 0;
109
+ if (!hasPrompt && !hasReply) return null;
110
+ return {
111
+ input: estimateTokens(prompt),
112
+ output: estimateTokens(reply),
113
+ cacheRead: 0,
114
+ cacheWrite: 0,
115
+ real: false,
116
+ };
117
+ }
118
+
69
119
  // Per-million-token USD rates. Cache-read/write matter for long sessions.
70
120
  //
71
121
  // These are the PROVIDER's rates, for the fallback path where a turn has no
@@ -239,6 +289,12 @@ function emptyRoll() {
239
289
  cacheWrite: 0,
240
290
  /** Dispatches that reported no usage at all — the honest gap in `tokens`. */
241
291
  unknown: 0,
292
+ /**
293
+ * Exchanges folded from TEXT rather than reported usage, on the CLI's
294
+ * `real: false` rule. They are counted in `tokens` — that is the point —
295
+ * and named here so an estimated figure is never read as a measured one.
296
+ */
297
+ estimated: 0,
242
298
  /** Settled charges (server-settled `costUsd`), rolled. */
243
299
  cost: 0,
244
300
  /** Locally priced turns, rolled. Never mixed into `cost`. */
@@ -253,11 +309,14 @@ function emptyRoll() {
253
309
  *
254
310
  * @param {object} [roll] the roll so far (emptyRoll() when omitted)
255
311
  * @param {object} [usage] the response's `usage` object
256
- * @param {{model?: string, costUsd?: number, calls?: number, turns?: number}} [opts]
312
+ * @param {{model?: string, costUsd?: number, calls?: number, turns?: number,
313
+ * prompt?: string, reply?: string, estimated?: boolean}} [opts]
257
314
  * `calls` defaults to 1; a pooled turn may report how many provider
258
315
  * calls it actually made. `turns: 0` folds a dispatch that is not a
259
316
  * turn of its own — the discovery-lane card, which bills like any other
260
- * call but is not something the user asked for.
317
+ * call but is not something the user asked for. `prompt`/`reply` are the
318
+ * turn's text, used ONLY when the wire reported no usage, so the turn is
319
+ * estimated rather than dropped (the CLI's `appendHistory` rule).
261
320
  * @returns {object} the new roll
262
321
  */
263
322
  function rollTurn(roll, usage, opts = {}) {
@@ -265,8 +324,31 @@ function rollTurn(roll, usage, opts = {}) {
265
324
  next.turns += opts.turns === undefined ? 1 : Number(opts.turns) || 0;
266
325
  next.calls += opts.calls === undefined ? 1 : Number(opts.calls) || 0;
267
326
  const turn = turnAccounting(usage, opts.model, { costUsd: opts.costUsd });
327
+ // A row folded from text rather than from the wire: either this turn's own
328
+ // fallback below, or a stored ledger row carrying `real: false` — the shape
329
+ // the CLI's `appendHistory` writes for an exchange the provider did not
330
+ // report on.
331
+ const estimated = opts.estimated === true || (usage && usage.real === false);
268
332
  if (turn.tokens == null) {
269
- next.unknown += 1;
333
+ // No reported usage. The CLI does not let such a turn vanish from the
334
+ // total: `appendHistory` estimates it from the text and marks it
335
+ // `real: false` (aegiscodex-dev/src/history.js), and
336
+ // `aggregateSessionUsage` then adds it like any other row. Folding the same
337
+ // estimate here is what makes the live roll and the rebuilt roll the same
338
+ // number, and what stops the total standing still on exactly the turns the
339
+ // pool declined to report on — the symptom this fallback exists to remove.
340
+ const est = estimatedBuckets(opts.prompt, opts.reply);
341
+ if (!est) {
342
+ // Nothing reported AND no text to estimate from. The single case that
343
+ // stays uncounted: `unknown` names the gap, where a fabricated zero would
344
+ // read as a measurement.
345
+ next.unknown += 1;
346
+ return next;
347
+ }
348
+ next.tokens += est.input + est.output;
349
+ next.input += est.input;
350
+ next.output += est.output;
351
+ next.estimated += 1;
270
352
  return next;
271
353
  }
272
354
  const b = usageBuckets(usage);
@@ -275,6 +357,14 @@ function rollTurn(roll, usage, opts = {}) {
275
357
  next.output += b.output;
276
358
  next.cacheRead += b.cacheRead;
277
359
  next.cacheWrite += b.cacheWrite;
360
+ if (estimated) {
361
+ // Money stays out of an estimated exchange, deliberately and in both
362
+ // directions: the CLI writes no `costUsd` for one, so pricing it here would
363
+ // make the live roll drift from the rebuilt one — and pricing tokens that
364
+ // were themselves guessed would stack one guess on another.
365
+ next.estimated += 1;
366
+ return next;
367
+ }
278
368
  if (turn.real) next.cost += turn.cost;
279
369
  else if (turn.cost != null) next.estimate += turn.cost;
280
370
  return next;
@@ -307,6 +397,55 @@ function rollMessages(messages) {
307
397
  return roll;
308
398
  }
309
399
 
400
+ /**
401
+ * The ledger row one finished dispatch must carry into the shared session
402
+ * store, so the rolling total can be REBUILT when the thread is reopened —
403
+ * the desktop's counterpart of the CLI's `appendHistory`
404
+ * (aegiscodex-dev/src/history.js:36).
405
+ *
406
+ * One authority for the row, on purpose. The CLI writes a `tokens` object for
407
+ * EVERY exchange and skips none:
408
+ *
409
+ * tokens: usage
410
+ * ? { input, output, cacheRead, cacheWrite, real: true }
411
+ * : { input: estimateTokens(prompt), output: estimateTokens(reply), real: false }
412
+ *
413
+ * …and `aggregateSessionUsage` then sums `t.input || 0` over every entry with
414
+ * `real` as a FLAG, not a gate. Mirroring that shape here is what makes the
415
+ * live roll and the rebuilt roll the same number: `rollTurn` is handed this
416
+ * exact object on reopen, so an estimated exchange adds the same buckets it
417
+ * added live and is counted under `estimated` in both.
418
+ *
419
+ * Returns `null` when there is neither reported usage nor text to estimate
420
+ * from — the one case that must stay unrecorded, because a fabricated
421
+ * `{input: 0, output: 0}` row would read as a measured zero forever after.
422
+ *
423
+ * @param {object} [usage] the response's `usage` object
424
+ * @param {object} [turn] its accounting (turnAccounting), when already done
425
+ * @param {{model?: string, costUsd?: number, calls?: number, prompt?: string,
426
+ * reply?: string}} [opts]
427
+ * @returns {object|null} the row's ledger fields
428
+ */
429
+ function ledgerRow(usage, turn, opts = {}) {
430
+ const t = turn || turnAccounting(usage, opts.model, { costUsd: opts.costUsd });
431
+ const calls = Number.isFinite(opts.calls) && opts.calls > 0 ? opts.calls : undefined;
432
+ if (t.tokens != null) {
433
+ const row = { tokens: usageBuckets(usage) };
434
+ // Only a SETTLED charge is persisted. The CLI writes `costUsd` for a real
435
+ // charge only, and the rebuild prices an unpriced row from the same rate
436
+ // table this window used — persisting a local guess would let a stale
437
+ // table outlive the change that wrote it.
438
+ if (t.real && t.cost != null) row.costUsd = t.cost;
439
+ if (calls !== undefined) row.calls = calls;
440
+ return row;
441
+ }
442
+ const est = estimatedBuckets(opts.prompt, opts.reply);
443
+ if (!est) return null;
444
+ const row = { tokens: est };
445
+ if (calls !== undefined) row.calls = calls;
446
+ return row;
447
+ }
448
+
310
449
  /**
311
450
  * The CLI's rendering of a session tally — `cli/src/format.js fmtTokens`, which
312
451
  * is the one `tokenSummary` actually imports (`cli/src/app.js:50`). NOT the
@@ -343,10 +482,19 @@ function fmtRoll(roll) {
343
482
  // are kept, because a line that is only *sometimes* shaped like the CLI's is a
344
483
  // lookalike rather than the same quantity. The call count is also the number
345
484
  // that reveals a fan-out, which is why it is not hidden at 1.
346
- const bits = [
347
- `${fmtTokens(r.tokens)} tok (${fmtTokens(r.input)} in / ${fmtTokens(r.output)} out)`,
348
- `${r.calls} call${r.calls === 1 ? '' : 's'}`,
349
- ];
485
+ const bits = [];
486
+ // The token half only when something was actually counted. `rollTurn` counts
487
+ // turns and calls BEFORE it looks at the token count, so a dispatch that
488
+ // 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.
492
+ if (r.tokens > 0) {
493
+ bits.push(
494
+ `${fmtTokens(r.tokens)} tok (${fmtTokens(r.input)} in / ${fmtTokens(r.output)} out)`
495
+ );
496
+ }
497
+ bits.push(`${r.calls} call${r.calls === 1 ? '' : 's'}`);
350
498
  // Money is where this line departs from `tokenSummary`, deliberately: that
351
499
  // one sums a single `session.cost` in EUR via fmtEur, while this surface keeps
352
500
  // a settled charge and a local estimate apart so a `~`-estimate can never be
@@ -363,6 +511,11 @@ function fmtRoll(roll) {
363
511
  // the count appears only when something went unreported, and it never changes
364
512
  // a number — it only says the number is not the whole story.
365
513
  if (r.unknown) bits.push(`${r.unknown} unrpt`);
514
+ // The other half of the same honesty: a total that includes estimated
515
+ // exchanges says so, because the CLI marks the same rows `real: false` and a
516
+ // reader is entitled to know which figure they are looking at. It changes no
517
+ // number — it says the number is partly inferred.
518
+ if (r.estimated) bits.push(`${r.estimated} est`);
366
519
  return bits.join(' · ');
367
520
  }
368
521
 
@@ -387,9 +540,12 @@ if (typeof module !== 'undefined' && module.exports) {
387
540
  usageBuckets,
388
541
  usageCost,
389
542
  turnAccounting,
543
+ estimateTokens,
544
+ estimatedBuckets,
390
545
  emptyRoll,
391
546
  rollTurn,
392
547
  rollMessages,
548
+ ledgerRow,
393
549
  fmtTokens,
394
550
  fmtRoll,
395
551
  fmtCost,