aegis-desktop 0.8.31 → 0.8.32

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.
@@ -28,17 +28,37 @@
28
28
  * THE SIGNAL (see the report / the module's `probe`): main injects a probe that
29
29
  * calls `aegis.verifyApiKey()` — aegis1 `/api/verify-api-key`, which its own
30
30
  * docstring describes as "Verify a shared api_key and return plan/email/
31
- * memory_token". The plan field is the account's stored plan, and aegis1 owns
32
- * the vocabulary for it (`services/billing_service.py`: `PAID_PLANS = ("pro",)`
33
- * plus the legacy aliases that normalize onto it). This module MIRRORS that
34
- * vocabulary rather than inventing one; there is no new billing anything here,
35
- * no new endpoint, and no write to the server.
31
+ * memory_token". The plan field is the account's plan, and aegis1 owns the
32
+ * vocabulary for it (`services/billing_service.py`: `TIERS = ("free", "pro",
33
+ * "unlimited")`, `PAID_PLANS = ("pro", "unlimited")`, plus the legacy aliases
34
+ * that normalize onto one of them). This module MIRRORS that vocabulary rather
35
+ * than inventing one; there is no new billing anything here, no new endpoint,
36
+ * and no write to the server. The probe prefers the server's `plan_effective`
37
+ * (its own `is_pro()` verdict, trial included) over the stored `plan` string
38
+ * when it is present, because a welcome-trial account is a real Unlimited
39
+ * entitlement that `users.plan` does not record — see `classifyProbe`.
40
+ *
41
+ * THE BUG THIS VOCABULARY COST (fixed here). `PAID_PLANS` read `("pro",)`,
42
+ * mirroring the retired €2 line and MISSING `unlimited` — the €9/mo tier that
43
+ * is the only one sold. So `normalizePlan('unlimited')` fell through to `free`,
44
+ * and a paying Unlimited subscriber with a spent-out bank was handed the
45
+ * free-tier boundary: the fan-out refused, the cloud drain clamped to one local
46
+ * worker, and a "top up your balance" prompt — for an account whose plan is
47
+ * precisely what pays for the drain. aegis1's own `LEGACY_PLAN_ALIASES` carries
48
+ * the matching warning from the other side ("Deliberately NOT an alias:
49
+ * `unlimited` is a live tier you can subscribe to, so it must resolve through
50
+ * TIERS"), and its `test_tier_vocabulary` pins `PAID_PLANS` at two entries.
51
+ * Two implementations of "is this account paid" had already drifted once; this
52
+ * was the drift.
36
53
  *
37
54
  * PHASE 6 — THE SECOND SIGNAL, THE BALANCE. The plan is only half of how this
38
55
  * product charges. aegis1's actual paywall is the Token Bank running dry: the
39
56
  * balance is prepaid EUR the OPERATOR credits by hand, and `app.py` states the
40
57
  * consequence plainly — "Pool-key users with token bank balance skip legacy
41
- * credit checks." So entitlement here is `pro || balance > 0`, and when the
58
+ * credit checks." So entitlement here is `paid_plan || balance > 0` — and the
59
+ * `paid_plan` half is what carries an Unlimited subscriber whose bank is empty;
60
+ * the bank is an OVERAGE lever, never the thing that decides whether a paying
61
+ * account may use the feature it pays for. When the
42
62
  * bank reads zero the wall names the fix that exists (a top-up, campaign
43
63
  * `topup_bank`) instead of a plan checkout the user may already own. Nothing in
44
64
  * this module grants a balance, expires one, or auto-charges: the operator
@@ -64,8 +84,33 @@
64
84
  /** The free-tier fan-out ceiling: one worker per task, i.e. a plain turn. */
65
85
  const FREE_MAX_WORKERS = 1;
66
86
 
67
- /** Aegis1's paid tier. MIRROR of services/billing_service.py `PAID_PLANS`. */
68
- const PAID_PLANS = Object.freeze(['pro']);
87
+ /**
88
+ * Aegis1's paid tiers. MIRROR of services/billing_service.py
89
+ * `PAID_PLANS = ("pro", "unlimited")` — pinned literal on purpose, because this
90
+ * is the line that has to move whenever the server sells another tier.
91
+ *
92
+ * `unlimited` (€9/mo) is the tier ON SALE and the one a paying customer almost
93
+ * certainly owns; `pro` is the grandfathered €2 line, kept because its
94
+ * subscribers keep their entitlements. Reading only `pro` was not a narrower
95
+ * version of this gate, it was a gate pointing at the wrong tier: every
96
+ * Unlimited account fell to the free boundary. `starter` stays out — aegis1
97
+ * itself calls that one free.
98
+ */
99
+ const PAID_PLANS = Object.freeze(['pro', 'unlimited']);
100
+
101
+ /**
102
+ * What each paid tier is CALLED, so a surface that names the account's plan
103
+ * names the one the server granted. The CLI has read the tier off the server's
104
+ * plan string since 6443a60 (`cli/src/entitlement.js::paymentView`) after it
105
+ * told a €9 subscriber they owned the retired €2 plan; this is the same value,
106
+ * resolved once in the shared module both hosts read.
107
+ */
108
+ const TIER_NAMES = Object.freeze({ pro: 'Pro', unlimited: 'Unlimited' });
109
+
110
+ /** The display name for a plan string, or null when it is not a paid tier. */
111
+ function tierName(plan) {
112
+ return TIER_NAMES[normalizePlan(plan)] || null;
113
+ }
69
114
 
70
115
  /**
71
116
  * MIRROR of services/billing_service.py `LEGACY_PLAN_ALIASES`: every stored
@@ -251,11 +296,20 @@ function promptUrlFor(host) {
251
296
  * card on the next refresh and long enough that a drain never probes per task. */
252
297
  const DEFAULT_TTL_MS = 60 * 1000;
253
298
 
254
- /** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does. */
299
+ /** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does.
300
+ *
301
+ * Aegis1 checks its live `TIERS` BEFORE the alias table (`normalize_plan`), so
302
+ * an alias can never shadow a tier it also names — and `unlimited` is a live
303
+ * tier. It is therefore returned as itself and never collapsed onto `pro`: the
304
+ * two grant different entitlements server-side (the monthly Unlimited
305
+ * allowance and the BYOK fee waiver are `is_unlimited()`-gated), and telling a
306
+ * €9 customer they own the retired €2 plan is the mistake commit 6443a60
307
+ * exists to record.
308
+ */
255
309
  function normalizePlan(plan) {
256
310
  const raw = String(plan == null ? '' : plan).trim().toLowerCase();
257
311
  if (!raw) return 'free';
258
- if (PAID_PLANS.includes(raw)) return 'pro';
312
+ if (PAID_PLANS.includes(raw)) return raw;
259
313
  const aliased = LEGACY_PLAN_ALIASES[raw];
260
314
  return aliased || 'free';
261
315
  }
@@ -265,9 +319,9 @@ function normalizePlan(plan) {
265
319
  * drift into two different instructions. */
266
320
  const ASK_EXAMPLE = 'Do X autonomously';
267
321
 
268
- /** True only for a tier aegis1 itself would call paid. */
322
+ /** True only for a tier aegis1 itself would call paid (`pro` or `unlimited`). */
269
323
  function isPaidPlan(plan) {
270
- return normalizePlan(plan) === 'pro';
324
+ return normalizePlan(plan) !== 'free';
271
325
  }
272
326
 
273
327
  /**
@@ -367,7 +421,19 @@ function classifyProbe(result, error) {
367
421
  const body = result && typeof result === 'object' ? result : {};
368
422
  const bal = balanceOf(body);
369
423
  if (body.valid === false) return { source: 'no-key', plan: 'free', degraded: false, ...bal };
370
- const stated = String(body.plan == null ? '' : body.plan).trim();
424
+ // WHICH plan field. `plan_effective` is aegis1's own entitlement verdict —
425
+ // the same predicate its `is_pro()` gates pooled calls on, so it counts the
426
+ // 30-day welcome trial that never writes `users.plan` (see app.py's
427
+ // register_device: the trial lives in `subscriptions`). The stored `plan`
428
+ // column cannot express that account, so an installing user read as `free`
429
+ // here and only the €12 signup credit kept the gate open — spend it, or fail
430
+ // to receive it, and a real Unlimited account was refused the feature. The
431
+ // stored string is still the fallback: an older server that predates the
432
+ // field must keep working, and it is the same value when nothing is on trial.
433
+ const rawPlan = body.plan_effective != null && String(body.plan_effective).trim()
434
+ ? body.plan_effective
435
+ : body.plan;
436
+ const stated = String(rawPlan == null ? '' : rawPlan).trim();
371
437
  if (!stated) {
372
438
  // A key that verified but carried no plan: aegis1's own default for such an
373
439
  // account is `free` (the column default), and a missing field is not proof
@@ -411,6 +477,8 @@ function createEntitlement({
411
477
  balanceZero: false,
412
478
  wall: 'plan',
413
479
  plan: 'free',
480
+ tier: null,
481
+ tierName: null,
414
482
  planRaw: null,
415
483
  maxWorkers: FREE_MAX_WORKERS,
416
484
  source: 'unresolved',
@@ -424,12 +492,20 @@ function createEntitlement({
424
492
  let inflight = null;
425
493
 
426
494
  function fromProbe({ source, plan, degraded, planRaw, balanceKnown = false, balanceMicros = null }) {
427
- const pro = isPaidPlan(plan);
495
+ // The tier itself, not a paid/not-paid bit: `unlimited` and `pro` are both
496
+ // paid and must stay distinguishable, because the surfaces that name the
497
+ // account's plan (the desktop's queue boundary, the CLI's `/billing`) name
498
+ // the one the server granted. `pro` below keeps its original meaning — "the
499
+ // PLAN is paid for", as opposed to a free plan carried by a funded bank.
500
+ const tier = normalizePlan(plan);
501
+ const pro = tier !== 'free';
428
502
  const banked = balanceKnown && Number.isFinite(balanceMicros) && balanceMicros > 0;
429
503
  // TWO independent ways to be paid for: a paid plan, or a funded bank. The
430
504
  // operator controls the second by hand (aegis1 admin `add_funds`), which is
431
505
  // the lever this wall is built around — nothing here grants, times out or
432
- // auto-charges; it only reads the number the operator set.
506
+ // auto-charges; it only reads the number the operator set. The FIRST way is
507
+ // the one a paying subscriber holds, and it does not weaken when their bank
508
+ // runs dry: the balance is overage, the subscription is the entitlement.
433
509
  const entitled = pro || banked;
434
510
  // The bank was READABLE and EMPTY — the operator's own signal that this
435
511
  // account is now paying, not the absence of a plan.
@@ -447,7 +523,12 @@ function createEntitlement({
447
523
  balanceMicros: balanceKnown ? balanceMicros : null,
448
524
  balanceZero,
449
525
  wall,
450
- plan: pro ? 'pro' : 'free',
526
+ // The tier a paid PLAN grants, else `free`. A funded bank on a free plan
527
+ // is still `free` here — the bank is not a tier, and naming one would put
528
+ // a plan on the account the server never granted.
529
+ plan: pro ? tier : 'free',
530
+ tier: pro ? tier : null,
531
+ tierName: pro ? (TIER_NAMES[tier] || null) : null,
451
532
  planRaw: planRaw == null ? null : String(planRaw),
452
533
  // A paid session has no ceiling of its own here: main.js still clamps the
453
534
  // renderer's number to QUEUE_MAX_WORKERS, which stays the only upper bound.
@@ -543,6 +624,8 @@ module.exports = {
543
624
  createEntitlement,
544
625
  normalizePlan,
545
626
  isPaidPlan,
627
+ tierName,
628
+ TIER_NAMES,
546
629
  classifyProbe,
547
630
  fanoutRefusal,
548
631
  balanceRefusal,
package/main.js CHANGED
@@ -3546,14 +3546,15 @@ function bootstrap() {
3546
3546
  // Nothing configured anywhere (env, this app's encrypted copy, or the
3547
3547
  // shared store): a fresh install. Auto-provision a free device key
3548
3548
  // from aegis1 — same shared helper mcp/server.js and the CLI use, so
3549
- // the desktop app works with no manual sign-in step. It starts at
3550
- // zero token-bank balance server-side (register-device), and pooled
3551
- // aegiscloud models additionally require an upgraded plan (aegis1's
3552
- // is_pro() gate) — this only unblocks BYOK and account status/
3553
- // settings until the account is funded and/or upgraded. Failure
3554
- // (offline, aegis1 unreachable) falls through exactly as before: the
3555
- // renderer's normal "not signed in" state handles it, and the next
3556
- // launch tries again.
3549
+ // the desktop app works with no manual sign-in step. A brand-new
3550
+ // fingerprint is granted aegis1's 30-day welcome-trial `unlimited`
3551
+ // entitlement (token-bank credit + a subscriptions row) in the same
3552
+ // register-device call that mints the key — see client/
3553
+ // auto-provision.js's header for the aegis1 commit that made this a
3554
+ // real grant rather than a badge. Failure (offline, aegis1
3555
+ // unreachable) falls through exactly as before: the renderer's
3556
+ // normal "not signed in" state handles it, and the next launch tries
3557
+ // again.
3557
3558
  const result = await autoProvision.autoProvisionApiKey(aegis, credentials);
3558
3559
  if (result.key) settings.setAegisKey(result.key);
3559
3560
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.31",
4
+ "version": "0.8.32",
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
@@ -1536,9 +1536,14 @@ function renderQueueEntitlement(state) {
1536
1536
  if (!resolved) els.queueBoundary.textContent = '';
1537
1537
  else if (entitled) {
1538
1538
  // Say which of the two ways this account is paid for, so a funded bank
1539
- // does not read as a Pro plan somebody did not buy.
1539
+ // does not read as a Pro plan somebody did not buy — and name WHICH tier
1540
+ // the plan is: `ent.tierName` is resolved in main from the plan string the
1541
+ // server stated (shared module's TIER_NAMES), so a €9 Unlimited subscriber
1542
+ // is not told they own the retired €2 plan. That mislabelling is the same
1543
+ // class CLI commit 6443a60 fixed in `/billing`; the literal is only a
1544
+ // fallback for a snapshot from a host that predates the field.
1540
1545
  els.queueBoundary.textContent = ent.pro
1541
- ? 'Pro — the cloud drain and the fan-out are unlocked.'
1546
+ ? `${ent.tierName || 'Pro'} — the cloud drain and the fan-out are unlocked.`
1542
1547
  : 'Balance on your account — the cloud drain and the fan-out are unlocked.';
1543
1548
  } else {
1544
1549
  // The refusal names the Pro value AND keeps the free tier's real reach
package/vendor/aegis.js CHANGED
@@ -841,10 +841,16 @@ function createClient(opts = {}) {
841
841
  * Ask aegis1 for a free device key when the caller has none configured yet
842
842
  * (`POST /api/v1/auth/register-device`). Idempotent server-side on
843
843
  * `fingerprint` — the same fingerprint always returns the same account's
844
- * key rather than minting a new one. The account starts at zero token-bank
845
- * balance: this hands out an account, not free inference, so the existing
846
- * balance=0 gate on `/api/v1/chat/completions` still applies unchanged.
847
- * Sends no API key of its own — there isn't one yet.
844
+ * key rather than minting a new one. A BRAND-NEW fingerprint (first call
845
+ * ever for this machine) is additionally granted aegis1's 30-day
846
+ * welcome-trial `unlimited` entitlement in the SAME request that mints the
847
+ * key — a token-bank credit of UNLIMITED_CLOUD_ALLOWANCE_MICROS plus a
848
+ * time-boxed `subscriptions` row (aegis1 commit 4372830), so a fresh
849
+ * install can use pooled cloud models immediately instead of starting at a
850
+ * zero balance. Re-registering an already-known fingerprint does NOT
851
+ * re-grant it; the pool balance gate still applies once the allowance is
852
+ * spent or the 30 days lapse. Sends no API key of its own — there isn't
853
+ * one yet.
848
854
  */
849
855
  async function registerDevice(fingerprint, opts) {
850
856
  return apiPost('/api/v1/auth/register-device', { fingerprint }, undefined, opts);
@@ -12,11 +12,18 @@
12
12
  * device-fingerprint.js) so a wiped credential store still resolves back to
13
13
  * the same account rather than minting a new, unfunded one.
14
14
  *
15
- * The provisioned account starts at ZERO token-bank balance server-side, and
16
- * aegis1 additionally requires an upgraded (paid, active) plan to use pooled
17
- * aegiscloud models at all (nexus_chat_completions' is_pro() gate) — this
18
- * only unblocks BYOK and account status/settings until the account is funded
19
- * and/or upgraded. It is account creation, not a grant of free inference.
15
+ * As of aegis1 commit 4372830, a brand-new fingerprint (first call ever for
16
+ * this machine) is granted the 30-day welcome-trial `unlimited` entitlement
17
+ * server-side — a token-bank credit (UNLIMITED_CLOUD_ALLOWANCE_MICROS) and a
18
+ * time-boxed `unlimited` row in `subscriptions` — in the SAME request that
19
+ * mints the key, not a separate step this client has to ask for. is_pro()'s
20
+ * pool gate and the BYOK fee waiver both see that grant immediately. This
21
+ * comment previously said the account "starts at ZERO balance" and "only
22
+ * unblocks BYOK" — that described pre-4372830 behavior and was stale; see
23
+ * services/billing_service.py's is_pro()/is_unlimited() docstrings on the
24
+ * aegis1 side for the current contract. A re-registration of an already-known
25
+ * fingerprint (the idempotent path) does NOT re-grant the trial — it only
26
+ * happens once per machine, ever.
20
27
  */
21
28
  const { machineFingerprint } = require('./device-fingerprint.js');
22
29