@genesislcap/ai-assistant 15.23.3 → 15.24.0

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.
Files changed (46) hide show
  1. package/dist/ai-assistant.api.json +419 -50
  2. package/dist/ai-assistant.d.ts +217 -683
  3. package/dist/chat-driver.cjs.map +1 -1
  4. package/dist/chat-driver.mjs.map +1 -1
  5. package/dist/custom-elements.json +310 -609
  6. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +16 -0
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  8. package/dist/dts/components/settings-modal/index.d.ts +1 -0
  9. package/dist/dts/components/settings-modal/index.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.d.ts +215 -0
  11. package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -0
  12. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  13. package/dist/dts/main/attachment-controller.d.ts +66 -0
  14. package/dist/dts/main/attachment-controller.d.ts.map +1 -0
  15. package/dist/dts/main/budget-controller.d.ts +368 -0
  16. package/dist/dts/main/budget-controller.d.ts.map +1 -0
  17. package/dist/dts/main/composer-menu-controller.d.ts +113 -0
  18. package/dist/dts/main/composer-menu-controller.d.ts.map +1 -0
  19. package/dist/dts/main/composer-resize-controller.d.ts +80 -0
  20. package/dist/dts/main/composer-resize-controller.d.ts.map +1 -0
  21. package/dist/dts/main/context-gate-controller.d.ts +197 -0
  22. package/dist/dts/main/context-gate-controller.d.ts.map +1 -0
  23. package/dist/dts/main/cost-ledger-controller.d.ts +228 -0
  24. package/dist/dts/main/cost-ledger-controller.d.ts.map +1 -0
  25. package/dist/dts/main/main.d.ts +201 -722
  26. package/dist/dts/main/main.d.ts.map +1 -1
  27. package/dist/dts/main/main.types.d.ts +25 -0
  28. package/dist/dts/main/main.types.d.ts.map +1 -1
  29. package/dist/dts/main/meta-snapshot.d.ts +50 -0
  30. package/dist/dts/main/meta-snapshot.d.ts.map +1 -0
  31. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  32. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +18 -0
  33. package/dist/esm/components/settings-modal/index.js +1 -0
  34. package/dist/esm/components/settings-modal/settings-modal.js +436 -0
  35. package/dist/esm/components/settings-modal/settings-modal.template.js +15 -4
  36. package/dist/esm/main/attachment-controller.js +254 -0
  37. package/dist/esm/main/budget-controller.js +745 -0
  38. package/dist/esm/main/composer-menu-controller.js +234 -0
  39. package/dist/esm/main/composer-resize-controller.js +119 -0
  40. package/dist/esm/main/context-gate-controller.js +289 -0
  41. package/dist/esm/main/cost-ledger-controller.js +498 -0
  42. package/dist/esm/main/main.js +501 -2268
  43. package/dist/esm/main/meta-snapshot.js +108 -0
  44. package/dist/esm/state/persistence/session-persister.js +5 -4
  45. package/docs/migration-GENC-1580.md +209 -0
  46. package/package.json +16 -16
