aegis-desktop 0.7.3 → 0.7.4

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.4",
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
@@ -296,14 +296,24 @@ function renderRollMeter(sessionId) {
296
296
  * and `costUsd` into the very same file — came back with its full total. That
297
297
  * asymmetry is the accounting difference, not the rendering of it.
298
298
  *
299
- * Nothing is written for a turn that reported no usage: a fabricated
299
+ * One authority writes the row: `ledgerRow` in usage.js, which mirrors the
300
+ * CLI's `appendHistory` shape exactly. A turn the wire did not report on is
301
+ * STILL written — as the CLI writes it, an estimate from the turn's own text
302
+ * marked `real: false` — because that is what keeps the live roll and the
303
+ * rebuilt roll the same number. Only a dispatch with neither reported usage
304
+ * nor any text to estimate from writes nothing: a fabricated
300
305
  * `{input: 0, output: 0}` row would read as a measured zero forever after,
301
306
  * which is the one lie the token meter was built to avoid.
302
307
  */
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;
308
+ function ledgerFields(usage, model, turn, text) {
309
+ const row = ledgerRow(usage, turn, {
310
+ model,
311
+ costUsd: turn && turn.real && typeof turn.cost === 'number' ? turn.cost : undefined,
312
+ calls: text && text.calls,
313
+ prompt: text && text.prompt,
314
+ reply: text && text.reply,
315
+ });
316
+ const fields = row ? Object.assign({}, row) : {};
307
317
  if (model) fields.model = model;
308
318
  return fields;
309
319
  }
@@ -2101,6 +2111,11 @@ async function spawnPath(card, spec) {
2101
2111
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
2102
2112
  calls: data && data.calls,
2103
2113
  turns: 0,
2114
+ // The dispatch's own prompt and stream, for the same reason as the turn
2115
+ // site: a path that reports no usage is estimated from its own text and
2116
+ // counted, instead of leaving the lane's calls out of the session total.
2117
+ prompt: `Original request:\n${spec.prompt}\n\n${spec.path.hint}`,
2118
+ reply: text,
2104
2119
  });
2105
2120
  const rollLine = fmtRoll(roll);
2106
2121
  if (rollLine) bits.push(`session: ${rollLine}`);
@@ -3205,6 +3220,14 @@ async function send() {
3205
3220
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
3206
3221
  });
3207
3222
  if (turn.tokens != null) bits.push(`tokens: ${turn.tokens}`);
3223
+ // A turn the wire did not report on still gets a figure — the same text
3224
+ // estimate that goes into the session total, marked `~` so an inferred
3225
+ // count is never read as a reported one. Printing nothing here while the
3226
+ // session total moved was the other half of "the counter looks dead".
3227
+ else {
3228
+ const est = estimatedBuckets(prompt, text);
3229
+ if (est) bits.push(`~${est.input + est.output} tokens`);
3230
+ }
3208
3231
  if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
3209
3232
  // …AND the running session total beside it, which is the number the CLI
3210
3233
  // prints. The per-turn figure answers "what did that call cost"; only the
@@ -3216,6 +3239,11 @@ async function send() {
3216
3239
  model,
3217
3240
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
3218
3241
  calls: data && data.calls,
3242
+ // The turn's own text, used ONLY when the wire reported no usage — so
3243
+ // such a turn is estimated and counted rather than dropped. This is the
3244
+ // CLI's `appendHistory` rule and the reason the total moves every turn.
3245
+ prompt,
3246
+ reply: text,
3219
3247
  });
3220
3248
  const rollLine = fmtRoll(roll);
3221
3249
  if (rollLine) bits.push(`session: ${rollLine}`);
@@ -3228,7 +3256,11 @@ async function send() {
3228
3256
  // The turn's ledger fields, so this window's spend survives the window
3229
3257
  // — see ledgerFields. This is what makes the rolling meter the same
3230
3258
  // quantity after a reopen as it was before one.
3231
- ...ledgerFields(data && data.usage, model, turn),
3259
+ ...ledgerFields(data && data.usage, model, turn, {
3260
+ prompt,
3261
+ reply: text,
3262
+ calls: data && data.calls,
3263
+ }),
3232
3264
  });
3233
3265
  await sync.save({ id: sessionId, title: prompt.slice(0, 60) });
3234
3266
  } catch {
@@ -3252,9 +3284,31 @@ async function send() {
3252
3284
  if (isCancellation(err, { userStopped })) {
3253
3285
  const text = streamedText || reasoningText || '(stopped before any output)';
3254
3286
  threadMessages.push({ role: 'assistant', content: text });
3255
- addMessage('assistant', text, 'stopped by you', sessionId, toolLog);
3287
+ // A stopped turn is a real exchange and the CLI records one: its
3288
+ // `appendHistory` writes a `status: 'stopped'` entry for every stopped
3289
+ // turn, and `aggregateSessionUsage` sums it like any other. The desktop
3290
+ // wrote `{role, content}` and folded nothing, so an Escape mid-answer
3291
+ // left the session total standing still on a turn the provider had
3292
+ // already billed. Folded here like any other turn; with no wire usage
3293
+ // on this path the figure is the text estimate, marked `est` — and
3294
+ // never a fabricated zero.
3295
+ const turn = turnAccounting(undefined, model, {});
3296
+ const roll = foldRoll(sessionId, undefined, { model, prompt, reply: text });
3297
+ const stopBits = ['stopped by you'];
3298
+ if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
3299
+ else {
3300
+ const est = estimatedBuckets(prompt, text);
3301
+ if (est) stopBits.push(`~${est.input + est.output} tokens`);
3302
+ }
3303
+ const stopRollLine = fmtRoll(roll);
3304
+ if (stopRollLine) stopBits.push(`session: ${stopRollLine}`);
3305
+ addMessage('assistant', text, stopBits.join(' · '), sessionId, toolLog);
3256
3306
  try {
3257
- await sync.append(sessionId, { role: 'assistant', content: text });
3307
+ await sync.append(sessionId, {
3308
+ role: 'assistant',
3309
+ content: text,
3310
+ ...ledgerFields(undefined, model, turn, { prompt, reply: text }),
3311
+ });
3258
3312
  } catch {
3259
3313
  /* persistence is non-fatal */
3260
3314
  }
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,