@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.
- package/dist/ai-assistant.api.json +419 -50
- package/dist/ai-assistant.d.ts +217 -683
- package/dist/chat-driver.cjs.map +1 -1
- package/dist/chat-driver.mjs.map +1 -1
- package/dist/custom-elements.json +310 -609
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +16 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/index.d.ts +1 -0
- package/dist/dts/components/settings-modal/index.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.d.ts +215 -0
- package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -0
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/main/attachment-controller.d.ts +66 -0
- package/dist/dts/main/attachment-controller.d.ts.map +1 -0
- package/dist/dts/main/budget-controller.d.ts +368 -0
- package/dist/dts/main/budget-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-menu-controller.d.ts +113 -0
- package/dist/dts/main/composer-menu-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-resize-controller.d.ts +80 -0
- package/dist/dts/main/composer-resize-controller.d.ts.map +1 -0
- package/dist/dts/main/context-gate-controller.d.ts +197 -0
- package/dist/dts/main/context-gate-controller.d.ts.map +1 -0
- package/dist/dts/main/cost-ledger-controller.d.ts +228 -0
- package/dist/dts/main/cost-ledger-controller.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +201 -722
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +25 -0
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/dts/main/meta-snapshot.d.ts +50 -0
- package/dist/dts/main/meta-snapshot.d.ts.map +1 -0
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +18 -0
- package/dist/esm/components/settings-modal/index.js +1 -0
- package/dist/esm/components/settings-modal/settings-modal.js +436 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +15 -4
- package/dist/esm/main/attachment-controller.js +254 -0
- package/dist/esm/main/budget-controller.js +745 -0
- package/dist/esm/main/composer-menu-controller.js +234 -0
- package/dist/esm/main/composer-resize-controller.js +119 -0
- package/dist/esm/main/context-gate-controller.js +289 -0
- package/dist/esm/main/cost-ledger-controller.js +498 -0
- package/dist/esm/main/main.js +501 -2268
- package/dist/esm/main/meta-snapshot.js +108 -0
- package/dist/esm/state/persistence/session-persister.js +5 -4
- package/docs/migration-GENC-1580.md +209 -0
- package/package.json +16 -16
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { __decorate } from "tslib";
|
|
2
|
+
import { observable, volatile } from '@genesislcap/web-core';
|
|
3
|
+
import { deleteBankedBaseline, getBankedBaseline, setBankedBaseline, } from '../utils/banked-usage-baselines';
|
|
4
|
+
import { collectSessionModels } from '../utils/collect-session-models';
|
|
5
|
+
import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
|
|
6
|
+
import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
|
|
7
|
+
import { logger } from '../utils/logger';
|
|
8
|
+
import { resolveCostHistoryConfig, } from '../utils/resolve-cost-history-config';
|
|
9
|
+
import { addUsage, emptyUsage, totalTokens } from '../utils/sum-usage';
|
|
10
|
+
/**
|
|
11
|
+
* How long cost-ledger writes coalesce. Comfortably longer than the gap between the turns of a
|
|
12
|
+
* tool loop, so a multi-step run writes once rather than per turn, and short enough that a flush
|
|
13
|
+
* has landed well before a user could close the tab. Nothing reads the stored ledger while the
|
|
14
|
+
* assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
|
|
15
|
+
*/
|
|
16
|
+
const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
|
|
17
|
+
/**
|
|
18
|
+
* The cost ledger: this session's usage, the persisted per-project build history it
|
|
19
|
+
* upserts into, and the pre-transcript ("banked") spend that makes a row report a
|
|
20
|
+
* project's LIFETIME total rather than whatever is currently on screen (GENC-1475).
|
|
21
|
+
*
|
|
22
|
+
* The invariant the whole file protects: **a row moves forward, never backwards.**
|
|
23
|
+
* Every figure is derived from state rather than incremented, so the several element
|
|
24
|
+
* instances one session sees — layout re-renders, popout, docking — write the same
|
|
25
|
+
* row instead of each adding a contribution. `cost-session-banking.test.ts` pins the
|
|
26
|
+
* cases where earlier attempts got this wrong in both directions.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export class CostLedgerController {
|
|
31
|
+
constructor(deps) {
|
|
32
|
+
this.deps = deps;
|
|
33
|
+
/**
|
|
34
|
+
* Persisted rows, newest write wins. Observable because the Usage tab renders them
|
|
35
|
+
* (through the host's `costSessionHistory` and `sortedCostSessionHistory`), and
|
|
36
|
+
* because the derived lifetime totals below have to re-run when a row lands.
|
|
37
|
+
*/
|
|
38
|
+
this.history = [];
|
|
39
|
+
/**
|
|
40
|
+
* Title tracked for the in-flight session, and the session key it accrued under so
|
|
41
|
+
* a title change can tell a rename (same key) from a project switch (key already
|
|
42
|
+
* flipped) — see {@link CostLedgerController.syncActiveTitle}.
|
|
43
|
+
*
|
|
44
|
+
* Not observable: read only by the title getter's own fallback chain, which already
|
|
45
|
+
* depends on `chatConfig` and `messages`, and written only from lifecycle hooks.
|
|
46
|
+
*/
|
|
47
|
+
this.activeTitle = '';
|
|
48
|
+
// NOTE: the pre-transcript ("banked") spend is deliberately NOT held here either. It
|
|
49
|
+
// is keyed by session in `banked-usage-baselines` so the many element instances a
|
|
50
|
+
// single session sees share one baseline. See `bankedUsageFor`.
|
|
51
|
+
/**
|
|
52
|
+
* What the last read of the backing store told us, and the two gates every write
|
|
53
|
+
* consults. Neither is observable — nothing renders them.
|
|
54
|
+
*
|
|
55
|
+
* `loaded`: whether the row list reflects storage yet. Gates writes so a project's
|
|
56
|
+
* banked spend is never derived from a ledger that simply hasn't arrived.
|
|
57
|
+
*
|
|
58
|
+
* `unreadable`: set when a provider read failed, latching writes off for this
|
|
59
|
+
* page-load. `saveCostHistory` replaces the whole stored list, so writing anything
|
|
60
|
+
* derived from the empty list we fell back to would destroy rows we never read.
|
|
61
|
+
*
|
|
62
|
+
* Private, though `cost-session-banking.test.ts` drives both to model a provider
|
|
63
|
+
* read that is in flight or has failed. That works because the test reaches them
|
|
64
|
+
* through an `as unknown as { costLedger: … }` structural cast, which erases the
|
|
65
|
+
* class type — so exposing them bought nothing and only put a mutable latch on the
|
|
66
|
+
* surface of an `@internal` controller (PR #2503 review).
|
|
67
|
+
*/
|
|
68
|
+
this.loaded = false;
|
|
69
|
+
this.unreadable = false;
|
|
70
|
+
/** Pending (adopted but unwritten) ledger changes — see {@link CostLedgerController.persist}. */
|
|
71
|
+
this.dirty = false;
|
|
72
|
+
}
|
|
73
|
+
/** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
|
|
74
|
+
get config() {
|
|
75
|
+
return resolveCostHistoryConfig(this.deps.chatConfig());
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Distinct models the registry currently resolves to — the live session's model chips.
|
|
79
|
+
*
|
|
80
|
+
* `providerStatuses` carries one entry per registry SLOT, so a vendor configured across several
|
|
81
|
+
* tiers that happen to resolve to the same model reports it once per tier, and the chips
|
|
82
|
+
* rendered it once per tier too. Which tier a model came from is not what these chips are for.
|
|
83
|
+
*
|
|
84
|
+
* Deduped on the label actually displayed, not on `status.model`: a slot whose provider reports
|
|
85
|
+
* no model falls back to the slot's own name, and those are distinct per slot, so keying on the
|
|
86
|
+
* label keeps them from collapsing into one another.
|
|
87
|
+
*/
|
|
88
|
+
get uniqueProviderModels() {
|
|
89
|
+
var _a, _b, _c, _d;
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
for (const entry of this.deps.providerStatuses()) {
|
|
92
|
+
const model = (_b = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : entry.name;
|
|
93
|
+
if (!model)
|
|
94
|
+
continue;
|
|
95
|
+
const existing = seen.get(model);
|
|
96
|
+
if (!existing) {
|
|
97
|
+
seen.set(model, { model, provider: (_c = entry.status) === null || _c === void 0 ? void 0 : _c.provider });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Keep whichever tier knew the vendor, so the chip still gets its colour when one slot
|
|
101
|
+
// reports a provider and another does not.
|
|
102
|
+
if (existing.provider === undefined && ((_d = entry.status) === null || _d === void 0 ? void 0 : _d.provider) !== undefined) {
|
|
103
|
+
existing.provider = entry.status.provider;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...seen.values()];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build history for display: most recently worked on first.
|
|
110
|
+
*
|
|
111
|
+
* The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
|
|
112
|
+
* sitting wherever it was first seen — so ordering is applied here, on read, where it also
|
|
113
|
+
* covers rows a host provider supplied.
|
|
114
|
+
*/
|
|
115
|
+
get sortedHistory() {
|
|
116
|
+
return sortRecordsByRecency(this.history);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Lifetime usage across every project in the history scope — cost and all four token
|
|
120
|
+
* buckets. What the Usage tab's lifetime section renders.
|
|
121
|
+
*
|
|
122
|
+
* Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
|
|
123
|
+
* usage and the active project's row is kept current on every transcript change, so
|
|
124
|
+
* adding the live total would count this project's spend twice. That double-count is what
|
|
125
|
+
* made the figure grow on every refresh: a page-hide banked a row while the restored
|
|
126
|
+
* session still carried the same spend.
|
|
127
|
+
*/
|
|
128
|
+
get cumulativeUsage() {
|
|
129
|
+
void this.history;
|
|
130
|
+
// With history off there are no rows to sum, so the live session IS all we know.
|
|
131
|
+
if (!this.config.enabled)
|
|
132
|
+
return this.deps.sessionUsage();
|
|
133
|
+
return this.history.reduce((total, record) => addUsage(total, record.usage), emptyUsage());
|
|
134
|
+
}
|
|
135
|
+
/** Lifetime USD across every project in the history scope. */
|
|
136
|
+
get cumulativeCostUsd() {
|
|
137
|
+
return this.cumulativeUsage.costUsd;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether the live session figures amount to this project's whole history, which is true
|
|
141
|
+
* exactly when the transcript is restored on load. Drives the Usage tab's live labels
|
|
142
|
+
* ("Project cost" vs "Session cost") so they state the scope rather than imply one.
|
|
143
|
+
*
|
|
144
|
+
* Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
|
|
145
|
+
* (see `CostSessionRecord.banked`) and are lifetime either way.
|
|
146
|
+
*/
|
|
147
|
+
get liveUsageIsProjectLifetime() {
|
|
148
|
+
return this.deps.persistenceActive();
|
|
149
|
+
}
|
|
150
|
+
/** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
|
|
151
|
+
get liveTitle() {
|
|
152
|
+
var _a;
|
|
153
|
+
const messages = this.deps.messages();
|
|
154
|
+
const appTitle = ((_a = this.config.title) === null || _a === void 0 ? void 0 : _a.trim()) || this.activeTitle.trim();
|
|
155
|
+
if (appTitle)
|
|
156
|
+
return appTitle;
|
|
157
|
+
return deriveCostSessionTitleFromMessages(messages);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Write this session's usage into persisted build history as a single row keyed by the
|
|
161
|
+
* session key, replacing any existing row for that key.
|
|
162
|
+
*
|
|
163
|
+
* **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
|
|
164
|
+
* The name is historical: it does NOT end the session or reset the running totals.
|
|
165
|
+
* Resetting them is what used to double-count spend, since the row was banked while the
|
|
166
|
+
* persisted transcript still carried the same usage, so a reload restored it and the
|
|
167
|
+
* Usage tab counted both.
|
|
168
|
+
*
|
|
169
|
+
* No-op unless `chatConfig.costHistory.enabled` is `true`, when the live session has no
|
|
170
|
+
* usage yet, or when the session has no key to upsert against. Gating on the *live*
|
|
171
|
+
* figures (not the banked total) means a reload that restores nothing rewrites nothing.
|
|
172
|
+
*/
|
|
173
|
+
finalize(options) {
|
|
174
|
+
if (!this.config.enabled)
|
|
175
|
+
return;
|
|
176
|
+
// Wait for the rows before writing: a provider read is async, and banking from a
|
|
177
|
+
// not-yet-loaded ledger would read as "nothing came before" and rewrite this project's
|
|
178
|
+
// row down to the current transcript. Skipping is free — this is called on every
|
|
179
|
+
// transcript change, so the next one writes.
|
|
180
|
+
if (!this.loaded)
|
|
181
|
+
return;
|
|
182
|
+
const live = this.deps.sessionUsage();
|
|
183
|
+
const tokensConsumed = totalTokens(live);
|
|
184
|
+
if (live.costUsd <= 0 && tokensConsumed <= 0)
|
|
185
|
+
return;
|
|
186
|
+
// The session key identifies the work — a project id, for hosts that key sessions
|
|
187
|
+
// per project. Without one there is nothing stable to upsert against, so skip
|
|
188
|
+
// rather than write a row that can never be matched again.
|
|
189
|
+
const projectKey = this.deps.stateKey();
|
|
190
|
+
if (!projectKey)
|
|
191
|
+
return;
|
|
192
|
+
const messages = this.deps.messages();
|
|
193
|
+
const title = resolveCostSessionTitle({
|
|
194
|
+
explicit: options === null || options === void 0 ? void 0 : options.title,
|
|
195
|
+
appTitle: this.config.title,
|
|
196
|
+
messages,
|
|
197
|
+
headerTitle: this.deps.headerTitle(),
|
|
198
|
+
});
|
|
199
|
+
// Lifetime = what came before this transcript + what this transcript proves. Both
|
|
200
|
+
// terms are derived from state, never incremented, so the two element instances that
|
|
201
|
+
// share a session write the same row rather than each adding their own contribution.
|
|
202
|
+
const banked = this.bankedUsageFor(projectKey);
|
|
203
|
+
const record = {
|
|
204
|
+
// Stable per project so repeat upserts replace the row instead of stacking, and
|
|
205
|
+
// so the delete action keeps targeting the same row across reloads.
|
|
206
|
+
id: projectKey,
|
|
207
|
+
projectKey,
|
|
208
|
+
title,
|
|
209
|
+
updatedAt: new Date().toISOString(),
|
|
210
|
+
usage: addUsage(banked, live),
|
|
211
|
+
banked,
|
|
212
|
+
models: collectSessionModels(messages, this.deps.providerStatuses()),
|
|
213
|
+
};
|
|
214
|
+
this.persist(upsertRecord(this.history, record));
|
|
215
|
+
// NOTE: the live counters are deliberately NOT reset here. Resetting was what
|
|
216
|
+
// double-counted spend — the row was banked while the persisted transcript still
|
|
217
|
+
// carried the same usage, so a reload restored it and the Usage tab added both.
|
|
218
|
+
// The row is a projection of the session totals, not a transfer out of them.
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Spend for `projectKey` that predates the current transcript, resolved once per SESSION and
|
|
222
|
+
* shared by every element instance wired to it (see `banked-usage-baselines`), so it cannot
|
|
223
|
+
* drift as the row it came from is rewritten by our own upserts.
|
|
224
|
+
*
|
|
225
|
+
* The two cases turn on whether the transcript is authoritative for this project:
|
|
226
|
+
*
|
|
227
|
+
* - **Transcript restored** (`persistenceActive`): it already accounts for everything after
|
|
228
|
+
* the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
|
|
229
|
+
* `usage` here would add the restored history to itself on every reload.
|
|
230
|
+
* - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
|
|
231
|
+
* whole of it becomes banked. Without this the next turn would rewrite the row downwards
|
|
232
|
+
* to just that turn, losing the project's earlier spend.
|
|
233
|
+
*
|
|
234
|
+
* The baseline is deliberately NOT element or controller state. This element is remounted
|
|
235
|
+
* repeatedly inside a single session (the host re-rendering its layout, popping out, docking)
|
|
236
|
+
* and a fresh instance re-deriving would take the second branch against rows that already hold
|
|
237
|
+
* the live transcript — banking the session as its own baseline. Keying on the session instead
|
|
238
|
+
* means a remount inherits the baseline that transcript was paired with.
|
|
239
|
+
*/
|
|
240
|
+
bankedUsageFor(projectKey) {
|
|
241
|
+
const resolved = getBankedBaseline(projectKey);
|
|
242
|
+
if (resolved)
|
|
243
|
+
return resolved;
|
|
244
|
+
const existing = this.history.find((r) => r.projectKey === projectKey);
|
|
245
|
+
// `persistenceActive` is what decides whether the transcript speaks for the project's
|
|
246
|
+
// history: it is on only when a provider is wired AND the user is remembering the chat.
|
|
247
|
+
const banked = resolveBankedUsage(existing, this.deps.persistenceActive());
|
|
248
|
+
setBankedBaseline(projectKey, banked);
|
|
249
|
+
return banked;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Re-baseline this session against an emptied transcript: what came before, plus what the
|
|
253
|
+
* transcript being discarded proved. `discarded` must be read BEFORE the live figures are
|
|
254
|
+
* reset, since that is the only record of the latter.
|
|
255
|
+
*
|
|
256
|
+
* Not the same as dropping the baseline. Dropping it re-derives through `persistenceActive`,
|
|
257
|
+
* and with persistence ON that carries the row's *old* banked figure forward — discarding
|
|
258
|
+
* every turn between the last baseline and the clear. The transcript is gone either way, so
|
|
259
|
+
* the branch that reads it as authoritative no longer applies.
|
|
260
|
+
*
|
|
261
|
+
* Derived from the memoised baseline and the live figure rather than from the project's row,
|
|
262
|
+
* because a row is only as current as the last {@link CostLedgerController.finalize} — which
|
|
263
|
+
* skips its write while the ledger is unloaded, and while the session has no usage yet.
|
|
264
|
+
*/
|
|
265
|
+
rebaselineOnClearedTranscript(key, discarded) {
|
|
266
|
+
// Already resolved for this session? Then the new baseline is fully determined without
|
|
267
|
+
// consulting the ledger at all — `bankedUsageFor` would short-circuit on this same value — so
|
|
268
|
+
// answer before the guard below, which would otherwise discard a figure that is both correct
|
|
269
|
+
// and independent of the rows. Losing it costs real spend: with a row at `usage: 120,
|
|
270
|
+
// banked: 90` and 30 on screen, a clear during a reload would drop the memoised 90, and the
|
|
271
|
+
// next turn would re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
|
|
272
|
+
const resolved = getBankedBaseline(key);
|
|
273
|
+
if (resolved) {
|
|
274
|
+
setBankedBaseline(key, addUsage(resolved, discarded));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Nothing memoised, so a baseline can only come from the rows — and a stamped one is STICKY
|
|
278
|
+
// (`bankedUsageFor` short-circuits on any stored entry, and a zero usage is a *resolved* value
|
|
279
|
+
// there, not "unresolved"). So unless the rows are genuinely known, do not guess:
|
|
280
|
+
//
|
|
281
|
+
// - `enabled` false: `reload` sets `loaded` TRUE over a deliberately empty list, meaning
|
|
282
|
+
// "nothing will be loaded" rather than "rows are known". The flag alone therefore is not
|
|
283
|
+
// enough. Reachable because `enabled` is `cost?.enabled === true` — false for an unbound
|
|
284
|
+
// `chatConfig`, and for any host resolving it from an async setting, exactly as
|
|
285
|
+
// `chatConfigChanged` already expects.
|
|
286
|
+
// - Provider read in flight: the list is emptied and the flag cleared synchronously, on
|
|
287
|
+
// connect and again on every `chatConfig` rebind.
|
|
288
|
+
// - Unreadable: the rows we hold are a fallback, not the ledger.
|
|
289
|
+
//
|
|
290
|
+
// In all three, pinning would bury this project's real total the moment it arrived — a later
|
|
291
|
+
// load does NOT invalidate baselines (deliberately, see `reload`), so the next write would
|
|
292
|
+
// replace the row off the pinned figure and lose everything before it.
|
|
293
|
+
//
|
|
294
|
+
// Dropping instead leaves the next write to re-derive from rows that actually arrived. It
|
|
295
|
+
// under-banks `discarded` in that window — a bounded loss, and what every clear did before
|
|
296
|
+
// this method existed. Nothing is written while disabled, so there is no banked value to
|
|
297
|
+
// preserve in that case either.
|
|
298
|
+
if (!this.config.enabled || !this.loaded || this.unreadable) {
|
|
299
|
+
deleteBankedBaseline(key);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
setBankedBaseline(key, addUsage(this.bankedUsageFor(key), discarded));
|
|
303
|
+
}
|
|
304
|
+
/** Remove one entry from persisted build history. */
|
|
305
|
+
remove(id) {
|
|
306
|
+
// Deleting a row discards its banked spend by intent, so drop the baseline too —
|
|
307
|
+
// otherwise the next turn would rewrite the row we just removed, restoring the total the
|
|
308
|
+
// user asked to be rid of.
|
|
309
|
+
//
|
|
310
|
+
// Resolve the row first: `id` is a row id while baselines are keyed by projectKey. They
|
|
311
|
+
// match only for rows this element wrote (`finalize` sets `id: projectKey`); a row from a
|
|
312
|
+
// host provider can carry any id, and deleting by the wrong namespace would silently leave
|
|
313
|
+
// the baseline in place.
|
|
314
|
+
const removed = this.history.find((record) => record.id === id);
|
|
315
|
+
if (removed)
|
|
316
|
+
deleteBankedBaseline(removed.projectKey);
|
|
317
|
+
this.persist(this.history.filter((record) => record.id !== id));
|
|
318
|
+
}
|
|
319
|
+
/** Clear all persisted build history for this assistant scope. */
|
|
320
|
+
clear() {
|
|
321
|
+
// Every row is gone, so nothing is banked any more — without this the next turn would
|
|
322
|
+
// resurrect the pre-clear total. Dropped per row rather than for the active session alone,
|
|
323
|
+
// so a project whose row was cleared while another session was displaying it does not keep
|
|
324
|
+
// a baseline pointing at spend that no longer exists anywhere.
|
|
325
|
+
for (const record of this.history)
|
|
326
|
+
deleteBankedBaseline(record.projectKey);
|
|
327
|
+
// Provider-backed: write an empty ledger through. The `localStorage` path instead
|
|
328
|
+
// removes its key outright rather than leaving an empty array behind.
|
|
329
|
+
if (this.costHistoryProvider()) {
|
|
330
|
+
this.persist([]);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
clearCostSessionHistory(this.scope());
|
|
334
|
+
this.history = [];
|
|
335
|
+
}
|
|
336
|
+
scope() {
|
|
337
|
+
const scope = this.config.scope;
|
|
338
|
+
if (scope)
|
|
339
|
+
return scope;
|
|
340
|
+
return this.deps.elementId() || 'default';
|
|
341
|
+
}
|
|
342
|
+
/** Re-read the ledger for the current scope. Runs on connect and on every `chatConfig` rebind. */
|
|
343
|
+
reload() {
|
|
344
|
+
// NOTE: deliberately does NOT invalidate the banked figure. This runs again whenever
|
|
345
|
+
// `chatConfig` rebinds — hosts commonly build it in a template expression, so a new
|
|
346
|
+
// object arrives mid-session — and by then the rows include our own upserts, whose
|
|
347
|
+
// `usage` already contains the live transcript. Re-deriving from those would add the
|
|
348
|
+
// current session to itself. `bankedUsageFor` keys its memo on the project instead,
|
|
349
|
+
// which handles a genuine session switch without trusting reload timing.
|
|
350
|
+
this.loaded = false;
|
|
351
|
+
this.unreadable = false;
|
|
352
|
+
if (!this.config.enabled) {
|
|
353
|
+
this.history = [];
|
|
354
|
+
this.loaded = true;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const scope = this.scope();
|
|
358
|
+
const provider = this.costHistoryProvider();
|
|
359
|
+
if (!provider) {
|
|
360
|
+
this.history = loadCostSessionHistory(scope);
|
|
361
|
+
this.loaded = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Provider reads are async, so the Usage tab renders an empty list for a tick before
|
|
365
|
+
// this lands. Seeded empty rather than left stale so a scope change can't briefly show
|
|
366
|
+
// the previous scope's rows.
|
|
367
|
+
this.history = [];
|
|
368
|
+
void provider.loadCostHistory(scope)
|
|
369
|
+
.then((records) => {
|
|
370
|
+
// Guard against a scope change while the load was in flight — a late result must
|
|
371
|
+
// not overwrite the rows of whatever scope is current now.
|
|
372
|
+
if (this.scope() === scope) {
|
|
373
|
+
// Filtered exactly as the localStorage path is. A host's rows are as capable of
|
|
374
|
+
// being malformed as a hand-edited storage entry, and an unchecked one poisons
|
|
375
|
+
// every total with NaN or throws in the sort during render.
|
|
376
|
+
this.history = (records !== null && records !== void 0 ? records : []).filter(isCostSessionRecord);
|
|
377
|
+
this.loaded = true;
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
.catch((e) => {
|
|
381
|
+
logger.warn('Cost history unreadable — recording locally only this page-load', e);
|
|
382
|
+
// Writes stay blocked, and `persist` will not save even if something else calls it.
|
|
383
|
+
// `saveCostHistory` REPLACES the stored list, so writing an upsert against the empty
|
|
384
|
+
// list we fell back to would delete every other project's row and reset this
|
|
385
|
+
// project's banked total — destroying data we simply failed to read. The row
|
|
386
|
+
// rebuilds from the transcript on the next successful load.
|
|
387
|
+
this.unreadable = true;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The persistence provider, but only when it implements BOTH cost-history methods.
|
|
392
|
+
*
|
|
393
|
+
* Both or neither: a provider that can save but not load would silently accumulate a
|
|
394
|
+
* ledger nobody ever reads back, so a partial implementation is treated as absent and
|
|
395
|
+
* falls back to `localStorage`.
|
|
396
|
+
*/
|
|
397
|
+
costHistoryProvider() {
|
|
398
|
+
const provider = this.deps.provider();
|
|
399
|
+
return (provider === null || provider === void 0 ? void 0 : provider.loadCostHistory) && provider.saveCostHistory ? provider : undefined;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Adopt `records` as the live list, and schedule a write to whichever backend owns cost
|
|
403
|
+
* history — the provider when the host implements it, else `localStorage`.
|
|
404
|
+
*
|
|
405
|
+
* The list is adopted synchronously because that is what the Usage tab renders; only the
|
|
406
|
+
* *write* is deferred. This runs on every usage change (about once per LLM call, plus once
|
|
407
|
+
* per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
|
|
408
|
+
* per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
|
|
409
|
+
* because the write is a full replace of idempotent state: the newest call wins and no
|
|
410
|
+
* intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
|
|
411
|
+
*/
|
|
412
|
+
persist(records) {
|
|
413
|
+
this.history = records;
|
|
414
|
+
// A ledger we could not read must never be written back — see `reload`. Guarded here
|
|
415
|
+
// rather than only at the finalize gate so it also covers the row-removal and clear
|
|
416
|
+
// paths, which write directly.
|
|
417
|
+
if (this.unreadable)
|
|
418
|
+
return;
|
|
419
|
+
this.dirty = true;
|
|
420
|
+
if (this.flushTimer !== undefined)
|
|
421
|
+
clearTimeout(this.flushTimer);
|
|
422
|
+
this.flushTimer = setTimeout(() => this.flush(), LEDGER_FLUSH_DEBOUNCE_MS);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Write the pending ledger now. Safe to call when nothing is pending.
|
|
426
|
+
*
|
|
427
|
+
* Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
|
|
428
|
+
* conversation content, so a failed write is logged and never surfaced into the chat. On
|
|
429
|
+
* `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
|
|
430
|
+
* the same unload race as every other network save the assistant makes.
|
|
431
|
+
*/
|
|
432
|
+
flush() {
|
|
433
|
+
if (this.flushTimer !== undefined) {
|
|
434
|
+
clearTimeout(this.flushTimer);
|
|
435
|
+
this.flushTimer = undefined;
|
|
436
|
+
}
|
|
437
|
+
if (!this.dirty || this.unreadable)
|
|
438
|
+
return;
|
|
439
|
+
this.dirty = false;
|
|
440
|
+
const records = this.history;
|
|
441
|
+
const scope = this.scope();
|
|
442
|
+
const provider = this.costHistoryProvider();
|
|
443
|
+
if (!provider) {
|
|
444
|
+
saveCostSessionHistory(scope, records);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
void provider.saveCostHistory(scope, records).catch((e) => {
|
|
448
|
+
logger.warn('Cost history save failed', e);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Finalize the previous session when the host-provided title changes — a rename should land on
|
|
453
|
+
* the row under its old name rather than silently relabelling accrued spend.
|
|
454
|
+
*
|
|
455
|
+
* Only when the session key still matches the one that title accrued under. Hosts change
|
|
456
|
+
* `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
|
|
457
|
+
* upsert the outgoing project's live usage onto the incoming project's row as
|
|
458
|
+
* `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
|
|
459
|
+
* row; with the upsert it overwrites a real one.
|
|
460
|
+
*/
|
|
461
|
+
syncActiveTitle() {
|
|
462
|
+
var _a, _b;
|
|
463
|
+
const previous = this.activeTitle;
|
|
464
|
+
const next = (_b = (_a = this.config.title) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
|
|
465
|
+
const key = this.deps.stateKey();
|
|
466
|
+
if (previous && previous !== next && this.activeKey === key) {
|
|
467
|
+
this.finalize({ title: previous });
|
|
468
|
+
}
|
|
469
|
+
if (previous !== next) {
|
|
470
|
+
this.activeTitle = next;
|
|
471
|
+
this.activeKey = key;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
__decorate([
|
|
476
|
+
observable
|
|
477
|
+
], CostLedgerController.prototype, "history", void 0);
|
|
478
|
+
__decorate([
|
|
479
|
+
volatile
|
|
480
|
+
], CostLedgerController.prototype, "config", null);
|
|
481
|
+
__decorate([
|
|
482
|
+
volatile
|
|
483
|
+
], CostLedgerController.prototype, "uniqueProviderModels", null);
|
|
484
|
+
__decorate([
|
|
485
|
+
volatile
|
|
486
|
+
], CostLedgerController.prototype, "sortedHistory", null);
|
|
487
|
+
__decorate([
|
|
488
|
+
volatile
|
|
489
|
+
], CostLedgerController.prototype, "cumulativeUsage", null);
|
|
490
|
+
__decorate([
|
|
491
|
+
volatile
|
|
492
|
+
], CostLedgerController.prototype, "cumulativeCostUsd", null);
|
|
493
|
+
__decorate([
|
|
494
|
+
volatile
|
|
495
|
+
], CostLedgerController.prototype, "liveUsageIsProjectLifetime", null);
|
|
496
|
+
__decorate([
|
|
497
|
+
volatile
|
|
498
|
+
], CostLedgerController.prototype, "liveTitle", null);
|