Repository navigation
Expand file tree
/
Copy pathconfig.ts
More file actions
251 lines (238 loc) · 16.2 KB
/
Copy pathconfig.ts
File metadata and controls
251 lines (238 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
* Process configuration and the plan section 02 engineering defaults.
* Defaults are acceptance-test inputs and calibration starting points, not measured optima.
*/
import { Config, Effect, Option, Redacted, Schema } from 'effect';
import { AccessScopeName, type JobKind, Unavailable } from '@sanctum/contracts';
/** Plan section 02 "Recommended engineering defaults"; change here, never as scattered literals. */
export const engineeringDefaults = {
archiveChunkSeconds: 30,
browserCommitIntervalMs: 2_000,
heartbeatIntervalMs: 15_000,
ownershipLeaseMs: 45_000,
contextJob: { quietPeriodMs: 25_000, turnThreshold: 4 },
boundaryEvaluationGapMs: 5 * 60_000,
/** An open meeting with no speech for this long (listener paused, stopped or silent) closes as an explicit close would (docs/DECISIONS.md). */
meetingIdleCloseMs: 10 * 60_000,
playbackUrlTtlMs: 5 * 60_000,
/** Per-workspace external action submissions per window; more pause the job until the window frees up. */
actionBudget: { perWindow: 30, windowMs: 60_000 },
/** No provider answer within this after submission: record `unknown` now; a later answer still settles it. */
actionSubmitTimeoutMs: 30_000,
/**
* Per-attempt handler ceiling; a hit fails the attempt retryably. Calibration inputs, not
* measured optima. `action.execute` stays above `actionSubmitTimeoutMs` so the executor's own
* ambiguous-outcome path answers first.
*/
jobs: {
ceilingMs: 5 * 60_000,
ceilingByKind: {
'recording.assemble': 30 * 60_000,
'transcript.reconcile': 20 * 60_000,
/** Above pyannote's poll budget (maxPolls × pollIntervalMs = 10 min) plus audio preparation. */
'speakers.refine': 20 * 60_000,
/** Above one research call (4 min, never retried in-process) plus discovery and planning. */
'research.run': 20 * 60_000,
/** Object deletes are idempotent, so a hit keeps its progress and the next attempt continues. */
'workspace.purge': 30 * 60_000,
/** Above two sequential model calls (extraction provider, then the Workers AI fallback) at `modelRequest.extractionTimeoutMs` each. */
'context.refresh': 15 * 60_000,
'memory.commit': 15 * 60_000,
/** Hard maximum for notes; `notesCeilingMs` in extraction.ts sets the actual limit from the meeting's transcript size. */
'notes.summarize': 60 * 60_000,
} as Partial<Record<JobKind, number>>,
/**
* Attempts for jobs that fail only when a model is slow or unavailable. The capped 5-minute backoff
* spreads 24 attempts over about 83 minutes, so a closed meeting's backlog outlasts a provider outage; other kinds get 5.
*/
maxAttemptsByKind: { 'context.refresh': 24, 'memory.commit': 24, 'notes.summarize': 24 } as Partial<Record<JobKind, number>>,
/** A purge retries every retryable error at the capped backoff until it succeeds: the INT UNSIGNED maximum of `jobs.max_attempts`. */
purgeMaxAttempts: 2 ** 32 - 1,
},
/**
* Live speech-to-text. Whisper is batch-only, so live audio goes out in chunks of `minMs` to `maxMs`
* (measured in docs/DECISIONS.md): the first 200 ms pause under `speechFloorRms` after `minMs` ends a chunk. Without one by `maxMs`
* the cut falls at the quietest 20 ms, maybe inside a word, so the next chunk starts `overlapMs` before it. While Sanctum replies,
* chunks run `replyMinMs` to `replyMaxMs` instead, so a barge-in such as "Sanctum, stop" is heard fast. At most
* `concurrency` chunks in flight; results still arrive in audio order, so in-flight chunks must
* span the slowest answers (12–20 s, about 1 in 100) or later chunks wait and live falls behind.
* A chunk unanswered after `hedgeMs` (above every normal answer) is sent once more; the first
* success wins, a failed duplicate never does, and none is sent during a 429 pause. Audio without 100 ms in a row at `speechFloorRms` (PCM16 RMS, about -56 dBFS) is
* never sent. A 429 pauses requests for its Retry-After, else `rateLimitBackoffMs`, and the chunks meanwhile are skipped.
*/
liveAsr: { minMs: 6_000, maxMs: 10_000, replyMinMs: 1_500, replyMaxMs: 2_500, overlapMs: 750, concurrency: 4, hedgeMs: 8_000, requestTimeoutMs: 60_000, speechFloorRms: 50, rateLimitBackoffMs: 30_000 },
/**
* Requested speech: open window, quiet audio that ends a direct-request turn, and echo memory
* after playback. Finals arrive once per live chunk: a turn ends once live speech-to-text answered
* `endOfTurnMs` past it, else after `turnWaitMs` without new finals, which outlasts the next chunk (`liveAsr.maxMs`)
* and its request; echo memory outlasts them too. The reply
* waits at most `workDecisionWaitMs` for the work decision, then speaks the normal answer.
*/
speech: { windowMs: 30_000, endOfTurnMs: 700, turnWaitMs: 15_000, echoTailMs: 15_000, workDecisionWaitMs: 1_500 },
/** No automatic expiry until a retention policy is selected (docs/DECISIONS.md). */
recordingExpiry: null,
/**
* Initial model and reasoning effort per role (`none` disables it); docs/DECISIONS.md owns provider selection.
* `<ROLE>_MODEL_PROVIDER` / `<ROLE>_MODEL` override these defaults.
*/
modelRoles: {
voice: { provider: 'workers-ai', model: '@cf/qwen/qwen3.8-27b', reasoning: 'none' },
/** Cerebras serves this Qwen in about 0.2 s against 35-100 s on Workers AI (docs/DECISIONS.md); a failure falls back to the planner's Workers AI Qwen. */
extraction: { provider: 'cerebras', model: 'qwen-3.8-27b', reasoning: 'low' },
planner: { provider: 'workers-ai', model: '@cf/qwen/qwen3.8-27b', reasoning: 'low' },
classifier: { provider: 'cerebras', model: 'gpt-oss-120b', reasoning: 'low' },
/** OpenAI Responses hosted web search, a paid call held to `paidResearchAllowance`. */
research: { provider: 'openai', model: 'gpt-4.1-mini-2025-04-14', reasoning: null },
},
/** Per-attempt timeout (`extractionTimeoutMs` for extraction and notes, where a Workers AI call took up to 99 s), bounded transport attempts, output cap and hosted-search budget. */
modelRequest: { timeoutMs: 60_000, extractionTimeoutMs: 120_000, maxAttempts: 3, maxOutputTokens: 4_096, researchMaxSearches: 3, researchMaxContinuations: 3 },
/** Integration gateways (plan section 10): model-facing output budget, options page, upstream timeout. */
pipedream: { outputBudgetBytes: 16_384, optionsPageSize: 20, requestTimeoutMs: 30_000 },
/** WorkOS Events API polling (`workos.sync`): time between runs, pages read per run, request timeout. */
workosSync: { intervalMs: 60_000, pagesPerRun: 10, requestTimeoutMs: 30_000 },
} as const;
export type ModelRoleName = keyof typeof engineeringDefaults.modelRoles;
/** Default model for each provider when `<ROLE>_MODEL` is unset; research chooses among the providers that host web search. */
const providerModels = { 'workers-ai': engineeringDefaults.modelRoles.voice.model, anthropic: 'claude-sonnet-5-5', openai: engineeringDefaults.modelRoles.research.model, cerebras: engineeringDefaults.modelRoles.classifier.model } as const;
const modelRole = (role: ModelRoleName) => {
const fallback = engineeringDefaults.modelRoles[role];
const providers = role === 'research' ? (['openai', 'anthropic'] as const)
: role === 'classifier' || role === 'extraction' ? (['cerebras', 'workers-ai', 'anthropic'] as const) : (['workers-ai', 'anthropic'] as const);
const prefix = role.toUpperCase();
return Config.all({
provider: Config.literal(...providers)(`${prefix}_MODEL_PROVIDER`).pipe(Config.withDefault(fallback.provider)),
model: Config.option(Config.string(`${prefix}_MODEL`)),
}).pipe(Config.map(({ provider, model }) => ({
provider,
model: Option.getOrElse(model, () => (provider === fallback.provider ? fallback.model : providerModels[provider])),
reasoning: fallback.reasoning,
})));
};
/** Decisions in docs/DECISIONS.md an operator lists in `SANCTUM_SELECTED_DECISIONS`; each unlisted one blocks production. */
const activationDecisions = ['identity_issuer', 'mcp_authorization_server', 'meeting_retention', 'outside_meeting_speech'] as const;
type ActivationDecision = (typeof activationDecisions)[number];
const noDecisions: ReadonlyArray<ActivationDecision> = [];
/** Delegated MCP tokens; the issuer is compared with the token `iss` exactly, so kept as written. */
export const mcpAuthorizationConfig = Config.all({
resource: Config.option(Config.url('SANCTUM_MCP_RESOURCE')),
issuer: Config.option(Config.string('SANCTUM_MCP_ISSUER')),
jwksUrl: Config.option(Config.url('SANCTUM_MCP_JWKS_URL')),
/** Granted only to verified tokens that carry no Sanctum scope name; empty fails closed. */
defaultScopes: Config.array(Config.literal(...AccessScopeName.literals)(), 'SANCTUM_MCP_DEFAULT_SCOPES').pipe(
Config.withDefault([] as ReadonlyArray<AccessScopeName>),
),
});
const port = (name: string, fallback: number) => Config.port(name).pipe(Config.withDefault(fallback));
/** `SANCTUM_DEFAULT_SEAT_LIMIT`: a positive integer; absent means no limit. A workspace's own `seat_limit` overrides it. */
export const defaultSeatLimit: Config.Config<number | null> = Schema.Config('SANCTUM_DEFAULT_SEAT_LIMIT', Schema.NumberFromString.pipe(Schema.int(), Schema.positive())).pipe(
Config.option,
Config.map(Option.getOrNull),
);
/** Days between deleting a workspace and purging its recordings and rows; the owner can undo until then. */
export const workspacePurgeGraceDays = Config.integer('SANCTUM_WORKSPACE_PURGE_GRACE_DAYS').pipe(
Config.validate({ message: 'must be at least 1 day', validation: days => days >= 1 }),
Config.withDefault(7),
);
/**
* Paid web-research calls started per UTC day, in each workspace and across all of them; each is
* reserved before it is sent, and 0 turns them off. docs/operations.md gives the worst-case monthly cost.
*/
export const paidResearchAllowance = Config.all({
perWorkspace: Config.integer('SANCTUM_PAID_RESEARCH_CALLS_PER_DAY').pipe(Config.validate({ message: 'must be 0 or more', validation: calls => calls >= 0 }), Config.withDefault(2)),
total: Config.integer('SANCTUM_PAID_RESEARCH_CALLS_PER_DAY_TOTAL').pipe(Config.validate({ message: 'must be 0 or more', validation: calls => calls >= 0 }), Config.withDefault(4)),
});
export const serverConfig = Config.all({
environment: Config.literal('development', 'test', 'production')('SANCTUM_ENV').pipe(Config.withDefault('development')),
/** Default seat limit; null is unlimited. Read here so a malformed value fails at startup. */
seatLimit: defaultSeatLimit,
workspacePurgeGraceDays,
apiPort: port('API_PORT', 7102),
mysql: Config.all({
host: Config.string('MYSQL_HOST').pipe(Config.withDefault('127.0.0.1')),
port: port('MYSQL_PORT', 3306),
database: Config.string('MYSQL_DATABASE').pipe(Config.withDefault('sanctum')),
username: Config.string('MYSQL_USER').pipe(Config.withDefault('sanctum')),
password: Config.redacted('MYSQL_PASSWORD').pipe(Config.withDefault(Redacted.make(''))),
maxConnections: Config.integer('MYSQL_POOL_SIZE').pipe(Config.withDefault(10)),
queueLimit: Config.integer('MYSQL_POOL_QUEUE').pipe(Config.withDefault(100)),
/** PEM CA certificate; when set, connections require TLS verified against it, host name included. */
caCert: Config.option(Config.string('MYSQL_CA_CERT')).pipe(Config.map(Option.getOrUndefined)),
}),
/** Decisions an operator has explicitly selected and configured, comma-separated. */
selectedDecisions: Config.array(Config.literal(...activationDecisions)(), 'SANCTUM_SELECTED_DECISIONS').pipe(Config.withDefault(noDecisions)),
/** Human login with any standard OIDC issuer: WorkOS AuthKit hosted, embedded Better Auth self-hosted. */
signIn: Config.all({
/** Compared with the ID token `iss` exactly, so kept as written. */
issuer: Config.option(Config.string('SANCTUM_OIDC_ISSUER')),
clientId: Config.option(Config.string('SANCTUM_OIDC_CLIENT_ID')),
/** Unset means a public client, which must use PKCE. */
clientSecret: Config.option(Config.redacted('SANCTUM_OIDC_CLIENT_SECRET')),
redirectUri: Config.option(Config.url('SANCTUM_OIDC_REDIRECT_URI')),
scopes: Config.string('SANCTUM_OIDC_SCOPES').pipe(Config.withDefault('openid profile email')),
}),
mcpAuthorization: mcpAuthorizationConfig,
/** `better-auth` serves the OIDC issuer and MCP authorization server in-process at `/idp`. */
embeddedIssuer: Config.option(Config.literal('better-auth')('SANCTUM_EMBEDDED_ISSUER')),
betterAuthSecret: Config.option(Config.redacted('BETTER_AUTH_SECRET')),
/** WorkOS Organizations sync (hosted): server-only API key; without it no organization is read or created. */
workos: Config.all({
apiKey: Config.option(Config.redacted('WORKOS_API_KEY')),
/** A signed-in user with no membership may create a workspace (with a WorkOS organization). */
selfServeWorkspaces: Config.boolean('SANCTUM_SELF_SERVE_WORKSPACES').pipe(Config.withDefault(false)),
}),
modelRoles: Config.all({ voice: modelRole('voice'), extraction: modelRole('extraction'), planner: modelRole('planner'), classifier: modelRole('classifier'), research: modelRole('research') }),
/** Server-only provider keys. The spoken classifier (speech-requests.ts) and extraction (extraction.ts) fall back to the planner's Workers AI Qwen. */
modelKeys: Config.all({ anthropic: Config.option(Config.redacted('ANTHROPIC_API_KEY')), openai: Config.option(Config.redacted('OPENAI_API_KEY')), cerebras: Config.option(Config.redacted('CEREBRAS_API_KEY')) }),
/** Server-only Workers AI credentials shared by speech-to-text, requested speech, and text models. */
workersAi: Config.option(
Config.all({
baseUrl: Config.string('WORKERS_AI_ACCOUNT_ID').pipe(Config.map(account => `https://api.cloudflare.com/client/v4/accounts/${account}/ai`)),
apiToken: Config.redacted('WORKERS_AI_API_TOKEN'),
}),
),
/** Pipedream Connect; without credentials every integration call fails as `Unavailable`. */
pipedream: Config.all({
apiUrl: Config.string('PIPEDREAM_API_URL').pipe(Config.withDefault('https://api.pipedream.com')),
environment: Config.literal('development', 'production')('PIPEDREAM_ENVIRONMENT').pipe(Config.withDefault('development')),
credentials: Config.option(
Config.all({
projectId: Config.string('PIPEDREAM_PROJECT_ID'),
clientId: Config.string('PIPEDREAM_CLIENT_ID'),
clientSecret: Config.redacted('PIPEDREAM_CLIENT_SECRET'),
}),
),
}),
});
export type ServerConfig = Config.Config.Success<typeof serverConfig>;
/**
* A listed decision with missing settings is an operator error in every environment. Production
* also refuses to start while any decision is unlisted; development and tests run with fixtures.
* Selecting a decision is an operator action, never a code default.
*/
export const requireActivation = (
config: Pick<ServerConfig, 'environment' | 'selectedDecisions' | 'signIn' | 'mcpAuthorization' | 'embeddedIssuer' | 'betterAuthSecret'>,
) => {
/** Settings a listed decision needs before it counts as selected; the other decisions have none. */
const settings: Partial<Record<ActivationDecision, Record<string, Option.Option<unknown>>>> = {
identity_issuer: {
SANCTUM_OIDC_ISSUER: config.signIn.issuer,
SANCTUM_OIDC_CLIENT_ID: config.signIn.clientId,
SANCTUM_OIDC_REDIRECT_URI: config.signIn.redirectUri,
...(Option.isSome(config.embeddedIssuer) ? { BETTER_AUTH_SECRET: config.betterAuthSecret } : {}),
},
mcp_authorization_server: {
SANCTUM_MCP_RESOURCE: config.mcpAuthorization.resource,
SANCTUM_MCP_ISSUER: config.mcpAuthorization.issuer,
SANCTUM_MCP_JWKS_URL: config.mcpAuthorization.jwksUrl,
},
};
const incomplete = config.selectedDecisions.flatMap(decision => {
const unset = Object.entries(settings[decision] ?? {}).flatMap(([name, value]) => (Option.isNone(value) ? [name] : []));
return unset.length === 0 ? [] : [`${decision} needs ${unset.join(', ')}`];
});
if (incomplete.length > 0) return Effect.fail(new Unavailable({ message: `Selected decisions are not configured: ${incomplete.join('; ')}`, retryable: false }));
const missing = activationDecisions.filter(decision => !config.selectedDecisions.includes(decision));
return config.environment !== 'production' || missing.length === 0
? Effect.void
: Effect.fail(new Unavailable({ message: `Production activation blocked; unselected: ${missing.join(', ')}`, retryable: false }));
};