@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
package/src/config/env.ts CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  import { join } from "path";
17
17
 
18
+ import type { BillingMode } from "@schlessera/brain-ui-sdk/protocol";
19
+
18
20
  import { SEVERITIES, type Severity } from "../observability/types.js";
19
21
  import { envFlag } from "./env-core.js";
20
22
 
@@ -257,6 +259,25 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
257
259
  default: "(none)",
258
260
  required: false,
259
261
  },
262
+ {
263
+ name: "CLAUDE_CODE_OAUTH_TOKEN",
264
+ description:
265
+ "Consulted for PRESENCE only, to classify billing: with it set and no " +
266
+ "ANTHROPIC_API_KEY, ambient-credential Claude profiles (the built-in " +
267
+ "default and discovered models) count as subscription-billed. The token " +
268
+ "itself is consumed by the Claude backend / Agent SDK, not this package.",
269
+ default: null,
270
+ required: false,
271
+ },
272
+ {
273
+ name: "ANTHROPIC_API_KEY",
274
+ description:
275
+ "Consulted for PRESENCE only, to classify billing: when set it wins " +
276
+ "over CLAUDE_CODE_OAUTH_TOKEN (mirroring the Agent SDK's credential " +
277
+ "precedence), so ambient-credential profiles count as api-billed.",
278
+ default: null,
279
+ required: false,
280
+ },
260
281
  {
261
282
  name: "BRAIN_UI_MODEL_DISCOVERY",
262
283
  description:
@@ -270,10 +291,23 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
270
291
  default: "24",
271
292
  required: false,
272
293
  },
294
+ {
295
+ name: "BRAIN_UI_PRICING_DISCOVERY",
296
+ description:
297
+ 'Remote model-pricing refresh (LiteLLM + OpenRouter catalogs); "0"/"off"/"false" disables, and runs then roll up with unknown effective cost. Defaults ON, except under a test runner (NODE_ENV=test) where it defaults OFF.',
298
+ default: "on (off under NODE_ENV=test)",
299
+ required: false,
300
+ },
301
+ {
302
+ name: "BRAIN_UI_PRICING_TTL_HOURS",
303
+ description: "How long a fetched model-pricing table stays fresh, in hours.",
304
+ default: "24",
305
+ required: false,
306
+ },
273
307
  {
274
308
  name: "NODE_ENV",
275
309
  description:
276
- "Only consulted for test-runner detection: flips the model-discovery default to off under bun test. Never gates any security behavior.",
310
+ "Only consulted for test-runner detection: flips the model-discovery and pricing-discovery defaults to off under bun test. Never gates any security behavior.",
277
311
  default: "(unset)",
278
312
  required: false,
279
313
  },
@@ -347,6 +381,15 @@ export interface AgentConfig {
347
381
  profilesJson: string | null;
348
382
  modelDiscovery: boolean;
349
383
  modelTtlMs: number;
384
+ /**
385
+ * Billing classification for profiles running on AMBIENT credentials (the
386
+ * built-in default and discovered models): "subscription" iff the
387
+ * environment holds a CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY —
388
+ * the same precedence the Agent SDK applies — else "api". Declared profiles
389
+ * carrying their own credential env vars are classified "api" by the
390
+ * registry regardless of this value.
391
+ */
392
+ ambientBilling: BillingMode;
350
393
  }
351
394
 
