@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
@@ -1,4 +1,5 @@
1
1
  import { createSlice } from '@genesislcap/foundation-redux';
2
+ import { emptyUsage, sumUsage } from '../utils/sum-usage';
2
3
  /**
3
4
  * Build a fresh default session state. A factory (not just a shared const) so
4
5
  * each call returns independent collections — `resetSession` can hand back a
@@ -16,8 +17,7 @@ export function createDefaultSessionState() {
16
17
  suggestionsState: { status: 'idle' },
17
18
  contextTokens: undefined,
18
19
  contextLimit: undefined,
19
- sessionCostUsd: 0,
20
- sessionTokensConsumed: 0,
20
+ sessionUsage: emptyUsage(),
21
21
  activeModel: undefined,
22
22
  activeProviderName: undefined,
23
23
  providerStatuses: [],
@@ -70,11 +70,8 @@ export const aiAssistantSlice = createSlice({
70
70
  setContextLimit(state, action) {
71
71
  state.contextLimit = action.payload;
72
72
  },
73
- setSessionCostUsd(state, action) {
74
- state.sessionCostUsd = action.payload;
75
- },
76
- setSessionTokensConsumed(state, action) {
77
- state.sessionTokensConsumed = action.payload;
73
+ setSessionUsage(state, action) {
74
+ state.sessionUsage = action.payload;
78
75
  },
79
76
  setActiveModel(state, action) {
80
77
  state.activeModel = action.payload;
@@ -135,7 +132,13 @@ export const aiAssistantSlice = createSlice({
135
132
  state.messages = p.messages;
136
133
  state.pinnedAgentName = p.pinnedAgentName;
137
134
  state.flowOwnerAgentName = p.flowOwnerAgentName;
138
- state.sessionCostUsd = p.sessionCostUsd;
135
+ // Derive the totals from the restored transcript rather than trusting a stored
136
+ // counter. Restore dispatches this action directly, bypassing the host's
137
+ // `messages` setter — the thing that normally recomputes — so a stored total
138
+ // would sit stale (historically at 0) until the next turn arrived. The
139
+ // transcript is the source of truth: per-message usage survives the snapshot,
140
+ // and a compacted summary carries the usage of the turns it replaced.
141
+ state.sessionUsage = sumUsage(p.messages);
139
142
  state.contextTokens = p.contextTokens;
140
143
  state.contextLimit = p.contextLimit;
141
144
  state.activeModel = p.activeModel;
@@ -8,10 +8,14 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
8
8
  let start = createDefaultSessionState();
9
9
  start = reduce(start, setState('loading')); // prove it overrides any prior state
10
10
  const payload = {
11
- messages: [{ role: 'user', content: 'hi' }],
11
+ // Usage rides on the message: `loadSession` derives the session totals from the
12
+ // restored transcript rather than from a stored counter, so the cost has to be in
13
+ // the messages for the assertion below to see it.
14
+ messages: [
15
+ { role: 'user', content: 'hi', cost: 1.25, inputTokens: 100, outputTokens: 20 },
16
+ ],
12
17
  pinnedAgentName: 'Guided Booking',
13
18
  flowOwnerAgentName: 'Guided Booking',
14
- sessionCostUsd: 1.25,
15
19
  contextTokens: 1200,
16
20
  contextLimit: 200000,
17
21
  activeModel: 'claude-sonnet-4-6',
@@ -21,7 +25,11 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
21
25
  assert.is(next.messages.length, 1);
22
26
  assert.is(next.pinnedAgentName, 'Guided Booking');
23
27
  assert.is(next.flowOwnerAgentName, 'Guided Booking');
24
- assert.is(next.sessionCostUsd, 1.25);
28
+ assert.is(next.sessionUsage.costUsd, 1.25);
29
+ // Derived from the same walk: 100 prompt tokens with no cache split reported, so all
30
+ // of it is uncached input, plus the 20 generated.
31
+ assert.is(next.sessionUsage.uncachedInputTokens, 100);
32
+ assert.is(next.sessionUsage.outputTokens, 20);
25
33
  assert.is(next.contextTokens, 1200);
26
34
  assert.is(next.contextLimit, 200000);
27
35
  assert.is(next.activeModel, 'claude-sonnet-4-6');
@@ -35,7 +43,6 @@ Suite('resetSession wipes back to a fresh default', () => {
35
43
  messages: [],
36
44
  pinnedAgentName: 'A',
37
45
  flowOwnerAgentName: 'A',
38
- sessionCostUsd: 9,
39
46
  contextTokens: 5,
40
47
  contextLimit: 10,
41
48
  activeModel: 'm',
@@ -43,7 +50,7 @@ Suite('resetSession wipes back to a fresh default', () => {
43
50
  }));
44
51
  const cleared = reduce(state, resetSession());
45
52
  assert.is(cleared.messages.length, 0);
46
- assert.is(cleared.sessionCostUsd, 0);
53
+ assert.is(cleared.sessionUsage.costUsd, 0);
47
54
  assert.is(cleared.pinnedAgentName, null);
48
55
  assert.is(cleared.flowOwnerAgentName, null);
49
56
  assert.is(cleared.contextTokens, undefined);
@@ -195,8 +195,8 @@ export const DEBUG_LOG_README = [
195
195
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
196
196
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
197
197
  '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.',
198
- "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.",
199
- "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.",
198
+ "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.",
199
+ "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.",
200
200
  "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.",
201
201
  "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.",
202
202
  "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'.",
@@ -61,7 +61,11 @@ Suite('save → reload → restore brings back messages, selection, counters', (
61
61
  const restored = reduce(freshStore, loadSession(toLoadSessionPayload(snapshot)));
62
62
  assert.is(restored.messages.length, 2);
63
63
  assert.is(restored.pinnedAgentName, 'Guided Booking');
64
- assert.is(restored.sessionCostUsd, 0.5);
64
+ // Cost comes from the restored transcript, NOT the snapshot's stored counter — so it
65
+ // is whatever the messages actually carry. Computed independently here rather than
66
+ // by calling the summing helper under test.
67
+ const expectedCost = restored.messages.reduce((sum, m) => { var _a, _b; return sum + ((_a = m.cost) !== null && _a !== void 0 ? _a : 0) + ((_b = m.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
68
+ assert.is(restored.sessionUsage.costUsd, expectedCost);
65
69
  assert.is(restored.contextTokens, 800);
66
70
  assert.is(restored.contextLimit, 200000);
67
71
  assert.is(restored.activeModel, 'claude-sonnet-4-6');
@@ -225,7 +225,7 @@ export class SessionPersister {
225
225
  activeAgentName: (_d = s.activeAgent) === null || _d === void 0 ? void 0 : _d.name,
226
226
  pinnedAgentName: s.pinnedAgentName,
227
227
  flowOwnerAgentName: null,
228
- sessionCostUsd: s.sessionCostUsd,
228
+ sessionCostUsd: s.sessionUsage.costUsd,
229
229
  contextTokens: s.contextTokens,
230
230
  contextLimit: s.contextLimit,
231
231
  activeModel: s.activeModel,
@@ -256,7 +256,7 @@ export class SessionPersister {
256
256
  activeAgentName: (_f = s.activeAgent) === null || _f === void 0 ? void 0 : _f.name,
257
257
  pinnedAgentName: s.pinnedAgentName,
258
258
  flowOwnerAgentName: s.flowOwnerAgentName,
259
- sessionCostUsd: s.sessionCostUsd,
259
+ sessionCostUsd: s.sessionUsage.costUsd,
260
260
  contextTokens: s.contextTokens,
261
261
  contextLimit: s.contextLimit,
262
262
  activeModel: s.activeModel,
@@ -44,7 +44,16 @@ function makePersister(over = {}) {
44
44
  flowActivationPrompt: null,
45
45
  activeAgent: undefined,
46
46
  pinnedAgentName: null,
47
- sessionCostUsd: 0,
47
+ // Inline rather than via `emptyUsage()` to keep this mock self-contained. The
48
+ // shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
49
+ // the object crashes the autosave timer rather than failing an assertion.
50
+ sessionUsage: {
51
+ costUsd: 0,
52
+ uncachedInputTokens: 0,
53
+ cacheReadTokens: 0,
54
+ cacheWriteTokens: 0,
55
+ outputTokens: 0,
56
+ },
48
57
  get restoring() {
49
58
  return f.restoring;
50
59
  },
@@ -92,12 +92,16 @@ export function isRestorableSession(value, opts) {
92
92
  * @internal
93
93
  */
