@genesislcap/ai-assistant 15.7.0 → 15.7.2

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/src/main/main.ts CHANGED
@@ -123,6 +123,11 @@ import { ChatSuggestions } from '../suggestions/chat-suggestions';
123
123
  import type { InteractionContext } from '../types/interaction-context';
124
124
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
125
125
  import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
126
+ import {
127
+ deleteBankedBaseline,
128
+ getBankedBaseline,
129
+ setBankedBaseline,
130
+ } from '../utils/banked-usage-baselines';
126
131
  import { collectSessionModels } from '../utils/collect-session-models';
127
132
  import {
128
133
  clearCostSessionHistory,
@@ -877,11 +882,6 @@ export class FoundationAiAssistant extends GenesisElement {
877
882
  *
878
883
  * @beta
879
884
  */
880
- /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
881
- private isVendorSwept(vendor: AIProviderType): boolean {
882
- return (this._sessionRef?.store.aiAssistant.sweptVendors ?? []).includes(vendor);
883
- }
884
-
885
885
  setVendorBlocked(vendor: AIProviderType, blocked: boolean, reason?: string | null): void {
886
886
  if (vendor === 'none') {
887
887
  logger.warn(
@@ -893,6 +893,11 @@ export class FoundationAiAssistant extends GenesisElement {
893
893
  this._sessionRef?.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
894
894
  }
895
895
 
896
+ /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
897
+ private isVendorSwept(vendor: AIProviderType): boolean {
898
+ return (this._sessionRef?.store.aiAssistant.sweptVendors ?? []).includes(vendor);
899
+ }
900
+
896
901
  /**
897
902
  * The walled vendors the user can still be routed to — i.e.
898
903
  * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
@@ -1535,9 +1540,9 @@ export class FoundationAiAssistant extends GenesisElement {
1535
1540
  }
1536
1541
 
1537
1542
  /**
1538
- * Spend for `projectKey` that predates the current transcript, resolved once per project
1539
- * and memoised (`_bankedFor`) so it cannot drift mid-session as the row it came from is
1540
- * rewritten by our own upserts.
1543
+ * Spend for `projectKey` that predates the current transcript, resolved once per SESSION and
1544
+ * shared by every element instance wired to it (see `banked-usage-baselines`), so it cannot
1545
+ * drift as the row it came from is rewritten by our own upserts.
1541
1546
  *
1542
1547
  * The two cases turn on whether the transcript is authoritative for this project:
1543
1548
  *
@@ -1547,28 +1552,77 @@ export class FoundationAiAssistant extends GenesisElement {
1547
1552
  * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
1548
1553
  * whole of it becomes banked. Without this the next turn would rewrite the row downwards
1549
1554
  * to just that turn, losing the project's earlier spend.
1555
+ *
1556
+ * The baseline is deliberately NOT element state. This element is remounted repeatedly inside
1557
+ * a single session (the host re-rendering its layout, popping out, docking) and a fresh
1558
+ * instance re-deriving would take the second branch against rows that already hold the live
1559
+ * transcript — banking the session as its own baseline. Keying on the session instead means a
1560
+ * remount inherits the baseline that transcript was paired with.
1550
1561
  */
1551
1562
  private bankedUsageFor(projectKey: string): AggregateUsage {
1552
- if (this._bankedFor === projectKey) return this._bankedUsage;
1563
+ const resolved = getBankedBaseline(projectKey);
1564
+ if (resolved) return resolved;
1553
1565
  const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
1554
1566
  // `persistenceActive` is what decides whether the transcript speaks for the project's
1555
1567
  // history: it is on only when a provider is wired AND the user is remembering the chat.
1556
- this._bankedUsage = resolveBankedUsage(existing, this.persistenceActive);
1557
- this._bankedFor = projectKey;
1558
- return this._bankedUsage;
1568
+ const banked = resolveBankedUsage(existing, this.persistenceActive);
1569
+ setBankedBaseline(projectKey, banked);
1570
+ return banked;
1559
1571
  }
1560
1572
 
1561
1573
  /**
1562
- * Discard the banked figure for when the spend it stands for has been deliberately
1563
- * thrown away (the user clearing history, or deleting this project's row).
1574
+ * Re-baseline this session against an emptied transcript: what came before, plus what the
1575
+ * transcript being discarded proved. `discarded` must be read BEFORE the live figures are
1576
+ * reset, since that is the only record of the latter.
1564
1577
  *
1565
- * NOT for reloads. A reload re-reads rows that already include our own upserts, so
1566
- * re-deriving from them would fold the live session into its own baseline; only a genuine
1567
- * project change should re-derive, which `bankedUsageFor`'s per-project memo handles.
1578
+ * Not the same as dropping the baseline. Dropping it re-derives through `persistenceActive`,
1579
+ * and with persistence ON that carries the row's *old* banked figure forward discarding
1580
+ * every turn between the last baseline and the clear. The transcript is gone either way, so
1581
+ * the branch that reads it as authoritative no longer applies.
1582
+ *
1583
+ * Derived from the memoised baseline and the live figure rather than from the project's row,
1584
+ * because a row is only as current as the last `finalizeCostSession` — which skips its write
1585
+ * while the ledger is unloaded, and while the session has no usage yet.
1568
1586
  */
1569
- private resetBankedUsage(): void {
1570
- this._bankedFor = undefined;
1571
- this._bankedUsage = emptyUsage();
1587
+ private rebaselineOnClearedTranscript(key: string, discarded: AggregateUsage): void {
1588
+ // Already resolved for this session? Then the new baseline is fully determined without
1589
+ // consulting the ledger at all — `bankedUsageFor` would short-circuit on this same value — so
1590
+ // answer before the guard below, which would otherwise discard a figure that is both correct
1591
+ // and independent of the rows. Losing it costs real spend: with a row at `usage: 120,
1592
+ // banked: 90` and 30 on screen, a clear during a reload would drop the memoised 90, and the
1593
+ // next turn would re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
1594
+ const resolved = getBankedBaseline(key);
1595
+ if (resolved) {
1596
+ setBankedBaseline(key, addUsage(resolved, discarded));
1597
+ return;
1598
+ }
1599
+
1600
+ // Nothing memoised, so a baseline can only come from the rows — and a stamped one is STICKY
1601
+ // (`bankedUsageFor` short-circuits on any stored entry, and a zero usage is a *resolved* value
1602
+ // there, not "unresolved"). So unless the rows are genuinely known, do not guess:
1603
+ //
1604
+ // - `enabled` false: `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a
1605
+ // deliberately empty list, meaning "nothing will be loaded" rather than "rows are known".
1606
+ // The flag alone therefore is not enough. Reachable because `enabled` is
1607
+ // `cost?.enabled === true` — false for an unbound `chatConfig`, and for any host resolving
1608
+ // it from an async setting, exactly as `chatConfigChanged` already expects.
1609
+ // - Provider read in flight: the list is emptied and the flag cleared synchronously, on
1610
+ // connect and again on every `chatConfig` rebind.
1611
+ // - Unreadable: the rows we hold are a fallback, not the ledger.
1612
+ //
1613
+ // In all three, pinning would bury this project's real total the moment it arrived — a later
1614
+ // load does NOT invalidate baselines (deliberately, see `reloadCostSessionHistory`), so the
1615
+ // next write would replace the row off the pinned figure and lose everything before it.
1616
+ //
1617
+ // Dropping instead leaves the next write to re-derive from rows that actually arrived. It
1618
+ // under-banks `discarded` in that window — a bounded loss, and what every clear did before
1619
+ // this method existed. Nothing is written while disabled, so there is no banked value to
1620
+ // preserve in that case either.
1621
+ if (!this.resolvedCostHistory.enabled || !this._costHistoryLoaded || this._ledgerUnreadable) {
1622
+ deleteBankedBaseline(key);
1623
+ return;
1624
+ }
1625
+ setBankedBaseline(key, addUsage(this.bankedUsageFor(key), discarded));
1572
1626
  }
1573
1627
 
1574
1628
  showConfirmationDialog(
@@ -1593,24 +1647,26 @@ export class FoundationAiAssistant extends GenesisElement {
1593
1647
 
1594
1648
  /** Remove one entry from persisted build history. */
1595
1649
  removeCostSessionFromHistory(id: string): void {
1596
- // Deleting the active project's row discards its banked spend by intent, so drop the
1597
- // memo too — otherwise the next turn would rewrite the row we just removed, restoring
1598
- // the total the user asked to be rid of.
1650
+ // Deleting a row discards its banked spend by intent, so drop the baseline too —
1651
+ // otherwise the next turn would rewrite the row we just removed, restoring the total the
1652
+ // user asked to be rid of.
1599
1653
  //
1600
- // Resolve the row first: `id` is a row id while `_bankedFor` is a projectKey. They match
1601
- // only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a row from
1602
- // a host provider can carry any id, and comparing across the two namespaces would silently
1603
- // skip the reset.
1654
+ // Resolve the row first: `id` is a row id while baselines are keyed by projectKey. They
1655
+ // match only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a
1656
+ // row from a host provider can carry any id, and deleting by the wrong namespace would
1657
+ // silently leave the baseline in place.
1604
1658
  const removed = this.costSessionHistory.find((record) => record.id === id);
1605
- if (removed && this._bankedFor === removed.projectKey) this.resetBankedUsage();
1659
+ if (removed) deleteBankedBaseline(removed.projectKey);
1606
1660
  this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
1607
1661
  }
1608
1662
 
1609
1663
  /** Clear all persisted build history for this assistant scope. */
1610
1664
  clearCostSessionHistory(): void {
1611
- // Every row is gone, so nothing is banked any more — without this the active project's
1612
- // next turn would resurrect its pre-clear total.
1613
- this.resetBankedUsage();
1665
+ // Every row is gone, so nothing is banked any more — without this the next turn would
1666
+ // resurrect the pre-clear total. Dropped per row rather than for the active session alone,
1667
+ // so a project whose row was cleared while another session was displaying it does not keep
1668
+ // a baseline pointing at spend that no longer exists anywhere.
1669
+ for (const record of this.costSessionHistory) deleteBankedBaseline(record.projectKey);
1614
1670
  // Provider-backed: write an empty ledger through. The `localStorage` path instead
1615
1671
  // removes its key outright rather than leaving an empty array behind.
1616
1672
  if (this.costHistoryProvider()) {
@@ -1843,10 +1899,9 @@ export class FoundationAiAssistant extends GenesisElement {
1843
1899
  * (same key) from a project switch (key already flipped) — see `syncActiveCostSessionTitle`.
1844
1900
  */
1845
1901
  private _activeCostSessionKey?: string;
1846
- /** Memoised pre-transcript spend for `_bankedFor` see `bankedUsageFor`. */
1847
- private _bankedUsage: AggregateUsage = emptyUsage();
1848
- /** Project key `_bankedUsage` was resolved for; `undefined` means "not yet resolved". */
1849
- private _bankedFor: string | undefined;
1902
+ // NOTE: the pre-transcript ("banked") spend is deliberately NOT held here. It is keyed by
1903
+ // session in `banked-usage-baselines` so the many element instances a single session sees —
1904
+ // layout re-renders, popout, docking share one baseline. See `bankedUsageFor`.
1850
1905
  /**
1851
1906
  * Whether the row list reflects storage yet. Gates cost writes so a project's banked
1852
1907
  * spend is never derived from a ledger that simply hasn't arrived.
@@ -2663,6 +2718,10 @@ export class FoundationAiAssistant extends GenesisElement {
2663
2718
  // keeping the high-water `lastEventIndex` would then skip every event of the
2664
2719
  // resumed session (GENC-1351 §5.12 / #9). Clear (same-session) still uses reset.
2665
2720
  deleteDiagnosticsCursorsFor(oldKey);
2721
+ // The cost baseline is paired with the transcript in the store just disposed, so it goes
2722
+ // with it (GENC-1475). A later switch back re-restores from the provider — or starts empty
2723
+ // where it does not — and re-derives from the ledger rows accordingly.
2724
+ deleteBankedBaseline(oldKey);
2666
2725
  clearMetaSession(oldKey);
2667
2726
  // Activate the incoming session. Swapping the observable `_sessionRef` re-points
2668
2727
  // every store-backed getter binding at the new store; `activateSession` rebuilds
@@ -3272,9 +3331,16 @@ export class FoundationAiAssistant extends GenesisElement {
3272
3331
  } catch (e) {
3273
3332
  logger.error('Session clear: agent serialize() failed:', e);
3274
3333
  }
3334
+ // Read BEFORE `resetSession()` zeroes it: this is what the transcript about to be discarded
3335
+ // proved, and nothing else records it. Banking it is what stops the next turn rewriting the
3336
+ // project's row down to itself and losing the spend up to the clear (GENC-1475).
3337
+ const discarded = this.sessionUsage;
3275
3338
  if (this.driver) this.teardownDriver(key);
3276
3339
  this._sessionRef?.actions.aiAssistant.resetSession();
3277
- if (key) this.rebuildDriver(key);
3340
+ if (key) {
3341
+ this.rebaselineOnClearedTranscript(key, discarded);
3342
+ this.rebuildDriver(key);
3343
+ }
3278
3344
  // Restore-core is owned by the shared per-session persister (GENC-1351 §5.10):
3279
3345
  // it drops the snapshot + resets the carry-forward cache. Diagnostics is
3280
3346
  // append-only and handled separately below.
@@ -3396,10 +3462,35 @@ export class FoundationAiAssistant extends GenesisElement {
3396
3462
  return this.settingsCostCurrentSlotted.length > 0;
3397
3463
  }
3398
3464
 
3465
+ /**
3466
+ * Whether the build-history section has anything in it — the built-in row list, or a host's own
3467
+ * markup in the `settings-cost-history` slot.
3468
+ *
3469
+ * Slotted content is checked FIRST, ahead of `enabled`. That flag governs the *built-in* ledger:
3470
+ * it gates the row writes, the reload, and the lifetime sum — and it defaults to `false`
3471
+ * (`cost?.enabled === true`), while the Usage tab itself shows for `showCost`/`showTokens`, which
3472
+ * default true. So a host can legitimately render its own build history into the slot and never
3473
+ * opt into our `localStorage` ledger. Short-circuiting on `enabled` above the slot check hid that
3474
+ * host's markup outright, since the slot is the only thing in the container.
3475
+ */
3399
3476
  @volatile
3400
3477
  get settingsCostHistorySectionVisible(): boolean {
3478
+ if (this.settingsCostHistorySlotted.length > 0) return true;
3401
3479
  if (!this.resolvedCostHistory.enabled) return false;
3402
- return this.costSessionHistory.length > 0 || this.settingsCostHistorySlotted.length > 0;
3480
+ return this.costSessionHistory.length > 0;
3481
+ }
3482
+
3483
+ /**
3484
+ * Whether the "Build history" heading has anything under it.
3485
+ *
3486
+ * Broader than {@link FoundationAiAssistant.settingsCostHistorySectionVisible} because the
3487
+ * heading also covers the lifetime total, which renders before the first row exists (rows are
3488
+ * written as spend accrues, the lifetime section only needs history switched on). Gating the
3489
+ * heading on the rows alone left that total sitting under nothing.
3490
+ */
3491
+ @volatile
3492
+ get settingsCostHistoryHeadingVisible(): boolean {
3493
+ return this.settingsCostHistorySectionVisible || this.settingsCostLifetimeVisible;
3403
3494
  }
3404
3495
 
3405
3496
  /** Whether the built-in Usage tab summary block has anything to show. */
@@ -0,0 +1,90 @@
1
+ import type { AggregateUsage } from '@genesislcap/foundation-ai';
2
+
3
+ /**
4
+ * Per-session banked baselines for the cost ledger (GENC-1475) — the spend a project had
5
+ * accrued *before* the transcript currently in memory. Module-level and keyed by the session
6
+ * `stateKey`, exactly like the diagnostics cursors and the meta-event buffer.
7
+ *
8
+ * The lifetime is the point of this module. A baseline is only meaningful paired with the
9
+ * transcript it complements: `row.usage = baseline + whatever that transcript proves`. The
10
+ * transcript lives in the per-key session store, so the baseline must live and die with that
11
+ * store — which means module state keyed the same way, NOT element state.
12
+ *
13
+ * Held on the element it silently double-counted. Element instances are created and destroyed
14
+ * many times inside one session (the assistant is remounted as the host re-renders its layout,
15
+ * and a popped-out bubble and a docked panel can be wired to the same session at once), and each
16
+ * new instance re-derived the baseline from the ledger rows. By then those rows contain the
17
+ * instance-before-last's own upserts, whose `usage` already includes the live transcript — so
18
+ * where the transcript is NOT restored on load (`resolveBankedUsage`'s "bank the whole prior
19
+ * total" branch, i.e. persistence off) the live session was banked as its own baseline. Lifetime
20
+ * then jumped by the whole session-to-date on every remount while the live figure kept climbing
21
+ * normally, which is the "the two totals went up by different amounts" report.
22
+ *
23
+ * Module state dies with the JS realm, so a real page reload starts clean — exactly right, since
24
+ * that is also when the transcript starts over.
25
+ *
26
+ * @internal
27
+ */
28
+ const baselinesByKey = new Map<string, AggregateUsage>();
29
+
30
+ /**
31
+ * The baseline resolved for `key`, or `undefined` when this session has not resolved one yet.
32
+ *
33
+ * `undefined` rather than an empty usage: "nothing banked" is a legitimate resolved value (a
34
+ * project's first ever session), and it must not be mistaken for "not resolved", or every write
35
+ * would re-derive and hit the double-count above.
36
+ *
37
+ * Returns a copy — see `setBankedBaseline` for why both ends of this map are cloned.
38
+ *
39
+ * @internal
40
+ */
41
+ export function getBankedBaseline(key: string): AggregateUsage | undefined {
42
+ const usage = baselinesByKey.get(key);
43
+ return usage ? { ...usage } : undefined;
44
+ }
45
+
46
+ /**
47
+ * Record the baseline for `key`, replacing any previous one.
48
+ *
49
+ * Stores a copy, and `getBankedBaseline` hands back a copy, so nothing outside this module can
50
+ * reach the object the map holds. Both ends have to clone, because a baseline is aliased at both:
51
+ * `resolveBankedUsage` returns a *reference into a ledger row* (`row.banked` or `row.usage`),
52
+ * which is what gets stored here, and the caller then assigns what it reads back onto the NEXT
53
+ * row's `banked` field. Uncloned, one object is reachable from page-global state, two rows, and —
54
+ * since the row list is public and gets handed to a host's `saveCostHistory` — from host code.
55
+ * Whoever mutated it would silently rewrite this project's baseline for the rest of the page.
56
+ *
57
+ * A shallow spread is a complete copy: `AggregateUsage` is five flat numbers, no nested state.
58
+ *
59
+ * @internal
60
+ */
61
+ export function setBankedBaseline(key: string, usage: AggregateUsage): void {
62
+ baselinesByKey.set(key, { ...usage });
63
+ }
64
+
65
+ /**
66
+ * Drop the baseline for `key`, so the next write re-derives it from the ledger rows.
67
+ *
68
+ * For when the pairing is broken deliberately at the *row* end — the row this baseline came from
69
+ * has been deleted, or the whole ledger cleared — and for a session teardown, where the store
70
+ * (and with it the transcript) is disposed and a later return re-restores from scratch.
71
+ *
72
+ * NOT for a mid-session reload of the rows: those rows already include this session's own
73
+ * upserts, so re-deriving from them folds the live transcript into its own baseline.
74
+ *
75
+ * @internal
76
+ */
77
+ export function deleteBankedBaseline(key: string): void {
78
+ baselinesByKey.delete(key);
79
+ }
80
+
81
+ /**
82
+ * Forget every baseline — the page-load boundary, which production gets for free from module
83
+ * state dying with the realm. Exists so tests can model separate page loads (and so one test's
84
+ * session key cannot leak a baseline into the next).
85
+ *
86
+ * @internal
87
+ */
88
+ export function resetBankedBaselines(): void {
89
+ baselinesByKey.clear();
90
+ }