352
395
  export interface VoiceConfig {
@@ -380,6 +423,8 @@ export interface ServerConfig {
380
423
  webauthn: WebAuthnConfig;
381
424
  agent: AgentConfig;
382
425
  voice: VoiceConfig;
426
+ /** Model-pricing service (BRAIN_UI_PRICING_*); inline like wsRate. */
427
+ pricing: { enabled: boolean; ttlMs: number };
383
428
  }
384
429
 
385
430
  // --- resolver ----------------------------------------------------------------
@@ -438,12 +483,50 @@ function parseSeverity(raw: string | undefined): Severity {
438
483
 
439
484
  const AUTH_MODES: readonly AuthModeName[] = ["password", "tailscale", "proxy", "none"];
440
485
 
486
+ /**
487
+ * Ambient billing classification from an environment (presence-only reads).
488
+ * The API key wins over the OAuth token — the Agent SDK's own precedence —
489
+ * and no usable credential at all classifies "api" (nothing
490
+ * subscription-billed can run without the token). Exported for the activity
491
+ * store's standalone default, so the cron wrapper classifies from the same
492
+ * predicate the server config does.
493
+ */
494
+ export function resolveAmbientBillingMode(env: EnvRecord = process.env): BillingMode {
495
+ return !env.ANTHROPIC_API_KEY?.trim() && env.CLAUDE_CODE_OAUTH_TOKEN?.trim()
496
+ ? "subscription"
497
+ : "api";
498
+ }
499
+
500
+ /**
501
+ * THE derivation of the pricing config (kill switch, TTL, brain path) —
502
+ * consumed by resolveServerConfig and used directly by the cron wrapper's
503
+ * bare `createActivityStore(db)` path, where no ServerConfig exists. Kept
504
+ * here so process.env reads stay in the one chokepoint the env-access gate
505
+ * allows.
506
+ */
507
+ export function resolveStandalonePricingConfig(env: EnvRecord = process.env): {
508
+ brainPath: string;
509
+ enabled: boolean;
510
+ ttlMs: number;
511
+ } {
512
+ const ttlHours = Number(env.BRAIN_UI_PRICING_TTL_HOURS);
513
+ return {
514
+ brainPath: env.BRAIN_PATH || join(env.HOME || "/root", "brain"),
515
+ enabled: envFlag(env.BRAIN_UI_PRICING_DISCOVERY, env.NODE_ENV !== "test"),
516
+ ttlMs:
517
+ Number.isFinite(ttlHours) && ttlHours > 0
518
+ ? ttlHours * 60 * 60 * 1000
519
+ : 24 * 60 * 60 * 1000,
520
+ };
521
+ }
522
+
441
523
  /**
442
524
  * Resolve an environment into a {@link ServerConfig}. Defaults to the real
443
525
  * process environment; tests pass their own record instead of mutating it.
444
526
  */
445
527
  export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig {
446
- const brainPath = env.BRAIN_PATH || join(env.HOME || "/root", "brain");
528
+ const { brainPath, enabled: pricingEnabled, ttlMs: pricingTtlMs } =
529
+ resolveStandalonePricingConfig(env);
447
530
 
448
531
  const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
449
532
  const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
@@ -494,6 +577,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
494
577
  profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
495
578
  modelDiscovery,
496
579
  modelTtlMs: ttlHours * 60 * 60 * 1000,
580
+ ambientBilling: resolveAmbientBillingMode(env),
497
581
  },
498
582
  logLevel: parseSeverity(env.BRAIN_UI_LOG_LEVEL),
499
583
  wsRate: {
@@ -506,6 +590,10 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
506
590
  keytermLimit: Number(env.VOICE_KEYTERM_LIMIT || 500),
507
591
  cacheDir: env.VOICE_CACHE_DIR || join(brainPath, ".brain-ui"),
508
592
  },
593
+ pricing: {
594
+ enabled: pricingEnabled,
595
+ ttlMs: pricingTtlMs,
596
+ },
509
597
  };
510
598
  }
511
599
 
