aegis-desktop 1.0.13 → 1.0.15
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/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/lib/local/entitlement.js
CHANGED
|
@@ -1,656 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* entitlement.js — the Pro boundary for the autonomous queue.
|
|
10
|
-
*
|
|
11
|
-
* Phase 4 of docs/upgrade-conversion-plan.md. The queue is the feature people
|
|
12
|
-
* actually pay for, and until this file existed it was 100% free and ungated:
|
|
13
|
-
* `registerQueueIpc` in main.js checked no key, no plan and no quota, so the
|
|
14
|
-
* single most expensive thing this app can do (an unattended task, optionally
|
|
15
|
-
* fanned out across pooled workers) ran forever on the free tier.
|
|
16
|
-
*
|
|
17
|
-
* WHAT IS GATED, AND WHY IT IS THE FAN-OUT. Free keeps everything local: the
|
|
18
|
-
* drain runs on the user's own key, in this process, while the window is open.
|
|
19
|
-
* The *fan-out* is the multiplier — aegis1's pool_brain spawns up to `workers`
|
|
20
|
-
* reasoning workers plus a synthesis pass and re-sends the task context to each
|
|
21
|
-
* (see autonomous.js resolveFanout) — so that is the worker ceiling this module
|
|
22
|
-
* enforces: free = 1 worker (a plain single-pass turn), Pro = the fan-out plus
|
|
23
|
-
* the cloud drain it belongs to. Nothing here changes price, the memory cap or
|
|
24
|
-
* the free-tier definition; it only stops the free tier from spending the Pro
|
|
25
|
-
* tier's multiplier.
|
|
26
|
-
*
|
|
27
|
-
* WHERE THE GATE LIVES. This module is the DECISION; main.js is the DOOR.
|
|
28
|
-
* `registerQueueIpc` consults it, so a renderer that never renders the fan-out
|
|
29
|
-
* control cannot get a fan-out anyway — renderer-side gating is cosmetic and
|
|
30
|
-
* trivially bypassed (any devtools console can call `window.queue.enqueue`).
|
|
31
|
-
* Nothing in the renderer decides entitlement; it displays what main tells it.
|
|
32
|
-
*
|
|
33
|
-
* THE SIGNAL (see the report / the module's `probe`): main injects a probe that
|
|
34
|
-
* calls `aegis.verifyApiKey()` — aegis1 `/api/verify-api-key`, which its own
|
|
35
|
-
* docstring describes as "Verify a shared api_key and return plan/email/
|
|
36
|
-
* memory_token". The plan field is the account's plan, and aegis1 owns the
|
|
37
|
-
* vocabulary for it (`services/billing_service.py`: `TIERS = ("free", "pro",
|
|
38
|
-
* "unlimited")`, `PAID_PLANS = ("pro", "unlimited")`, plus the legacy aliases
|
|
39
|
-
* that normalize onto one of them). This module MIRRORS that vocabulary rather
|
|
40
|
-
* than inventing one; there is no new billing anything here, no new endpoint,
|
|
41
|
-
* and no write to the server. The probe prefers the server's `plan_effective`
|
|
42
|
-
* (its own `is_pro()` verdict, trial included) over the stored `plan` string
|
|
43
|
-
* when it is present, because a welcome-trial account is a real Unlimited
|
|
44
|
-
* entitlement that `users.plan` does not record — see `classifyProbe`.
|
|
45
|
-
*
|
|
46
|
-
* THE BUG THIS VOCABULARY COST (fixed here). `PAID_PLANS` read `("pro",)`,
|
|
47
|
-
* mirroring the retired €2 line and MISSING `unlimited` — the €9/mo tier that
|
|
48
|
-
* is the only one sold. So `normalizePlan('unlimited')` fell through to `free`,
|
|
49
|
-
* and a paying Unlimited subscriber with a spent-out bank was handed the
|
|
50
|
-
* free-tier boundary: the fan-out refused, the cloud drain clamped to one local
|
|
51
|
-
* worker, and a "top up your balance" prompt — for an account whose plan is
|
|
52
|
-
* precisely what pays for the drain. aegis1's own `LEGACY_PLAN_ALIASES` carries
|
|
53
|
-
* the matching warning from the other side ("Deliberately NOT an alias:
|
|
54
|
-
* `unlimited` is a live tier you can subscribe to, so it must resolve through
|
|
55
|
-
* TIERS"), and its `test_tier_vocabulary` pins `PAID_PLANS` at two entries.
|
|
56
|
-
* Two implementations of "is this account paid" had already drifted once; this
|
|
57
|
-
* was the drift.
|
|
58
|
-
*
|
|
59
|
-
* PHASE 6 — THE SECOND SIGNAL, THE BALANCE. The plan is only half of how this
|
|
60
|
-
* product charges. aegis1's actual paywall is the Token Bank running dry: the
|
|
61
|
-
* balance is prepaid EUR the OPERATOR credits by hand, and `app.py` states the
|
|
62
|
-
* consequence plainly — "Pool-key users with token bank balance skip legacy
|
|
63
|
-
* credit checks." So entitlement here is `paid_plan || balance > 0` — and the
|
|
64
|
-
* `paid_plan` half is what carries an Unlimited subscriber whose bank is empty;
|
|
65
|
-
* the bank is an OVERAGE lever, never the thing that decides whether a paying
|
|
66
|
-
* account may use the feature it pays for. When the
|
|
67
|
-
* bank reads zero the wall names the fix that exists (a top-up, campaign
|
|
68
|
-
* `topup_bank`) instead of a plan checkout the user may already own. Nothing in
|
|
69
|
-
* this module grants a balance, expires one, or auto-charges: the operator
|
|
70
|
-
* decides when an account pays, and this file only reads the number they set.
|
|
71
|
-
* An unreadable balance is UNKNOWN, not zero — a network blip must never
|
|
72
|
-
* manufacture a paywall, and only a balance we actually read can be "empty".
|
|
73
|
-
*
|
|
74
|
-
* FAIL CLOSED, AND SAY SO. Every way of not knowing — no key on the machine, an
|
|
75
|
-
* offline/500 probe, a plan string outside the vocabulary — resolves to the
|
|
76
|
-
* free boundary, and the snapshot carries `degraded: true` plus the reason so
|
|
77
|
-
* the UI and the report can be honest about it. The fan-out is real money; an
|
|
78
|
-
* unknown entitlement must not hand it out. The cost is the opposite error (a
|
|
79
|
-
* paying customer offline at that instant is held to one worker for the length
|
|
80
|
-
* of one TTL), which is bounded, visible in the note, and recoverable by
|
|
81
|
-
* re-probing — the next `refresh()` after the TTL flips it back.
|
|
82
|
-
*
|
|
83
|
-
* Pure Node on purpose: no Electron, no network of its own (the probe is
|
|
84
|
-
* injected), and `now` is a seam, so the whole thing is unit-testable —
|
|
85
|
-
* including the bypass path (a renderer asking for workers > 1 while free must
|
|
86
|
-
* be refused HERE, not merely hidden in the UI).
|
|
87
|
-
*/
|
|
88
|
-
|
|
89
|
-
/** The free-tier fan-out ceiling: one worker per task, i.e. a plain turn. */
|
|
90
|
-
const FREE_MAX_WORKERS = 1;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Aegis1's paid tiers. MIRROR of services/billing_service.py
|
|
94
|
-
* `PAID_PLANS = ("pro", "unlimited")` — pinned literal on purpose, because this
|
|
95
|
-
* is the line that has to move whenever the server sells another tier.
|
|
96
|
-
*
|
|
97
|
-
* `unlimited` (€9/mo) is the tier ON SALE and the one a paying customer almost
|
|
98
|
-
* certainly owns; `pro` is the grandfathered €2 line, kept because its
|
|
99
|
-
* subscribers keep their entitlements. Reading only `pro` was not a narrower
|
|
100
|
-
* version of this gate, it was a gate pointing at the wrong tier: every
|
|
101
|
-
* Unlimited account fell to the free boundary. `starter` stays out — aegis1
|
|
102
|
-
* itself calls that one free.
|
|
103
|
-
*/
|
|
104
|
-
const PAID_PLANS = Object.freeze(['pro', 'unlimited']);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* What each paid tier is CALLED, so a surface that names the account's plan
|
|
108
|
-
* names the one the server granted. The CLI has read the tier off the server's
|
|
109
|
-
* plan string since 6443a60 (`cli/src/entitlement.js::paymentView`) after it
|
|
110
|
-
* told a €9 subscriber they owned the retired €2 plan; this is the same value,
|
|
111
|
-
* resolved once in the shared module both hosts read.
|
|
112
|
-
*/
|
|
113
|
-
const TIER_NAMES = Object.freeze({ pro: 'Pro', unlimited: 'Unlimited' });
|
|
114
|
-
|
|
115
|
-
/** The display name for a plan string, or null when it is not a paid tier. */
|
|
116
|
-
function tierName(plan) {
|
|
117
|
-
return TIER_NAMES[normalizePlan(plan)] || null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* MIRROR of services/billing_service.py `LEGACY_PLAN_ALIASES`: every stored
|
|
122
|
-
* plan string that normalizes onto a tier. `starter` is free-equivalent by
|
|
123
|
-
* aegis1's own definition — reading it as paid here would hand the fan-out to
|
|
124
|
-
* the one account the server itself treats as free.
|
|
125
|
-
*/
|
|
126
|
-
const LEGACY_PLAN_ALIASES = Object.freeze({
|
|
127
|
-
cloud: 'pro',
|
|
128
|
-
memory: 'pro',
|
|
129
|
-
s: 'pro',
|
|
130
|
-
heavy: 'pro',
|
|
131
|
-
team: 'pro',
|
|
132
|
-
starter: 'free',
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The Pro value, in the words every surface repeats (Phase 3's PAID_PROMISE is
|
|
137
|
-
* the same claim). A refusal that only stated the limit ("max 1 worker") reads
|
|
138
|
-
* as a bug; naming what Pro buys is the whole point of the wall.
|
|
139
|
-
*/
|
|
140
|
-
const PRO_VALUE =
|
|
141
|
-
'Pro puts the drain in the cloud, so the job keeps running after you close the laptop';
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* The honesty line — Phase 3's rule, and the second half of the same claim:
|
|
145
|
-
* everything that runs on the user's own machine stays free forever, and the
|
|
146
|
-
* cloud drain is the thing being sold. Both hosts that print a paid prompt
|
|
147
|
-
* print this beside it (the desktop keeps its own literal in
|
|
148
|
-
* `desktop/renderer/app.js`; the terminal reads it from here), because the
|
|
149
|
-
* promise without the honesty line is the four-bullet feature list again.
|
|
150
|
-
*
|
|
151
|
-
* Kept byte-identical to the renderer's `FREE_HONESTY`, and asserted verbatim
|
|
152
|
-
* by both hosts' tests, so the terminal and the GUI cannot come to disagree
|
|
153
|
-
* about what is free — a disagreement that would either overcharge a promise
|
|
154
|
-
* or undercut the offer, depending on which copy a user believed.
|
|
155
|
-
*/
|
|
156
|
-
const FREE_HONESTY =
|
|
157
|
-
'Everything on your machine is free, permanently. The cloud drain is what you pay for.';
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The BYOK lane's price, in words — built from the SERVER's published rate.
|
|
161
|
-
*
|
|
162
|
-
* The desktop README says the byok lane is "billed a flat handling fee" and
|
|
163
|
-
* aegis1 charges exactly that (services/pricing.price_byok_call), but no client
|
|
164
|
-
* surface said so: a user who pasted their own Anthropic key into AEGIS read
|
|
165
|
-
* "your provider's cost" and never learned AEGIS adds a fee. That is the one
|
|
166
|
-
* place in this product where the bill and the pitch disagreed, and it is the
|
|
167
|
-
* copy the desktop session flagged as an unresolved honesty conflict.
|
|
168
|
-
*
|
|
169
|
-
* `fee` is aegis1's own `/api/v1/byok/providers` payload — a live payload, never
|
|
170
|
-
* a hardcoded number, for the reason the engine already states: a client that
|
|
171
|
-
* hardcodes a fee is a client that can disagree with the ledger. No published
|
|
172
|
-
* fee (old server, offline, unauthenticated read that returned nothing) returns
|
|
173
|
-
* '' and every host then says NOTHING about the price, which is the only honest
|
|
174
|
-
* alternative to inventing one.
|
|
175
|
-
*
|
|
176
|
-
* `require_account` / `anonymous_daily_calls` come from the same payload and
|
|
177
|
-
* describe enforcement, not price: with the relay requiring an AEGIS account the
|
|
178
|
-
* sentence has to say so, or the user meets a 402 the client never warned about
|
|
179
|
-
* — the exact failure tests/test_byok_fee.py::test_the_published_gate_warning_is
|
|
180
|
-
* _true_whenever_the_gate_can_402 exists to prevent.
|
|
181
|
-
*/
|
|
182
|
-
const BYOK_FEE_NOTE =
|
|
183
|
-
'Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider\'s own bill.';
|
|
184
|
-
|
|
185
|
-
/** Per-million-token rendering of a published per-1k rate. */
|
|
186
|
-
function ratePerMillion(usdPer1k) {
|
|
187
|
-
return `$${(Number(usdPer1k) * 1000).toFixed(2)}/M`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function byokFeeLine(fee) {
|
|
191
|
-
const f = fee && typeof fee === 'object' ? fee : null;
|
|
192
|
-
if (!f) return '';
|
|
193
|
-
const inRate = Number(f.in_usd_per_1k);
|
|
194
|
-
const outRate = Number(f.out_usd_per_1k);
|
|
195
|
-
if (!Number.isFinite(inRate) || !Number.isFinite(outRate)) return '';
|
|
196
|
-
if (f.disabled === true) {
|
|
197
|
-
// The deployment switched the fee off; saying "free" is then the truth
|
|
198
|
-
// rather than a promise this client cannot keep.
|
|
199
|
-
return 'Bring your own key is relayed free on this deployment right now.';
|
|
200
|
-
}
|
|
201
|
-
// The price clause stands alone when the server published no enforcement
|
|
202
|
-
// detail — which is every server deployed before this change, and the
|
|
203
|
-
// default-off gate after it. Joined with ' — ' only when there IS a second
|
|
204
|
-
// clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
|
|
205
|
-
// is what the first live run of this line actually printed.
|
|
206
|
-
const price = `Bring your own key: relayed through AEGIS, handled at `
|
|
207
|
-
+ `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
|
|
208
|
-
+ `on top of your provider's bill`;
|
|
209
|
-
let clause = '';
|
|
210
|
-
if (f.require_account === true) {
|
|
211
|
-
clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';
|
|
212
|
-
} else {
|
|
213
|
-
const cap = Number(f.anonymous_daily_calls);
|
|
214
|
-
if (Number.isFinite(cap) && cap > 0) {
|
|
215
|
-
clause = `with ${cap} relayed turns/24h before an AEGIS account is required`;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return clause ? `${price} — ${clause}.` : `${price}.`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Campaign id for this prompt (Phase 1's fixed ids; aegis1 keeps the slug). */
|
|
222
|
-
const CAMPAIGN = 'queue_cap';
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Where a refusal sends someone. A single-quoted literal so
|
|
226
|
-
* desktop/test/renderer-attribution.test.mjs can see it, and on aegis1's
|
|
227
|
-
* `/go/` route rather than the raw /subscribe path: `/go/` owns utm_source and
|
|
228
|
-
* utm_medium and maps `s`/`c` onto them, and `c=queue_cap` is what makes this
|
|
229
|
-
* click attributable to the refusal that produced it. Appending utm_* here
|
|
230
|
-
* would be a silent zero (aegis1 go_short_link() documents it) — the route
|
|
231
|
-
* finds no `s`, files the click as 'direct', and the link still works.
|
|
232
|
-
*/
|
|
233
|
-
const UPGRADE_URL = 'https://aegiscloud.org/go/upgrade?s=desktop&c=queue_cap';
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* The BALANCE wall (Phase 6). Aegis1's paywall is not a feature tier — it is
|
|
237
|
-
* the Token Bank running dry. `balance_micros` is a prepaid EUR balance
|
|
238
|
-
* (db.py), the operator credits it by hand, and aegis1 says the consequence
|
|
239
|
-
* out loud in app.py: "Pool-key users with token bank balance skip legacy
|
|
240
|
-
* credit checks." So the truthful trigger is balance == 0, and the money path
|
|
241
|
-
* that fixes it is a TOP-UP, not a plan checkout.
|
|
242
|
-
*
|
|
243
|
-
* Campaign is `topup_bank` — the id Phase 1 already reserved for
|
|
244
|
-
* "top-up / bank / balance surfaces" (desktop/renderer/app.js). Reusing it
|
|
245
|
-
* rather than minting a sixth id keeps the documented list in the plan and the
|
|
246
|
-
* code-level list in the renderer from drifting; a new id in one place and not
|
|
247
|
-
* the other is exactly the silent-zero failure this project keeps hitting.
|
|
248
|
-
*/
|
|
249
|
-
const BALANCE_CAMPAIGN = 'topup_bank';
|
|
250
|
-
|
|
251
|
-
/** Where the balance wall sends someone: `/go/topup` (aegis1's `/go/<page>`
|
|
252
|
-
* route handles both pages), same `s=desktop` requirement as UPGRADE_URL. */
|
|
253
|
-
const BALANCE_URL = 'https://aegiscloud.org/go/topup?s=desktop&c=topup_bank';
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* HOST SEAM — the one thing that had to change for the terminal to reuse this
|
|
257
|
-
* module (Phase 7).
|
|
258
|
-
*
|
|
259
|
-
* This file is no longer desktop-only: `cli/src/autonomous.js` gates the SAME
|
|
260
|
-
* fan-out through the SAME decision, because the paid multiplier must not cost
|
|
261
|
-
* money in one host and be free in another. But the two hosts are not the same
|
|
262
|
-
* channel: a click from the terminal is a `cli` conversion and a click from the
|
|
263
|
-
* GUI is a `desktop` one, and aegis1 files whatever `s` it is given.
|
|
264
|
-
*
|
|
265
|
-
* The URLs above are literals (`s=desktop`), so a CLI refusal printing one
|
|
266
|
-
* would file every terminal click as a desktop one — silently, in the worst
|
|
267
|
-
* available way, since the link still works and the only thing it destroys is
|
|
268
|
-
* the number. That is the same failure `/go/` exists to prevent, so it gets the
|
|
269
|
-
* same treatment: the channel is a parameter, and `host` (NOT `source`, which
|
|
270
|
-
* already means "where this verdict came from" in the snapshot) is what
|
|
271
|
-
* selects it.
|
|
272
|
-
*
|
|
273
|
-
* `SIGNUP_SOURCES` mirrors aegis1's accepted set. An unknown host is not sent:
|
|
274
|
-
* it falls back to the desktop default rather than minting a source the server
|
|
275
|
-
* will file as 'direct' — a wrong datapoint is worse than a missing one.
|
|
276
|
-
*/
|
|
277
|
-
const SIGNUP_SOURCES = Object.freeze(['desktop', 'cli', 'mcp']);
|
|
278
|
-
const DEFAULT_HOST = 'desktop';
|
|
279
|
-
|
|
280
|
-
/** aegis1's `/go/<page>` short form, for a DIFFERENT channel than the literals
|
|
281
|
-
* above. `page` is `upgrade` or `topup`; the wire names (utm_*) are never sent
|
|
282
|
-
* here — `/go/` reads `s`/`c` and re-emits them itself (signup.js documents the
|
|
283
|
-
* measured difference). */
|
|
284
|
-
function goUrl(page, campaign, host) {
|
|
285
|
-
const slug = String(host == null ? '' : host).trim().toLowerCase();
|
|
286
|
-
const s = SIGNUP_SOURCES.includes(slug) ? slug : DEFAULT_HOST;
|
|
287
|
-
return `https://aegiscloud.org/go/${String(page).trim().toLowerCase()}?s=${s}&c=${campaign}`;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** The campaign-tagged prompt URL for one host: the same page and campaign as
|
|
291
|
-
* the literals, filed under `host`'s channel. */
|
|
292
|
-
function promptUrlFor(host) {
|
|
293
|
-
return {
|
|
294
|
-
upgrade: goUrl('upgrade', CAMPAIGN, host),
|
|
295
|
-
balance: goUrl('topup', BALANCE_CAMPAIGN, host),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** How long a resolved snapshot is trusted before the next probe. The probe is
|
|
300
|
-
* one small POST; a minute is short enough that a fresh purchase unlocks the
|
|
301
|
-
* card on the next refresh and long enough that a drain never probes per task. */
|
|
302
|
-
const DEFAULT_TTL_MS = 60 * 1000;
|
|
303
|
-
|
|
304
|
-
/** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does.
|
|
305
|
-
*
|
|
306
|
-
* Aegis1 checks its live `TIERS` BEFORE the alias table (`normalize_plan`), so
|
|
307
|
-
* an alias can never shadow a tier it also names — and `unlimited` is a live
|
|
308
|
-
* tier. It is therefore returned as itself and never collapsed onto `pro`: the
|
|
309
|
-
* two grant different entitlements server-side (the monthly Unlimited
|
|
310
|
-
* allowance and the BYOK fee waiver are `is_unlimited()`-gated), and telling a
|
|
311
|
-
* €9 customer they own the retired €2 plan is the mistake commit 6443a60
|
|
312
|
-
* exists to record.
|
|
313
|
-
*/
|
|
314
|
-
function normalizePlan(plan) {
|
|
315
|
-
const raw = String(plan == null ? '' : plan).trim().toLowerCase();
|
|
316
|
-
if (!raw) return 'free';
|
|
317
|
-
if (PAID_PLANS.includes(raw)) return raw;
|
|
318
|
-
const aliased = LEGACY_PLAN_ALIASES[raw];
|
|
319
|
-
return aliased || 'free';
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** The sentence a user types to route a task to the drain — quoted verbatim in
|
|
323
|
-
* every refusal and in the CLI's promotion block, so the two hosts cannot
|
|
324
|
-
* drift into two different instructions. */
|
|
325
|
-
const ASK_EXAMPLE = 'Do X autonomously';
|
|
326
|
-
|
|
327
|
-
/** True only for a tier aegis1 itself would call paid (`pro` or `unlimited`). */
|
|
328
|
-
function isPaidPlan(plan) {
|
|
329
|
-
return normalizePlan(plan) !== 'free';
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* The refusal, as the sentence a user reads. Names the Pro value (the cloud
|
|
334
|
-
* drain surviving app close), not just the ceiling, and carries the campaign
|
|
335
|
-
* id in the link it is paired with (see `gateFanout`'s `upgradeUrl`).
|
|
336
|
-
*/
|
|
337
|
-
function fanoutRefusal() {
|
|
338
|
-
return (
|
|
339
|
-
`Fan-out needs Pro: ${PRO_VALUE}. ` +
|
|
340
|
-
// The ask, in the message, as the sentence to write — the CLI prints the
|
|
341
|
-
// same two lines (`cli/src/autonomous.js`), so a user who meets this wall
|
|
342
|
-
// in either host reads one instruction, not two vocabularies. `autonomously`
|
|
343
|
-
// is the word the queue actually routes on (queue.js stores the inverse as
|
|
344
|
-
// `singlePass`), which is why the example quotes it verbatim instead of
|
|
345
|
-
// naming the feature.
|
|
346
|
-
`Write it in the message: "${ASK_EXAMPLE}". ` +
|
|
347
|
-
'Unlock: Pro, or any Token Bank balance. ' +
|
|
348
|
-
'Free keeps the local drain on your own key — one worker per task, while this window is open.'
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* The refusal when the account's BANK is empty rather than its plan being
|
|
354
|
-
* free. Still exactly one reason to pay (PRO_VALUE — Phase 3's rule: a refusal
|
|
355
|
-
* that pitches something else is a second, competing offer at the moment the
|
|
356
|
-
* first is declined), and it names the fix that actually exists: top the bank
|
|
357
|
-
* up. "Your AEGIS balance is empty." is already aegis1's own string for this
|
|
358
|
-
* state (translations.py), so the two surfaces say the same sentence.
|
|
359
|
-
*/
|
|
360
|
-
function balanceRefusal() {
|
|
361
|
-
return (
|
|
362
|
-
'Your AEGIS balance is empty — ' +
|
|
363
|
-
`${PRO_VALUE}. ` +
|
|
364
|
-
// Same ask as the plan wall, but the unlock line follows THIS wall: the
|
|
365
|
-
// bank is empty, so topping it up is the action that clears it. Naming only
|
|
366
|
-
// "upgrade to Pro" would send a user who already owns Pro (and simply spent
|
|
367
|
-
// the bank) to a checkout for a plan they have — the exact confusion this
|
|
368
|
-
// module exists to remove. Kept symmetric with `fanoutRefusal` otherwise.
|
|
369
|
-
`Write it in the message: "${ASK_EXAMPLE}". ` +
|
|
370
|
-
'Unlock: top up the Token Bank, or upgrade to Pro. ' +
|
|
371
|
-
'Local drains stay free: one worker per task, on your own key, while this window is open.'
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* The account's bank, from whatever shape the probe answered.
|
|
377
|
-
*
|
|
378
|
-
* aegis1's `/api/token-bank/balance` returns `balance_eur` (micros stay
|
|
379
|
-
* internal — its own docstring says so), and that value is ROUNDED to cents
|
|
380
|
-
* before it is sent. 0.004 EUR therefore arrives as 0 and reads as empty;
|
|
381
|
-
* that is the server's rounding, not this module's, and it is the safe
|
|
382
|
-
* direction for a wall (it charges nothing, it only withholds the multiplier).
|
|
383
|
-
*
|
|
384
|
-
* `balanceKnown: false` is the important state: a probe that did not report a
|
|
385
|
-
* balance has NOT proven the bank is funded, so it must not unlock anything.
|
|
386
|
-
* Fail closed, same as the plan signal.
|
|
387
|
-
*
|
|
388
|
-
* The returned keys are `balanceKnown` / `balanceMicros` — deliberately the
|
|
389
|
-
* SAME names `fromProbe` destructures, because these are spread straight into
|
|
390
|
-
* `classifyProbe`'s result (`...bal`). They were once `known` / `micros`, and
|
|
391
|
-
* the mismatch made every funded bank read as `undefined` → not banked, so the
|
|
392
|
-
* operator's lever silently did nothing. Keep these names in lockstep with the
|
|
393
|
-
* `...bal` spreads below; the test asserts the exact shape.
|
|
394
|
-
*/
|
|
395
|
-
function balanceOf(body) {
|
|
396
|
-
if (!body || typeof body !== 'object') return { balanceKnown: false, balanceMicros: null };
|
|
397
|
-
let raw = null;
|
|
398
|
-
if (body.balance_micros != null) raw = Number(body.balance_micros);
|
|
399
|
-
else if (body.balance_eur != null) raw = Number(body.balance_eur) * 1_000_000;
|
|
400
|
-
if (raw == null || !Number.isFinite(raw)) return { balanceKnown: false, balanceMicros: null };
|
|
401
|
-
return { balanceKnown: true, balanceMicros: Math.trunc(raw) };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Coerce a probe's answer into the three states this module cares about.
|
|
406
|
-
*
|
|
407
|
-
* The probe resolves aegis1's `{ valid, plan }` for a good key. A missing key
|
|
408
|
-
* comes back as HTTP 400 `api_key required` (or resolves `valid: false`), which
|
|
409
|
-
* is not a failure — it is the definitive "this install is free". Everything
|
|
410
|
-
* else that did not produce a plan is `unreachable`, which also lands on the
|
|
411
|
-
* free boundary but is a different FACT and must stay distinguishable in the
|
|
412
|
-
* snapshot (a report that cannot tell "free" from "could not ask" is guessing).
|
|
413
|
-
*/
|
|
414
|
-
function classifyProbe(result, error) {
|
|
415
|
-
if (error) {
|
|
416
|
-
const bal = balanceOf(error && error.data);
|
|
417
|
-
const status = error.status;
|
|
418
|
-
const data = (error && error.data) || {};
|
|
419
|
-
const code = String(data.error || data.code || '');
|
|
420
|
-
const message = String((error && error.message) || '');
|
|
421
|
-
if (status === 400 || status === 401 || /api_key required|invalid api key|unauthor/i.test(`${code} ${message}`)) {
|
|
422
|
-
return { source: 'no-key', plan: 'free', degraded: false, ...bal };
|
|
423
|
-
}
|
|
424
|
-
return { source: 'unreachable', plan: 'free', degraded: true, ...bal };
|
|
425
|
-
}
|
|
426
|
-
const body = result && typeof result === 'object' ? result : {};
|
|
427
|
-
const bal = balanceOf(body);
|
|
428
|
-
if (body.valid === false) return { source: 'no-key', plan: 'free', degraded: false, ...bal };
|
|
429
|
-
// WHICH plan field. `plan_effective` is aegis1's own entitlement verdict —
|
|
430
|
-
// the same predicate its `is_pro()` gates pooled calls on, so it counts the
|
|
431
|
-
// 30-day welcome trial that never writes `users.plan` (see app.py's
|
|
432
|
-
// register_device: the trial lives in `subscriptions`). The stored `plan`
|
|
433
|
-
// column cannot express that account, so an installing user read as `free`
|
|
434
|
-
// here and only the €12 signup credit kept the gate open — spend it, or fail
|
|
435
|
-
// to receive it, and a real Unlimited account was refused the feature. The
|
|
436
|
-
// stored string is still the fallback: an older server that predates the
|
|
437
|
-
// field must keep working, and it is the same value when nothing is on trial.
|
|
438
|
-
const rawPlan = body.plan_effective != null && String(body.plan_effective).trim()
|
|
439
|
-
? body.plan_effective
|
|
440
|
-
: body.plan;
|
|
441
|
-
const stated = String(rawPlan == null ? '' : rawPlan).trim();
|
|
442
|
-
if (!stated) {
|
|
443
|
-
// A key that verified but carried no plan: aegis1's own default for such an
|
|
444
|
-
// account is `free` (the column default), and a missing field is not proof
|
|
445
|
-
// of payment — so this is the free boundary, marked as not proven.
|
|
446
|
-
return { source: 'no-plan-field', plan: 'free', degraded: true, ...bal };
|
|
447
|
-
}
|
|
448
|
-
// The server STATED a plan, so the verdict is proven — free or paid.
|
|
449
|
-
// `degraded` means "no real answer" (timeout, no plan field, no key); a stated
|
|
450
|
-
// `free` IS an answer. This was once `degraded: !known`, where `known` meant
|
|
451
|
-
// "is this a PAID plan" — so every honestly-free account was marked degraded,
|
|
452
|
-
// and every promotion path that requires `degraded === false` (the CLI's
|
|
453
|
-
// per-drain upsell, the desktop's refusal link) went silent. The gate then
|
|
454
|
-
// withheld the fan-out and never said how to pay for it: it charged nothing
|
|
455
|
-
// and converted nobody. Unknown plan strings still normalize to `free`, so the
|
|
456
|
-
// money direction stays fail-closed.
|
|
457
|
-
return { source: 'server-plan', plan: normalizePlan(stated), degraded: false, ...bal };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* The gate. `probe` is injected (main.js passes the shared client's
|
|
462
|
-
* verifyApiKey; tests pass a stub), so this module never touches the network
|
|
463
|
-
* itself and every branch below is reachable in a unit test.
|
|
464
|
-
*/
|
|
465
|
-
function createEntitlement({
|
|
466
|
-
probe,
|
|
467
|
-
now = Date.now,
|
|
468
|
-
ttlMs = DEFAULT_TTL_MS,
|
|
469
|
-
// The CHANNEL the prompts in this instance link to. 'desktop' by default so
|
|
470
|
-
// every existing caller is unchanged; `cli/src/entitlement.js` passes 'cli'.
|
|
471
|
-
host = DEFAULT_HOST,
|
|
472
|
-
} = {}) {
|
|
473
|
-
const urls = promptUrlFor(host);
|
|
474
|
-
// Start at the free boundary, not at Pro. Until a probe has answered, nothing
|
|
475
|
-
// has been paid for — and `current()` (the sync read the drain clamp uses) is
|
|
476
|
-
// never allowed to be optimistic.
|
|
477
|
-
let snapshot = Object.freeze({
|
|
478
|
-
pro: false,
|
|
479
|
-
entitled: false,
|
|
480
|
-
balanceKnown: false,
|
|
481
|
-
balanceMicros: null,
|
|
482
|
-
balanceZero: false,
|
|
483
|
-
wall: 'plan',
|
|
484
|
-
plan: 'free',
|
|
485
|
-
tier: null,
|
|
486
|
-
tierName: null,
|
|
487
|
-
planRaw: null,
|
|
488
|
-
maxWorkers: FREE_MAX_WORKERS,
|
|
489
|
-
source: 'unresolved',
|
|
490
|
-
degraded: true,
|
|
491
|
-
checkedAt: null,
|
|
492
|
-
campaign: CAMPAIGN,
|
|
493
|
-
host: SIGNUP_SOURCES.includes(String(host).toLowerCase()) ? String(host).toLowerCase() : DEFAULT_HOST,
|
|
494
|
-
upgradeUrl: urls.upgrade,
|
|
495
|
-
refusal: null,
|
|
496
|
-
});
|
|
497
|
-
let inflight = null;
|
|
498
|
-
|
|
499
|
-
function fromProbe({ source, plan, degraded, planRaw, balanceKnown = false, balanceMicros = null }) {
|
|
500
|
-
// The tier itself, not a paid/not-paid bit: `unlimited` and `pro` are both
|
|
501
|
-
// paid and must stay distinguishable, because the surfaces that name the
|
|
502
|
-
// account's plan (the desktop's queue boundary, the CLI's `/billing`) name
|
|
503
|
-
// the one the server granted. `pro` below keeps its original meaning — "the
|
|
504
|
-
// PLAN is paid for", as opposed to a free plan carried by a funded bank.
|
|
505
|
-
const tier = normalizePlan(plan);
|
|
506
|
-
const pro = tier !== 'free';
|
|
507
|
-
const banked = balanceKnown && Number.isFinite(balanceMicros) && balanceMicros > 0;
|
|
508
|
-
// TWO independent ways to be paid for: a paid plan, or a funded bank. The
|
|
509
|
-
// operator controls the second by hand (aegis1 admin `add_funds`), which is
|
|
510
|
-
// the lever this wall is built around — nothing here grants, times out or
|
|
511
|
-
// auto-charges; it only reads the number the operator set. The FIRST way is
|
|
512
|
-
// the one a paying subscriber holds, and it does not weaken when their bank
|
|
513
|
-
// runs dry: the balance is overage, the subscription is the entitlement.
|
|
514
|
-
const entitled = pro || banked;
|
|
515
|
-
// The bank was READABLE and EMPTY — the operator's own signal that this
|
|
516
|
-
// account is now paying, not the absence of a plan.
|
|
517
|
-
const balanceZero = balanceKnown && !banked;
|
|
518
|
-
// Which wall to print. Entitled accounts get none. An account whose bank we
|
|
519
|
-
// read and found empty gets the TOP-UP wall, because topping up is the
|
|
520
|
-
// action that actually clears it. Everything else — unknown balance, no
|
|
521
|
-
// key, unreachable probe — gets the PLAN wall: we have not proven the bank
|
|
522
|
-
// is the problem, and a top-up prompt we cannot justify is a dark pattern.
|
|
523
|
-
const wall = entitled ? null : (balanceZero ? 'balance' : 'plan');
|
|
524
|
-
return Object.freeze({
|
|
525
|
-
pro,
|
|
526
|
-
entitled,
|
|
527
|
-
balanceKnown: Boolean(balanceKnown),
|
|
528
|
-
balanceMicros: balanceKnown ? balanceMicros : null,
|
|
529
|
-
balanceZero,
|
|
530
|
-
wall,
|
|
531
|
-
// The tier a paid PLAN grants, else `free`. A funded bank on a free plan
|
|
532
|
-
// is still `free` here — the bank is not a tier, and naming one would put
|
|
533
|
-
// a plan on the account the server never granted.
|
|
534
|
-
plan: pro ? tier : 'free',
|
|
535
|
-
tier: pro ? tier : null,
|
|
536
|
-
tierName: pro ? (TIER_NAMES[tier] || null) : null,
|
|
537
|
-
planRaw: planRaw == null ? null : String(planRaw),
|
|
538
|
-
// A paid session has no ceiling of its own here: main.js still clamps the
|
|
539
|
-
// renderer's number to QUEUE_MAX_WORKERS, which stays the only upper bound.
|
|
540
|
-
maxWorkers: entitled ? null : FREE_MAX_WORKERS,
|
|
541
|
-
source,
|
|
542
|
-
// `degraded` means "this verdict was not proven by the server" — for a
|
|
543
|
-
// paid verdict that is impossible (a plan string or a funded bank is what
|
|
544
|
-
// proves it), so it only ever marks a free boundary we could not confirm.
|
|
545
|
-
degraded: Boolean(degraded),
|
|
546
|
-
checkedAt: now(),
|
|
547
|
-
campaign: wall === 'balance' ? BALANCE_CAMPAIGN : CAMPAIGN,
|
|
548
|
-
upgradeUrl: wall === 'balance' ? urls.balance : urls.upgrade,
|
|
549
|
-
refusal: entitled ? null : (wall === 'balance' ? balanceRefusal() : fanoutRefusal()),
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Re-probe (or return the cached verdict while it is fresh). Never throws:
|
|
555
|
-
* a probe that fails IS an answer here (`unreachable` → free boundary).
|
|
556
|
-
*/
|
|
557
|
-
async function refresh({ force = false } = {}) {
|
|
558
|
-
const fresh = snapshot.checkedAt != null && now() - snapshot.checkedAt < ttlMs;
|
|
559
|
-
if (fresh && !force) return snapshot;
|
|
560
|
-
if (inflight) return inflight;
|
|
561
|
-
inflight = (async () => {
|
|
562
|
-
if (typeof probe !== 'function') {
|
|
563
|
-
snapshot = fromProbe({ source: 'no-probe', plan: 'free', degraded: true });
|
|
564
|
-
return snapshot;
|
|
565
|
-
}
|
|
566
|
-
try {
|
|
567
|
-
const answer = await probe();
|
|
568
|
-
const c = classifyProbe(answer, null);
|
|
569
|
-
snapshot = fromProbe({ ...c, planRaw: answer && answer.plan });
|
|
570
|
-
} catch (err) {
|
|
571
|
-
const c = classifyProbe(null, err);
|
|
572
|
-
snapshot = fromProbe(c);
|
|
573
|
-
}
|
|
574
|
-
return snapshot;
|
|
575
|
-
})();
|
|
576
|
-
try {
|
|
577
|
-
return await inflight;
|
|
578
|
-
} finally {
|
|
579
|
-
inflight = null;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** The cached verdict, synchronously. Never probes — this is the read the
|
|
584
|
-
* drain clamp makes on the hot path, and an optimistic answer would be a
|
|
585
|
-
* free fan-out. */
|
|
586
|
-
function current() {
|
|
587
|
-
return snapshot;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* THE GATE. Decide what a renderer's enqueue payload may do.
|
|
592
|
-
*
|
|
593
|
-
* `workers` is the number the renderer asked for (`null` = "no pick"), and
|
|
594
|
-
* `fanout` is its explicit fan-me-out flag. Either one naming more than one
|
|
595
|
-
* worker is a fan-out request, and a fan-out request from a non-Pro session
|
|
596
|
-
* is REFUSED — not silently clamped, because the request came from a control
|
|
597
|
-
* that is supposed to mean something, and a silent downgrade reads as a bug
|
|
598
|
-
* while teaching the user nothing. (A fan-out already sitting in the queue
|
|
599
|
-
* file — the CLI, a hand-edited queue.jsonl — is the other case: that one is
|
|
600
|
-
* clamped to a single pass at drain time, see main.js's `canFanOut`.)
|
|
601
|
-
*/
|
|
602
|
-
function gateFanout({ workers, fanout } = {}) {
|
|
603
|
-
const requested = Number.isInteger(workers) ? workers : null;
|
|
604
|
-
const wantsFanout = fanout === true || (requested != null && requested > FREE_MAX_WORKERS);
|
|
605
|
-
if (!wantsFanout) return { ok: true, workers: requested, fanout: Boolean(fanout) };
|
|
606
|
-
if (snapshot.entitled) return { ok: true, workers: requested, fanout: true };
|
|
607
|
-
return {
|
|
608
|
-
ok: false,
|
|
609
|
-
// The code names WHICH wall, because the two have different fixes: an
|
|
610
|
-
// empty bank is topped up, a free plan is upgraded. A caller that showed
|
|
611
|
-
// the same message for both would be sending half its users to the wrong
|
|
612
|
-
// checkout, which is the exact confusion this phase exists to remove.
|
|
613
|
-
code: snapshot.wall === 'balance' ? 'fanout_requires_balance' : 'fanout_requires_pro',
|
|
614
|
-
reason: snapshot.refusal || fanoutRefusal(),
|
|
615
|
-
campaign: snapshot.campaign,
|
|
616
|
-
upgradeUrl: snapshot.upgradeUrl,
|
|
617
|
-
wall: snapshot.wall,
|
|
618
|
-
pro: false,
|
|
619
|
-
entitled: false,
|
|
620
|
-
maxWorkers: FREE_MAX_WORKERS,
|
|
621
|
-
entitlement: snapshot,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
return { refresh, current, gateFanout };
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
module.exports = {
|
|
629
|
-
createEntitlement,
|
|
630
|
-
normalizePlan,
|
|
631
|
-
isPaidPlan,
|
|
632
|
-
tierName,
|
|
633
|
-
TIER_NAMES,
|
|
634
|
-
classifyProbe,
|
|
635
|
-
fanoutRefusal,
|
|
636
|
-
balanceRefusal,
|
|
637
|
-
ASK_EXAMPLE,
|
|
638
|
-
balanceOf,
|
|
639
|
-
FREE_MAX_WORKERS,
|
|
640
|
-
PAID_PLANS,
|
|
641
|
-
LEGACY_PLAN_ALIASES,
|
|
642
|
-
CAMPAIGN,
|
|
643
|
-
UPGRADE_URL,
|
|
644
|
-
BALANCE_CAMPAIGN,
|
|
645
|
-
BALANCE_URL,
|
|
646
|
-
PRO_VALUE,
|
|
647
|
-
FREE_HONESTY,
|
|
648
|
-
BYOK_FEE_NOTE,
|
|
649
|
-
byokFeeLine,
|
|
650
|
-
DEFAULT_TTL_MS,
|
|
651
|
-
// Phase 7: the host seam + the builder the CLI binds itself to.
|
|
652
|
-
SIGNUP_SOURCES,
|
|
653
|
-
DEFAULT_HOST,
|
|
654
|
-
goUrl,
|
|
655
|
-
promptUrlFor,
|
|
656
|
-
};
|
|
3
|
+
"use strict";const FREE_MAX_WORKERS=1,PAID_PLANS=Object.freeze(["pro","unlimited"]),TIER_NAMES=Object.freeze({pro:"Pro",unlimited:"Unlimited"});function tierName(e){return TIER_NAMES[normalizePlan(e)]||null}const LEGACY_PLAN_ALIASES=Object.freeze({cloud:"pro",memory:"pro",s:"pro",heavy:"pro",team:"pro",starter:"free"}),PRO_VALUE="Pro puts the drain in the cloud, so the job keeps running after you close the laptop",FREE_HONESTY="Everything on your machine is free, permanently. The cloud drain is what you pay for.",BYOK_FEE_NOTE="Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider's own bill.";function ratePerMillion(e){return`$${(1e3*Number(e)).toFixed(2)}/M`}function byokFeeLine(e){const n=e&&"object"==typeof e?e:null;if(!n)return"";const r=Number(n.in_usd_per_1k),a=Number(n.out_usd_per_1k);if(!Number.isFinite(r)||!Number.isFinite(a))return"";if(!0===n.disabled)return"Bring your own key is relayed free on this deployment right now.";const o=`Bring your own key: relayed through AEGIS, handled at ${ratePerMillion(r)} in / ${ratePerMillion(a)} out, on top of your provider's bill — no fee at all on Unlimited, and no fee at all during the free 1-month Unlimited trial`;let l="";if(!0===n.require_account)l="and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed";else{const e=Number(n.anonymous_daily_calls);Number.isFinite(e)&&e>0&&(l=`with ${e} relayed turns/24h before an AEGIS account is required`)}return l?`${o} — ${l}.`:`${o}.`}const CAMPAIGN="queue_cap",UPGRADE_URL="https://aegiscloud.org/go/upgrade?s=desktop&c=queue_cap",BALANCE_CAMPAIGN="topup_bank",BALANCE_URL="https://aegiscloud.org/go/topup?s=desktop&c=topup_bank",SIGNUP_SOURCES=Object.freeze(["desktop","cli","mcp"]),DEFAULT_HOST="desktop";function goUrl(e,n,r){const a=String(null==r?"":r).trim().toLowerCase(),o=SIGNUP_SOURCES.includes(a)?a:DEFAULT_HOST;return`https://aegiscloud.org/go/${String(e).trim().toLowerCase()}?s=${o}&c=${n}`}function promptUrlFor(e){return{upgrade:goUrl("upgrade",CAMPAIGN,e),balance:goUrl("topup",BALANCE_CAMPAIGN,e)}}const DEFAULT_TTL_MS=6e4;function normalizePlan(e){const n=String(null==e?"":e).trim().toLowerCase();return n?PAID_PLANS.includes(n)?n:LEGACY_PLAN_ALIASES[n]||"free":"free"}const ASK_EXAMPLE="Do X autonomously";function isPaidPlan(e){return"free"!==normalizePlan(e)}function fanoutRefusal(){return`Fan-out needs Pro: ${PRO_VALUE}. Write it in the message: "${ASK_EXAMPLE}". Unlock: Pro, or any Token Bank balance. Free keeps the local drain on your own key — one worker per task, while this window is open.`}function balanceRefusal(){return`Your AEGIS balance is empty — ${PRO_VALUE}. Write it in the message: "${ASK_EXAMPLE}". Unlock: top up the Token Bank, or upgrade to Pro. Local drains stay free: one worker per task, on your own key, while this window is open.`}function balanceOf(e){if(!e||"object"!=typeof e)return{balanceKnown:!1,balanceMicros:null};let n=null;return null!=e.balance_micros?n=Number(e.balance_micros):null!=e.balance_eur&&(n=1e6*Number(e.balance_eur)),null!=n&&Number.isFinite(n)?{balanceKnown:!0,balanceMicros:Math.trunc(n)}:{balanceKnown:!1,balanceMicros:null}}function classifyProbe(e,n){if(n){const e=balanceOf(n&&n.data),r=n.status,a=n&&n.data||{},o=String(a.error||a.code||""),l=String(n&&n.message||"");return 400===r||401===r||/api_key required|invalid api key|unauthor/i.test(`${o} ${l}`)?{source:"no-key",plan:"free",degraded:!1,...e}:{source:"unreachable",plan:"free",degraded:!0,...e}}const r=e&&"object"==typeof e?e:{},a=balanceOf(r);if(!1===r.valid)return{source:"no-key",plan:"free",degraded:!1,...a};const o=null!=r.plan_effective&&String(r.plan_effective).trim()?r.plan_effective:r.plan,l=String(null==o?"":o).trim();return l?{source:"server-plan",plan:normalizePlan(l),degraded:!1,...a}:{source:"no-plan-field",plan:"free",degraded:!0,...a}}function createEntitlement({probe:e,now:n=Date.now,ttlMs:r=6e4,host:a=DEFAULT_HOST}={}){const o=promptUrlFor(a);let l=Object.freeze({pro:!1,entitled:!1,balanceKnown:!1,balanceMicros:null,balanceZero:!1,wall:"plan",plan:"free",tier:null,tierName:null,planRaw:null,maxWorkers:1,source:"unresolved",degraded:!0,checkedAt:null,campaign:CAMPAIGN,host:SIGNUP_SOURCES.includes(String(a).toLowerCase())?String(a).toLowerCase():DEFAULT_HOST,upgradeUrl:o.upgrade,refusal:null}),t=null;function i({source:e,plan:r,degraded:a,planRaw:l,balanceKnown:t=!1,balanceMicros:i=null}){const u=normalizePlan(r),c="free"!==u,s=t&&Number.isFinite(i)&&i>0,d=c||s,f=t&&!s,p=d?null:f?"balance":"plan";return Object.freeze({pro:c,entitled:d,balanceKnown:Boolean(t),balanceMicros:t?i:null,balanceZero:f,wall:p,plan:c?u:"free",tier:c?u:null,tierName:c&&TIER_NAMES[u]||null,planRaw:null==l?null:String(l),maxWorkers:d?null:1,source:e,degraded:Boolean(a),checkedAt:n(),campaign:"balance"===p?BALANCE_CAMPAIGN:CAMPAIGN,upgradeUrl:"balance"===p?o.balance:o.upgrade,refusal:d?null:"balance"===p?balanceRefusal():fanoutRefusal()})}return{refresh:async function({force:a=!1}={}){if(null!=l.checkedAt&&n()-l.checkedAt<r&&!a)return l;if(t)return t;t=(async()=>{if("function"!=typeof e)return l=i({source:"no-probe",plan:"free",degraded:!0}),l;try{const n=await e(),r=classifyProbe(n,null);l=i({...r,planRaw:n&&n.plan})}catch(e){const n=classifyProbe(null,e);l=i(n)}return l})();try{return await t}finally{t=null}},current:function(){return l},gateFanout:function({workers:e,fanout:n}={}){const r=Number.isInteger(e)?e:null;return!0===n||null!=r&&r>1?l.entitled?{ok:!0,workers:r,fanout:!0}:{ok:!1,code:"balance"===l.wall?"fanout_requires_balance":"fanout_requires_pro",reason:l.refusal||fanoutRefusal(),campaign:l.campaign,upgradeUrl:l.upgradeUrl,wall:l.wall,pro:!1,entitled:!1,maxWorkers:1,entitlement:l}:{ok:!0,workers:r,fanout:Boolean(n)}}}}module.exports={createEntitlement:createEntitlement,normalizePlan:normalizePlan,isPaidPlan:isPaidPlan,tierName:tierName,TIER_NAMES:TIER_NAMES,classifyProbe:classifyProbe,fanoutRefusal:fanoutRefusal,balanceRefusal:balanceRefusal,ASK_EXAMPLE:ASK_EXAMPLE,balanceOf:balanceOf,FREE_MAX_WORKERS:1,PAID_PLANS:PAID_PLANS,LEGACY_PLAN_ALIASES:LEGACY_PLAN_ALIASES,CAMPAIGN:CAMPAIGN,UPGRADE_URL:UPGRADE_URL,BALANCE_CAMPAIGN:BALANCE_CAMPAIGN,BALANCE_URL:BALANCE_URL,PRO_VALUE:PRO_VALUE,FREE_HONESTY:FREE_HONESTY,BYOK_FEE_NOTE:BYOK_FEE_NOTE,byokFeeLine:byokFeeLine,DEFAULT_TTL_MS:6e4,SIGNUP_SOURCES:SIGNUP_SOURCES,DEFAULT_HOST:DEFAULT_HOST,goUrl:goUrl,promptUrlFor:promptUrlFor};
|