@schlessera/brain-ui-server 0.18.0 → 0.20.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 (130) hide show
  1. package/README.md +5 -1
  2. package/data/model-prices.json +63 -0
  3. package/dist/activity/digest.d.ts +29 -0
  4. package/dist/activity/digest.d.ts.map +1 -0
  5. package/dist/activity/digest.js +89 -0
  6. package/dist/activity/digest.js.map +1 -0
  7. package/dist/activity/notify.d.ts +62 -0
  8. package/dist/activity/notify.d.ts.map +1 -0
  9. package/dist/activity/notify.js +193 -0
  10. package/dist/activity/notify.js.map +1 -0
  11. package/dist/activity/push-sender.d.ts +63 -0
  12. package/dist/activity/push-sender.d.ts.map +1 -0
  13. package/dist/activity/push-sender.js +131 -0
  14. package/dist/activity/push-sender.js.map +1 -0
  15. package/dist/activity/query.d.ts +16 -0
  16. package/dist/activity/query.d.ts.map +1 -0
  17. package/dist/activity/query.js +155 -0
  18. package/dist/activity/query.js.map +1 -0
  19. package/dist/activity/recorder.d.ts +53 -0
  20. package/dist/activity/recorder.d.ts.map +1 -0
  21. package/dist/activity/recorder.js +238 -0
  22. package/dist/activity/recorder.js.map +1 -0
  23. package/dist/activity/runtime.d.ts +37 -0
  24. package/dist/activity/runtime.d.ts.map +1 -0
  25. package/dist/activity/runtime.js +127 -0
  26. package/dist/activity/runtime.js.map +1 -0
  27. package/dist/activity/span-sink.d.ts +22 -0
  28. package/dist/activity/span-sink.d.ts.map +1 -0
  29. package/dist/activity/span-sink.js +138 -0
  30. package/dist/activity/span-sink.js.map +1 -0
  31. package/dist/activity/store.d.ts +256 -0
  32. package/dist/activity/store.d.ts.map +1 -0
  33. package/dist/activity/store.js +657 -0
  34. package/dist/activity/store.js.map +1 -0
  35. package/dist/activity/stream.d.ts +49 -0
  36. package/dist/activity/stream.d.ts.map +1 -0
  37. package/dist/activity/stream.js +353 -0
  38. package/dist/activity/stream.js.map +1 -0
  39. package/dist/agent/backend.d.ts +13 -4
  40. package/dist/agent/backend.d.ts.map +1 -1
  41. package/dist/agent/backend.js +68 -5
  42. package/dist/agent/backend.js.map +1 -1
  43. package/dist/app.d.ts.map +1 -1
  44. package/dist/app.js +33 -2
  45. package/dist/app.js.map +1 -1
  46. package/dist/config/env.d.ts +36 -0
  47. package/dist/config/env.d.ts.map +1 -1
  48. package/dist/config/env.js +66 -2
  49. package/dist/config/env.js.map +1 -1
  50. package/dist/cron/scheduler.d.ts.map +1 -1
  51. package/dist/cron/scheduler.js +20 -5
  52. package/dist/cron/scheduler.js.map +1 -1
  53. package/dist/db/client.d.ts.map +1 -1
  54. package/dist/db/client.js +32 -6
  55. package/dist/db/client.js.map +1 -1
  56. package/dist/db/settings.d.ts +15 -0
  57. package/dist/db/settings.d.ts.map +1 -1
  58. package/dist/db/settings.js +43 -0
  59. package/dist/db/settings.js.map +1 -1
  60. package/dist/index.d.ts +4 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +7 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/pricing/model-pricing.d.ts +61 -0
  65. package/dist/pricing/model-pricing.d.ts.map +1 -0
  66. package/dist/pricing/model-pricing.js +379 -0
  67. package/dist/pricing/model-pricing.js.map +1 -0
  68. package/dist/routes/activity.d.ts +20 -0
  69. package/dist/routes/activity.d.ts.map +1 -0
  70. package/dist/routes/activity.js +308 -0
  71. package/dist/routes/activity.js.map +1 -0
  72. package/dist/routes/models.d.ts +6 -0
  73. package/dist/routes/models.d.ts.map +1 -1
  74. package/dist/routes/models.js +38 -3
  75. package/dist/routes/models.js.map +1 -1
  76. package/dist/routes/push.d.ts +6 -0
  77. package/dist/routes/push.d.ts.map +1 -0
  78. package/dist/routes/push.js +73 -0
  79. package/dist/routes/push.js.map +1 -0
  80. package/dist/routes/sessions.d.ts.map +1 -1
  81. package/dist/routes/sessions.js +20 -4
  82. package/dist/routes/sessions.js.map +1 -1
  83. package/dist/ws/bridge.d.ts +2 -1
  84. package/dist/ws/bridge.d.ts.map +1 -1
  85. package/dist/ws/bridge.js +27 -2
  86. package/dist/ws/bridge.js.map +1 -1
  87. package/dist/ws/connection.d.ts.map +1 -1
  88. package/dist/ws/connection.js +9 -1
  89. package/dist/ws/connection.js.map +1 -1
  90. package/dist/ws/dispatch.d.ts.map +1 -1
  91. package/dist/ws/dispatch.js +11 -0
  92. package/dist/ws/dispatch.js.map +1 -1
  93. package/dist/ws/host.d.ts +16 -0
  94. package/dist/ws/host.d.ts.map +1 -1
  95. package/dist/ws/host.js +2 -0
  96. package/dist/ws/host.js.map +1 -1
  97. package/dist/ws/run-session.d.ts.map +1 -1
  98. package/dist/ws/run-session.js +55 -1
  99. package/dist/ws/run-session.js.map +1 -1
  100. package/migrations/007_activity.sql +114 -0
  101. package/migrations/008_push_subscriptions.sql +24 -0
  102. package/migrations/009_activity_followups.sql +22 -0
  103. package/migrations/010_effective_cost.sql +21 -0
  104. package/package.json +6 -3
  105. package/src/activity/digest.ts +120 -0
  106. package/src/activity/notify.ts +307 -0
  107. package/src/activity/push-sender.ts +215 -0
  108. package/src/activity/query.ts +187 -0
  109. package/src/activity/recorder.ts +313 -0
  110. package/src/activity/runtime.ts +169 -0
  111. package/src/activity/span-sink.ts +152 -0
  112. package/src/activity/store.ts +1069 -0
  113. package/src/activity/stream.ts +396 -0
  114. package/src/agent/backend.ts +91 -10
  115. package/src/app.ts +35 -2
  116. package/src/config/env.ts +90 -2
  117. package/src/cron/scheduler.ts +28 -8
  118. package/src/db/client.ts +31 -6
  119. package/src/db/settings.ts +56 -0
  120. package/src/index.ts +23 -0
  121. package/src/pricing/model-pricing.ts +497 -0
  122. package/src/routes/activity.ts +371 -0
  123. package/src/routes/models.ts +57 -6
  124. package/src/routes/push.ts +90 -0
  125. package/src/routes/sessions.ts +24 -4
  126. package/src/ws/bridge.ts +29 -2
  127. package/src/ws/connection.ts +9 -1
  128. package/src/ws/dispatch.ts +13 -0
  129. package/src/ws/host.ts +18 -0
  130. package/src/ws/run-session.ts +71 -2