@@ -0,0 +1,745 @@
1
+ import { __decorate } from "tslib";
2
+ import { BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
3
+ import { observable, volatile } from '@genesislcap/web-core';
4
+ import { formatUsd } from '../utils/format-usd';
5
+ import { logger } from '../utils/logger';
6
+ /**
7
+ * Closing sentence for a wall the user cannot route around. Singular/plural
8
+ * variants because the banner names one vendor or several.
9
+ */
10
+ const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
11
+ const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
12
+ /**
13
+ * Banner copy built from the figures a 402 actually reported, or `undefined`
14
+ * when the proxy sent none.
15
+ *
16
+ * `undefined` is the meaningful return, not a fallback: paired with the
17
+ * slice's "a re-latch without a reason keeps the existing explanation" rule, it
18
+ * makes a figureless driver latch leave a host-supplied reason intact rather
19
+ * than blanking it back to the default at the exact moment the wall is hit.
20
+ *
21
+ * Exported for the unit test that pins the formatting; not part of the element
22
+ * API.
23
+ *
24
+ * @internal
25
+ */
26
+ export function formatBlockedReason(
27
+ // `Partial`, so the store's `VendorBudgetFigures` satisfies it as well as a live
28
+ // 402's payload: the banner composes from stored figures and the latch from the
29
+ // fresh ones, and both carry the same dollars. One formatter for both means a
30
+ // remembered wall and a just-hit one cannot word themselves differently.
31
+ budget, vendor) {
32
+ if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
33
+ return undefined;
34
+ const money = (v) => (v == null ? 'an unknown amount' : formatUsd(v));
35
+ const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
36
+ // No "you have $0.00 left" clause here, deliberately: "limit is reached" already
37
+ // says the allowance is gone, and a remainder clamped at zero CONTRADICTS the
38
+ // figures beside it — spend overshoots the cap on the last allowed call, so the
39
+ // words would read "$0.00 left" next to numbers showing $12.34 past it.
40
+ // A per-vendor statement carries NO action clause, because the right action is
41
+ // not knowable at latch time: whether "switch vendor" or "contact your
42
+ // administrator" is the honest advice depends on whether any OTHER vendor still
43
+ // has headroom, which only the composing getter can see. The vendor-agnostic
44
+ // form keeps its historical trailing sentence — nothing composes onto it.
45
+ if (!vendor)
46
+ return `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
47
+ return `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`;
48
+ }
49
+ /**
50
+ * Human-readable name for a vendor, falling back to its raw type.
51
+ *
52
+ * `'none'` needs no special case: it is the "no provider configured" sentinel,
53
+ * it is filtered out of {@link BudgetController.reachableVendors}, the latch
54
+ * refuses to attribute a wall to it, and {@link BudgetController.setVendorBlocked}
55
+ * rejects it at the boundary — so nothing can put it in front of this function.
56
+ * The `?? vendor` fallback is for a vendor type added to `AIProviderType` without
57
+ * a label, which would print the type rather than nothing.
58
+ */
59
+ function vendorDisplayName(vendor) {
60
+ var _a;
61
+ return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
62
+ }
63
+ /**
64
+ * `a`, `a and b`, `a, b and c`.
65
+ *
66
+ * The general joiner is kept rather than hardcoded to a pair because the list
67
+ * is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
68
+ * the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
69
+ * 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
70
+ * not require an edit here. Its upper bound is not an assertion in a comment — it is
71
+ * the key count of `VENDOR_LABELS`, since every element of every list passed here
72
+ * comes from `blockedVendors` or `reachableVendors`, both of which hold
73
+ * `AIProviderType` values, and the type has five members of which `'none'` is
74
+ * excluded from both.
75
+ */
76
+ function formatVendorList(vendors) {
77
+ return joinList(vendors.map(vendorDisplayName));
78
+ }
79
+ /**
80
+ * `"A"`, `"A and B"`, `"A, B and C"` — over already-rendered labels, so a caller
81
+ * can annotate each name (the multi-vendor banner appends each vendor's figures)
82
+ * without duplicating the joining rules.
83
+ */
84
+ function joinList(names) {
85
+ var _a;
86
+ if (names.length <= 1)
87
+ return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
88
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
89
+ }
90
+ /**
91
+ * Whole-number percentage of a budget consumed, for the meter (GENC-1464).
92
+ *
93
+ * Deliberately unclamped — spend can genuinely overshoot the cap (the last
94
+ * allowed call may cross it), and the meter's TEXT shows the truth; only the
95
+ * bar clamps, at the render site. The one special case is a `$0` cap, which the
96
+ * platform treats as "no spend allowed" (not "unlimited"): it has no headroom
97
+ * by definition, so the meter reads full rather than dividing by zero.
98
+ *
99
+ * Exported for the unit test that pins the arithmetic; not element API.
100
+ *
101
+ * @internal
102
+ */
103
+ export function budgetPercentOf(figures) {
104
+ if (figures.budgetUsd <= 0)
105
+ return 100;
106
+ return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
107
+ }
108
+ /**
109
+ * The AI-spend budget: which vendors are walled, whether that locks the composer,
110
+ * the banner copy that explains it, and the settings-modal meter (GENC-1464).
111
+ *
112
+ * Two invariants run through the whole file:
113
+ *
114
+ * - **Walls latch; locking derives.** A wall is one-way per vendor and nothing in
115
+ * here clears it, because nothing the user can do refills a budget. But
116
+ * {@link BudgetController.blocked} is *derived* from "is every reachable vendor
117
+ * walled", so a host swapping its registry to a vendor with headroom recovers
118
+ * the composer without un-latching anything.
119
+ * - **The banner may only name vendors the user could actually be routed to.**
120
+ * Hence {@link BudgetController.relevantBlockedVendors}, and hence the
121
+ * provenance mark the sweep writes.
122
+ *
123
+ * @internal
124
+ */
125
+ export class BudgetController {
126
+ constructor(deps) {
127
+ this.deps = deps;
128
+ /**
129
+ * When budget figures last arrived — from the host's
130
+ * {@link BudgetController.setVendorBudget} or the wall latch's own feed — as
131
+ * epoch ms; `null` until the first feed.
132
+ *
133
+ * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
134
+ * value (an unchanged re-feed must not re-render the meter rows), but "when
135
+ * was this fetched" must move on every feed, changed figures or not — a
136
+ * refresh that confirms the same dollars is still a refresh. Only the
137
+ * toolbar label observes this, so a stamp re-renders one span, not the rows.
138
+ */
139
+ this.meterLastFedAt = null;
140
+ /**
141
+ * One warning per element for garbage fed to
142
+ * {@link BudgetController.setVendorBudget}. Once, not per call, because the
143
+ * documented feed re-runs after every turn — a host bug would otherwise repeat
144
+ * the same warning on every send for the life of the page.
145
+ *
146
+ * Not observable: nothing renders it.
147
+ */
148
+ this.warningIssued = false;
149
+ }
150
+ get slice() {
151
+ var _a;
152
+ return (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
153
+ }
154
+ /**
155
+ * Whether the assistant is blocked by a backend condition and cannot send.
156
+ *
157
+ * Derived, not stored — see the class note. Unknown reachability (statuses not
158
+ * loaded yet, or providers that report no status) fails SAFE: any wall blocks
159
+ * everything, and that needs no clause of its own, because `every` on the empty
160
+ * set is vacuously true.
161
+ */
162
+ get blocked() {
163
+ const session = this.slice;
164
+ if (!session)
165
+ return false;
166
+ if (session.blocked)
167
+ return true;
168
+ // Byte-identical to the old stored read for every host that never adopts the
169
+ // per-vendor API — which is what makes this derivation non-breaking.
170
+ if (session.blockedVendors.length === 0)
171
+ return false;
172
+ return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
173
+ }
174
+ set blocked(value) {
175
+ var _a;
176
+ (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
177
+ }
178
+ /**
179
+ * The distinct vendors the registry can currently reach, from the provider
180
+ * statuses the element loads on connect and refreshes on every
181
+ * observable-registry change.
182
+ *
183
+ * This is the answer to "which vendor would the next turn use" — a question
184
+ * that has **no** correct answer and deliberately gets no API. The provider is
185
+ * resolved per turn AND per agent: `activeProviderInput` may be an async
186
+ * function of the turn's context, an orchestrated turn picks its agent with an
187
+ * LLM `classify()` call, and sub-agents resolve their own providers. Any
188
+ * pre-turn "peek" would therefore be a guess that is wrong precisely on the
189
+ * multi-agent hosts per-vendor budgets exist for.
190
+ *
191
+ * Asking instead which vendors are REACHABLE is answerable, synchronous, and
192
+ * free — and it is enough: the composer must stay live while any reachable
193
+ * vendor has headroom, and must lock when none does. It also makes
194
+ * vendor-switch recovery a derivation rather than a mutation.
195
+ */
196
+ get reachableVendors() {
197
+ var _a;
198
+ const seen = new Set();
199
+ const registry = this.deps.providerRegistry();
200
+ for (const entry of this.deps.providerStatuses()) {
201
+ const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
202
+ if (!provider || provider === 'none')
203
+ continue;
204
+ // Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
205
+ // `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
206
+ // implement `getStatus()` only. Counting a chat-less provider here made
207
+ // every consumer wrong at once: it was headroom for the `blocked`
208
+ // derivation (so `otherVendorAvailable: false` failed to lock the
209
+ // composer and each send burned another doomed 402), and it was a switch
210
+ // target for the banner ("Switch to Chrome to keep going" toward a
211
+ // provider that cannot chat).
212
+ //
213
+ // The gate is CAPABILITY, not vendor identity, so a host's custom
214
+ // chat-capable OpenAI provider still counts — the same distinction
215
+ // `validate-providers.ts` already enforces by throwing. And it drops a
216
+ // vendor only on positive evidence: statuses normally come from
217
+ // `providerRegistry.listStatuses()`, whose `name` always resolves, but a
218
+ // host (or test) that assigns `providerStatuses` directly may have no
219
+ // matching registry entry, and an unresolvable name must not silently
220
+ // erase a vendor the status layer vouched for.
221
+ const registered = registry === null || registry === void 0 ? void 0 : registry.get(entry.name);
222
+ if (registered && typeof registered.chat !== 'function')
223
+ continue;
224
+ seen.add(provider);
225
+ }
226
+ return [...seen];
227
+ }
228
+ /** Vendors currently walled, in the order they were walled. */
229
+ get blockedVendors() {
230
+ var _a, _b;
231
+ return (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedVendors) !== null && _b !== void 0 ? _b : [];
232
+ }
233
+ /** Whether this specific vendor's budget is walled, regardless of the others. */
234
+ isVendorBlocked(vendor) {
235
+ return this.blockedVendors.includes(vendor);
236
+ }
237
+ /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
238
+ isVendorSwept(vendor) {
239
+ var _a, _b;
240
+ return ((_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
241
+ }
242
+ /**
243
+ * Wall (or release) one vendor's budget, optionally with banner copy for it.
244
+ *
245
+ * `'none'` is rejected with a warning rather than accepted: it is the "no
246
+ * provider configured" sentinel, not a vendor. Latching it would put the raw
247
+ * sentinel in the banner ("none's AI usage limit is reached") and could never be
248
+ * undone by derivation, because `'none'` never appears in
249
+ * {@link BudgetController.reachableVendors} — so `blocked` could never become
250
+ * derivable from it either.
251
+ */
252
+ setVendorBlocked(vendor, blocked, reason) {
253
+ var _a;
254
+ if (vendor === 'none') {
255
+ logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
256
+ 'ignoring. Use setBlocked() for a vendor-agnostic block.');
257
+ return;
258
+ }
259
+ (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
260
+ }
261
+ warnOnce(message) {
262
+ if (this.warningIssued)
263
+ return;
264
+ this.warningIssued = true;
265
+ logger.warn(`FoundationAiAssistant.setVendorBudget: ${message}`);
266
+ }
267
+ /**
268
+ * Feed (or clear) one vendor's AI-spend figures for the meter. Returns `false`
269
+ * — warning once, not per call — for a non-budgeted vendor or a figure that is
270
+ * not a finite number `>= 0`, and also for "no store yet", which is a lifecycle
271
+ * window rather than bad input and so is deliberately not warned about.
272
+ *
273
+ * The full host-facing contract, including the intended `GET /api/budget` feed,
274
+ * is on the element's `setVendorBudget`.
275
+ */
276
+ setVendorBudget(vendor, figures) {
277
+ if (!BUDGETED_VENDORS.includes(vendor)) {
278
+ this.warnOnce(`'${vendor}' is not a budgeted vendor — ignoring. Only the proxy-metered vendors ` +
279
+ `(${BUDGETED_VENDORS.join(', ')}) can carry budget figures.`);
280
+ return false;
281
+ }
282
+ // Nullish, not `!== null`: an untyped host's missing lookup arrives as
283
+ // `undefined`, and dereferencing it here would throw at a boundary whose
284
+ // whole contract is to return false instead. `undefined` clears, like null.
285
+ if (figures != null) {
286
+ const validUsd = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
287
+ if (!validUsd(figures.budgetUsd) || !validUsd(figures.spentUsd)) {
288
+ this.warnOnce(`invalid figures for '${vendor}' — ignoring. budgetUsd and spentUsd must both be ` +
289
+ `finite numbers >= 0 (got budgetUsd: ${figures.budgetUsd}, spentUsd: ${figures.spentUsd}).`);
290
+ return false;
291
+ }
292
+ }
293
+ const session = this.deps.sessionRef();
294
+ // No store yet (fed before the first append, or mid pop-out remount): there
295
+ // is nowhere to write, so say so rather than returning an accepted the
296
+ // caller would never retry. Not warned — this is a lifecycle window, not
297
+ // bad input, and the documented per-turn re-feed lands the figures as soon
298
+ // as the store exists.
299
+ if (!session)
300
+ return false;
301
+ session.actions.aiAssistant.setVendorBudget({
302
+ vendor: vendor,
303
+ // A fresh literal, never the caller's object — see the reducer, which
304
+ // must not adopt (and potentially freeze) state the host still owns.
305
+ figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
306
+ });
307
+ if (figures != null)
308
+ this.meterLastFedAt = Date.now();
309
+ return true;
310
+ }
311
+ /** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
312
+ get updatedLabel() {
313
+ if (this.meterLastFedAt == null)
314
+ return undefined;
315
+ return `Updated ${new Date(this.meterLastFedAt).toLocaleTimeString()}`;
316
+ }
317
+ /**
318
+ * The walled vendors the user can still be routed to — i.e.
319
+ * {@link BudgetController.blockedVendors} narrowed to the reachable set, which
320
+ * is the only set the banner may name.
321
+ *
322
+ * A wall the registry can no longer reach is not news: it cannot be hit, and
323
+ * naming it puts a vendor in front of the user that is not theirs. The concrete
324
+ * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
325
+ * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
326
+ * usage limits are reached for Anthropic and Gemini" to a user who has never
327
+ * had an Anthropic key. The spurious second name also flips the copy onto the
328
+ * plural branch, so even the closing sentence is wrong.
329
+ *
330
+ * When nothing is reachable YET (the statuses are still loading — the exact
331
+ * window the pre-flight and the cross-tab bus latch into), the fallback keeps
332
+ * every wall for LOCKING (`blocked` still derives true) but names only the
333
+ * vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
334
+ * sweep added. A refusal is a fact about a vendor this user just used; a sweep
335
+ * entry is the proxy's headroom verdict about a vendor the host may not even
336
+ * ship — naming it here reproduced the exact failure above from the other
337
+ * direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
338
+ * and flipped the copy onto the plural branch with it. Self-corrects when the
339
+ * statuses land: from then on reachability, not provenance, decides.
340
+ */
341
+ get relevantBlockedVendors() {
342
+ var _a, _b;
343
+ const walled = this.blockedVendors;
344
+ const reachable = this.reachableVendors;
345
+ if (reachable.length === 0) {
346
+ const swept = (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.sweptVendors) !== null && _b !== void 0 ? _b : [];
347
+ return walled.filter((v) => !swept.includes(v));
348
+ }
349
+ return walled.filter((v) => reachable.includes(v));
350
+ }
351
+ /**
352
+ * Whether the blocked banner has anything to say — a vendor-agnostic block, or
353
+ * a partial wall that the NEXT send would actually hit.
354
+ *
355
+ * Partial exhaustion is announced only while the ACTIVE vendor (the registry
356
+ * default the next send resolves to) is among the walled ones. A user who has
357
+ * switched to a vendor with headroom has already acted on the advice —
358
+ * keeping the banner up past that point nagged about a vendor they just left
359
+ * (GENC-1464 tester feedback), while switching BACK to the walled vendor
360
+ * brings the banner straight back. Two conservative fallbacks keep it
361
+ * fail-informative: while the statuses are still loading the active vendor is
362
+ * unknowable, so any reachable-and-walled vendor still announces; and a
363
+ * vendor-agnostic block announces always, exactly as before.
364
+ */
365
+ get bannerVisible() {
366
+ if (this.blocked)
367
+ return true;
368
+ const walled = this.relevantBlockedVendors;
369
+ if (walled.length === 0)
370
+ return false;
371
+ const active = this.activeVendor;
372
+ if (!active)
373
+ return true;
374
+ return walled.includes(active);
375
+ }
376
+ /**
377
+ * Whether a suggestions fetch would hit a wall.
378
+ *
379
+ * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
380
+ * go to the registry **default** and never to a per-agent override
381
+ * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
382
+ * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
383
+ * suggestions call would use is knowable, and this is the one place in the
384
+ * feature where that is true.
385
+ */
386
+ get suggestionsBlocked() {
387
+ if (this.blocked)
388
+ return true;
389
+ const active = this.activeVendor;
390
+ return !!active && this.isVendorBlocked(active);
391
+ }
392
+ /**
393
+ * The vendor the NEXT send resolves to — the registry default's provider, as
394
+ * reported through `providerStatuses`. `undefined` while the statuses are
395
+ * still loading (or when the default reports the `'none'` sentinel), which
396
+ * every consumer treats conservatively.
397
+ */
398
+ get activeVendor() {
399
+ var _a, _b;
400
+ const vendor = (_b = (_a = this.deps.providerStatuses().find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
401
+ return vendor && vendor !== 'none' ? vendor : undefined;
402
+ }
403
+ /** Host override for the banner copy, or `null` for the element's default. */
404
+ get blockedReason() {
405
+ var _a, _b;
406
+ return (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedReason) !== null && _b !== void 0 ? _b : null;
407
+ }
408
+ set blockedReason(value) {
409
+ var _a;
410
+ // Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
411
+ // is derived now, so re-latching with it would pass `false` for a partially
412
+ // walled session and clear every per-vendor latch as a side effect of setting
413
+ // a string.
414
+ (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
415
+ }
416
+ /**
417
+ * Block or unblock the assistant, optionally with custom banner copy.
418
+ *
419
+ * Blocking writes the **vendor-agnostic** block — what a host with one budget
420
+ * pot means — and does not populate {@link BudgetController.blockedVendors}.
421
+ * Unblocking clears the per-vendor walls as well, so a host asserting "the wall
422
+ * is gone" after its own pre-flight cannot be silently overruled by a driver
423
+ * latch it never saw.
424
+ */
425
+ setBlocked(blocked, reason) {
426
+ var _a;
427
+ (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
428
+ }
429
+ /**
430
+ * Latch the backend block off a turn outcome. The single decision point for
431
+ * both latch sites (the `tool-loop-end` bus subscription and `send()`'s
432
+ * return-value check), so the rule cannot diverge between them.
433
+ *
434
+ * Idempotent and one-way **per vendor**: it never unblocks, and it never
435
+ * re-writes an existing wall. That guard is load-bearing, not just an
436
+ * optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
437
+ * `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
438
+ * migration guide's Option C recommends) sets its own detailed reason and the
439
+ * return-value latch would otherwise land a moment later and blank it.
440
+ *
441
+ * The guard being per-vendor rather than global is the whole difference. A
442
+ * single global "already blocked, do nothing" would swallow a second vendor's
443
+ * wall entirely.
444
+ *
445
+ * **Vendor attribution, in precedence order:**
446
+ *
447
+ * 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
448
+ * so it is the only source that cannot be a guess. A label the element does
449
+ * not recognise leaves it unclaimed by any vendor.
450
+ * 2. `budget.vendor` — which the driver resolved from the label where it could,
451
+ * and otherwise from the proxy's own `vendor` field on the 402. It is
452
+ * therefore NOT simply `vendorLabel` normalised, and the two can disagree.
453
+ * 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
454
+ * best guess at the turn's vendor, which may be the previous turn's vendor
455
+ * or nothing at all.
456
+ *
457
+ * A wall that names no recognised vendor falls back to the vendor-agnostic
458
+ * block — fail-safe, and identical to the pre-per-vendor behaviour.
459
+ *
460
+ * **`otherVendorAvailable: false` is server-known truth and outranks every
461
+ * local guess.** Only the proxy knows whether anything else has headroom; this
462
+ * element can only observe which vendors the registry can reach. So when the
463
+ * proxy says no, every other *budgeted* vendor is walled in the same pass. That
464
+ * is what makes `blocked` derive true — locking the composer on the first wall
465
+ * rather than leaving it live against a vendor set the banner would otherwise
466
+ * have advised switching to.
467
+ *
468
+ * The sweep runs over `BUDGETED_VENDORS`, NOT
469
+ * {@link BudgetController.reachableVendors}: reachability is loaded
470
+ * asynchronously, so a reachability-scoped sweep dropped the verdict entirely
471
+ * when the wall beat the statuses home — making it effectively un-latch, since
472
+ * the single wall derived `blocked` only while nothing was reachable, and the
473
+ * composer came back to life against a budget the proxy had already said was
474
+ * gone. Sweeping the metered list instead makes the verdict independent of load
475
+ * order. Walling a vendor the registry cannot reach costs nothing: `blocked`
476
+ * only asks whether every REACHABLE vendor is walled, and the banner reads
477
+ * {@link BudgetController.relevantBlockedVendors}.
478
+ *
479
+ * One boundary remains on the sweep: it walls only vendors the proxy meters,
480
+ * because a vendor it does not meter has no budget there to exhaust, so "no
481
+ * other vendor has budget" is not a statement about it.
482
+ *
483
+ * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
484
+ * both halves of that matter. Last, so the vendor that actually refused this
485
+ * turn heads the wall order the multi-vendor banner reads. Outside the guard,
486
+ * because the verdict can arrive on a LATER 402 for a vendor that is already
487
+ * walled.
488
+ *
489
+ * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
490
+ * @param ref - the session store to write through, captured by the caller so a
491
+ * teardown race cannot land the wall in a new session's store.
492
+ * @param budget - what the 402 reported: figures when the proxy sent any, the
493
+ * refusing vendor, and its `otherVendorAvailable` verdict.
494
+ * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
495
+ * when the budget payload names none.
496
+ */
497
+ latchFrom(reason, ref = this.deps.sessionRef(), budget, vendorHint) {
498
+ var _a, _b;
499
+ if (reason !== 'budget-exhausted')
500
+ return;
501
+ const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
502
+ // Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
503
+ // itself reported, the moment the wall lands. Hosts refresh the meter on
504
+ // `tool-loop-end`, and a walled turn may never reach one — the user then
505
+ // stares at the pre-turn figures next to a banner saying the budget is gone
506
+ // until something else ends a loop (observed live: the meter only caught up
507
+ // when the user cancelled the stuck interaction). Deliberately NOT under the
508
+ // per-vendor idempotence guard below, for the same reason as the sweep: a
509
+ // later 402 for an already-walled vendor carries fresher spend. Both figures
510
+ // are required (`VendorBudgetFigures` is total, and half a meter is a lie),
511
+ // and the write goes through the captured `ref` like every other write here,
512
+ // so a teardown race cannot land it in a new session's store.
513
+ let metered = false;
514
+ if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
515
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
516
+ vendor,
517
+ figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
518
+ });
519
+ this.meterLastFedAt = Date.now();
520
+ metered = true;
521
+ }
522
+ if (!vendor || vendor === 'none') {
523
+ if (!this.blocked) {
524
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
525
+ }
526
+ }
527
+ else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
528
+ // The idempotence guard protects a host-supplied reason from being
529
+ // overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
530
+ // one (the sweep writes no reason, and any host call clears the swept
531
+ // mark), so upgrading it on a first-hand refusal clobbers nothing and
532
+ // gains the vendor its own figures plus a place in the early-window
533
+ // banner (see `relevantBlockedVendors`).
534
+ if (this.reachableVendors.length === 0) {
535
+ // Not fatal — `blocked` still derives to true, so the UI is correct — but
536
+ // the host gets no per-vendor benefit and would otherwise have no signal.
537
+ logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
538
+ 'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
539
+ 'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
540
+ 'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
541
+ }
542
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
543
+ vendor,
544
+ blocked: true,
545
+ // No reason when the meter snap above already stored these figures: the
546
+ // banner composes the same sentence from them, so carrying a copy here
547
+ // would duplicate the dollars in two places that can drift apart. It would
548
+ // also make this latch INDISTINGUISHABLE from host copy, which the banner
549
+ // must preserve verbatim — and preserving boilerplate is what forced it to
550
+ // repeat a sentence per walled vendor instead of folding them into one.
551
+ // The partial-figures case still needs copy carried here: it has no meter
552
+ // entry to compose from, and "an unknown amount of $50.00" is worth saying.
553
+ reason: metered ? undefined : formatBlockedReason(budget, vendor),
554
+ });
555
+ }
556
+ // Last, so the vendor that actually refused this turn heads the wall order the
557
+ // multi-vendor banner reads — and unconditionally, NOT under the per-vendor
558
+ // idempotence guard above, because the verdict can arrive on a later 402 for a
559
+ // vendor that is already walled.
560
+ if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
561
+ return;
562
+ for (const other of BUDGETED_VENDORS) {
563
+ if (other === vendor || this.isVendorBlocked(other))
564
+ continue;
565
+ // No reason: the slice's "an omitted reason keeps the existing explanation"
566
+ // rule applies, and there are no figures for this vendor — only the proxy's
567
+ // word that it has nothing left. `swept: true` records exactly that
568
+ // provenance, so the unknown-reachability banner can decline to name it.
569
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
570
+ }
571
+ }
572
+ /**
573
+ * Copy shown in the blocked banner, in ascending specificity:
574
+ *
575
+ * 1. the host's `blockedReason` — a whole-banner override, unchanged;
576
+ * 2. copy composed from the walled vendors (their per-vendor reasons when set,
577
+ * else their figures);
578
+ * 3. the default budget-exhaustion message.
579
+ *
580
+ * The action clause is composed HERE, not stored at latch time, because whether
581
+ * "switch vendor" or "contact your administrator" is honest depends on whether
582
+ * another reachable vendor still has headroom — which changes when the registry
583
+ * changes, long after the wall was latched. Telling a user to switch to a vendor
584
+ * that is also exhausted is worse than saying nothing.
585
+ *
586
+ * **Per-vendor copy is composed however many vendors are walled**, which is what
587
+ * `setVendorBlocked`'s `reason` parameter promises. It used to be honoured only
588
+ * on the single-vendor branch, so a host that had carefully set copy for each of
589
+ * its vendors watched all of it vanish the moment a second one walled.
590
+ */
591
+ get effectiveBlockedReason() {
592
+ var _a, _b;
593
+ if (this.blockedReason)
594
+ return this.blockedReason;
595
+ const walled = this.relevantBlockedVendors;
596
+ if (walled.length === 0)
597
+ return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
598
+ const free = this.reachableVendors.filter((v) => !walled.includes(v));
599
+ const action = free.length
600
+ ? `Switch to ${formatVendorList(free)} to keep going.`
601
+ : walled.length > 1
602
+ ? RAISE_LIMITS_ACTION
603
+ : RAISE_LIMIT_ACTION;
604
+ const reasons = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedVendorReasons;
605
+ const budgets = (_b = this.slice) === null || _b === void 0 ? void 0 : _b.vendorBudgets;
606
+ // The wall's own figures, read from the entry the METER renders, so the two
607
+ // cannot state different dollars for the same vendor. A vendor with no entry
608
+ // (a swept wall — the proxy's word, no figures of its own) contributes a bare
609
+ // name rather than a gap where numbers should be.
610
+ const statementFor = (v) => {
611
+ var _a, _b;
612
+ return (_b = (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : formatBlockedReason(budgets === null || budgets === void 0 ? void 0 : budgets[v], v)) !== null && _b !== void 0 ? _b : `${vendorDisplayName(v)}'s AI usage limit is reached.`;
613
+ };
614
+ const nameWithFigures = (v) => {
615
+ const figures = budgets === null || budgets === void 0 ? void 0 : budgets[v];
616
+ return figures
617
+ ? `${vendorDisplayName(v)} (${formatUsd(figures.spentUsd)} of ${formatUsd(figures.budgetUsd)})`
618
+ : vendorDisplayName(v);
619
+ };
620
+ if (walled.length === 1)
621
+ return `${statementFor(walled[0])} ${action}`;
622
+ // Several walled vendors: fold them into ONE sentence carrying each vendor's
623
+ // figures, rather than repeating "X's AI usage limit is reached (…)." per
624
+ // vendor — the same clause twice reads like a stutter, and the second copy of
625
+ // the boilerplate adds nothing the first did not already say.
626
+ //
627
+ // Host copy is not foldable: `setVendorBlocked` promises it survives verbatim,
628
+ // and it cannot be rewritten into a list item. So the moment any walled vendor
629
+ // carries some, the branch below lists one statement per vendor instead —
630
+ // falling back to composed copy for the ones that have none, so the sentence
631
+ // set stays complete rather than silently naming a subset.
632
+ if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
633
+ return `AI usage limits are reached for ${joinList(walled.map(nameWithFigures))}. ${action}`;
634
+ }
635
+ return `${walled.map(statementFor).join(' ')} ${action}`;
636
+ }
637
+ /**
638
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
639
+ * or `undefined` to let the driver compose it per wall.
640
+ *
641
+ * A set `blockedReason` still wins — a white-labelled host must not read its own
642
+ * explanation in the banner and shipped copy directly below it.
643
+ *
644
+ * `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
645
+ * fallback: it hands composition to the driver, which builds the bubble from
646
+ * EACH wall's own 402 — naming the exhausted vendor and advising a switch only
647
+ * when that refusal's `otherVendorAvailable` positively vouched for headroom
648
+ * elsewhere. That is immune to the staleness this getter used to guard against
649
+ * by freezing the copy: construction-time advice could outlive its truth
650
+ * (`getOrCreateDriver` re-runs on an agents swap and would have baked the
651
+ * sentence into every later bubble), but per-wall advice is derived from the
652
+ * refusal it annotates and dies with the turn. Forcing the default here was also
653
+ * what made the driver's composed bubble unreachable in every real mount — the
654
+ * banner advised switching while the bubble under it never did (the GENC-1464
655
+ * tester finding, root cause).
656
+ */
657
+ get transcriptMessage() {
658
+ var _a;
659
+ return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
660
+ }
661
+ /**
662
+ * Built-in per-vendor budget meter (GENC-1464) — the money sibling of the
663
+ * settings modal's context-usage gate, with the same shape: not configured off,
664
+ * and there is data to show. "Data" here is "at least one vendor has figures",
665
+ * so a host that feeds nothing (or only unlimited vendors) gets no meter and no
666
+ * empty container.
667
+ *
668
+ * Answered from the RAW slice state rather than from
669
+ * {@link BudgetController.settingsRows}, whose `.length` would format every row
670
+ * (a `formatUsd` pair and a `budgetPercentOf` each) only to ask whether any
671
+ * exist. This is read several times per render pass — the getter, the section
672
+ * gate, the template's `when` — so it stays as cheap as the context sibling's
673
+ * `!= null`.
674
+ */
675
+ get settingsUsageVisible() {
676
+ var _a, _b;
677
+ const budgets = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.vendorBudgets;
678
+ return (((_b = this.deps.chatConfig().ui) === null || _b === void 0 ? void 0 : _b.showBudgetUsage) !== false &&
679
+ budgets != null &&
680
+ // Keyed off BUDGETED_VENDORS, exactly as the rows are, so a key outside
681
+ // that list could never surface a meter with no row to show.
682
+ BUDGETED_VENDORS.some((vendor) => budgets[vendor] != null));
683
+ }
684
+ /**
685
+ * The meter's rows — one per vendor that currently has figures, in
686
+ * `BUDGETED_VENDORS` order (the proxy's own metered list) so the rendering is
687
+ * stable however the host ordered its feed calls. Pre-formatted; see
688
+ * {@link SettingsBudgetRow} for why the text is unclamped while the bar is.
689
+ */
690
+ get settingsRows() {
691
+ var _a;
692
+ const budgets = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.vendorBudgets;
693
+ if (!budgets)
694
+ return [];
695
+ const rows = [];
696
+ for (const vendor of BUDGETED_VENDORS) {
697
+ const figures = budgets[vendor];
698
+ if (!figures)
699
+ continue;
700
+ const percent = budgetPercentOf(figures);
701
+ rows.push({
702
+ vendor,
703
+ label: vendorDisplayName(vendor),
704
+ figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
705
+ // The same clamp the context indicator applies to its bar. No lower
706
+ // clamp needed: the boundary refuses negative figures.
707
+ barValue: Math.min(100, percent),
708
+ });
709
+ }
710
+ return rows;
711
+ }
712
+ }
713
+ __decorate([
714
+ observable
715
+ ], BudgetController.prototype, "meterLastFedAt", void 0);
716
+ __decorate([
717
+ volatile
718
+ ], BudgetController.prototype, "blocked", null);
719
+ __decorate([
720
+ volatile
721
+ ], BudgetController.prototype, "reachableVendors", null);
722
+ __decorate([
723
+ volatile
724
+ ], BudgetController.prototype, "updatedLabel", null);
725
+ __decorate([
726
+ volatile
727
+ ], BudgetController.prototype, "relevantBlockedVendors", null);
728
+ __decorate([
729
+ volatile
730
+ ], BudgetController.prototype, "bannerVisible", null);
731
+ __decorate([
732
+ volatile
733
+ ], BudgetController.prototype, "suggestionsBlocked", null);
734
+ __decorate([
735
+ volatile
736
+ ], BudgetController.prototype, "activeVendor", null);
737
+ __decorate([
738
+ volatile
739
+ ], BudgetController.prototype, "effectiveBlockedReason", null);
740
+ __decorate([
741
+ volatile
742
+ ], BudgetController.prototype, "settingsUsageVisible", null);
743
+ __decorate([
744
+ volatile
745
+ ], BudgetController.prototype, "settingsRows", null);