twinclaw 1.0.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 (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,415 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { clearRuntimeBudgetState, getRuntimeBudgetState, getRuntimeDailyUsageAggregate, getRuntimeSessionUsageAggregate, listRuntimeBudgetEvents, listRuntimeProviderUsageAggregates, recordRuntimeBudgetEvent, recordRuntimeUsageEvent, setRuntimeBudgetState, } from './db.js';
3
+ import { logThought } from '../utils/logger.js';
4
+ import { getConfigValue } from '../config/config-loader.js';
5
+ const MANUAL_PROFILE_KEY = 'manual_profile';
6
+ const DEFAULT_SESSION_ID = 'global';
7
+ const DEFAULT_LOCAL_MODEL_ID = 'local';
8
+ const DEFAULT_LIMITS = {
9
+ dailyRequestLimit: 2_400,
10
+ dailyTokenLimit: 5_000_000,
11
+ sessionRequestLimit: 300,
12
+ sessionTokenLimit: 700_000,
13
+ providerRequestLimit: 1_000,
14
+ providerTokenLimit: 2_200_000,
15
+ warningRatio: 0.8,
16
+ warningPacingMs: 250,
17
+ hardLimitPacingMs: 1_250,
18
+ providerCooldownMs: 60_000,
19
+ };
20
+ const PROFILE_ORDER = ['economy', 'balanced', 'performance'];
21
+ export class RuntimeBudgetGovernor {
22
+ #limits;
23
+ #defaultProfile;
24
+ #preferLocalModel;
25
+ #localModelId;
26
+ #now;
27
+ #providerCooldowns = new Map();
28
+ #signatures = new Map();
29
+ #manualProfile;
30
+ constructor(config = {}) {
31
+ this.#limits = resolveLimits(config.limits);
32
+ this.#defaultProfile =
33
+ parseProfile(getConfigValue('RUNTIME_BUDGET_DEFAULT_PROFILE')) ??
34
+ config.defaultProfile ??
35
+ 'performance';
36
+ this.#preferLocalModel =
37
+ config.preferLocalModel ??
38
+ parseBoolean(getConfigValue('RUNTIME_BUDGET_PREFER_LOCAL_MODEL')) ??
39
+ false;
40
+ this.#localModelId =
41
+ getConfigValue('RUNTIME_BUDGET_LOCAL_MODEL_ID')?.trim() || config.localModelId || DEFAULT_LOCAL_MODEL_ID;
42
+ this.#now = config.now ?? (() => Date.now());
43
+ this.#manualProfile = parseProfile(getRuntimeBudgetState(MANUAL_PROFILE_KEY));
44
+ }
45
+ get limits() {
46
+ return this.#limits;
47
+ }
48
+ getRoutingDirective(sessionId) {
49
+ const normalizedSessionId = normalizeSessionId(sessionId);
50
+ const evaluation = this.#evaluate(normalizedSessionId);
51
+ this.#recordTransition(normalizedSessionId, evaluation);
52
+ return evaluation.directive;
53
+ }
54
+ recordUsage(input) {
55
+ const normalizedSessionId = normalizeSessionId(input.sessionId);
56
+ const event = {
57
+ id: randomUUID(),
58
+ sessionId: normalizedSessionId,
59
+ modelId: input.modelId,
60
+ providerId: input.providerId,
61
+ profile: input.profile,
62
+ stage: input.stage,
63
+ requestTokens: Math.max(0, Math.floor(input.requestTokens)),
64
+ responseTokens: Math.max(0, Math.floor(input.responseTokens)),
65
+ latencyMs: Math.max(0, Math.floor(input.latencyMs)),
66
+ statusCode: input.statusCode ?? null,
67
+ error: input.error ?? null,
68
+ };
69
+ recordRuntimeUsageEvent(event);
70
+ if (input.stage === 'failure' && input.statusCode === 429) {
71
+ this.applyProviderCooldown(input.providerId, normalizedSessionId, 'Rate-limit response detected.');
72
+ }
73
+ }
74
+ applyProviderCooldown(providerId, sessionId, reason = 'Provider cooldown applied.') {
75
+ const normalizedProvider = providerId.trim().toLowerCase();
76
+ if (!normalizedProvider) {
77
+ return;
78
+ }
79
+ const now = this.#now();
80
+ const current = this.#providerCooldowns.get(normalizedProvider) ?? 0;
81
+ const next = Math.max(current, now + this.#limits.providerCooldownMs);
82
+ this.#providerCooldowns.set(normalizedProvider, next);
83
+ const normalizedSessionId = normalizeSessionId(sessionId);
84
+ this.#recordBudgetEvent({
85
+ sessionId: normalizedSessionId,
86
+ severity: 'warning',
87
+ profile: this.#manualProfile ?? this.#defaultProfile,
88
+ action: 'provider_cooldown',
89
+ reason,
90
+ detail: {
91
+ providerId: normalizedProvider,
92
+ cooldownUntil: new Date(next).toISOString(),
93
+ },
94
+ });
95
+ }
96
+ setManualProfile(profile, sessionId) {
97
+ const normalizedSessionId = normalizeSessionId(sessionId);
98
+ this.#manualProfile = profile;
99
+ if (profile) {
100
+ setRuntimeBudgetState(MANUAL_PROFILE_KEY, profile);
101
+ }
102
+ else {
103
+ clearRuntimeBudgetState(MANUAL_PROFILE_KEY);
104
+ }
105
+ this.#recordBudgetEvent({
106
+ sessionId: normalizedSessionId,
107
+ severity: 'ok',
108
+ profile: profile ?? this.#defaultProfile,
109
+ action: 'none',
110
+ reason: profile
111
+ ? `Manual budget profile override set to '${profile}'.`
112
+ : 'Manual budget profile override cleared.',
113
+ detail: { manualProfile: profile },
114
+ });
115
+ }
116
+ resetPolicyState(sessionId) {
117
+ const normalizedSessionId = normalizeSessionId(sessionId);
118
+ this.#providerCooldowns.clear();
119
+ this.#signatures.clear();
120
+ this.setManualProfile(null, normalizedSessionId);
121
+ this.#recordBudgetEvent({
122
+ sessionId: normalizedSessionId,
123
+ severity: 'ok',
124
+ profile: this.#defaultProfile,
125
+ action: 'none',
126
+ reason: 'Runtime budget policy state reset.',
127
+ detail: {},
128
+ });
129
+ }
130
+ getSnapshot(sessionId, eventLimit = 50) {
131
+ const normalizedSessionId = normalizeSessionId(sessionId);
132
+ const evaluation = this.#evaluate(normalizedSessionId);
133
+ return {
134
+ sessionId: normalizedSessionId,
135
+ manualProfile: evaluation.manualProfile,
136
+ limits: evaluation.limits,
137
+ daily: evaluation.daily,
138
+ session: evaluation.session,
139
+ providers: evaluation.providers,
140
+ directive: evaluation.directive,
141
+ recentEvents: this.getRecentEvents(eventLimit),
142
+ };
143
+ }
144
+ getRecentEvents(limit = 100) {
145
+ return listRuntimeBudgetEvents(limit).map((row) => this.#toEvent(row));
146
+ }
147
+ #evaluate(sessionId) {
148
+ this.#pruneCooldowns();
149
+ this.#manualProfile = parseProfile(getRuntimeBudgetState(MANUAL_PROFILE_KEY));
150
+ const daily = toUsageAggregate(getRuntimeDailyUsageAggregate());
151
+ const session = toUsageAggregate(getRuntimeSessionUsageAggregate(sessionId));
152
+ const providers = listRuntimeProviderUsageAggregates().map((row) => ({
153
+ providerId: row.provider_id,
154
+ ...toUsageAggregate(row),
155
+ }));
156
+ const severity = resolveSeverity(this.#limits, daily, session, providers);
157
+ const actions = resolveActions(severity, this.#preferLocalModel);
158
+ const blockedProviders = providers
159
+ .filter((provider) => provider.requestCount >= this.#limits.providerRequestLimit ||
160
+ provider.requestTokens >= this.#limits.providerTokenLimit)
161
+ .map((provider) => provider.providerId);
162
+ for (const providerId of blockedProviders) {
163
+ if (!this.#providerCooldowns.has(providerId)) {
164
+ this.#providerCooldowns.set(providerId, this.#now() + this.#limits.providerCooldownMs);
165
+ }
166
+ }
167
+ const cooldownProviders = [...this.#providerCooldowns.entries()]
168
+ .filter(([, until]) => until > this.#now())
169
+ .map(([providerId]) => providerId);
170
+ const finalBlockedProviders = [...new Set([...blockedProviders, ...cooldownProviders])];
171
+ if (finalBlockedProviders.length > 0 && !actions.includes('provider_cooldown')) {
172
+ actions.push('provider_cooldown');
173
+ }
174
+ const profile = this.#manualProfile ?? resolveProfile(this.#defaultProfile, severity);
175
+ const blockedModelIds = severity === 'hard_limit'
176
+ ? ['primary']
177
+ : this.#preferLocalModel && profile === 'economy'
178
+ ? ['primary', 'fallback_1', 'fallback_2'].filter((id) => id !== this.#localModelId)
179
+ : [];
180
+ const pacingDelayMs = severity === 'hard_limit'
181
+ ? this.#limits.hardLimitPacingMs
182
+ : severity === 'warning'
183
+ ? this.#limits.warningPacingMs
184
+ : 0;
185
+ const directive = {
186
+ profile,
187
+ severity,
188
+ actions,
189
+ pacingDelayMs,
190
+ blockedModelIds,
191
+ blockedProviders: finalBlockedProviders,
192
+ reason: buildReason(this.#limits, severity, daily, session, providers),
193
+ evaluatedAt: new Date(this.#now()).toISOString(),
194
+ };
195
+ return {
196
+ manualProfile: this.#manualProfile,
197
+ limits: this.#limits,
198
+ daily,
199
+ session,
200
+ providers,
201
+ directive,
202
+ };
203
+ }
204
+ #recordTransition(sessionId, evaluation) {
205
+ const signature = [
206
+ evaluation.directive.severity,
207
+ evaluation.directive.profile,
208
+ evaluation.directive.actions.join(','),
209
+ evaluation.directive.blockedProviders.join(','),
210
+ evaluation.directive.blockedModelIds.join(','),
211
+ ].join('|');
212
+ const previous = this.#signatures.get(sessionId);
213
+ if (previous === signature) {
214
+ return;
215
+ }
216
+ this.#signatures.set(sessionId, signature);
217
+ this.#recordBudgetEvent({
218
+ sessionId,
219
+ severity: evaluation.directive.severity,
220
+ profile: evaluation.directive.profile,
221
+ action: evaluation.directive.actions[0] ?? 'none',
222
+ reason: evaluation.directive.reason,
223
+ detail: {
224
+ actions: evaluation.directive.actions,
225
+ blockedProviders: evaluation.directive.blockedProviders,
226
+ blockedModelIds: evaluation.directive.blockedModelIds,
227
+ pacingDelayMs: evaluation.directive.pacingDelayMs,
228
+ },
229
+ });
230
+ }
231
+ #recordBudgetEvent(input) {
232
+ recordRuntimeBudgetEvent({
233
+ id: randomUUID(),
234
+ sessionId: input.sessionId,
235
+ severity: input.severity,
236
+ profile: input.profile,
237
+ action: input.action,
238
+ reason: input.reason,
239
+ detailJson: JSON.stringify(input.detail),
240
+ });
241
+ void logThought(`[RuntimeBudget] severity=${input.severity} profile=${input.profile} action=${input.action} reason=${input.reason}`);
242
+ }
243
+ #toEvent(row) {
244
+ return {
245
+ id: row.id,
246
+ sessionId: row.session_id,
247
+ severity: row.severity,
248
+ profile: row.profile,
249
+ action: row.action,
250
+ reason: row.reason,
251
+ detail: parseDetail(row.detail_json),
252
+ createdAt: row.created_at,
253
+ };
254
+ }
255
+ #pruneCooldowns() {
256
+ const now = this.#now();
257
+ for (const [providerId, cooldownUntil] of this.#providerCooldowns.entries()) {
258
+ if (cooldownUntil <= now) {
259
+ this.#providerCooldowns.delete(providerId);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ function normalizeSessionId(value) {
265
+ const normalized = value?.trim();
266
+ return normalized || DEFAULT_SESSION_ID;
267
+ }
268
+ function toUsageAggregate(row) {
269
+ return {
270
+ requestCount: Number(row.request_count ?? 0),
271
+ requestTokens: Number(row.request_tokens ?? 0),
272
+ responseTokens: Number(row.response_tokens ?? 0),
273
+ failureCount: Number(row.failure_count ?? 0),
274
+ skippedCount: Number(row.skipped_count ?? 0),
275
+ };
276
+ }
277
+ function resolveProfile(defaultProfile, severity) {
278
+ if (severity === 'hard_limit') {
279
+ return 'economy';
280
+ }
281
+ if (severity === 'warning' && defaultProfile === 'performance') {
282
+ return 'balanced';
283
+ }
284
+ return defaultProfile;
285
+ }
286
+ function resolveSeverity(limits, daily, session, providers) {
287
+ const hardBreached = daily.requestCount >= limits.dailyRequestLimit ||
288
+ daily.requestTokens >= limits.dailyTokenLimit ||
289
+ session.requestCount >= limits.sessionRequestLimit ||
290
+ session.requestTokens >= limits.sessionTokenLimit ||
291
+ providers.some((provider) => provider.requestCount >= limits.providerRequestLimit ||
292
+ provider.requestTokens >= limits.providerTokenLimit);
293
+ if (hardBreached) {
294
+ return 'hard_limit';
295
+ }
296
+ const ratios = [
297
+ safeRatio(daily.requestCount, limits.dailyRequestLimit),
298
+ safeRatio(daily.requestTokens, limits.dailyTokenLimit),
299
+ safeRatio(session.requestCount, limits.sessionRequestLimit),
300
+ safeRatio(session.requestTokens, limits.sessionTokenLimit),
301
+ ...providers.flatMap((provider) => [
302
+ safeRatio(provider.requestCount, limits.providerRequestLimit),
303
+ safeRatio(provider.requestTokens, limits.providerTokenLimit),
304
+ ]),
305
+ ];
306
+ if (ratios.some((ratio) => ratio >= limits.warningRatio)) {
307
+ return 'warning';
308
+ }
309
+ return 'ok';
310
+ }
311
+ function resolveActions(severity, preferLocalModel) {
312
+ if (severity === 'ok') {
313
+ return ['none'];
314
+ }
315
+ const actions = ['intelligent_pacing'];
316
+ if (severity === 'hard_limit') {
317
+ actions.push('fallback_tightening');
318
+ if (preferLocalModel) {
319
+ actions.push('prefer_local_model');
320
+ }
321
+ }
322
+ return actions;
323
+ }
324
+ function buildReason(limits, severity, daily, session, providers) {
325
+ if (severity === 'ok') {
326
+ return 'Budget utilization is below warning thresholds.';
327
+ }
328
+ const providerHeavy = providers
329
+ .filter((provider) => provider.requestCount >= limits.providerRequestLimit * limits.warningRatio ||
330
+ provider.requestTokens >= limits.providerTokenLimit * limits.warningRatio)
331
+ .map((provider) => provider.providerId);
332
+ const parts = [
333
+ `dailyRequests=${daily.requestCount}/${limits.dailyRequestLimit}`,
334
+ `dailyTokens=${daily.requestTokens}/${limits.dailyTokenLimit}`,
335
+ `sessionRequests=${session.requestCount}/${limits.sessionRequestLimit}`,
336
+ `sessionTokens=${session.requestTokens}/${limits.sessionTokenLimit}`,
337
+ ];
338
+ if (providerHeavy.length > 0) {
339
+ parts.push(`providers=${providerHeavy.join(',')}`);
340
+ }
341
+ return severity === 'hard_limit'
342
+ ? `Hard budget threshold reached (${parts.join(' | ')}).`
343
+ : `Warning budget threshold reached (${parts.join(' | ')}).`;
344
+ }
345
+ function safeRatio(value, limit) {
346
+ if (!Number.isFinite(limit) || limit <= 0) {
347
+ return 0;
348
+ }
349
+ return value / limit;
350
+ }
351
+ function parseDetail(value) {
352
+ try {
353
+ const parsed = JSON.parse(value);
354
+ return parsed ?? {};
355
+ }
356
+ catch {
357
+ return {};
358
+ }
359
+ }
360
+ function resolveLimits(overrides = {}) {
361
+ return {
362
+ dailyRequestLimit: readIntEnv('RUNTIME_BUDGET_DAILY_REQUEST_LIMIT', overrides.dailyRequestLimit ?? DEFAULT_LIMITS.dailyRequestLimit),
363
+ dailyTokenLimit: readIntEnv('RUNTIME_BUDGET_DAILY_TOKEN_LIMIT', overrides.dailyTokenLimit ?? DEFAULT_LIMITS.dailyTokenLimit),
364
+ sessionRequestLimit: readIntEnv('RUNTIME_BUDGET_SESSION_REQUEST_LIMIT', overrides.sessionRequestLimit ?? DEFAULT_LIMITS.sessionRequestLimit),
365
+ sessionTokenLimit: readIntEnv('RUNTIME_BUDGET_SESSION_TOKEN_LIMIT', overrides.sessionTokenLimit ?? DEFAULT_LIMITS.sessionTokenLimit),
366
+ providerRequestLimit: readIntEnv('RUNTIME_BUDGET_PROVIDER_REQUEST_LIMIT', overrides.providerRequestLimit ?? DEFAULT_LIMITS.providerRequestLimit),
367
+ providerTokenLimit: readIntEnv('RUNTIME_BUDGET_PROVIDER_TOKEN_LIMIT', overrides.providerTokenLimit ?? DEFAULT_LIMITS.providerTokenLimit),
368
+ warningRatio: readFloatEnv('RUNTIME_BUDGET_WARNING_RATIO', overrides.warningRatio ?? DEFAULT_LIMITS.warningRatio),
369
+ warningPacingMs: readIntEnv('RUNTIME_BUDGET_WARNING_PACING_MS', overrides.warningPacingMs ?? DEFAULT_LIMITS.warningPacingMs),
370
+ hardLimitPacingMs: readIntEnv('RUNTIME_BUDGET_HARD_PACING_MS', overrides.hardLimitPacingMs ?? DEFAULT_LIMITS.hardLimitPacingMs),
371
+ providerCooldownMs: readIntEnv('RUNTIME_BUDGET_PROVIDER_COOLDOWN_MS', overrides.providerCooldownMs ?? DEFAULT_LIMITS.providerCooldownMs),
372
+ };
373
+ }
374
+ function readIntEnv(name, fallback) {
375
+ const raw = getConfigValue(name);
376
+ if (!raw) {
377
+ return fallback;
378
+ }
379
+ const parsed = Number(raw);
380
+ if (!Number.isFinite(parsed)) {
381
+ return fallback;
382
+ }
383
+ return Math.max(1, Math.floor(parsed));
384
+ }
385
+ function readFloatEnv(name, fallback) {
386
+ const raw = getConfigValue(name);
387
+ if (!raw) {
388
+ return fallback;
389
+ }
390
+ const parsed = Number(raw);
391
+ if (!Number.isFinite(parsed)) {
392
+ return fallback;
393
+ }
394
+ return Math.min(0.99, Math.max(0.01, parsed));
395
+ }
396
+ function parseBoolean(raw) {
397
+ if (!raw) {
398
+ return null;
399
+ }
400
+ const normalized = raw.trim().toLowerCase();
401
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
402
+ return true;
403
+ }
404
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
405
+ return false;
406
+ }
407
+ return null;
408
+ }
409
+ function parseProfile(raw) {
410
+ if (!raw) {
411
+ return null;
412
+ }
413
+ const normalized = raw.trim().toLowerCase();
414
+ return PROFILE_ORDER.find((profile) => profile === normalized) ?? null;
415
+ }