@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.
Files changed (98) hide show
  1. package/dist/ai-assistant.api.json +571 -82
  2. package/dist/ai-assistant.d.ts +336 -36
  3. package/dist/chat-driver.cjs +104 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +104 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +314 -36
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +12 -0
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  11. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
  12. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  13. package/dist/dts/index.d.ts +1 -0
  14. package/dist/dts/index.d.ts.map +1 -1
  15. package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
  16. package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
  17. package/dist/dts/main/main.d.ts +184 -24
  18. package/dist/dts/main/main.d.ts.map +1 -1
  19. package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
  20. package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
  24. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  25. package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
  26. package/dist/dts/state/session-store.d.ts +1 -2
  27. package/dist/dts/state/session-store.d.ts.map +1 -1
  28. package/dist/dts/styles/settings-section.d.ts +29 -0
  29. package/dist/dts/styles/settings-section.d.ts.map +1 -0
  30. package/dist/dts/utils/condense-history.d.ts +14 -0
  31. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  32. package/dist/dts/utils/cost-session-history.d.ts +103 -12
  33. package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
  34. package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
  35. package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
  36. package/dist/dts/utils/sum-costs.d.ts.map +1 -1
  37. package/dist/dts/utils/sum-tokens.d.ts +8 -8
  38. package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
  39. package/dist/dts/utils/sum-usage.d.ts +59 -0
  40. package/dist/dts/utils/sum-usage.d.ts.map +1 -0
  41. package/dist/dts/utils/sum-usage.test.d.ts +2 -0
  42. package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
  43. package/dist/esm/components/chat-driver/chat-driver.js +9 -1
  44. package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
  45. package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
  46. package/dist/esm/index.js +1 -0
  47. package/dist/esm/main/cost-session-banking.test.js +308 -0
  48. package/dist/esm/main/main.js +424 -71
  49. package/dist/esm/state/ai-assistant-slice.js +11 -8
  50. package/dist/esm/state/ai-assistant-slice.test.js +12 -5
  51. package/dist/esm/state/debug-event-log.js +2 -2
  52. package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
  53. package/dist/esm/state/persistence/session-persister.js +2 -2
  54. package/dist/esm/state/persistence/session-persister.test.js +10 -1
  55. package/dist/esm/state/persistence/session-snapshot.js +6 -2
  56. package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
  57. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
  58. package/dist/esm/styles/settings-section.js +39 -0
  59. package/dist/esm/utils/condense-history.js +18 -5
  60. package/dist/esm/utils/condense-history.test.js +67 -0
  61. package/dist/esm/utils/cost-session-history.js +92 -15
  62. package/dist/esm/utils/cost-session-history.test.js +155 -13
  63. package/dist/esm/utils/resolve-cost-history-config.js +2 -1
  64. package/dist/esm/utils/sum-costs.js +2 -13
  65. package/dist/esm/utils/sum-tokens.js +10 -27
  66. package/dist/esm/utils/sum-tokens.test.js +1 -5
  67. package/dist/esm/utils/sum-usage.js +123 -0
  68. package/dist/esm/utils/sum-usage.test.js +120 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +17 -17
  71. package/src/components/chat-driver/chat-driver.ts +21 -0
  72. package/src/components/settings-modal/settings-modal.styles.ts +237 -18
  73. package/src/components/settings-modal/settings-modal.template.ts +270 -81
  74. package/src/index.ts +1 -0
  75. package/src/main/cost-session-banking.test.ts +407 -0
  76. package/src/main/main.ts +433 -68
  77. package/src/provider/assistant-app-settings.ts +31 -5
  78. package/src/state/ai-assistant-slice.test.ts +12 -5
  79. package/src/state/ai-assistant-slice.ts +21 -13
  80. package/src/state/debug-event-log.ts +2 -2
  81. package/src/state/persistence/session-persistence-provider.ts +24 -0
  82. package/src/state/persistence/session-persistence.integration.test.ts +8 -1
  83. package/src/state/persistence/session-persister.test.ts +10 -1
  84. package/src/state/persistence/session-persister.ts +2 -2
  85. package/src/state/persistence/session-snapshot.test.ts +4 -1
  86. package/src/state/persistence/session-snapshot.ts +5 -1
  87. package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
  88. package/src/styles/settings-section.ts +40 -0
  89. package/src/utils/condense-history.test.ts +103 -0
  90. package/src/utils/condense-history.ts +33 -3
  91. package/src/utils/cost-session-history.test.ts +187 -16
  92. package/src/utils/cost-session-history.ts +142 -23
  93. package/src/utils/resolve-cost-history-config.ts +10 -3
  94. package/src/utils/sum-costs.ts +2 -9
  95. package/src/utils/sum-tokens.test.ts +1 -11
  96. package/src/utils/sum-tokens.ts +10 -26
  97. package/src/utils/sum-usage.test.ts +140 -0
  98. package/src/utils/sum-usage.ts +130 -0