@@ -115,15 +115,35 @@ export function createCronScheduler(deps: {
115
115
 
116
116
  return {
117
117
  getCronStatus() {
118
- return jobs.map((job) => {
119
- const lastRun = db
120
- .query(
121
- "SELECT * FROM cron_runs WHERE job_name = ? ORDER BY started_at DESC LIMIT 1"
122
- )
123
- .get(job.name) as any;
124
-
118
+ // Every job name that ever recorded a run, not just the two in-process
119
+ // triggers: module jobs and the maintain/digest crontab entries land in
120
+ // cron_runs through the same recorder and were previously invisible
121
+ // here (the "cron-status name gap"). The in-process names are unioned
122
+ // in so a fresh deployment still lists them before their first run.
123
+ // One window query yields each job's latest run.
124
+ const lastRuns = db
125
+ .query(
126
+ `SELECT job_name, started_at, status, duration_ms, error_message FROM (
127
+ SELECT *, ROW_NUMBER() OVER (
128
+ PARTITION BY job_name ORDER BY started_at DESC
129
+ ) AS rn FROM cron_runs
130
+ ) WHERE rn = 1 ORDER BY job_name`
131
+ )
132
+ .all() as Array<{
133
+ job_name: string;
134
+ started_at: number;
135
+ status: string;
136
+ duration_ms: number | null;
137
+ error_message: string | null;
138
+ }>;
139
+ const byName = new Map(lastRuns.map((r) => [r.job_name, r]));
140
+ const names = [
141
+ ...new Set([...jobs.map((j) => j.name), ...lastRuns.map((r) => r.job_name)]),
142
+ ];
143
+ return names.map((name) => {
144
+ const lastRun = byName.get(name);
125
145
  return {
126
- name: job.name,
146
+ name,
127
147
  lastRunAt: lastRun?.started_at ?? null,
128
148
  lastStatus: lastRun?.status ?? null,
129
149
  lastDurationMs: lastRun?.duration_ms ?? null,
package/src/db/client.ts CHANGED
@@ -26,6 +26,12 @@ export function createUiDb(dbPath: string, options: CreateUiDbOptions = {}): Dat
26
26
  const db = new Database(dbPath, { create: true });
27
27
  db.exec("PRAGMA journal_mode = WAL");
28
28
  db.exec("PRAGMA foreign_keys = ON");
29
+ // Two processes write this database (the server and the cron wrapper). The
30
+ // timeout makes a writer wait for a held lock instead of failing on it.
31
+ // It does NOT rescue a deferred transaction that loses the snapshot-upgrade
32
+ // race (that still throws SQLITE_BUSY) — which is why activity writes use
33
+ // immediate transactions; see src/activity/store.ts.
34
+ db.exec("PRAGMA busy_timeout = 5000");
29
35
  runMigrations(db, options.log);
30
36
  return db;
31
37
  }
@@ -67,11 +73,30 @@ function runMigrations(database: Database, log?: Logger) {
67
73
  log?.emit({ severityText: "INFO", body: "applying migration", attributes: { file } });
68
74
  const sql = readFileSync(join(migrationsDir, file), "utf-8");
69
75
 
70
- database.transaction(() => {
71
- database.exec(sql);
72
- database
73
- .prepare("INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)")
74
- .run(file, Date.now());
75
- })();
76
+ try {
77
+ database.transaction(() => {
78
+ database.exec(sql);
79
+ database
80
+ .prepare("INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)")
81
+ .run(file, Date.now());
82
+ })();
83
+ } catch (err) {
84
+ // Two processes racing a first boot: the loser's transaction fails on
85
+ // the UNIQUE _migrations.filename insert (or on DDL the winner already
86
+ // ran). If the file is now recorded as applied, treat it as such and
87
+ // move on; anything else is a real migration failure.
88
+ const nowApplied = database
89
+ .query("SELECT 1 FROM _migrations WHERE filename = ?")
90
+ .get(file);
91
+ const uniqueViolation =
92
+ err instanceof Error &&
93
+ err.message.includes("UNIQUE constraint failed: _migrations.filename");
94
+ if (!nowApplied && !uniqueViolation) throw err;
95
+ log?.emit({
96
+ severityText: "INFO",
97
+ body: "migration applied concurrently by another process; skipping",
98
+ attributes: { file },
99
+ });
100
+ }
76
101
  }
77
102
  }
@@ -8,8 +8,12 @@
8
8
 
9
9
  import type { Logger } from "@opentelemetry/api-logs";
10
10
  import type { Database } from "bun:sqlite";
11
+ import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protocol";
11
12
 
12
13
  const HIDDEN_MODELS_KEY = "models.hidden";
14
+ const BILLING_OVERRIDES_KEY = "models.billing";
15
+ const DETAIL_RETENTION_KEY = "activity.retention.detailDays";
16
+ const DETAIL_RETENTION_DEFAULT_DAYS = 7;
13
17
 
14
18
  export function getSetting<T>(db: Database, key: string, fallback: T, log?: Logger): T {
15
19
  const row = db
@@ -47,3 +51,55 @@ export function setHiddenModelIds(db: Database, ids: string[]): void {
47
51
  const unique = [...new Set(ids.filter((id) => typeof id === "string" && id))];
48
52
  setSetting(db, HIDDEN_MODELS_KEY, unique);
49
53
  }
54
+
55
+ /**
56
+ * Per-profile billing-mode overrides: profile id → forced classification. A
57
+ * profile absent from the record is "auto" (the registry's derived mode
58
+ * applies). Anything that is not exactly "subscription" or "api" is dropped
59
+ * on read, so a corrupt row degrades to auto rather than misbilling.
60
+ */
61
+ export function getBillingOverrides(
62
+ db: Database,
63
+ log?: Logger
64
+ ): Record<string, BillingMode> {
65
+ const value = getSetting<unknown>(db, BILLING_OVERRIDES_KEY, {}, log);
66
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
67
+ return Object.create(null);
68
+ }
69
+ // Null-prototype: profile ids are user-supplied strings — an id like
70
+ // "__proto__" must be an ordinary key, and a "constructor" lookup by a
71
+ // consumer must not resolve a prototype member.
72
+ const overrides: Record<string, BillingMode> = Object.create(null);
73
+ for (const [profileId, mode] of Object.entries(value)) {
74
+ if (isBillingMode(mode)) overrides[profileId] = mode;
75
+ }
76
+ return overrides;
77
+ }
78
+
79
+ /** Replace the override record (the client always sends the full record, not a delta). */
80
+ export function setBillingOverrides(
81
+ db: Database,
82
+ overrides: Record<string, BillingMode>
83
+ ): void {
84
+ const clean = Object.fromEntries(
85
+ Object.entries(overrides).filter(
86
+ ([profileId, mode]) => profileId && isBillingMode(mode)
87
+ )
88
+ );
89
+ setSetting(db, BILLING_OVERRIDES_KEY, clean);
90
+ }
91
+
92
+ /** Minimum days a finished run keeps its detail (spans/events) before the
93
+ * digest-covered prune may take it. 0 is valid (prune as soon as covered);
94
+ * anything non-numeric or negative degrades to the default. */
95
+ export function getDetailRetentionDays(db: Database, log?: Logger): number {
96
+ const value = getSetting<unknown>(db, DETAIL_RETENTION_KEY, DETAIL_RETENTION_DEFAULT_DAYS, log);
97
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
98
+ return DETAIL_RETENTION_DEFAULT_DAYS;
99
+ }
100
+ return value;
101
+ }
102
+
103
+ export function setDetailRetentionDays(db: Database, days: number): void {
104
+ setSetting(db, DETAIL_RETENTION_KEY, days);
105
+ }
package/src/index.ts CHANGED
@@ -80,6 +80,29 @@ export { createBrainClient, type BrainClient } from "./brain/client.js";
80
80
  // `cronJobs` reflects what actually ran.
81
81
  export { recordCronRun, type CronRunRecord } from "./cron/scheduler.js";
82
82
 
83
+ // The activity record. The cron wrapper is a second PROCESS writing spans
84
+ // into the same store (root span + heartbeat + span-sink ingest); everything
85
+ // else consumes it through the app.
86
+ export {
87
+ createActivityStore,
88
+ SPAN_OUTCOMES,
89
+ type ActivityStore,
90
+ type SpanRow,
91
+ type SpanEventRow,
92
+ type SpanOutcome,
93
+ type SpanUsage,
94
+ type ActivityChange,
95
+ type RollupPricing,
96
+ } from "./activity/store.js";
97
+ export { createActivityStream, type ActivityStream } from "./activity/stream.js";
98
+ export { ingestSpanSink } from "./activity/span-sink.js";
99
+ export {
100
+ generateActivityDigest,
101
+ latestActivityDigest,
102
+ DIGEST_JOB_NAME,
103
+ type ActivityDigest,
104
+ } from "./activity/digest.js";
105
+
83
106
  // Share staging: a deployment can sweep expired staging dirs at boot; the
84
107
  // intake route also sweeps opportunistically on every share.
85
108
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";