94
94
  export function toLoadSessionPayload(s) {
95
- var _a, _b, _c;
95
+ var _a, _b;
96
96
  return {
97
97
  messages: s.messages,
98
98
  pinnedAgentName: (_a = s.pinnedAgentName) !== null && _a !== void 0 ? _a : null,
99
99
  flowOwnerAgentName: (_b = s.flowOwnerAgentName) !== null && _b !== void 0 ? _b : null,
100
- sessionCostUsd: (_c = s.sessionCostUsd) !== null && _c !== void 0 ? _c : 0,
100
+ // `sessionCostUsd` is deliberately NOT projected: the slice re-derives cost and
101
+ // token totals from `messages` on load. The snapshot field is still written (its
102
+ // shape is public, and it is a useful cross-check in a saved blob), but it is no
103
+ // longer authoritative — a snapshot saved mid-reset could hold a zeroed total
104
+ // while its transcript still shows the spend.
101
105
  contextTokens: s.contextTokens,
102
106
  contextLimit: s.contextLimit,
103
107
  activeModel: s.activeModel,
@@ -49,7 +49,10 @@ Suite('toLoadSessionPayload projects only the redux-restorable fields', () => {
49
49
  const p = toLoadSessionPayload(base());
50
50
  assert.is(p.pinnedAgentName, 'A');
51
51
  assert.is(p.flowOwnerAgentName, null);
52
- assert.is(p.sessionCostUsd, 2);
52
+ // Cost is deliberately NOT projected — the slice re-derives it (and the token
53
+ // buckets) from `messages`, so a stored counter would be a second, divergeable
54
+ // source of truth.
55
+ assert.is(p.sessionCostUsd, undefined);
53
56
  assert.is(p.contextTokens, 10);
54
57
  assert.is(p.contextLimit, 100);
55
58
  assert.is(p.activeModel, 'm');
@@ -55,7 +55,16 @@ function makeMemoryProvider() {
55
55
  function makeStoreFake(over = {}) {
56
56
  return {
57
57
  store: {
58
- aiAssistant: Object.assign({ messages: [], flowOwnerAgentName: null, flowActivationPrompt: null, activeAgent: undefined, pinnedAgentName: null, sessionCostUsd: 0, contextTokens: 0, contextLimit: 0, activeModel: undefined, activeProviderName: undefined, restoring: false }, over),
58
+ aiAssistant: Object.assign({ messages: [], flowOwnerAgentName: null, flowActivationPrompt: null, activeAgent: undefined, pinnedAgentName: null,
59
+ // The persister reads `sessionUsage.costUsd` when building a snapshot, so this
60
+ // has to be the object rather than a bare cost scalar.
61
+ sessionUsage: {
62
+ costUsd: 0,
63
+ uncachedInputTokens: 0,
64
+ cacheReadTokens: 0,
65
+ cacheWriteTokens: 0,
66
+ outputTokens: 0,
67
+ }, contextTokens: 0, contextLimit: 0, activeModel: undefined, activeProviderName: undefined, restoring: false }, over),
59
68
  },
60
69
  actions: {
61
70
  aiAssistant: {
@@ -0,0 +1,39 @@
1
+ import { css } from '@genesislcap/web-core';
2
+ /**
3
+ * Heading style for a settings-modal section, published so a host can title its own section.
4
+ *
5
+ * The assistant renders no heading for the `settings-app` slot — no text and no icon — because
6
+ * only the host knows what that section is. The consequence is that a host supplying one has to
7
+ * reproduce the styling of the built-in headings beside it, and a copied rule drifts the moment
8
+ * either side changes. So the declarations live here, are applied by the assistant's own
9
+ * headings, and are exported for the host to apply to its.
10
+ *
11
+ * Compose it into a component's styles and put `ai-settings-section-title` on the heading:
12
+ *
13
+ * ```ts
14
+ * import { assistantSettingsSectionTitleStyles } from '@genesislcap/ai-assistant';
15
+ *
16
+ * const styles = css`
17
+ * ${assistantSettingsSectionTitleStyles}
18
+ * :host { display: block; }
19
+ * `;
20
+ * ```
21
+ *
22
+ * Carries no margin on purpose: spacing belongs to the surrounding layout, which differs
23
+ * between the assistant's own sections and a slotted one, and is the single declaration hosts
24
+ * had to diverge on when they copied this rule.
25
+ *
26
+ * @beta
27
+ */
28
+ export const assistantSettingsSectionTitleStyles = css `
29
+ .ai-settings-section-title {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: calc(var(--design-unit) * 2px);
33
+ font-size: 11px;
34
+ font-weight: 600;
35
+ letter-spacing: 0.08em;
36
+ text-transform: uppercase;
37
+ color: var(--neutral-foreground-hint);
38
+ }
39
+ `;
@@ -98,7 +98,7 @@ function estimateTokensSaved(origLen, stubLen) {
98
98
  * @param onCondensed - Invoked once per payload at its full→stub transition.
99
99
  */
100
100
  export function applyCondensation(history, policies, ctx, onCondensed) {
101
- var _a;
101
+ var _a, _b;
102
102
  if (policies.size === 0)
103
103
  return history;
104
104
  const triggersOf = (entry) => Array.isArray(entry.policy.on) ? entry.policy.on : [entry.policy.on];
@@ -106,15 +106,26 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
106
106
  // lookup. For each `superseded` key the LAST call in history order survives.
107
107
  // The name lookup lets a tool message (which carries only an id) name its tool
108
108
  // in stubs and events.
109
+ // Batch boundary: the collapse set is resolved as of this model-call and stays put
110
+ // until the next boundary, so the prefix is append-only in between. `batchCalls` of 1
111
+ // (the default) makes this `ctx.modelCall` — i.e. no batching.
112
+ const batchCalls = Math.max(1, Math.floor((_a = ctx.batchCalls) !== null && _a !== void 0 ? _a : 1));
113
+ const asOf = batchCalls === 1 ? ctx.modelCall : Math.floor(ctx.modelCall / batchCalls) * batchCalls;
114
+ // A call made after the boundary is invisible to this pass in BOTH directions: it
115
+ // cannot supersede an older call (which would collapse the older payload early), and
116
+ // it cannot itself collapse (which would elide the very result just fetched). Unbatched,
117
+ // the boundary is the live clock and this gate is off entirely — a call registered on
118
+ // the current model-call is still eligible, as it always was.
119
+ const withinBatch = (entry) => batchCalls === 1 || entry.iteration <= asOf;
109
120
  const latestByKey = new Map();
110
121
  const nameById = new Map();
111
122
  for (const msg of history) {
112
- if (!((_a = msg.toolCalls) === null || _a === void 0 ? void 0 : _a.length))
123
+ if (!((_b = msg.toolCalls) === null || _b === void 0 ? void 0 : _b.length))
113
124
  continue;
114
125
  for (const tc of msg.toolCalls) {
115
126
  nameById.set(tc.id, tc.name);
116
127
  const entry = policies.get(tc.id);
117
- if (!entry)
128
+ if (!entry || !withinBatch(entry))
118
129
  continue;
119
130
  for (const t of triggersOf(entry)) {
120
131
  if (t.kind === 'superseded')
@@ -128,7 +139,7 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
128
139
  // past the last allowed view: turns:1 → seen once, then gone. Monotonic
129
140
  // clock, so this holds across turn boundaries too.
130
141
  case 'age':
131
- return ctx.modelCall - entry.iteration > trig.turns;
142
+ return asOf - entry.iteration > trig.turns;
132
143
  case 'turnEnd':
133
144
  return ctx.turn > entry.turn;
134
145
  case 'agentEnd':
@@ -144,7 +155,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
144
155
  };
145
156
  // The first listed trigger that fires — drives the collapse, the stub reason,
146
157
  // and the event label. `undefined` means the payload stays full.
147
- const firstFired = (toolCallId, entry) => triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));
158
+ const firstFired = (toolCallId, entry) => withinBatch(entry)
159
+ ? triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t))
160
+ : undefined;
148
161
  return history.map((msg) => {
149
162
  var _a, _b, _c, _d;
150
163
  // Tool-call ARGS live on the assistant message.
@@ -27,6 +27,7 @@ const ctx = (o = {}) => {
27
27
  turn: (_b = o.turn) !== null && _b !== void 0 ? _b : 0,
28
28
  activationEnded: (_c = o.activationEnded) !== null && _c !== void 0 ? _c : (() => false),
29
29
  phaseEnded: (_d = o.phaseEnded) !== null && _d !== void 0 ? _d : (() => false),
30
+ batchCalls: o.batchCalls,
30
31
  });
31
32
  };
32
33
  const sink = () => {
@@ -336,3 +337,69 @@ suite('multi-trigger first-wins: the earliest-listed firing trigger is the reaso
336
337
  assert.is((_b = events.find((e) => e.toolCallId === 'm2')) === null || _b === void 0 ? void 0 : _b.trigger, 'turnEnd', 'the supersession survivor still collapses via the next trigger');
337
338
  });
338
339
  suite.run();
340
+ // ── batched collapse (GENC-1476) ───────────────────────────────────────────
341
+ // Condensation rewrites history in place, so collapsing the instant a trigger fires
342
+ // breaks the prompt cache on nearly every call of a re-read loop. Batching holds the
343
+ // collapse set still between boundaries.
344
+ const batch = createLogicSuite('applyCondensation batching');
345
+ /** A re-read loop: `n` reads of the same path, each registered one model-call apart. */
346
+ const reReadLoop = (n) => {
347
+ const history = [];
348
+ const policies = new Map();
349
+ for (let i = 1; i <= n; i += 1) {
350
+ history.push(asstCall(`r${i}`, 'vfs_read', { path: 'A.tsx' }), toolMsg(`r${i}`, big()));
351
+ policies.set(`r${i}`, reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: i }));
352
+ }
353
+ return { history, policies };
354
+ };
355
+ /** Result content of every tool message, in order. */
356
+ const results = (out) => out.filter((m) => m.role === 'tool').map((m) => { var _a, _b; return (_b = (_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.content) !== null && _b !== void 0 ? _b : ''; });
357
+ batch('without batching every superseded read collapses immediately', () => {
358
+ const { history, policies } = reReadLoop(4);
359
+ const out = results(applyCondensation(history, policies, ctx({ modelCall: 4 }), sink().on));
360
+ // Only the newest survives; the other three are already stubs.
361
+ assert.is(out.filter((c) => c.startsWith('[')).length, 3);
362
+ assert.is(out[3].length, big().length);
363
+ });
364
+ batch('batching leaves the prefix untouched between boundaries', () => {
365
+ const { history, policies } = reReadLoop(4);
366
+ // Boundary is floor(4/10)*10 = 0, so no call is within the batch yet.
367
+ const out = results(applyCondensation(history, policies, ctx({ modelCall: 4, batchCalls: 10 }), sink().on));
368
+ assert.equal(out.map((c) => c.length), [big().length, big().length, big().length, big().length]);
369
+ });
370
+ batch('the batch boundary collapses the backlog in one step', () => {
371
+ const { history, policies } = reReadLoop(12);
372
+ const before = results(applyCondensation(history, policies, ctx({ modelCall: 9, batchCalls: 10 }), sink().on));
373
+ const after = results(applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on));
374
+ assert.is(before.filter((c) => c.startsWith('[')).length, 0);
375
+ // At the boundary, reads 1-9 collapse together; 10-12 are past it and stay full.
376
+ assert.is(after.filter((c) => c.startsWith('[')).length, 9);
377
+ });
378
+ batch('a call past the boundary neither collapses nor supersedes an older one', () => {
379
+ // The regression this guards: excluding the new call as a SUPERSEDER but still letting
380
+ // it collapse would elide the very result the agent just fetched.
381
+ const { history, policies } = reReadLoop(2);
382
+ policies.set('r2', reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: 7 }));
383
+ const out = results(applyCondensation(history, policies, ctx({ modelCall: 7, batchCalls: 5 }), sink().on));
384
+ assert.is(out[1].length, big().length, 'the newest read must stay full');
385
+ assert.is(out[0].length, big().length, 'the older read must not collapse early either');
386
+ });
387
+ batch('age is measured against the boundary, not the live clock', () => {
388
+ const history = [asstCall('g1', 'grep', {}), toolMsg('g1', big())];
389
+ const policies = new Map([
390
+ ['g1', reg({ on: { kind: 'age', turns: 1 }, response: 'pointer' }, { iteration: 1 })],
391
+ ]);
392
+ // Live clock says age 4 > 1 (would collapse); the boundary says 0, so it holds.
393
+ const held = results(applyCondensation(history, policies, ctx({ modelCall: 5, batchCalls: 10 }), sink().on));
394
+ assert.is(held[0].length, big().length);
395
+ const collapsed = results(applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on));
396
+ assert.ok(collapsed[0].startsWith('['));
397
+ });
398
+ batch('batchCalls of 1, 0 or absent all keep the historical behaviour', () => {
399
+ for (const batchCalls of [1, 0, undefined]) {
400
+ const { history, policies } = reReadLoop(3);
401
+ const out = results(applyCondensation(history, policies, ctx({ modelCall: 3, batchCalls }), sink().on));
402
+ assert.is(out.filter((c) => c.startsWith('[')).length, 2, `batchCalls=${batchCalls}`);
403
+ }
404
+ });
405
+ batch.run();
@@ -1,9 +1,28 @@
1
+ import { emptyUsage } from './sum-usage';
1
2
  const STORAGE_PREFIX = 'genesis-ai-assistant:cost-history:';
3
+ /**
4
+ * Every bucket must be a number before a row is trusted. Checked per field rather than
5
+ * just testing for an object, because a partially-shaped `usage` would sum as `NaN` and
6
+ * silently poison every total on the Usage tab.
7
+ */
8
+ function isAggregateUsage(value) {
9
+ if (typeof value !== 'object' || value == null)
10
+ return false;
11
+ const u = value;
12
+ return (typeof u.costUsd === 'number' &&
13
+ typeof u.uncachedInputTokens === 'number' &&
14
+ typeof u.cacheReadTokens === 'number' &&
15
+ typeof u.cacheWriteTokens === 'number' &&
16
+ typeof u.outputTokens === 'number');
17
+ }
2
18
  /** localStorage key for a host-scoped cost history list. */
3
19
  export function costHistoryStorageKey(scope) {
4
20
  return `${STORAGE_PREFIX}${scope}`;
5
21
  }
6
- /** Load persisted cost sessions for a scope (newest first). */
22
+ /**
23
+ * Load persisted cost sessions for a scope, in whatever order they were stored — this applies no
24
+ * ordering of its own. Display order is the caller's business; see `sortRecordsByRecency`.
25
+ */
7
26
  export function loadCostSessionHistory(scope) {
8
27
  if (typeof localStorage === 'undefined')
9
28
  return [];
@@ -31,17 +50,62 @@ export function saveCostSessionHistory(scope, records) {
31
50
  // Quota exceeded or private browsing — non-fatal.
32
51
  }
33
52
  }
34
- /** Prepend a record and persist. Returns the updated list. */
35
- export function appendCostSessionRecord(scope, record) {
36
- const records = [record, ...loadCostSessionHistory(scope)];
37
- saveCostSessionHistory(scope, records);
38
- return records;
53
+ /**
54
+ * Pure upsert — `records` with `record` replacing any row sharing its `projectKey`, or
55
+ * prepended when there is none.
56
+ *
57
+ * An upsert, not an append, because a row stands for a **project's lifetime usage** rather
58
+ * than a visit to it. Appending cut a fresh row per page-hide, so one project accumulated
59
+ * a stack of near-identical rows and its spend was counted once per refresh by anything
60
+ * summing the list.
61
+ *
62
+ * The resulting array order is incidental — it is insertion order, so a project returned to
63
+ * stays where it first landed. Do not read it as recency; `sortRecordsByRecency` is what the
64
+ * Usage tab renders through.
65
+ *
66
+ * Storage-agnostic on purpose: the same merge serves the synchronous `localStorage` path
67
+ * and the async provider-backed one, so the two can never drift on what "upsert" means.
68
+ * The caller owns persistence — see the assistant's `persistCostHistory`.
69
+ */
70
+ export function upsertRecord(records, record) {
71
+ const at = records.findIndex((r) => r.projectKey === record.projectKey);
72
+ return at === -1 ? [record, ...records] : records.map((r, i) => (i === at ? record : r));
73
+ }
74
+ /**
75
+ * Rows most-recently-worked-on first, as a new array.
76
+ *
77
+ * Applied at read time rather than baked into the stored order, so it covers rows that came from
78
+ * a host provider as well as from `localStorage`, and so no write path has to maintain it.
79
+ *
80
+ * `updatedAt` is ISO-8601, which sorts lexicographically — no date parsing, and no dependence on
81
+ * the strings being valid dates. Sorting by it rather than by insertion order is what makes the
82
+ * list track use: the active project moves to the top on its first upsert and stays there, since
83
+ * its row is rewritten on every transcript change.
84
+ */
85
+ export function sortRecordsByRecency(records) {
86
+ return [...records].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
39
87
  }
40
- /** Remove one record by id. Returns the updated list. */
41
- export function removeCostSessionRecord(scope, id) {
42
- const records = loadCostSessionHistory(scope).filter((r) => r.id !== id);
43
- saveCostSessionHistory(scope, records);
44
- return records;
88
+ /**
89
+ * The spend a project's next row must carry over from before its current transcript.
90
+ *
91
+ * `transcriptIsAuthoritative` means the open transcript already accounts for this project's
92
+ * history — true exactly when a session snapshot is restored on load. The two branches are
93
+ * not interchangeable, and picking the wrong one is silently destructive:
94
+ *
95
+ * - Authoritative: carry the previous row's own `banked` forward. Taking `usage` instead would
96
+ * re-add the restored history on every reload, the compounding double-count this replaced.
97
+ * - Not authoritative: the whole previous total becomes banked, because nothing in the fresh
98
+ * transcript accounts for it. Taking `banked` instead would rewrite the row down to the
99
+ * current page-load and drop the project's earlier spend.
100
+ *
101
+ * Note both branches are idempotent — the result never includes the current transcript — which
102
+ * is what lets two assistant instances sharing a session write the same row.
103
+ */
104
+ export function resolveBankedUsage(existing, transcriptIsAuthoritative) {
105
+ if (!existing)
106
+ return emptyUsage();
107
+ const carried = transcriptIsAuthoritative ? existing.banked : existing.usage;
108
+ return carried !== null && carried !== void 0 ? carried : emptyUsage();
45
109
  }
46
110
  /** Clear all persisted records for a scope. */
47
111
  export function clearCostSessionHistory(scope) {
@@ -62,14 +126,27 @@ export function formatCostSessionDate(iso) {
62
126
  year: 'numeric',
63
127
  }).format(new Date(iso));
64
128
  }
65
- function isCostSessionRecord(value) {
129
+ /**
130
+ * Whether a value is a usable row. Exported because rows arrive by two routes and BOTH need it:
131
+ * `loadCostSessionHistory` filters the `localStorage` path here, and the assistant filters what a
132
+ * host provider returns. A row that skips this check is not merely ignored downstream — a
133
+ * half-shaped `usage` sums to `NaN` through every total, and a missing `updatedAt` throws inside
134
+ * the sort that orders the list, during render.
135
+ */
136
+ export function isCostSessionRecord(value) {
66
137
  if (typeof value !== 'object' || value == null)
67
138
  return false;
68
139
  const r = value;
69
140
  return (typeof r.id === 'string' &&
141
+ // Rows written before the upsert model have no `projectKey` and cannot be keyed
142
+ // to a project, so they are discarded on load rather than silently pooled with
143
+ // the new per-project rows (where they would double-count that project).
144
+ typeof r.projectKey === 'string' &&
70
145
  typeof r.title === 'string' &&
71
- typeof r.endedAt === 'string' &&
72
- typeof r.costUsd === 'number' &&
73
- typeof r.tokensConsumed === 'number' &&
146
+ typeof r.updatedAt === 'string' &&
147
+ isAggregateUsage(r.usage) &&
148
+ // Absent is valid (nothing banked / pre-dates the field); present must be whole, since
149
+ // a half-shaped `banked` would poison the lifetime total exactly as a bad `usage` would.
150
+ (r.banked === undefined || isAggregateUsage(r.banked)) &&
74
151
  Array.isArray(r.models));
75
152
  }