@@ -21,16 +21,42 @@ export interface AssistantAppSettingsToggle {
21
21
  }
22
22
 
23
23
  /**
24
- * App-supplied settings for the platform settings modal UI Builder section.
25
- * Register a concrete implementation on the DI token at app bootstrap; the
26
- * assistant renders toggles with the same row layout as AI Chat Bot settings.
24
+ * Optional heading above a provider's toggle group.
25
+ *
26
+ * Entirely host-supplied the assistant has no default title and no default icon, because the
27
+ * section belongs to the host. Omit it and the toggles render bare, which is right when the
28
+ * host's slotted content already titles them.
29
+ *
30
+ * @beta
31
+ */
32
+ export interface AssistantAppSettingsHeading {
33
+ readonly title: string;
34
+ /**
35
+ * Icon name for the design system's icon element (a Font Awesome free name, e.g.
36
+ * `'object-group'`). Omitted renders text only; an unknown name renders nothing at all, so
37
+ * check it against the installed Font Awesome set.
38
+ */
39
+ readonly icon?: string;
40
+ }
41
+
42
+ /**
43
+ * App-supplied settings for the host's own section of the settings modal. Register a concrete
44
+ * implementation on the DI token at app bootstrap; the assistant renders the toggles with the
45
+ * same row layout as AI Chat Bot settings.
46
+ *
47
+ * The section itself is untitled by the assistant — no heading text and no icon — because only
48
+ * the host knows what it is. Title it from the `settings-app` slot, which renders above these
49
+ * toggles, and apply `assistantSettingsSectionTitleStyles` to match the built-in headings.
27
50
  *
28
51
  * @beta
29
52
  */
