@genesislcap/ai-assistant 15.4.1 → 15.6.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 +571 -82
- package/dist/ai-assistant.d.ts +336 -36
- package/dist/chat-driver.cjs +104 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +104 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +314 -36
- package/dist/dts/components/chat-driver/chat-driver.d.ts +12 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/index.d.ts +1 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
- package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +184 -24
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
- package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
- package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +1 -2
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/styles/settings-section.d.ts +29 -0
- package/dist/dts/styles/settings-section.d.ts.map +1 -0
- package/dist/dts/utils/condense-history.d.ts +14 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/cost-session-history.d.ts +103 -12
- package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
- package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
- package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
- package/dist/dts/utils/sum-costs.d.ts.map +1 -1
- package/dist/dts/utils/sum-tokens.d.ts +8 -8
- package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
- package/dist/dts/utils/sum-usage.d.ts +59 -0
- package/dist/dts/utils/sum-usage.d.ts.map +1 -0
- package/dist/dts/utils/sum-usage.test.d.ts +2 -0
- package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +9 -1
- package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
- package/dist/esm/index.js +1 -0
- package/dist/esm/main/cost-session-banking.test.js +308 -0
- package/dist/esm/main/main.js +424 -71
- package/dist/esm/state/ai-assistant-slice.js +11 -8
- package/dist/esm/state/ai-assistant-slice.test.js +12 -5
- package/dist/esm/state/debug-event-log.js +2 -2
- package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
- package/dist/esm/state/persistence/session-persister.js +2 -2
- package/dist/esm/state/persistence/session-persister.test.js +10 -1
- package/dist/esm/state/persistence/session-snapshot.js +6 -2
- package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
- package/dist/esm/styles/settings-section.js +39 -0
- package/dist/esm/utils/condense-history.js +18 -5
- package/dist/esm/utils/condense-history.test.js +67 -0
- package/dist/esm/utils/cost-session-history.js +92 -15
- package/dist/esm/utils/cost-session-history.test.js +155 -13
- package/dist/esm/utils/resolve-cost-history-config.js +2 -1
- package/dist/esm/utils/sum-costs.js +2 -13
- package/dist/esm/utils/sum-tokens.js +10 -27
- package/dist/esm/utils/sum-tokens.test.js +1 -5
- package/dist/esm/utils/sum-usage.js +123 -0
- package/dist/esm/utils/sum-usage.test.js +120 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/components/chat-driver/chat-driver.ts +21 -0
- package/src/components/settings-modal/settings-modal.styles.ts +237 -18
- package/src/components/settings-modal/settings-modal.template.ts +270 -81
- package/src/index.ts +1 -0
- package/src/main/cost-session-banking.test.ts +407 -0
- package/src/main/main.ts +433 -68
- package/src/provider/assistant-app-settings.ts +31 -5
- package/src/state/ai-assistant-slice.test.ts +12 -5
- package/src/state/ai-assistant-slice.ts +21 -13
- package/src/state/debug-event-log.ts +2 -2
- package/src/state/persistence/session-persistence-provider.ts +24 -0
- package/src/state/persistence/session-persistence.integration.test.ts +8 -1
- package/src/state/persistence/session-persister.test.ts +10 -1
- package/src/state/persistence/session-persister.ts +2 -2
- package/src/state/persistence/session-snapshot.test.ts +4 -1
- package/src/state/persistence/session-snapshot.ts +5 -1
- package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
- package/src/styles/settings-section.ts +40 -0
- package/src/utils/condense-history.test.ts +103 -0
- package/src/utils/condense-history.ts +33 -3
- package/src/utils/cost-session-history.test.ts +187 -16
- package/src/utils/cost-session-history.ts +142 -23
- package/src/utils/resolve-cost-history-config.ts +10 -3
- package/src/utils/sum-costs.ts +2 -9
- package/src/utils/sum-tokens.test.ts +1 -11
- package/src/utils/sum-tokens.ts +10 -26
- package/src/utils/sum-usage.test.ts +140 -0
- package/src/utils/sum-usage.ts +130 -0
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
2
|
import type { CostSessionRecord } from './cost-session-history';
|
|
3
3
|
import {
|
|
4
|
-
appendCostSessionRecord,
|
|
5
4
|
clearCostSessionHistory,
|
|
6
5
|
costHistoryStorageKey,
|
|
7
6
|
formatCostSessionDate,
|
|
8
7
|
loadCostSessionHistory,
|
|
9
|
-
|
|
8
|
+
resolveBankedUsage,
|
|
10
9
|
saveCostSessionHistory,
|
|
10
|
+
sortRecordsByRecency,
|
|
11
|
+
upsertRecord,
|
|
11
12
|
} from './cost-session-history';
|
|
13
|
+
import { emptyUsage } from './sum-usage';
|
|
12
14
|
|
|
13
15
|
const scope = `test-${Date.now()}`;
|
|
14
16
|
|
|
17
|
+
const usage = (costUsd: number): CostSessionRecord['usage'] => ({
|
|
18
|
+
costUsd,
|
|
19
|
+
uncachedInputTokens: 100_000,
|
|
20
|
+
cacheReadTokens: 1_200_000,
|
|
21
|
+
cacheWriteTokens: 60_000,
|
|
22
|
+
outputTokens: 40_000,
|
|
23
|
+
});
|
|
24
|
+
|
|
15
25
|
const sampleRecord = (overrides: Partial<CostSessionRecord> = {}): CostSessionRecord => ({
|
|
16
26
|
id: 'rec-1',
|
|
27
|
+
projectKey: 'proj-1',
|
|
17
28
|
title: 'Equity Options Pricer',
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
updatedAt: '2026-06-14T12:00:00.000Z',
|
|
30
|
+
usage: usage(1.86),
|
|
31
|
+
// Present by default so `delete record.banked` in the legacy-row cases actually removes
|
|
32
|
+
// something, and so the storage round-trip covers it.
|
|
33
|
+
banked: usage(0.5),
|
|
21
34
|
models: [{ model: 'claude-sonnet-4-6', provider: 'anthropic' }],
|
|
22
35
|
...overrides,
|
|
23
36
|
});
|
|
@@ -28,23 +41,90 @@ suite('uses a scoped storage key', () => {
|
|
|
28
41
|
assert.is(costHistoryStorageKey('my-app'), 'genesis-ai-assistant:cost-history:my-app');
|
|
29
42
|
});
|
|
30
43
|
|
|
31
|
-
suite('
|
|
44
|
+
suite('upsertRecord prepends a project it has not seen', () => {
|
|
45
|
+
const first = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', title: 'First' }));
|
|
46
|
+
const both = upsertRecord(first, sampleRecord({ id: 'b', projectKey: 'b', title: 'Second' }));
|
|
47
|
+
assert.is(both.length, 2);
|
|
48
|
+
// Insertion order, not recency — display ordering is sortRecordsByRecency's job.
|
|
49
|
+
assert.is(both[0]?.id, 'b', 'most recently added is prepended');
|
|
50
|
+
assert.is(both[1]?.id, 'a');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
suite('upsertRecord replaces a project in place rather than stacking a second row', () => {
|
|
54
|
+
const seeded = [
|
|
55
|
+
sampleRecord({ id: 'b', projectKey: 'b', usage: usage(2) }),
|
|
56
|
+
sampleRecord({ id: 'a', projectKey: 'a', usage: usage(1) }),
|
|
57
|
+
];
|
|
58
|
+
// Re-reporting a project must REPLACE its row. Appending here is what made a single
|
|
59
|
+
// project's spend count once per refresh in any total summed over the list.
|
|
60
|
+
const next = upsertRecord(seeded, sampleRecord({ id: 'a', projectKey: 'a', usage: usage(9.5) }));
|
|
61
|
+
assert.is(next.length, 2);
|
|
62
|
+
// Replaced in place rather than moved — the stored array is not a recency ordering.
|
|
63
|
+
assert.is(next[1]?.projectKey, 'a');
|
|
64
|
+
assert.is(next[1]?.usage.costUsd, 9.5);
|
|
65
|
+
assert.is(
|
|
66
|
+
next.reduce((sum, r) => sum + r.usage.costUsd, 0),
|
|
67
|
+
11.5,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
suite('round-trips records through storage', () => {
|
|
32
72
|
clearCostSessionHistory(scope);
|
|
33
|
-
|
|
34
|
-
|
|
73
|
+
const records = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', usage: usage(3.5) }));
|
|
74
|
+
saveCostSessionHistory(scope, records);
|
|
35
75
|
const loaded = loadCostSessionHistory(scope);
|
|
36
|
-
assert.is(loaded.length,
|
|
37
|
-
assert.is(loaded[0]?.
|
|
38
|
-
assert.is(loaded[
|
|
76
|
+
assert.is(loaded.length, 1);
|
|
77
|
+
assert.is(loaded[0]?.usage.costUsd, 3.5);
|
|
78
|
+
assert.is(loaded[0]?.usage.cacheReadTokens, 1_200_000, 'buckets survive serialization');
|
|
79
|
+
assert.is(loaded[0]?.banked?.costUsd, 0.5, 'banked survives serialization');
|
|
39
80
|
clearCostSessionHistory(scope);
|
|
40
81
|
});
|
|
41
82
|
|
|
42
|
-
suite('
|
|
83
|
+
suite('discards legacy rows that predate the per-project key', () => {
|
|
43
84
|
clearCostSessionHistory(scope);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
85
|
+
const legacy = sampleRecord({ id: 'old' }) as Partial<CostSessionRecord>;
|
|
86
|
+
delete legacy.projectKey;
|
|
87
|
+
saveCostSessionHistory(scope, [legacy as CostSessionRecord, sampleRecord({ id: 'new' })]);
|
|
88
|
+
const loaded = loadCostSessionHistory(scope);
|
|
89
|
+
// A keyless row can never be matched to a project, so pooling it with the new rows
|
|
90
|
+
// would permanently double-count whichever project it came from.
|
|
91
|
+
assert.is(loaded.length, 1);
|
|
92
|
+
assert.is(loaded[0]?.id, 'new');
|
|
93
|
+
clearCostSessionHistory(scope);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
suite('discards rows whose usage is not fully numeric', () => {
|
|
97
|
+
clearCostSessionHistory(scope);
|
|
98
|
+
const broken = sampleRecord({ id: 'broken' });
|
|
99
|
+
// A half-shaped `usage` is worse than a missing row: it sums as NaN and poisons every
|
|
100
|
+
// total on the Usage tab, with no clue where the NaN came from.
|
|
101
|
+
delete (broken.usage as Partial<CostSessionRecord['usage']>).outputTokens;
|
|
102
|
+
saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
|
|
103
|
+
const loaded = loadCostSessionHistory(scope);
|
|
104
|
+
assert.is(loaded.length, 1);
|
|
105
|
+
assert.is(loaded[0]?.id, 'ok');
|
|
106
|
+
clearCostSessionHistory(scope);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
suite('discards rows whose banked figure is half-shaped', () => {
|
|
110
|
+
clearCostSessionHistory(scope);
|
|
111
|
+
const broken = sampleRecord({ id: 'broken', banked: { ...usage(2) } });
|
|
112
|
+
// A malformed `banked` poisons the lifetime total exactly as a malformed `usage` does,
|
|
113
|
+
// since the two are added together to produce it.
|
|
114
|
+
delete (broken.banked as Partial<CostSessionRecord['usage']>).cacheWriteTokens;
|
|
115
|
+
saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
|
|
116
|
+
const loaded = loadCostSessionHistory(scope);
|
|
117
|
+
assert.is(loaded.length, 1);
|
|
118
|
+
assert.is(loaded[0]?.id, 'ok');
|
|
119
|
+
clearCostSessionHistory(scope);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
suite('keeps a row whose banked figure is absent', () => {
|
|
123
|
+
clearCostSessionHistory(scope);
|
|
124
|
+
const legacy = sampleRecord({ id: 'legacy' });
|
|
125
|
+
delete legacy.banked;
|
|
126
|
+
saveCostSessionHistory(scope, [legacy]);
|
|
127
|
+
assert.is(loadCostSessionHistory(scope).length, 1, 'absent is valid, unlike half-shaped');
|
|
48
128
|
clearCostSessionHistory(scope);
|
|
49
129
|
});
|
|
50
130
|
|
|
@@ -52,4 +132,95 @@ suite('formats session dates for display', () => {
|
|
|
52
132
|
assert.is(formatCostSessionDate('2026-06-14T12:00:00.000Z'), 'Jun 14, 2026');
|
|
53
133
|
});
|
|
54
134
|
|
|
135
|
+
suite('sortRecordsByRecency puts the most recently worked-on project first', () => {
|
|
136
|
+
const records = [
|
|
137
|
+
sampleRecord({ id: 'old', projectKey: 'old', updatedAt: '2026-01-01T00:00:00.000Z' }),
|
|
138
|
+
sampleRecord({ id: 'newest', projectKey: 'newest', updatedAt: '2026-08-04T09:00:00.000Z' }),
|
|
139
|
+
sampleRecord({ id: 'mid', projectKey: 'mid', updatedAt: '2026-05-01T00:00:00.000Z' }),
|
|
140
|
+
];
|
|
141
|
+
const sorted = sortRecordsByRecency(records);
|
|
142
|
+
assert.equal(
|
|
143
|
+
sorted.map((r) => r.id),
|
|
144
|
+
['newest', 'mid', 'old'],
|
|
145
|
+
);
|
|
146
|
+
// Read-time ordering, so the caller's array must be left alone.
|
|
147
|
+
assert.equal(
|
|
148
|
+
records.map((r) => r.id),
|
|
149
|
+
['old', 'newest', 'mid'],
|
|
150
|
+
'input not mutated',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
suite('sortRecordsByRecency overrides insertion order, which upsert leaves alone', () => {
|
|
155
|
+
// The behaviour this fixes: revisiting a project replaces its row IN PLACE, so insertion order
|
|
156
|
+
// strands it wherever it was first seen. Display order must come from updatedAt instead.
|
|
157
|
+
let records = upsertRecord(
|
|
158
|
+
[],
|
|
159
|
+
sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-01-01T00:00:00.000Z' }),
|
|
160
|
+
);
|
|
161
|
+
records = upsertRecord(
|
|
162
|
+
records,
|
|
163
|
+
sampleRecord({ id: 'b', projectKey: 'b', updatedAt: '2026-02-01T00:00:00.000Z' }),
|
|
164
|
+
);
|
|
165
|
+
// Work on 'a' again — newer than 'b', but upsert keeps it in slot 1.
|
|
166
|
+
records = upsertRecord(
|
|
167
|
+
records,
|
|
168
|
+
sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-03-01T00:00:00.000Z' }),
|
|
169
|
+
);
|
|
170
|
+
assert.is(records[1]?.id, 'a', 'stored order is unchanged by the revisit');
|
|
171
|
+
assert.is(sortRecordsByRecency(records)[0]?.id, 'a', 'display order tracks the revisit');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
suite('banks nothing for a project with no prior row', () => {
|
|
175
|
+
assert.equal(resolveBankedUsage(undefined, true), emptyUsage());
|
|
176
|
+
assert.equal(resolveBankedUsage(undefined, false), emptyUsage());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
suite('carries the prior banked figure forward when the transcript is authoritative', () => {
|
|
180
|
+
// Restored transcript: it already re-proves everything after the row's own banked figure,
|
|
181
|
+
// so only that figure carries over. Taking `usage` here is the compounding double-count.
|
|
182
|
+
const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
|
|
183
|
+
assert.is(resolveBankedUsage(prior, true).costUsd, 4);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
suite('banks the whole prior total when the transcript starts empty', () => {
|
|
187
|
+
// Nothing on screen accounts for the previous total, so all of it must carry over or the
|
|
188
|
+
// next turn rewrites the row down to just that turn.
|
|
189
|
+
const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
|
|
190
|
+
assert.is(resolveBankedUsage(prior, false).costUsd, 10);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
suite('treats a row predating the banked field as nothing banked', () => {
|
|
194
|
+
const legacy = sampleRecord({ usage: usage(7) });
|
|
195
|
+
delete legacy.banked;
|
|
196
|
+
assert.equal(resolveBankedUsage(legacy, true), emptyUsage());
|
|
197
|
+
// Without persistence the legacy total still carries, so upgrading loses no spend.
|
|
198
|
+
assert.is(resolveBankedUsage(legacy, false).costUsd, 7);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
suite('is stable across repeated reloads — the regression this guards', () => {
|
|
202
|
+
// Simulate the reload cycle: usage = banked + whatever the transcript proves. With a
|
|
203
|
+
// restored transcript (worth 6 on top of 4 banked) the row must land on 10 every time,
|
|
204
|
+
// not 10 → 14 → 18 as it did when a reload re-banked the restored history.
|
|
205
|
+
const transcript = 6;
|
|
206
|
+
let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
|
|
207
|
+
for (let reload = 0; reload < 3; reload += 1) {
|
|
208
|
+
const banked = resolveBankedUsage(row, true);
|
|
209
|
+
row = { ...row, banked, usage: { ...usage(banked.costUsd + transcript) } };
|
|
210
|
+
assert.is(row.usage.costUsd, 10, `reload ${reload}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
suite('accumulates across reloads when the transcript does not persist', () => {
|
|
215
|
+
// The mirror case: each page-load starts from an empty transcript, so the row must grow by
|
|
216
|
+
// whatever that load spends rather than being overwritten by it.
|
|
217
|
+
let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
|
|
218
|
+
for (const spend of [2, 3]) {
|
|
219
|
+
const banked = resolveBankedUsage(row, false);
|
|
220
|
+
row = { ...row, banked, usage: { ...usage(banked.costUsd + spend) } };
|
|
221
|
+
}
|
|
222
|
+
// 10 banked, +2 → 12; then 12 banked, +3 → 15. Never decreases.
|
|
223
|
+
assert.is(row.usage.costUsd, 15);
|
|
224
|
+
});
|
|
225
|
+
|
|
55
226
|
suite.run();
|
|
@@ -1,31 +1,89 @@
|
|
|
1
|
-
import type { AIProviderType } from '@genesislcap/foundation-ai';
|
|
1
|
+
import type { AggregateUsage, AIProviderType } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { emptyUsage } from './sum-usage';
|
|
2
3
|
|
|
3
|
-
/** A model used during a
|
|
4
|
+
/** A model used during a recorded cost session. */
|
|
4
5
|
export interface CostSessionModelEntry {
|
|
5
6
|
model: string;
|
|
6
7
|
/** Vendor id from `getStatus()` (e.g. `'anthropic'`, `'gemini'`). */
|
|
7
8
|
provider?: AIProviderType;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
/** Persisted
|
|
11
|
+
/** Persisted usage record for one project's assistant work. */
|
|
11
12
|
export interface CostSessionRecord {
|
|
12
13
|
id: string;
|
|
14
|
+
/**
|
|
15
|
+
* Stable identity of the work this row stands for — the assistant's session key
|
|
16
|
+
* (a project id, for hosts that key sessions per project). The upsert key: a row
|
|
17
|
+
* is one project's lifetime usage, so re-reporting the same key REPLACES the row
|
|
18
|
+
* rather than adding another.
|
|
19
|
+
*/
|
|
20
|
+
projectKey: string;
|
|
13
21
|
title: string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
/**
|
|
23
|
+
* ISO-8601 timestamp of the most recent usage recorded for this row.
|
|
24
|
+
*
|
|
25
|
+
* Named for what it is: the row is upserted as spend accrues, so this moves. It is
|
|
26
|
+
* NOT an end time — a row has no terminal state, because a project can always be
|
|
27
|
+
* worked on again.
|
|
28
|
+
*/
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
/**
|
|
31
|
+
* Cost plus the four token buckets — the same shape the live session tile renders, so
|
|
32
|
+
* a history row can show the identical breakdown instead of a lone merged total that
|
|
33
|
+
* cannot explain a large token count at a small cost.
|
|
34
|
+
*
|
|
35
|
+
* This is the project's **lifetime** usage: `banked` plus whatever the live transcript
|
|
36
|
+
* currently proves.
|
|
37
|
+
*/
|
|
38
|
+
usage: AggregateUsage;
|
|
39
|
+
/**
|
|
40
|
+
* Spend that predates the current transcript, and so cannot be re-derived from it.
|
|
41
|
+
*
|
|
42
|
+
* Without this a row could only ever report what the open transcript proves. That is
|
|
43
|
+
* fine when the transcript is restored on load (it *is* the project's history), but
|
|
44
|
+
* where it starts empty every refresh the row would be rewritten downwards on the next
|
|
45
|
+
* turn and the project's earlier spend lost. Banking it keeps `usage` a lifetime figure
|
|
46
|
+
* in both cases.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately part of the row rather than element state: two assistant instances (a
|
|
49
|
+
* docked bubble and a popped-out panel) share one session, and both write this row. A
|
|
50
|
+
* value derived from the row keeps every write idempotent, whereas an element-held
|
|
51
|
+
* accumulator would be applied once per instance.
|
|
52
|
+
*
|
|
53
|
+
* Optional: rows written before this existed have none, which reads as "nothing banked".
|
|
54
|
+
*/
|
|
55
|
+
banked?: AggregateUsage;
|
|
18
56
|
models: CostSessionModelEntry[];
|
|
19
57
|
}
|
|
20
58
|
|
|
21
59
|
const STORAGE_PREFIX = 'genesis-ai-assistant:cost-history:';
|
|
22
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every bucket must be a number before a row is trusted. Checked per field rather than
|
|
63
|
+
* just testing for an object, because a partially-shaped `usage` would sum as `NaN` and
|
|
64
|
+
* silently poison every total on the Usage tab.
|
|
65
|
+
*/
|
|
66
|
+
function isAggregateUsage(value: unknown): value is AggregateUsage {
|
|
67
|
+
if (typeof value !== 'object' || value == null) return false;
|
|
68
|
+
const u = value as AggregateUsage;
|
|
69
|
+
return (
|
|
70
|
+
typeof u.costUsd === 'number' &&
|
|
71
|
+
typeof u.uncachedInputTokens === 'number' &&
|
|
72
|
+
typeof u.cacheReadTokens === 'number' &&
|
|
73
|
+
typeof u.cacheWriteTokens === 'number' &&
|
|
74
|
+
typeof u.outputTokens === 'number'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
23
78
|
/** localStorage key for a host-scoped cost history list. */
|
|
24
79
|
export function costHistoryStorageKey(scope: string): string {
|
|
25
80
|
return `${STORAGE_PREFIX}${scope}`;
|
|
26
81
|
}
|
|
27
82
|
|
|
28
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Load persisted cost sessions for a scope, in whatever order they were stored — this applies no
|
|
85
|
+
* ordering of its own. Display order is the caller's business; see `sortRecordsByRecency`.
|
|
86
|
+
*/
|
|
29
87
|
export function loadCostSessionHistory(scope: string): CostSessionRecord[] {
|
|
30
88
|
if (typeof localStorage === 'undefined') return [];
|
|
31
89
|
try {
|
|
@@ -49,21 +107,69 @@ export function saveCostSessionHistory(scope: string, records: CostSessionRecord
|
|
|
49
107
|
}
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Pure upsert — `records` with `record` replacing any row sharing its `projectKey`, or
|
|
112
|
+
* prepended when there is none.
|
|
113
|
+
*
|
|
114
|
+
* An upsert, not an append, because a row stands for a **project's lifetime usage** rather
|
|
115
|
+
* than a visit to it. Appending cut a fresh row per page-hide, so one project accumulated
|
|
116
|
+
* a stack of near-identical rows and its spend was counted once per refresh by anything
|
|
117
|
+
* summing the list.
|
|
118
|
+
*
|
|
119
|
+
* The resulting array order is incidental — it is insertion order, so a project returned to
|
|
120
|
+
* stays where it first landed. Do not read it as recency; `sortRecordsByRecency` is what the
|
|
121
|
+
* Usage tab renders through.
|
|
122
|
+
*
|
|
123
|
+
* Storage-agnostic on purpose: the same merge serves the synchronous `localStorage` path
|
|
124
|
+
* and the async provider-backed one, so the two can never drift on what "upsert" means.
|
|
125
|
+
* The caller owns persistence — see the assistant's `persistCostHistory`.
|
|
126
|
+
*/
|
|
127
|
+
export function upsertRecord(
|
|
128
|
+
records: readonly CostSessionRecord[],
|
|
55
129
|
record: CostSessionRecord,
|
|
56
130
|
): CostSessionRecord[] {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
131
|
+
const at = records.findIndex((r) => r.projectKey === record.projectKey);
|
|
132
|
+
return at === -1 ? [record, ...records] : records.map((r, i) => (i === at ? record : r));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rows most-recently-worked-on first, as a new array.
|
|
137
|
+
*
|
|
138
|
+
* Applied at read time rather than baked into the stored order, so it covers rows that came from
|
|
139
|
+
* a host provider as well as from `localStorage`, and so no write path has to maintain it.
|
|
140
|
+
*
|
|
141
|
+
* `updatedAt` is ISO-8601, which sorts lexicographically — no date parsing, and no dependence on
|
|
142
|
+
* the strings being valid dates. Sorting by it rather than by insertion order is what makes the
|
|
143
|
+
* list track use: the active project moves to the top on its first upsert and stays there, since
|
|
144
|
+
* its row is rewritten on every transcript change.
|
|
145
|
+
*/
|
|
146
|
+
export function sortRecordsByRecency(records: readonly CostSessionRecord[]): CostSessionRecord[] {
|
|
147
|
+
return [...records].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
60
148
|
}
|
|
61
149
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The spend a project's next row must carry over from before its current transcript.
|
|
152
|
+
*
|
|
153
|
+
* `transcriptIsAuthoritative` means the open transcript already accounts for this project's
|
|
154
|
+
* history — true exactly when a session snapshot is restored on load. The two branches are
|
|
155
|
+
* not interchangeable, and picking the wrong one is silently destructive:
|
|
156
|
+
*
|
|
157
|
+
* - Authoritative: carry the previous row's own `banked` forward. Taking `usage` instead would
|
|
158
|
+
* re-add the restored history on every reload, the compounding double-count this replaced.
|
|
159
|
+
* - Not authoritative: the whole previous total becomes banked, because nothing in the fresh
|
|
160
|
+
* transcript accounts for it. Taking `banked` instead would rewrite the row down to the
|
|
161
|
+
* current page-load and drop the project's earlier spend.
|
|
162
|
+
*
|
|
163
|
+
* Note both branches are idempotent — the result never includes the current transcript — which
|
|
164
|
+
* is what lets two assistant instances sharing a session write the same row.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveBankedUsage(
|
|
167
|
+
existing: CostSessionRecord | undefined,
|
|
168
|
+
transcriptIsAuthoritative: boolean,
|
|
169
|
+
): AggregateUsage {
|
|
170
|
+
if (!existing) return emptyUsage();
|
|
171
|
+
const carried = transcriptIsAuthoritative ? existing.banked : existing.usage;
|
|
172
|
+
return carried ?? emptyUsage();
|
|
67
173
|
}
|
|
68
174
|
|
|
69
175
|
/** Clear all persisted records for a scope. */
|
|
@@ -85,15 +191,28 @@ export function formatCostSessionDate(iso: string): string {
|
|
|
85
191
|
}).format(new Date(iso));
|
|
86
192
|
}
|
|
87
193
|
|
|
88
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Whether a value is a usable row. Exported because rows arrive by two routes and BOTH need it:
|
|
196
|
+
* `loadCostSessionHistory` filters the `localStorage` path here, and the assistant filters what a
|
|
197
|
+
* host provider returns. A row that skips this check is not merely ignored downstream — a
|
|
198
|
+
* half-shaped `usage` sums to `NaN` through every total, and a missing `updatedAt` throws inside
|
|
199
|
+
* the sort that orders the list, during render.
|
|
200
|
+
*/
|
|
201
|
+
export function isCostSessionRecord(value: unknown): value is CostSessionRecord {
|
|
89
202
|
if (typeof value !== 'object' || value == null) return false;
|
|
90
203
|
const r = value as CostSessionRecord;
|
|
91
204
|
return (
|
|
92
205
|
typeof r.id === 'string' &&
|
|
206
|
+
// Rows written before the upsert model have no `projectKey` and cannot be keyed
|
|
207
|
+
// to a project, so they are discarded on load rather than silently pooled with
|
|
208
|
+
// the new per-project rows (where they would double-count that project).
|
|
209
|
+
typeof r.projectKey === 'string' &&
|
|
93
210
|
typeof r.title === 'string' &&
|
|
94
|
-
typeof r.
|
|
95
|
-
|
|
96
|
-
|
|
211
|
+
typeof r.updatedAt === 'string' &&
|
|
212
|
+
isAggregateUsage(r.usage) &&
|
|
213
|
+
// Absent is valid (nothing banked / pre-dates the field); present must be whole, since
|
|
214
|
+
// a half-shaped `banked` would poison the lifetime total exactly as a bad `usage` would.
|
|
215
|
+
(r.banked === undefined || isAggregateUsage(r.banked)) &&
|
|
97
216
|
Array.isArray(r.models)
|
|
98
217
|
);
|
|
99
218
|
}
|
|
@@ -8,12 +8,18 @@ export interface ResolvedCostHistoryConfig {
|
|
|
8
8
|
title?: string;
|
|
9
9
|
/** `localStorage` scope key; falls back to element `id` then `'default'`. */
|
|
10
10
|
scope?: string;
|
|
11
|
-
/** Show running session USD on the
|
|
11
|
+
/** Show running session USD on the Usage tab. Default: `true`. */
|
|
12
12
|
showCost: boolean;
|
|
13
|
-
/** Show cumulative session tokens on the
|
|
13
|
+
/** Show cumulative session tokens on the Usage tab. Default: `true`. */
|
|
14
14
|
showTokens: boolean;
|
|
15
|
-
/** Live-session badge label on the
|
|
15
|
+
/** Live-session badge label on the Usage tab summary. */
|
|
16
16
|
badgeLabel: string;
|
|
17
|
+
/**
|
|
18
|
+
* Host-supplied disclosure line for the Usage tab. Stays optional — unlike
|
|
19
|
+
* `badgeLabel` there is no default, because only the host knows what its `scope`
|
|
20
|
+
* spans, and the library must not assert it.
|
|
21
|
+
*/
|
|
22
|
+
summaryHint?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** Resolve `chatConfig.costHistory` with defaults applied. */
|
|
@@ -27,5 +33,6 @@ export function resolveCostHistoryConfig(chatConfig: ChatConfig): ResolvedCostHi
|
|
|
27
33
|
showCost: cost?.showCost !== false,
|
|
28
34
|
showTokens: cost?.showTokens !== false,
|
|
29
35
|
badgeLabel: cost?.badgeLabel ?? 'Currently building',
|
|
36
|
+
summaryHint: cost?.summaryHint?.trim() || undefined,
|
|
30
37
|
};
|
|
31
38
|
}
|
package/src/utils/sum-costs.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { sumUsage } from './sum-usage';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Sum cost across a message list: each message's provider-reported LLM `cost`
|
|
@@ -13,13 +14,5 @@ import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
|
13
14
|
* @internal
|
|
14
15
|
*/
|
|
15
16
|
export function sumCosts(messages: readonly ChatMessage[]): number {
|
|
16
|
-
|
|
17
|
-
for (const m of messages) {
|
|
18
|
-
if (m.cost != null) total += m.cost;
|
|
19
|
-
if (m.externalCostUsd != null) total += m.externalCostUsd;
|
|
20
|
-
for (const tc of m.toolCalls ?? []) {
|
|
21
|
-
if (tc.subAgentTrace) total += sumCosts(tc.subAgentTrace);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return total;
|
|
17
|
+
return sumUsage(messages).costUsd;
|
|
25
18
|
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
2
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
-
import {
|
|
4
|
-
formatCompactTokenCount,
|
|
5
|
-
formatCompactTokenCountPlain,
|
|
6
|
-
formatCompactTokenCountSafe,
|
|
7
|
-
sumTokens,
|
|
8
|
-
} from './sum-tokens';
|
|
3
|
+
import { formatCompactTokenCount, formatCompactTokenCountSafe, sumTokens } from './sum-tokens';
|
|
9
4
|
|
|
10
5
|
const assistantMsg = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
|
11
6
|
role: 'assistant',
|
|
@@ -94,10 +89,5 @@ formatSuite('safe formatter preserves compact markup', () => {
|
|
|
94
89
|
);
|
|
95
90
|
});
|
|
96
91
|
|
|
97
|
-
formatSuite('plain formatter returns compact text', () => {
|
|
98
|
-
assert.is(formatCompactTokenCountPlain(1_400_000), '1.4M');
|
|
99
|
-
assert.is(formatCompactTokenCountPlain(842), '842');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
92
|
suite.run();
|
|
103
93
|
formatSuite.run();
|
package/src/utils/sum-tokens.ts
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
2
|
import DOMPurify from 'dompurify';
|
|
3
|
-
|
|
4
|
-
/** Per-message token usage when the provider reports it. */
|
|
5
|
-
const messageTokenUsage = (m: ChatMessage): number => (m.inputTokens ?? 0) + (m.outputTokens ?? 0);
|
|
3
|
+
import { sumUsage, totalTokens } from './sum-usage';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Total tokens across a message list — the headline figure, with the per-bucket
|
|
7
|
+
* split available from {@link sumUsage} for anything that needs to show *why* a
|
|
8
|
+
* large token count cost little.
|
|
9
|
+
*
|
|
10
|
+
* Identical arithmetic to the old per-message `inputTokens + outputTokens` sum:
|
|
11
|
+
* the three input buckets partition `inputTokens`, so they re-add to it exactly.
|
|
12
|
+
* It does now additionally count usage banked by a compaction, which is the point
|
|
13
|
+
* — that spend used to vanish when the summarised turns left the transcript.
|
|
10
14
|
*
|
|
11
15
|
* Returns 0 when no message carries token metadata.
|
|
12
16
|
*
|
|
13
17
|
* @internal
|
|
14
18
|
*/
|
|
15
19
|
export function sumTokens(messages: readonly ChatMessage[]): number {
|
|
16
|
-
|
|
17
|
-
for (const m of messages) {
|
|
18
|
-
total += messageTokenUsage(m);
|
|
19
|
-
for (const tc of m.toolCalls ?? []) {
|
|
20
|
-
if (tc.subAgentTrace) total += sumTokens(tc.subAgentTrace);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return total;
|
|
20
|
+
return totalTokens(sumUsage(messages));
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
const TOKEN_SCALE_DECIMAL_FACTOR = 10;
|
|
@@ -53,19 +50,6 @@ const scaledNumericValue = (tokens: number): number | undefined => {
|
|
|
53
50
|
return roundScaled(tokens / TOKENS_PER_THOUSAND);
|
|
54
51
|
};
|
|
55
52
|
|
|
56
|
-
/**
|
|
57
|
-
* Compact plain-text token count (e.g. 1_400_000 → "1.4M").
|
|
58
|
-
*
|
|
59
|
-
* @internal
|
|
60
|
-
*/
|
|
61
|
-
export function formatCompactTokenCountPlain(tokens: number): string {
|
|
62
|
-
const suffix = scaledSuffixLabel(tokens);
|
|
63
|
-
if (!suffix) return tokens.toLocaleString();
|
|
64
|
-
const rounded = scaledNumericValue(tokens)!;
|
|
65
|
-
const value = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
66
|
-
return `${value}${suffix}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
/**
|
|
70
54
|
* Compact display for large token counts (e.g. 1_400_000 → value "1.4" + suffix "M").
|
|
71
55
|
* Returns HTML markup so the suffix can be styled separately in the settings modal.
|