@@ -177,6 +177,19 @@ export async function handleClientMessage(
177
177
  break;
178
178
  }
179
179
 
180
+ case "activity_subscribe": {
181
+ // View-scoped opt-in: without a subscription this connection never
182
+ // receives an activity frame. No turn correlation — subscriptions are
183
+ // connection state, not turn state.
184
+ host.activity?.stream.handleSubscribe(ws, msg);
185
+ break;
186
+ }
187
+
188
+ case "activity_unsubscribe": {
189
+ host.activity?.stream.handleUnsubscribe(ws, msg);
190
+ break;
191
+ }
192
+
180
193
  case "session_resume": {
181
194
  host.sendMessage(ws, {
182
195
  type: "session_info",
package/src/ws/host.ts CHANGED
@@ -5,6 +5,16 @@ import type { SessionCatalog } from "./session-catalog.js";
5
5
  import type { BackendRegistry } from "../agent/backend.js";
6
6
  import { createSilentObservability, type Observability } from "../observability/index.js";
7
7
  import { FrameRateLimiter } from "./rate-limit.js";
8
+ import type { ActivityStore } from "../activity/store.js";
9
+ import type { ActivityStream } from "../activity/stream.js";
10
+
11
+ /** The activity record and its live stream, when the host records activity. */
12
+ export interface ActivityRuntime {
13
+ store: ActivityStore;
14
+ stream: ActivityStream;
15
+ /** Read seam for the agent-facing query tool (bridge.queryActivity). */
16
+ query?: (query: import("@schlessera/brain-ui-sdk/server").ActivityQuery) => Record<string, unknown>;
17
+ }
8
18
 
9
19
  /** Host-side turn timeout. The backend no longer times out — the host owns it. */
10
20
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
@@ -65,6 +75,12 @@ export interface WsHostOptions {
65
75
  * fronting the socket with its own limiter wants.
66
76
  */
67
77
  wsRate?: { ratePerSecond: number; burst: number };
78
+ /**
79
+ * Activity recording (span store + live stream). Optional: a host without
80
+ * one records nothing and never sends activity frames — which is also what
81
+ * most existing tests want.
82
+ */
83
+ activity?: ActivityRuntime;
68
84
  }
69
85
 
70
86
  /** Identity of one turn, as it appears on a log record. */
@@ -102,6 +118,7 @@ export class WsHost {
102
118
  maxConcurrentSessions: () => number;
103
119
  readonly observability: Observability;
104
120
  readonly wsRate: { ratePerSecond: number; burst: number } | null;
121
+ readonly activity: ActivityRuntime | null;
105
122
  /** Scoped instruments, resolved once — `[ws]` is the existing log prefix. */
106
123
  readonly log: ReturnType<Observability["logger"]>;
107
124
  private readonly framesDropped: ReturnType<
@@ -130,6 +147,7 @@ export class WsHost {
130
147
  this.observability = options.observability ?? createSilentObservability();
131
148
  this.wsRate =
132
149
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
150
+ this.activity = options.activity ?? null;
133
151
  this.log = this.observability.logger("ws");
134
152
  const meter = this.observability.meter("ws");
135
153
  this.framesDropped = meter.createCounter("ws.frames.dropped", {
@@ -1,7 +1,13 @@
1
- import type { ChatImageAttachment, ClientEnvironment } from "@schlessera/brain-ui-sdk/protocol";
1
+ import type {
2
+ BillingMode,
3
+ ChatImageAttachment,
4
+ ClientEnvironment,
5
+ } from "@schlessera/brain-ui-sdk/protocol";
6
+ import type { BackendRegistry } from "../agent/backend.js";
2
7
  import type { WSContext } from "./clients.js";
3
8
  import { withSessionId, withTurnScope } from "./frames.js";
4
9
  import { makeBridge, emitTurnError } from "./bridge.js";
10
+ import { createTurnRecorder, type TurnRecorder } from "../activity/recorder.js";
5
11
  import { resolveTurnTarget } from "./routing.js";
6
12
  import type { QueuedFollowUp, RunningTurn } from "./turns.js";
7
13
  import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
@@ -114,7 +120,26 @@ export async function runSession(
114
120
  }, host.turnTimeoutMs);
115
121
  turn.timeoutHandle = timeoutHandle;
116
122
 
117
- const bridge = makeBridge(host, turn, text, backend.id);
123
+ // One recorder per turn identity: a queued follow-up re-mints turnId
124
+ // and gets its own run in the activity record. The recorder is handed
125
+ // the RESOLVED profile — `profileId` here is post pin-drop fallback and
126
+ // carries any session_info re-pin forward between queued turns — plus
127
+ // its billing mode, so the run's root span classifies what actually ran,
128
+ // never what was requested.
129
+ const billing = host.activity
130
+ ? await resolveRunBilling(host.registry, backend.id, profileId)
131
+ : undefined;
132
+ const recorder: TurnRecorder | undefined = host.activity
133
+ ? createTurnRecorder(
134
+ {
135
+ store: host.activity.store,
136
+ onWrite: () => host.activity!.stream.pump(),
137
+ log: host.log,
138
+ },
139
+ { turnId: turn.turnId, sessionId: turn.sessionId, ...billing }
140
+ )
141
+ : undefined;
142
+ const bridge = makeBridge(host, turn, text, backend.id, recorder);
118
143
  const startedAt = Date.now();
119
144
  host.reportTurnStarted(turn);
120
145
  try {
@@ -139,8 +164,22 @@ export async function runSession(
139
164
  } else {
140
165
  host.reportTurnCompleted(turn, Date.now() - startedAt);
141
166
  }
167
+ // Terminal precedence: cancellation and timeout are host-owned facts;
168
+ // otherwise the recorder refines from the buffered result frame.
169
+ recorder?.finish(
170
+ turn.cancelled
171
+ ? "cancelled"
172
+ : abortController.signal.aborted
173
+ ? "timeout"
174
+ : turn.lastResult === "error"
175
+ ? "error"
176
+ : "success"
177
+ );
142
178
  } catch (err) {
143
179
  emitTurnError(host, turn, err);
180
+ recorder?.finish(
181
+ turn.cancelled ? "cancelled" : abortController.signal.aborted ? "timeout" : "error"
182
+ );
144
183
  } finally {
145
184
  clearTimeout(timeoutHandle);
146
185
  }
@@ -167,6 +206,36 @@ export async function runSession(
167
206
  }
168
207
  }
169
208
 
209
+ /**
210
+ * Profile identity + billing mode for a turn's root span. When the slot has no
211
+ * resolved profile id (a resumed session whose pin was dropped), the backend
212
+ * runs its default profile — the backend's first roster entry — so THAT
213
+ * profile's identity and billing are recorded, not the dead pin's. Hidden
214
+ * profiles are included: a session pinned to a profile the user later hid
215
+ * still runs on it and must classify as it. Never throws: an unreadable
216
+ * roster records no billing attrs, and the rollup falls back to env
217
+ * classification.
218
+ */
219
+ async function resolveRunBilling(
220
+ registry: BackendRegistry,
221
+ backendId: string,
222
+ profileId: string | undefined
223
+ ): Promise<{ profileId: string; billingMode?: BillingMode } | undefined> {
224
+ try {
225
+ const providers = await registry.listAllProviders({ includeHidden: true });
226
+ const resolved = profileId
227
+ ? providers.find((provider) => provider.id === profileId)
228
+ : providers.find((provider) => provider.backendId === backendId);
229
+ if (!resolved) return undefined;
230
+ return {
231
+ profileId: resolved.id,
232
+ ...(resolved.billingMode ? { billingMode: resolved.billingMode } : {}),
233
+ };
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ }
238
+
170
239
  /** Queue sizes are only ever reported to a human, so one decimal of MB is plenty. */
171
240
  function formatMb(bytes: number): string {
172
241
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;