30
53
  export interface AssistantAppSettingsProvider {
31
- /** Section heading. Default: `"UI Builder Settings"`. */
32
- readonly sectionTitle?: string;
33
54
  readonly toggles: readonly AssistantAppSettingsToggle[];
55
+ /**
56
+ * Heading for the toggle group, for hosts that put other controls in the `settings-app` slot
57
+ * and need these distinguished from them. No default: absent means no heading.
58
+ */
59
+ readonly togglesHeading?: AssistantAppSettingsHeading;
34
60
  getValue(id: string): boolean;
35
61
  setValue(id: string, value: boolean): void;
36
62
  /** Notify the assistant to re-read toggle values / visibility. */
@@ -28,10 +28,14 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
28
28
  let start = createDefaultSessionState();
29
29
  start = reduce(start, setState('loading')); // prove it overrides any prior state
30
30
  const payload: LoadSessionPayload = {
31
- messages: [{ role: 'user', content: 'hi' }] as ChatMessage[],
31
+ // Usage rides on the message: `loadSession` derives the session totals from the
32
+ // restored transcript rather than from a stored counter, so the cost has to be in
33
+ // the messages for the assertion below to see it.
34
+ messages: [
35
+ { role: 'user', content: 'hi', cost: 1.25, inputTokens: 100, outputTokens: 20 },
36
+ ] as ChatMessage[],
32
37
  pinnedAgentName: 'Guided Booking',
33
38
  flowOwnerAgentName: 'Guided Booking',
34
- sessionCostUsd: 1.25,
35
39
  contextTokens: 1200,
36
40
  contextLimit: 200000,
37
41
  activeModel: 'claude-sonnet-4-6',
@@ -41,7 +45,11 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
41
45
  assert.is(next.messages.length, 1);
42
46
  assert.is(next.pinnedAgentName, 'Guided Booking');
43
47
  assert.is(next.flowOwnerAgentName, 'Guided Booking');
44
- assert.is(next.sessionCostUsd, 1.25);
48
+ assert.is(next.sessionUsage.costUsd, 1.25);
49
+ // Derived from the same walk: 100 prompt tokens with no cache split reported, so all
50
+ // of it is uncached input, plus the 20 generated.
51
+ assert.is(next.sessionUsage.uncachedInputTokens, 100);
52
+ assert.is(next.sessionUsage.outputTokens, 20);
45
53
  assert.is(next.contextTokens, 1200);
46
54
  assert.is(next.contextLimit, 200000);
47
55
  assert.is(next.activeModel, 'claude-sonnet-4-6');
@@ -58,7 +66,6 @@ Suite('resetSession wipes back to a fresh default', () => {
58
66
  messages: [],
59
67
  pinnedAgentName: 'A',
60
68
  flowOwnerAgentName: 'A',
61
- sessionCostUsd: 9,
62
69
  contextTokens: 5,
63
70
  contextLimit: 10,
64
71
  activeModel: 'm',
@@ -67,7 +74,7 @@ Suite('resetSession wipes back to a fresh default', () => {
67
74
  );
68
75
  const cleared = reduce(state, resetSession());
69
76
  assert.is(cleared.messages.length, 0);
70
- assert.is(cleared.sessionCostUsd, 0);
77
+ assert.is(cleared.sessionUsage.costUsd, 0);
71
78
  assert.is(cleared.pinnedAgentName, null);
72
79
  assert.is(cleared.flowOwnerAgentName, null);
73
80
  assert.is(cleared.contextTokens, undefined);
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AggregateUsage,
2
3
  AIProviderRegistryStatusEntry,
3
4
  ChatInputDuringExecutionMode,
4
5
  ChatMessage,
@@ -7,6 +8,7 @@ import type { PayloadAction } from '@genesislcap/foundation-redux';
7
8
  import { createSlice } from '@genesislcap/foundation-redux';
8
9
  import type { AgentConfig } from '../config/config';
9
10
  import type { AiAssistantAnimation, AiAssistantState, SuggestionsState } from '../main/main.types';
11
+ import { emptyUsage, sumUsage } from '../utils/sum-usage';
10
12
 
11
13
  /**
12
14
  * A single in-flight per-call chat-input override pushed by a `requestSubAgent`
@@ -40,10 +42,15 @@ export interface AiAssistantSessionState {
40
42
  suggestionsState: SuggestionsState;
41
43
  contextTokens: number | undefined;
42
44
  contextLimit: number | undefined;
43
- /** Aggregated USD cost across every chat turn in this session. */
44
- sessionCostUsd: number;
45
- /** Cumulative input + output tokens across every chat turn in this session. */
46
- sessionTokensConsumed: number;
45
+ /**
46
+ * Cost and per-bucket token totals across every chat turn in this session,
47
+ * including sub-agent turns and spend banked by a compaction.
48
+ *
49
+ * One field rather than a cost scalar plus a token scalar: they are derived from
50
+ * the same walk of the transcript, so keeping them together makes it impossible
51
+ * for the two to disagree about which turns they counted.
52
+ */
53
+ sessionUsage: AggregateUsage;
47
54
  /** Active model id (e.g. `claude-sonnet-4-6`), resolved on connect. */
48
55
  activeModel: string | undefined;
49
56
  /**
@@ -137,7 +144,6 @@ export interface LoadSessionPayload {
137
144
  messages: ChatMessage[];
138
145
  pinnedAgentName: string | null;
139
146
  flowOwnerAgentName: string | null;
140
- sessionCostUsd: number;
141
147
  contextTokens: number | undefined;
142
148
  contextLimit: number | undefined;
143
149
  activeModel: string | undefined;
@@ -161,8 +167,7 @@ export function createDefaultSessionState(): AiAssistantSessionState {
161
167
  suggestionsState: { status: 'idle' },
162
168
  contextTokens: undefined,
163
169
  contextLimit: undefined,
164
- sessionCostUsd: 0,
165
- sessionTokensConsumed: 0,
170
+ sessionUsage: emptyUsage(),
166
171
  activeModel: undefined,
167
172
  activeProviderName: undefined,
168
173
  providerStatuses: [],
@@ -217,11 +222,8 @@ export const aiAssistantSlice = createSlice({
217
222
  setContextLimit(state, action: PayloadAction<number | undefined>) {
218
223
  state.contextLimit = action.payload;
219
224
  },
220
- setSessionCostUsd(state, action: PayloadAction<number>) {
221
- state.sessionCostUsd = action.payload;
222
- },
223
- setSessionTokensConsumed(state, action: PayloadAction<number>) {
224
- state.sessionTokensConsumed = action.payload;
225
+ setSessionUsage(state, action: PayloadAction<AggregateUsage>) {
226
+ state.sessionUsage = action.payload;
225
227
  },
226
228
  setActiveModel(state, action: PayloadAction<string | undefined>) {
227
229
  state.activeModel = action.payload;
@@ -282,7 +284,13 @@ export const aiAssistantSlice = createSlice({
282
284
  state.messages = p.messages;
283
285
  state.pinnedAgentName = p.pinnedAgentName;
284
286
  state.flowOwnerAgentName = p.flowOwnerAgentName;
285
- state.sessionCostUsd = p.sessionCostUsd;
287
+ // Derive the totals from the restored transcript rather than trusting a stored
288
+ // counter. Restore dispatches this action directly, bypassing the host's
289
+ // `messages` setter — the thing that normally recomputes — so a stored total
290
+ // would sit stale (historically at 0) until the next turn arrived. The
291
+ // transcript is the source of truth: per-message usage survives the snapshot,
292
+ // and a compacted summary carries the usage of the turns it replaced.
293
+ state.sessionUsage = sumUsage(p.messages);
286
294
  state.contextTokens = p.contextTokens;
287
295
  state.contextLimit = p.contextLimit;
288
296
  state.activeModel = p.activeModel;
@@ -313,8 +313,8 @@ export const DEBUG_LOG_README: readonly string[] = [
313
313
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
314
314
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
315
315
  'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',
316
- "kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
317
- "Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\"<parent> › <sub-agent>\"` breadcrumb (composing when nested, e.g. `\"UI Builder › Planner › Grounding\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.",
316
+ "kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, where `inputTokens` is the WHOLE prompt for that request and `cacheReadTokens`/`cacheWriteTokens` BREAK IT DOWN rather than add to it — uncached input is `inputTokens` minus those two, and adding the cache fields to `inputTokens` counts the prompt twice. The cache fields are absent on providers that report no cache split (Gemini reports reads only, since implicit caching bills no write) and on messages persisted before they existed, so read them as 0 when missing. Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a large token count at a small cost means the prompt was mostly cache hits. `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
317
+ "Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\"<parent> › <sub-agent>\"` breadcrumb (composing when nested, e.g. `\"UI Builder › Planner › Grounding\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.",
318
318
  "kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
319
319
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
320
320
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
@@ -1,3 +1,4 @@
1
+ import type { CostSessionRecord } from '../../utils/cost-session-history';
1
2
  import type { DiagnosticEntry } from './diagnostics';
2
3
  import type { PersistedSession } from './session-snapshot';
3
4
 
@@ -43,6 +44,29 @@ export interface SessionPersistenceProvider {
43
44
  /** Persist the user's UI preferences for `sessionKey` (kept across `clear`). */
44
45
  savePreferences?(sessionKey: string, preferences: SessionPreferences): Promise<void>;
45
46
 
47
+ /**
48
+ * Scoped cost/build history — the per-project usage rows behind the Usage tab.
49
+ *
50
+ * ‼️ **Keyed by SCOPE, not `sessionKey`**, unlike every other method on this interface.
51
+ * A row holds one project's lifetime usage and the list spans projects, so keying it
52
+ * per session would defeat the model. The scope comes from
53
+ * `chatConfig.costHistory.scope`, falling back to the element `id`, then `'default'`.
54
+ *
55
+ * Optional: when a provider omits these, the assistant falls back to `localStorage`, so
56
+ * hosts that don't implement them are unaffected. Implementing them moves the ledger to
57
+ * the host's own backend, which is what stops it being stranded in one browser.
58
+ *
59
+ * Read/written **independently of the `enabled` toggle** (as with preferences and
60
+ * diagnostics): usage accounting is not conversation content, so a user turning off
61
+ * chat retention should not lose their cost ledger. Best-effort — a failed write must
62
+ * never affect the chat.
63
+ *
64
+ * `undefined` from `loadCostHistory` means "nothing stored", the same as `[]`.
65
+ */
66
+ loadCostHistory?(scope: string): Promise<CostSessionRecord[] | undefined>;
67
+ /** Replace the stored row list for `scope`. See {@link SessionPersistenceProvider.loadCostHistory}. */
68
+ saveCostHistory?(scope: string, records: CostSessionRecord[]): Promise<void>;
69
+
46
70
  /**
47
71
  * Server-saved diagnostics (GENC-1351 §5.8) — a **forward-only append** stream
48
72
  * that lets the downloadable debug log span the whole session lifetime, not just
@@ -77,7 +77,14 @@ Suite('save → reload → restore brings back messages, selection, counters', a
77
77
  const restored = reduce(freshStore, loadSession(toLoadSessionPayload(snapshot!)));
78
78
  assert.is(restored.messages.length, 2);
79
79
  assert.is(restored.pinnedAgentName, 'Guided Booking');
80
- assert.is(restored.sessionCostUsd, 0.5);
80
+ // Cost comes from the restored transcript, NOT the snapshot's stored counter — so it
81
+ // is whatever the messages actually carry. Computed independently here rather than
82
+ // by calling the summing helper under test.
83
+ const expectedCost = restored.messages.reduce(
84
+ (sum, m) => sum + (m.cost ?? 0) + (m.externalCostUsd ?? 0),
85
+ 0,
86
+ );
87
+ assert.is(restored.sessionUsage.costUsd, expectedCost);
81
88
  assert.is(restored.contextTokens, 800);
82
89
  assert.is(restored.contextLimit, 200000);
83
90
  assert.is(restored.activeModel, 'claude-sonnet-4-6');
@@ -101,7 +101,16 @@ function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fak
101
101
  flowActivationPrompt: null,
102
102
  activeAgent: undefined,
103
103
  pinnedAgentName: null,
104
- sessionCostUsd: 0,
104
+ // Inline rather than via `emptyUsage()` to keep this mock self-contained. The
105
+ // shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
106
+ // the object crashes the autosave timer rather than failing an assertion.
107
+ sessionUsage: {
108
+ costUsd: 0,
109
+ uncachedInputTokens: 0,
110
+ cacheReadTokens: 0,
111
+ cacheWriteTokens: 0,
112
+ outputTokens: 0,
113
+ },
105
114
  get restoring() {
106
115
  return f.restoring;
107
116
  },
@@ -282,7 +282,7 @@ export class SessionPersister {
282
282
  activeAgentName: s.activeAgent?.name,
283
283
  pinnedAgentName: s.pinnedAgentName,
284
284
  flowOwnerAgentName: null,
285
- sessionCostUsd: s.sessionCostUsd,
285
+ sessionCostUsd: s.sessionUsage.costUsd,
286
286
  contextTokens: s.contextTokens,
287
287
  contextLimit: s.contextLimit,
288
288
  activeModel: s.activeModel,
@@ -316,7 +316,7 @@ export class SessionPersister {
316
316
  activeAgentName: s.activeAgent?.name,
317
317
  pinnedAgentName: s.pinnedAgentName,
318
318
  flowOwnerAgentName: s.flowOwnerAgentName,
319
- sessionCostUsd: s.sessionCostUsd,
319
+ sessionCostUsd: s.sessionUsage.costUsd,
320
320
  contextTokens: s.contextTokens,
321
321
  contextLimit: s.contextLimit,
322
322
  activeModel: s.activeModel,
@@ -74,7 +74,10 @@ Suite('toLoadSessionPayload projects only the redux-restorable fields', () => {
74
74
  const p = toLoadSessionPayload(base());
75
75
  assert.is(p.pinnedAgentName, 'A');
76
76
  assert.is(p.flowOwnerAgentName, null);
77
- assert.is(p.sessionCostUsd, 2);
77
+ // Cost is deliberately NOT projected — the slice re-derives it (and the token
78
+ // buckets) from `messages`, so a stored counter would be a second, divergeable
79
+ // source of truth.
80
+ assert.is((p as { sessionCostUsd?: number }).sessionCostUsd, undefined);
78
81
  assert.is(p.contextTokens, 10);
79
82
  assert.is(p.contextLimit, 100);
80
83
  assert.is(p.activeModel, 'm');
@@ -190,7 +190,11 @@ export function toLoadSessionPayload(s: PersistedSession): LoadSessionPayload {
190
190
  messages: s.messages,
191
191
  pinnedAgentName: s.pinnedAgentName ?? null,
192
192
  flowOwnerAgentName: s.flowOwnerAgentName ?? null,
193
- sessionCostUsd: s.sessionCostUsd ?? 0,
193
+ // `sessionCostUsd` is deliberately NOT projected: the slice re-derives cost and
194
+ // token totals from `messages` on load. The snapshot field is still written (its
195
+ // shape is public, and it is a useful cross-check in a saved blob), but it is no
196
+ // longer authoritative — a snapshot saved mid-reset could hold a zeroed total
197
+ // while its transcript still shows the spend.
194
198
  contextTokens: s.contextTokens,
195
199
  contextLimit: s.contextLimit,
196
200
  activeModel: s.activeModel,
@@ -81,7 +81,15 @@ function makeStoreFake(over: Record<string, unknown> = {}) {
81
81
  flowActivationPrompt: null as string | null,
82
82
  activeAgent: undefined as { name: string } | undefined,
83
83
  pinnedAgentName: null as string | null,
84
- sessionCostUsd: 0,
84
+ // The persister reads `sessionUsage.costUsd` when building a snapshot, so this
85
+ // has to be the object rather than a bare cost scalar.
86
+ sessionUsage: {
87
+ costUsd: 0,
88
+ uncachedInputTokens: 0,
89
+ cacheReadTokens: 0,
90
+ cacheWriteTokens: 0,
91
+ outputTokens: 0,
92
+ },
85
93
  contextTokens: 0,
86
94
  contextLimit: 0,
87
95
  activeModel: undefined,
@@ -0,0 +1,40 @@
1
+ import { css, type ElementStyles } from '@genesislcap/web-core';
2
+
3
+ /**
4
+ * Heading style for a settings-modal section, published so a host can title its own section.
5
+ *
6
+ * The assistant renders no heading for the `settings-app` slot — no text and no icon — because
7
+ * only the host knows what that section is. The consequence is that a host supplying one has to
8
+ * reproduce the styling of the built-in headings beside it, and a copied rule drifts the moment
9
+ * either side changes. So the declarations live here, are applied by the assistant's own
10
+ * headings, and are exported for the host to apply to its.
11
+ *
12
+ * Compose it into a component's styles and put `ai-settings-section-title` on the heading:
13
+ *
14
+ * ```ts
15
+ * import { assistantSettingsSectionTitleStyles } from '@genesislcap/ai-assistant';
16
+ *
17
+ * const styles = css`
18
+ * ${assistantSettingsSectionTitleStyles}
19
+ * :host { display: block; }
20
+ * `;
21
+ * ```
22
+ *
23
+ * Carries no margin on purpose: spacing belongs to the surrounding layout, which differs
24
+ * between the assistant's own sections and a slotted one, and is the single declaration hosts
25
+ * had to diverge on when they copied this rule.
26
+ *
27
+ * @beta
28
+ */
29
+ export const assistantSettingsSectionTitleStyles: ElementStyles = css`
30
+ .ai-settings-section-title {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: calc(var(--design-unit) * 2px);
34
+ font-size: 11px;
35
+ font-weight: 600;
36
+ letter-spacing: 0.08em;
37
+ text-transform: uppercase;
38
+ color: var(--neutral-foreground-hint);
39
+ }
40
+ `;
@@ -44,12 +44,14 @@ const ctx = (
44
44
  turn?: number;
45
45
  activationEnded?: (a: number) => boolean;
46
46
  phaseEnded?: (e: number) => boolean;
47
+ batchCalls?: number;
47
48
  } = {},
48
49
  ): CondenseContext => ({
49
50
  modelCall: o.modelCall ?? 0,
50
51
  turn: o.turn ?? 0,
51
52
  activationEnded: o.activationEnded ?? (() => false),
52
53
  phaseEnded: o.phaseEnded ?? (() => false),
54
+ batchCalls: o.batchCalls,
53
55
  });
54
56
 
55
57
  const sink = () => {
@@ -434,3 +436,104 @@ suite('multi-trigger first-wins: the earliest-listed firing trigger is the reaso
434
436
  });
435
437
 
436
438
  suite.run();
439
+
440
+ // ── batched collapse (GENC-1476) ───────────────────────────────────────────
441
+ // Condensation rewrites history in place, so collapsing the instant a trigger fires
442
+ // breaks the prompt cache on nearly every call of a re-read loop. Batching holds the
443
+ // collapse set still between boundaries.
444
+
445
+ const batch = createLogicSuite('applyCondensation batching');
446
+
447
+ /** A re-read loop: `n` reads of the same path, each registered one model-call apart. */
448
+ const reReadLoop = (n: number) => {
449
+ const history: ChatMessage[] = [];
450
+ const policies = new Map<string, RegisteredCondensePolicy>();
451
+ for (let i = 1; i <= n; i += 1) {
452
+ history.push(asstCall(`r${i}`, 'vfs_read', { path: 'A.tsx' }), toolMsg(`r${i}`, big()));
453
+ policies.set(
454
+ `r${i}`,
455
+ reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: i }),
456
+ );
457
+ }
458
+ return { history, policies };
459
+ };
460
+
461
+ /** Result content of every tool message, in order. */
462
+ const results = (out: ChatMessage[]): string[] =>
463
+ out.filter((m) => m.role === 'tool').map((m) => m.toolResult?.content ?? '');
464
+
465
+ batch('without batching every superseded read collapses immediately', () => {
466
+ const { history, policies } = reReadLoop(4);
467
+ const out = results(applyCondensation(history, policies, ctx({ modelCall: 4 }), sink().on));
468
+ // Only the newest survives; the other three are already stubs.
469
+ assert.is(out.filter((c) => c.startsWith('[')).length, 3);
470
+ assert.is(out[3].length, big().length);
471
+ });
472
+
473
+ batch('batching leaves the prefix untouched between boundaries', () => {
474
+ const { history, policies } = reReadLoop(4);
475
+ // Boundary is floor(4/10)*10 = 0, so no call is within the batch yet.
476
+ const out = results(
477
+ applyCondensation(history, policies, ctx({ modelCall: 4, batchCalls: 10 }), sink().on),
478
+ );
479
+ assert.equal(
480
+ out.map((c) => c.length),
481
+ [big().length, big().length, big().length, big().length],
482
+ );
483
+ });
484
+
485
+ batch('the batch boundary collapses the backlog in one step', () => {
486
+ const { history, policies } = reReadLoop(12);
487
+ const before = results(
488
+ applyCondensation(history, policies, ctx({ modelCall: 9, batchCalls: 10 }), sink().on),
489
+ );
490
+ const after = results(
491
+ applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on),
492
+ );
493
+ assert.is(before.filter((c) => c.startsWith('[')).length, 0);
494
+ // At the boundary, reads 1-9 collapse together; 10-12 are past it and stay full.
495
+ assert.is(after.filter((c) => c.startsWith('[')).length, 9);
496
+ });
497
+
498
+ batch('a call past the boundary neither collapses nor supersedes an older one', () => {
499
+ // The regression this guards: excluding the new call as a SUPERSEDER but still letting
500
+ // it collapse would elide the very result the agent just fetched.
501
+ const { history, policies } = reReadLoop(2);
502
+ policies.set(
503
+ 'r2',
504
+ reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: 7 }),
505
+ );
506
+ const out = results(
507
+ applyCondensation(history, policies, ctx({ modelCall: 7, batchCalls: 5 }), sink().on),
508
+ );
509
+ assert.is(out[1].length, big().length, 'the newest read must stay full');
510
+ assert.is(out[0].length, big().length, 'the older read must not collapse early either');
511
+ });
512
+
513
+ batch('age is measured against the boundary, not the live clock', () => {
514
+ const history = [asstCall('g1', 'grep', {}), toolMsg('g1', big())];
515
+ const policies = new Map([
516
+ ['g1', reg({ on: { kind: 'age', turns: 1 }, response: 'pointer' }, { iteration: 1 })],
517
+ ]);
518
+ // Live clock says age 4 > 1 (would collapse); the boundary says 0, so it holds.
519
+ const held = results(
520
+ applyCondensation(history, policies, ctx({ modelCall: 5, batchCalls: 10 }), sink().on),
521
+ );
522
+ assert.is(held[0].length, big().length);
523
+ const collapsed = results(
524
+ applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on),
525
+ );
526
+ assert.ok(collapsed[0].startsWith('['));
527
+ });
528
+
529
+ batch('batchCalls of 1, 0 or absent all keep the historical behaviour', () => {
530
+ for (const batchCalls of [1, 0, undefined]) {
531
+ const { history, policies } = reReadLoop(3);
532
+ const out = results(
533
+ applyCondensation(history, policies, ctx({ modelCall: 3, batchCalls }), sink().on),
534
+ );
535
+ assert.is(out.filter((c) => c.startsWith('[')).length, 2, `batchCalls=${batchCalls}`);
536
+ }
537
+ });
538
+
539
+ batch.run();
@@ -75,6 +75,20 @@ export interface CondenseContext {
75
75
  * `phaseEnd` clock. True once `endPhase()` has been called since the call.
76
76
  */
77
77
  phaseEnded: (phaseEpoch: number) => boolean;
78
+ /**
79
+ * Collapse in batches of this many model-calls instead of continuously. A positive
80
+ * integer; decimals are floored and anything below `1` becomes `1`. Omit (or `1`) to
81
+ * collapse as soon as a trigger fires, which is the historical behaviour.
82
+ *
83
+ * Condensation rewrites a payload *in place*, so the model-bound history is not
84
+ * append-only and every prompt-cache entry written before the rewrite is invalidated
85
+ * from that position on. Collapsing the moment each trigger fires therefore breaks the
86
+ * cache on nearly every call of a re-read loop — the case condensation exists to
87
+ * contain. Batching holds the collapse set still between boundaries so the prefix is
88
+ * append-only in between, trading up to `batchCalls` calls' worth of extra context for
89
+ * one cache break per batch instead of one per call.
90
+ */
91
+ batchCalls?: number;
78
92
  }
79
93
 
80
94
  /**
@@ -193,6 +207,20 @@ export function applyCondensation(
193
207
  // lookup. For each `superseded` key the LAST call in history order survives.
194
208
  // The name lookup lets a tool message (which carries only an id) name its tool
195
209
  // in stubs and events.
210
+ // Batch boundary: the collapse set is resolved as of this model-call and stays put
211
+ // until the next boundary, so the prefix is append-only in between. `batchCalls` of 1
212
+ // (the default) makes this `ctx.modelCall` — i.e. no batching.
213
+ const batchCalls = Math.max(1, Math.floor(ctx.batchCalls ?? 1));
214
+ const asOf =
215
+ batchCalls === 1 ? ctx.modelCall : Math.floor(ctx.modelCall / batchCalls) * batchCalls;
216
+ // A call made after the boundary is invisible to this pass in BOTH directions: it
217
+ // cannot supersede an older call (which would collapse the older payload early), and
218
+ // it cannot itself collapse (which would elide the very result just fetched). Unbatched,
219
+ // the boundary is the live clock and this gate is off entirely — a call registered on
220
+ // the current model-call is still eligible, as it always was.
221
+ const withinBatch = (entry: RegisteredCondensePolicy): boolean =>
222
+ batchCalls === 1 || entry.iteration <= asOf;
223
+
196
224
  const latestByKey = new Map<string, string>();
197
225
  const nameById = new Map<string, string>();
198
226
  for (const msg of history) {
@@ -200,7 +228,7 @@ export function applyCondensation(
200
228
  for (const tc of msg.toolCalls) {
201
229
  nameById.set(tc.id, tc.name);
202
230
  const entry = policies.get(tc.id);
203
- if (!entry) continue;
231
+ if (!entry || !withinBatch(entry)) continue;
204
232
  for (const t of triggersOf(entry)) {
205
233
  if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
206
234
  }
@@ -217,7 +245,7 @@ export function applyCondensation(
217
245
  // past the last allowed view: turns:1 → seen once, then gone. Monotonic
218
246
  // clock, so this holds across turn boundaries too.
219
247
  case 'age':
220
- return ctx.modelCall - entry.iteration > trig.turns;
248
+ return asOf - entry.iteration > trig.turns;
221
249
  case 'turnEnd':
222
250
  return ctx.turn > entry.turn;
223
251
  case 'agentEnd':
@@ -238,7 +266,9 @@ export function applyCondensation(
238
266
  toolCallId: string,
239
267
  entry: RegisteredCondensePolicy,
240
268
  ): CondenseTrigger | undefined =>
241
- triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));
269
+ withinBatch(entry)
270
+ ? triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t))
271
+ : undefined;
242
272
 
243
273
  return history.map((msg) => {
244
274
  // Tool-call ARGS live on the assistant message.